From b0a040fcd05dd1fc30a9a20cdf1f28aafbdc68a6 Mon Sep 17 00:00:00 2001 From: Leonxlnx Date: Wed, 10 Jun 2026 00:46:24 +0200 Subject: [PATCH] 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 --- README.md | 2 +- js/engine.js | 10 +++++ js/exporter.js | 107 +++++++++++++++++++++++++++++++++++-------------- 3 files changed, 89 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index f16d1cf..92934d7 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Requires a browser with WebGL2 (Chrome, Edge, Firefox). ## Export - **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. ## Files diff --git a/js/engine.js b/js/engine.js index bb61d4a..c3f22d6 100644 --- a/js/engine.js +++ b/js/engine.js @@ -4,6 +4,7 @@ var Engine = (function () { var canvas, gl, program, uniforms = {}; var playing = true; + var suspended = false; var loopT = 0; // seconds into current loop var lastTick = 0; var fps = 60, fpsAcc = 0, fpsN = 0, fpsCb = null; @@ -119,6 +120,8 @@ var Engine = (function () { } function tick(now) { + if (suspended) return; + var dt = Math.min((now - lastTick) / 1000, 0.1); lastTick = now; @@ -157,6 +160,13 @@ var Engine = (function () { readPixels: readPixels, currentPhase: currentPhase, 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(); }, isPlaying: function () { return playing; }, onFps: function (cb) { fpsCb = cb; }, diff --git a/js/exporter.js b/js/exporter.js index 841259e..9f8ebdd 100644 --- a/js/exporter.js +++ b/js/exporter.js @@ -54,13 +54,13 @@ var Exporter = (function () { }, "image/png"); } - /* ---------- Video (realtime capture) ---------- */ + /* ---------- Video (deterministic frame capture — avoids captureStream(60) crashes) ---------- */ function pickVideoMime() { + /* VP8 first: most stable for canvas capture on Chrome/Edge */ var candidates = [ - "video/webm;codecs=vp9", "video/webm;codecs=vp8", - "video/webm", - "video/mp4" + "video/webm;codecs=vp9", + "video/webm" ]; for (var i = 0; i < candidates.length; i++) { if (window.MediaRecorder && MediaRecorder.isTypeSupported(candidates[i])) return candidates[i]; @@ -68,49 +68,98 @@ var Exporter = (function () { 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) { 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; } 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 nFrames = Math.max(2, Math.round(totalSec * fps)); + var frameMs = 1000 / fps; + var ext = "webm"; + + Engine.suspend(); + Engine.setPlaying(false); Engine.setSize(w, h); + showOverlay("Rendering video"); - var wasPlaying = Engine.isPlaying(); - Engine.setPlaying(true); - Engine.resetTime(); + 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 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 = []; - 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 () { - Engine.setSize(prev[0], prev[1]); - Engine.setPlaying(wasPlaying); + finishVideo(prev, wasPlaying, parts.length ? new Blob(parts, { type: mime }) : null, w, h, ext, totalSec); + }; + + 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(); busy = false; - if (!cancelled) { - download(new Blob(parts, { type: mime }), stamp(P, ext)); - UI.toast("Saved " + (durMs / 1000).toFixed(1) + "s " + ext.toUpperCase() + " (" + w + "\u00d7" + h + ")"); + if (blob && !cancelled) { + download(blob, stamp(P, extension)); + UI.toast("Saved " + seconds.toFixed(1) + "s " + extension.toUpperCase() + " (" + width + "\u00d7" + height + ")"); } - }; - rec.start(200); + } - var t0 = performance.now(); - (function poll() { - var el = performance.now() - t0; - if (cancelled) { rec.stop(); 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); - })(); + 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); + } } /* ---------- GIF (offline, deterministic, perfect loop) ---------- */