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: 项目说明全文
266 lines
7.9 KiB
JavaScript
266 lines
7.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 = "准备中";
|
||
$("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; }
|
||
};
|
||
})();
|