b0a040fcd0
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>
224 lines
6.9 KiB
JavaScript
224 lines
6.9 KiB
JavaScript
/* Export pipeline: PNG stills, WebM/MP4 video, looping GIF. */
|
|
|
|
var Exporter = (function () {
|
|
var cancelled = false;
|
|
var busy = false;
|
|
|
|
function $(id) { return document.getElementById(id); }
|
|
|
|
function showOverlay(title) {
|
|
cancelled = false;
|
|
$("overlay-title").textContent = title;
|
|
$("overlay-detail").textContent = "preparing";
|
|
$("overlay-bar").style.width = "0%";
|
|
$("overlay").hidden = false;
|
|
}
|
|
function setProgress(frac, detail) {
|
|
$("overlay-bar").style.width = Math.round(frac * 100) + "%";
|
|
if (detail) $("overlay-detail").textContent = detail;
|
|
}
|
|
function hideOverlay() { $("overlay").hidden = true; }
|
|
|
|
function download(blob, name) {
|
|
var a = document.createElement("a");
|
|
a.href = URL.createObjectURL(blob);
|
|
a.download = name;
|
|
a.click();
|
|
setTimeout(function () { URL.revokeObjectURL(a.href); }, 4000);
|
|
}
|
|
|
|
function stamp(P, ext) {
|
|
var mode = MODES[P.mode].key;
|
|
return "lumen-" + mode + "-" + String(Math.round(P.seed)).padStart(4, "0") + "." + ext;
|
|
}
|
|
|
|
function evenRound(v) { return 2 * Math.round(v / 2); }
|
|
|
|
/* ---------- PNG ---------- */
|
|
function exportPNG(P, aspect) {
|
|
if (busy) return;
|
|
busy = true;
|
|
var prev = Engine.size();
|
|
var h = parseInt(P.imgRes, 10);
|
|
var w = evenRound(h * aspect);
|
|
Engine.setSize(w, h);
|
|
Engine.renderAt(Engine.currentPhase());
|
|
Engine.canvas().toBlob(function (blob) {
|
|
Engine.setSize(prev[0], prev[1]);
|
|
Engine.renderAt(Engine.currentPhase());
|
|
busy = false;
|
|
if (blob) {
|
|
download(blob, stamp(P, "png"));
|
|
UI.toast("Saved " + w + "\u00d7" + h + " PNG");
|
|
}
|
|
}, "image/png");
|
|
}
|
|
|
|
/* ---------- 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=vp8",
|
|
"video/webm;codecs=vp9",
|
|
"video/webm"
|
|
];
|
|
for (var i = 0; i < candidates.length; i++) {
|
|
if (window.MediaRecorder && MediaRecorder.isTypeSupported(candidates[i])) return candidates[i];
|
|
}
|
|
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 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 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);
|
|
};
|
|
|
|
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 (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);
|
|
}
|
|
}
|
|
|
|
/* ---------- GIF (offline, deterministic, perfect loop) ---------- */
|
|
async function exportGIF(P, aspect) {
|
|
if (busy) return;
|
|
busy = true;
|
|
|
|
var prev = Engine.size();
|
|
var wasPlaying = Engine.isPlaying();
|
|
Engine.setPlaying(false);
|
|
|
|
var w = parseInt(P.gifW, 10);
|
|
var h = evenRound(w / aspect);
|
|
var fps = parseInt(P.gifFps, 10);
|
|
var nFrames = Math.max(2, Math.round(P.loop * fps));
|
|
|
|
showOverlay("Rendering GIF");
|
|
Engine.setSize(w, h);
|
|
|
|
var frames = [];
|
|
for (var f = 0; f < nFrames; f++) {
|
|
if (cancelled) break;
|
|
Engine.renderAt(f / nFrames);
|
|
frames.push(Engine.readPixels());
|
|
setProgress(0.4 * (f + 1) / nFrames, "capturing " + (f + 1) + "/" + nFrames);
|
|
if (f % 4 === 3) await wait(0);
|
|
}
|
|
|
|
Engine.setSize(prev[0], prev[1]);
|
|
Engine.setPlaying(wasPlaying);
|
|
|
|
if (cancelled) { hideOverlay(); busy = false; return; }
|
|
|
|
var data = await GIFEnc.encode({
|
|
frames: frames, width: w, height: h, fps: fps,
|
|
dither: P.gifDither,
|
|
onProgress: function (frac, detail) { setProgress(0.4 + 0.6 * frac, "encoding \u00b7 " + detail); },
|
|
isCancelled: function () { return cancelled; }
|
|
});
|
|
|
|
hideOverlay();
|
|
busy = false;
|
|
if (data && !cancelled) {
|
|
download(new Blob([data], { type: "image/gif" }), stamp(P, "gif"));
|
|
UI.toast("Saved " + nFrames + "-frame looping GIF (" + w + "\u00d7" + h + ")");
|
|
}
|
|
}
|
|
|
|
function wait(ms) { return new Promise(function (r) { setTimeout(r, ms); }); }
|
|
|
|
document.addEventListener("DOMContentLoaded", function () {
|
|
$("overlay-cancel").addEventListener("click", function () { cancelled = true; });
|
|
});
|
|
|
|
return {
|
|
exportPNG: exportPNG,
|
|
exportVideo: exportVideo,
|
|
exportGIF: exportGIF,
|
|
isBusy: function () { return busy; }
|
|
};
|
|
})();
|