Files
lumenshaders/js/exporter.js
T
svefnz d3471018b4 完整汉化:将所有用户可见文本翻译为简体中文
- index.html: 页面标题、meta 标签、按钮文字/提示、状态栏
- docs.html: 完整文档页面 9 个章节
- js/main.js: 模式名称、分区标题、滑块标签、toast 消息、按钮文字
- js/ui.js: 新种子按钮、随机化提示
- js/modals.js: 导出对话框标题/选项/标签、套装生成器文本
- js/exporter.js: 渲染进度文本、完成/错误 toast 消息
- js/palettes.js: 16 种调色板名称
- README.md: 项目说明全文
2026-06-16 19:58:21 +08:00

266 lines
7.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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 = "准备中";
$("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"));
FX.celebrate("已保存 " + 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("此浏览器不支持 WebCodecs,请使用最新版 Chrome、Edge 或 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("渲染视频中");
Engine.suspend();
Engine.setPlaying(false);
Engine.setSize(w, h);
var picked = await pickEncoderConfig(w, h, fps);
if (!picked) {
restore();
UI.toast("未找到支持的视频编码器 (VP9/VP8)");
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,
"帧 " + (f + 1) + "/" + nFrames + " \u00b7 " + w + "\u00d7" + h + " @ " + 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, "完成编码");
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("视频编码失败" + (encError && encError.message ? "" + encError.message : ""));
return;
}
setProgress(0.97, "写入 WebM 容器");
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"));
FX.celebrate("已保存 " + totalSec.toFixed(1) + "s 视频 \u00b7 " + w + "\u00d7" + h + " @ " + 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("渲染 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, "捕获 " + (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, "编码中 \u00b7 " + detail); },
isCancelled: function () { return cancelled; }
});
hideOverlay();
busy = false;
if (data && !cancelled) {
download(new Blob([data], { type: "image/gif" }), stamp(P, "gif"));
FX.celebrate("已保存 " + nFrames + " 帧循环 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; }
};
})();