diff --git a/README.md b/README.md index 92934d7..fa26344 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,8 @@ Requires a browser with WebGL2 (Chrome, Edge, Firefox). ## Export - **PNG** up to 3840×2160, rendered offscreen at full quality. -- **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. +- **Video** (WebM VP9/VP8) encoded offline with **WebCodecs** (`VideoEncoder`) and muxed by a built-in dependency-free WebM writer — no `MediaRecorder`, no realtime capture, no tab crashes. Configurable fps (24/30/60), resolution (720p–1440p) and exact length (1–8 loops or 5–60 seconds). +- **GIF** rendered deterministically frame by frame, encoded in-page with a dependency-free GIF89a encoder (median-cut palette + ordered dithering). Loop forever or play once. ## Files @@ -42,5 +42,6 @@ Requires a browser with WebGL2 (Chrome, Edge, Firefox). - `js/shaders.js` — the uber fragment shader with all 9 modes - `js/engine.js` — WebGL2 engine and render loop - `js/gifenc.js` — GIF encoder (quantizer + LZW) +- `js/webmmux.js` — WebM/Matroska muxer for WebCodecs output - `js/exporter.js` — PNG / video / GIF pipelines - `js/palettes.js`, `js/ui.js`, `js/main.js` — palettes, control builders, state and randomizer diff --git a/index.html b/index.html index 4a53fa5..28348d2 100644 --- a/index.html +++ b/index.html @@ -89,6 +89,7 @@ + diff --git a/js/exporter.js b/js/exporter.js index 9f8ebdd..63ea107 100644 --- a/js/exporter.js +++ b/js/exporter.js @@ -54,111 +54,153 @@ var Exporter = (function () { }, "image/png"); } - /* ---------- Video (deterministic frame capture — avoids captureStream(60) crashes) ---------- */ - function pickVideoMime() { - /* VP8 first: most stable for canvas capture on Chrome/Edge */ + /* ---------- 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 = [ - "video/webm;codecs=vp8", - "video/webm;codecs=vp9", - "video/webm" + { codec: "vp09.00.10.08", codecId: "V_VP9" }, + { codec: "vp8", codecId: "V_VP8" } ]; for (var i = 0; i < candidates.length; i++) { - if (window.MediaRecorder && MediaRecorder.isTypeSupported(candidates[i])) return candidates[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; } - 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) { + async 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; } + 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 = 30; - var loops = parseInt(P.vidLoops, 10) || 1; - var totalSec = P.loop * loops; + var fps = parseInt(P.vidFps, 10) || 30; + var totalSec = videoDurationSec(P); var nFrames = Math.max(2, Math.round(totalSec * fps)); - var frameMs = 1000 / fps; - var ext = "webm"; + showOverlay("Rendering video"); 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 picked = await pickEncoderConfig(w, h, fps); + if (!picked) { + restore(); + UI.toast("No supported video codec (VP9/VP8) found"); + 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); - }; + 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); - rec.start(250); - var finished = false; - captureVideoFrames(0); + var canvas = Engine.canvas(); + var usPerFrame = 1e6 / fps; - function finishVideo(prevSize, resumePlaying, blob, width, height, extension, seconds) { - if (finished) return; - finished = true; - Engine.setSize(prevSize[0], prevSize[1]); - Engine.setPlaying(resumePlaying); + 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; - 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); } } @@ -195,7 +237,7 @@ var Exporter = (function () { var data = await GIFEnc.encode({ frames: frames, width: w, height: h, fps: fps, - dither: P.gifDither, + dither: P.gifDither, loop: P.gifLoop, onProgress: function (frac, detail) { setProgress(0.4 + 0.6 * frac, "encoding \u00b7 " + detail); }, isCancelled: function () { return cancelled; } }); diff --git a/js/gifenc.js b/js/gifenc.js index b6a415b..37fbd2d 100644 --- a/js/gifenc.js +++ b/js/gifenc.js @@ -186,10 +186,12 @@ var GIFEnc = (function () { out.byte(col[0]); out.byte(col[1]); out.byte(col[2]); } - /* NETSCAPE loop forever */ - out.byte(0x21); out.byte(0xFF); out.byte(11); - out.string("NETSCAPE2.0"); - out.byte(3); out.byte(1); out.short(0); out.byte(0); + /* NETSCAPE loop extension: 0 = forever. Omitted entirely -> play once. */ + if (opts.loop !== false) { + out.byte(0x21); out.byte(0xFF); out.byte(11); + out.string("NETSCAPE2.0"); + out.byte(3); out.byte(1); out.short(0); out.byte(0); + } var delay = Math.max(2, Math.round(100 / fps)); var indices = new Uint8Array(w * h); diff --git a/js/main.js b/js/main.js index 210b242..74cf679 100644 --- a/js/main.js +++ b/js/main.js @@ -36,8 +36,8 @@ var P = { grain: 0.05, cell: 90, lines: 56, ca: 0.25, vig: 0.35, soft: 0.95, travel: 0.7, loop: 5, lockStyle: false, - imgRes: "2160", vidRes: "1080", vidLoops: 2, - gifW: "640", gifFps: 25, gifDither: true, + imgRes: "2160", vidRes: "1080", vidFps: "30", vidLen: "l2", + gifW: "640", gifFps: 25, gifDither: true, gifLoop: true, aspect: "16:9" }; @@ -222,10 +222,15 @@ function buildRail() { var sExp = UI.section(rail, "Export", null); reg(UI.selectRow(sExp, { label: "Image size", options: [["1080", "1920 \u00d7 1080"], ["1440", "2560 \u00d7 1440"], ["2160", "3840 \u00d7 2160"]], get: get("imgRes"), set: set("imgRes") })); reg(UI.selectRow(sExp, { label: "Video size", options: [["720", "720p"], ["1080", "1080p"], ["1440", "1440p"]], get: get("vidRes"), set: set("vidRes") })); - reg(UI.selectRow(sExp, { label: "Video loops", options: [["1", "1 loop"], ["2", "2 loops"], ["3", "3 loops"], ["4", "4 loops"]], get: function () { return String(P.vidLoops); }, set: function (v) { P.vidLoops = parseInt(v, 10); } })); + reg(UI.selectRow(sExp, { label: "Video fps", options: [["24", "24 fps"], ["30", "30 fps"], ["60", "60 fps"]], get: get("vidFps"), set: set("vidFps") })); + reg(UI.selectRow(sExp, { label: "Video length", options: [ + ["l1", "1 loop"], ["l2", "2 loops"], ["l3", "3 loops"], ["l4", "4 loops"], ["l6", "6 loops"], ["l8", "8 loops"], + ["s5", "5 seconds"], ["s10", "10 seconds"], ["s15", "15 seconds"], ["s30", "30 seconds"], ["s60", "60 seconds"] + ], get: get("vidLen"), set: set("vidLen") })); reg(UI.selectRow(sExp, { label: "GIF width", options: [["360", "360 px"], ["480", "480 px"], ["640", "640 px"], ["800", "800 px"]], get: get("gifW"), set: set("gifW") })); reg(UI.selectRow(sExp, { label: "GIF fps", options: [["15", "15 fps"], ["20", "20 fps"], ["25", "25 fps"], ["30", "30 fps"]], get: function () { return String(P.gifFps); }, set: function (v) { P.gifFps = parseInt(v, 10); } })); reg(UI.toggleRow(sExp, { label: "GIF dithering", get: get("gifDither"), set: set("gifDither") })); + reg(UI.toggleRow(sExp, { label: "GIF loop forever", get: get("gifLoop"), set: set("gifLoop") })); var grid = UI.el("div", "export-grid", sExp); UI.exportButton(grid, "Save image", "PNG", diff --git a/js/webmmux.js b/js/webmmux.js new file mode 100644 index 0000000..ff81965 --- /dev/null +++ b/js/webmmux.js @@ -0,0 +1,127 @@ +/* Minimal dependency-free WebM (Matroska) muxer for WebCodecs output. + Takes encoded VP8/VP9 chunks and produces a playable .webm file. */ + +var WebMMux = (function () { + + function concat(arrays) { + var total = 0; + for (var i = 0; i < arrays.length; i++) total += arrays[i].length; + var out = new Uint8Array(total); + var o = 0; + for (var j = 0; j < arrays.length; j++) { out.set(arrays[j], o); o += arrays[j].length; } + return out; + } + + /* EBML element IDs are written verbatim (big-endian) */ + function idBytes(id) { + var bytes = []; + while (id > 0) { bytes.unshift(id & 0xff); id = Math.floor(id / 256); } + return new Uint8Array(bytes); + } + + /* EBML data-size vint */ + function sizeBytes(n) { + if (n < 0x7F) return new Uint8Array([0x80 | n]); + if (n < 0x3FFF) return new Uint8Array([0x40 | (n >> 8), n & 0xff]); + if (n < 0x1FFFFF) return new Uint8Array([0x20 | (n >> 16), (n >> 8) & 0xff, n & 0xff]); + if (n < 0x0FFFFFFF) return new Uint8Array([0x10 | (n >> 24), (n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]); + /* up to ~1TB, plenty for any export */ + var hi = Math.floor(n / 0x100000000); + return new Uint8Array([0x08 | hi, (n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]); + } + + function el(id, payload) { + return concat([idBytes(id), sizeBytes(payload.length), payload]); + } + + function uintPayload(n) { + var bytes = []; + do { bytes.unshift(n & 0xff); n = Math.floor(n / 256); } while (n > 0); + return new Uint8Array(bytes); + } + + function strPayload(s) { + var out = new Uint8Array(s.length); + for (var i = 0; i < s.length; i++) out[i] = s.charCodeAt(i) & 0x7f; + return out; + } + + function float8Payload(d) { + var buf = new ArrayBuffer(8); + new DataView(buf).setFloat64(0, d, false); + return new Uint8Array(buf); + } + + /* frames: [{ data: Uint8Array, timestampMs: number, key: boolean }] */ + function mux(opts) { + var codecId = opts.codecId; // "V_VP8" | "V_VP9" + var w = opts.width, h = opts.height; + var durationMs = opts.durationMs; + var frames = opts.frames; + + var header = el(0x1A45DFA3, concat([ + el(0x4286, uintPayload(1)), // EBMLVersion + el(0x42F7, uintPayload(1)), // EBMLReadVersion + el(0x42F2, uintPayload(4)), // EBMLMaxIDLength + el(0x42F3, uintPayload(8)), // EBMLMaxSizeLength + el(0x4282, strPayload("webm")), // DocType + el(0x4287, uintPayload(2)), // DocTypeVersion + el(0x4285, uintPayload(2)) // DocTypeReadVersion + ])); + + var info = el(0x1549A966, concat([ + el(0x2AD7B1, uintPayload(1000000)), // TimecodeScale: 1ms + el(0x4489, float8Payload(durationMs)), // Duration + el(0x4D80, strPayload("lumen")), // MuxingApp + el(0x5741, strPayload("lumen")) // WritingApp + ])); + + var tracks = el(0x1654AE6B, el(0xAE, concat([ + el(0xD7, uintPayload(1)), // TrackNumber + el(0x73C5, uintPayload(1)), // TrackUID + el(0x83, uintPayload(1)), // TrackType: video + el(0x9C, uintPayload(0)), // FlagLacing + el(0x86, strPayload(codecId)), // CodecID + el(0xE0, concat([ // Video + el(0xB0, uintPayload(w)), // PixelWidth + el(0xBA, uintPayload(h)) // PixelHeight + ])) + ]))); + + /* clusters: start a new one every second (relative timecode is int16 ms) */ + var clusters = []; + var blocks = []; + var clusterTc = 0; + + function flushCluster() { + if (!blocks.length) return; + clusters.push(el(0x1F43B675, concat( + [el(0xE7, uintPayload(clusterTc))].concat(blocks) + ))); + blocks = []; + } + + for (var i = 0; i < frames.length; i++) { + var f = frames[i]; + var tc = Math.round(f.timestampMs); + if (!blocks.length) clusterTc = tc; + else if (tc - clusterTc > 1000 || (f.key && tc !== clusterTc)) { + flushCluster(); + clusterTc = tc; + } + var rel = tc - clusterTc; + var head = new Uint8Array(4); + head[0] = 0x81; // track 1 (vint) + head[1] = (rel >> 8) & 0xff; + head[2] = rel & 0xff; + head[3] = f.key ? 0x80 : 0x00; // flags + blocks.push(el(0xA3, concat([head, f.data]))); // SimpleBlock + } + flushCluster(); + + var segment = el(0x18538067, concat([info, tracks].concat(clusters))); + return concat([header, segment]); + } + + return { mux: mux }; +})();