/* 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 (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 = [ { codec: "vp09.00.10.08", codecId: "V_VP9" }, { codec: "vp8", codecId: "V_VP8" } ]; for (var i = 0; i < candidates.length; 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; } async function exportVideo(P, aspect) { if (busy) 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 = parseInt(P.vidFps, 10) || 30; var totalSec = videoDurationSec(P); var nFrames = Math.max(2, Math.round(totalSec * fps)); showOverlay("Rendering video"); Engine.suspend(); Engine.setPlaying(false); Engine.setSize(w, h); var picked = await pickEncoderConfig(w, h, fps); if (!picked) { restore(); UI.toast("No supported video codec (VP9/VP8) found"); return; } 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); var canvas = Engine.canvas(); var usPerFrame = 1e6 / fps; 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; } } /* ---------- 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, loop: P.gifLoop, 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; } }; })();