Major update: synth styles, gradient sets, export dialogs, glass UI, docs
- Synth styles: blend any two renderers with five mix operations into brand-new generated looks, savable to localStorage as style chips - Share codes bumped to v2 (synth fields), v1 codes still decode - Gradient set generator: 4-12 seed variations with preview grid and ZIP batch download via a dependency-free store-only ZIP writer - Export buttons now open dialogs with live preview and settings - UI redesign: glassy translucent surfaces, rounder geometry, staggered intro animation, hover micro-interactions, refined scrollbars - Docs page, generated favicon/touch icons and OG image, meta tags - Skip chromatic aberration during synth blends to keep the D3D shader linker under its inlining limit Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+7
-1
@@ -17,7 +17,8 @@ var Engine = (function () {
|
||||
"u_scale", "u_complex", "u_warp", "u_flow", "u_stretch",
|
||||
"u_light", "u_gloss", "u_lightAngle", "u_irid", "u_glow",
|
||||
"u_grain", "u_cell", "u_lines", "u_ca", "u_vig", "u_soft",
|
||||
"u_travel"
|
||||
"u_travel",
|
||||
"u_synth", "u_modeB", "u_mixOp", "u_blend"
|
||||
];
|
||||
|
||||
function compile(type, src) {
|
||||
@@ -106,6 +107,11 @@ var Engine = (function () {
|
||||
gl.uniform1f(uniforms.u_soft, P.soft);
|
||||
|
||||
gl.uniform1f(uniforms.u_travel, P.travel);
|
||||
|
||||
gl.uniform1i(uniforms.u_synth, P.synthOn ? 1 : 0);
|
||||
gl.uniform1i(uniforms.u_modeB, P.modeB | 0);
|
||||
gl.uniform1i(uniforms.u_mixOp, P.mixOp | 0);
|
||||
gl.uniform1f(uniforms.u_blend, P.blend);
|
||||
}
|
||||
|
||||
function renderAt(phase) {
|
||||
|
||||
+1
-1
@@ -91,7 +91,7 @@ var Exporter = (function () {
|
||||
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");
|
||||
UI.toast("This browser has no WebCodecs support, use a current Chrome, Edge or Firefox");
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
|
||||
+146
-28
@@ -36,6 +36,7 @@ var P = {
|
||||
light: 1.175, gloss: 44, lightAngle: 235, irid: 0.012, glow: 0.471,
|
||||
grain: 0.024, cell: 113, lines: 67, ca: 0.018, vig: 0.079, soft: 1.14,
|
||||
travel: 0.72, loop: 7.5,
|
||||
synthOn: false, modeB: 2, mixOp: 0, blend: 0.6,
|
||||
lockStyle: false,
|
||||
imgRes: "2160", vidRes: "1080", vidFps: "30", vidLen: "l2",
|
||||
gifW: "640", gifFps: 25, gifDither: true, gifLoop: true,
|
||||
@@ -134,14 +135,65 @@ function randomizeKeys(keys) {
|
||||
|
||||
function newSeed() { P.seed = Math.floor(rnd() * 10000); }
|
||||
|
||||
/* pairs that blend into something coherent instead of mud */
|
||||
var SYNTH_PAIRS = [
|
||||
[0, 2], [0, 3], [0, 5], [1, 2], [1, 4], [2, 5], [2, 6], [3, 4],
|
||||
[3, 5], [4, 5], [4, 6], [2, 7], [3, 8], [0, 8], [1, 5], [4, 8]
|
||||
];
|
||||
|
||||
function generateSynthStyle() {
|
||||
var pair = SYNTH_PAIRS[Math.floor(rnd() * SYNTH_PAIRS.length)];
|
||||
var flip = rnd() < 0.5;
|
||||
P.mode = flip ? pair[1] : pair[0];
|
||||
P.modeB = flip ? pair[0] : pair[1];
|
||||
P.mixOp = Math.floor(rnd() * 5);
|
||||
P.blend = 0.4 + rnd() * 0.55;
|
||||
P.synthOn = true;
|
||||
}
|
||||
|
||||
function synthName() {
|
||||
return "SYN " + MODES[P.mode].name.slice(0, 3).toUpperCase() + "+" +
|
||||
MODES[P.modeB].name.slice(0, 3).toUpperCase();
|
||||
}
|
||||
|
||||
function randomizeAll() {
|
||||
if (!P.lockStyle) P.mode = Math.floor(rnd() * MODES.length);
|
||||
if (!P.lockStyle) {
|
||||
if (rnd() < 0.38) {
|
||||
generateSynthStyle();
|
||||
} else {
|
||||
P.synthOn = false;
|
||||
P.mode = Math.floor(rnd() * MODES.length);
|
||||
}
|
||||
}
|
||||
randomizePalette();
|
||||
randomizeKeys(FORM_KEYS.concat(LIGHT_KEYS, TEXTURE_KEYS, MOTION_KEYS));
|
||||
newSeed();
|
||||
refreshAll();
|
||||
}
|
||||
|
||||
/* ---------------- saved styles (localStorage) ---------------- */
|
||||
|
||||
var STYLES_KEY = "lumen-styles-v1";
|
||||
|
||||
function loadSavedStyles() {
|
||||
try { return JSON.parse(localStorage.getItem(STYLES_KEY)) || []; }
|
||||
catch (e) { return []; }
|
||||
}
|
||||
function persistSavedStyles(list) {
|
||||
try { localStorage.setItem(STYLES_KEY, JSON.stringify(list)); } catch (e) {}
|
||||
}
|
||||
function saveCurrentStyle() {
|
||||
var list = loadSavedStyles();
|
||||
var base = P.synthOn ? synthName() : MODES[P.mode].name;
|
||||
var name = base + " " + String(Math.round(P.seed)).padStart(4, "0");
|
||||
list.unshift({ name: name, code: encodeDesign(), ts: Date.now() });
|
||||
if (list.length > 24) list.length = 24;
|
||||
persistSavedStyles(list);
|
||||
renderSavedStyles();
|
||||
UI.toast("Style saved: " + name);
|
||||
}
|
||||
var renderSavedStyles = function () {};
|
||||
|
||||
/* ---------------- design codes (share) ---------------- */
|
||||
|
||||
var SHARE_NUMS = [
|
||||
@@ -153,10 +205,11 @@ var SHARE_NUMS = [
|
||||
];
|
||||
|
||||
function encodeDesign() {
|
||||
var arr = [1, P.mode, Math.round(P.seed),
|
||||
var arr = [2, P.mode, Math.round(P.seed),
|
||||
P.c1.slice(1), P.c2.slice(1), P.c3.slice(1), P.c4.slice(1), P.bg.slice(1),
|
||||
P.aspect];
|
||||
SHARE_NUMS.forEach(function (k) { arr.push(Math.round(P[k] * 1000) / 1000); });
|
||||
arr.push(P.synthOn ? 1 : 0, P.modeB | 0, P.mixOp | 0, Math.round(P.blend * 1000) / 1000);
|
||||
var b64 = btoa(JSON.stringify(arr))
|
||||
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
return "LMN1." + b64;
|
||||
@@ -169,7 +222,10 @@ function decodeDesign(code) {
|
||||
var b64 = code.slice(5).replace(/-/g, "+").replace(/_/g, "/");
|
||||
while (b64.length % 4) b64 += "=";
|
||||
var arr = JSON.parse(atob(b64));
|
||||
if (!Array.isArray(arr) || arr[0] !== 1 || arr.length !== 9 + SHARE_NUMS.length) return false;
|
||||
var baseLen = 9 + SHARE_NUMS.length;
|
||||
var isV1 = Array.isArray(arr) && arr[0] === 1 && arr.length === baseLen;
|
||||
var isV2 = Array.isArray(arr) && arr[0] === 2 && arr.length === baseLen + 4;
|
||||
if (!isV1 && !isV2) return false;
|
||||
|
||||
var hexOk = function (h) { return /^[0-9a-fA-F]{6}$/.test(h); };
|
||||
if (![arr[3], arr[4], arr[5], arr[6], arr[7]].every(hexOk)) return false;
|
||||
@@ -183,6 +239,15 @@ function decodeDesign(code) {
|
||||
var v = Number(arr[9 + i]);
|
||||
if (isFinite(v)) P[k] = v;
|
||||
});
|
||||
if (isV2) {
|
||||
P.synthOn = !!arr[baseLen];
|
||||
P.modeB = Math.min(Math.max(Math.round(arr[baseLen + 1]) || 0, 0), MODES.length - 1);
|
||||
P.mixOp = Math.min(Math.max(Math.round(arr[baseLen + 2]) || 0, 0), 4);
|
||||
var bl = Number(arr[baseLen + 3]);
|
||||
P.blend = isFinite(bl) ? Math.min(Math.max(bl, 0), 1) : 0.6;
|
||||
} else {
|
||||
P.synthOn = false;
|
||||
}
|
||||
activePreset = -1;
|
||||
setPresetActive(-1);
|
||||
refreshAll();
|
||||
@@ -226,10 +291,56 @@ function buildRail() {
|
||||
|
||||
/* STYLE */
|
||||
var sStyle = UI.section(rail, "Style", function () {
|
||||
P.synthOn = false;
|
||||
P.mode = Math.floor(rnd() * MODES.length);
|
||||
refreshAll();
|
||||
});
|
||||
reg(UI.modeGrid(sStyle, MODES, get("mode"), function (v) { P.mode = v; updateMeta(); }));
|
||||
reg(UI.modeGrid(sStyle, MODES, function () { return P.synthOn ? -1 : P.mode; },
|
||||
function (v) { P.mode = v; P.synthOn = false; refreshAll(); }));
|
||||
|
||||
/* synth: generated styles */
|
||||
var synthRow = UI.el("div", "share-row synth-row", sStyle);
|
||||
var btnSynth = UI.el("button", "mini-btn", synthRow);
|
||||
btnSynth.innerHTML = '<svg viewBox="0 0 16 16"><path d="M2 8 C4 3 7 13 9 8 C11 3 13 11 14 8" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="8" cy="2.5" r="1.2" fill="currentColor"/></svg>New synth style';
|
||||
btnSynth.addEventListener("click", function () {
|
||||
generateSynthStyle();
|
||||
newSeed();
|
||||
refreshAll();
|
||||
UI.toast("Generated " + synthName());
|
||||
});
|
||||
var btnSave = UI.el("button", "mini-btn", synthRow);
|
||||
btnSave.innerHTML = '<svg viewBox="0 0 16 16"><path d="M3 2 H11 L14 5 V14 H3 Z" fill="none" stroke="currentColor" stroke-width="1.5"/><rect x="5.5" y="9" width="5" height="4" fill="currentColor"/></svg>Save style';
|
||||
btnSave.addEventListener("click", saveCurrentStyle);
|
||||
|
||||
var synthCtl = UI.el("div", "synth-ctl", sStyle);
|
||||
reg(UI.slider(synthCtl, { label: "Synth blend", min: 0, max: 1, step: 0.01, fmt: fmt2,
|
||||
get: get("blend"), set: function (v) { P.blend = v; updateMeta(); } }));
|
||||
reg(function () { synthCtl.style.display = P.synthOn ? "" : "none"; });
|
||||
|
||||
var savedWrap = UI.el("div", "saved-styles", sStyle);
|
||||
renderSavedStyles = function () {
|
||||
savedWrap.innerHTML = "";
|
||||
var list = loadSavedStyles();
|
||||
list.forEach(function (st, i) {
|
||||
var chip = UI.el("button", "saved-chip", savedWrap);
|
||||
var label = UI.el("span", null, chip);
|
||||
label.textContent = st.name;
|
||||
var del = UI.el("span", "saved-del", chip);
|
||||
del.innerHTML = "×";
|
||||
del.addEventListener("click", function (ev) {
|
||||
ev.stopPropagation();
|
||||
var l = loadSavedStyles();
|
||||
l.splice(i, 1);
|
||||
persistSavedStyles(l);
|
||||
renderSavedStyles();
|
||||
});
|
||||
chip.addEventListener("click", function () {
|
||||
if (decodeDesign(st.code)) UI.toast("Loaded " + st.name);
|
||||
});
|
||||
});
|
||||
};
|
||||
renderSavedStyles();
|
||||
|
||||
reg(UI.lockRow(sStyle, { label: "Keep style when randomizing", get: get("lockStyle"), set: set("lockStyle") }));
|
||||
|
||||
/* COLOR */
|
||||
@@ -286,7 +397,7 @@ function buildRail() {
|
||||
var btnCopyCode = UI.el("button", "mini-btn", shareRow);
|
||||
btnCopyCode.textContent = "Copy design code";
|
||||
btnCopyCode.addEventListener("click", function () {
|
||||
copyText(encodeDesign(), "Design code copied \u2014 paste it anywhere");
|
||||
copyText(encodeDesign(), "Design code copied, paste it anywhere");
|
||||
});
|
||||
var btnCopyLink = UI.el("button", "mini-btn", shareRow);
|
||||
btnCopyLink.textContent = "Copy link";
|
||||
@@ -315,36 +426,28 @@ function buildRail() {
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
/* EXPORT */
|
||||
/* EXPORT: buttons open a dialog with preview and settings */
|
||||
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 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",
|
||||
UI.exportButton(grid, "Image", "PNG",
|
||||
'<svg viewBox="0 0 16 16"><rect x="1.5" y="1.5" width="13" height="13" rx="2" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="5.5" cy="5.5" r="1.5" fill="currentColor"/><path d="M2 12 L6 8 L9 11 L11.5 8.5 L14 11" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>',
|
||||
function () { Exporter.exportPNG(P, ASPECTS[P.aspect]); });
|
||||
UI.exportButton(grid, "Record video", "WEBM",
|
||||
function () { Modals.openExport("png"); });
|
||||
UI.exportButton(grid, "Video", "WEBM",
|
||||
'<svg viewBox="0 0 16 16"><rect x="1.5" y="3.5" width="9" height="9" rx="2" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M10.5 7 L14.5 4.5 V11.5 L10.5 9" fill="currentColor"/></svg>',
|
||||
function () { Exporter.exportVideo(P, ASPECTS[P.aspect]); });
|
||||
UI.exportButton(grid, "Render loop GIF", "GIF",
|
||||
function () { Modals.openExport("video"); });
|
||||
UI.exportButton(grid, "Looping GIF", "GIF",
|
||||
'<svg viewBox="0 0 16 16"><path d="M13.5 8 a5.5 5.5 0 1 1 -1.6 -3.9" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M13.8 1.6 V4.4 H11" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>',
|
||||
function () { Exporter.exportGIF(P, ASPECTS[P.aspect]); });
|
||||
function () { Modals.openExport("gif"); });
|
||||
UI.exportButton(grid, "Gradient set", "ZIP",
|
||||
'<svg viewBox="0 0 16 16"><rect x="1.5" y="1.5" width="5.5" height="5.5" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.4"/><rect x="9" y="1.5" width="5.5" height="5.5" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.4"/><rect x="1.5" y="9" width="5.5" height="5.5" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.4"/><rect x="9" y="9" width="5.5" height="5.5" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.4"/></svg>',
|
||||
function () { Modals.openSetGenerator(); });
|
||||
}
|
||||
|
||||
/* ---------------- stage / meta ---------------- */
|
||||
|
||||
function updateMeta() {
|
||||
document.getElementById("meta-mode").textContent = MODES[P.mode].full;
|
||||
document.getElementById("meta-mode").textContent =
|
||||
P.synthOn ? synthName() : MODES[P.mode].full;
|
||||
document.getElementById("meta-seed").textContent = "seed " + String(Math.round(P.seed)).padStart(4, "0");
|
||||
document.getElementById("meta-loop").textContent = P.loop.toFixed(1) + "s loop";
|
||||
var s = Engine.size();
|
||||
@@ -414,6 +517,20 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
}
|
||||
}
|
||||
|
||||
/* intro: elements pop in big and settle into place, one after another */
|
||||
(function intro() {
|
||||
var items = [];
|
||||
document.querySelectorAll(".topbar > *").forEach(function (n) { items.push(n); });
|
||||
items.push(document.querySelector(".canvas-frame"));
|
||||
items.push(document.querySelector(".stage-meta"));
|
||||
document.querySelectorAll(".rail-section").forEach(function (n) { items.push(n); });
|
||||
items.forEach(function (n, i) {
|
||||
if (n) n.style.setProperty("--intro-d", (i * 65) + "ms");
|
||||
});
|
||||
document.body.classList.add("intro");
|
||||
setTimeout(function () { document.body.classList.remove("intro"); }, items.length * 65 + 1100);
|
||||
})();
|
||||
|
||||
Engine.onFps(function (fps) {
|
||||
document.getElementById("meta-fps").textContent = fps + " fps";
|
||||
});
|
||||
@@ -422,9 +539,10 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
document.getElementById("btn-play").addEventListener("click", function () {
|
||||
setPlayingUI(!Engine.isPlaying());
|
||||
});
|
||||
document.getElementById("btn-export-png").addEventListener("click", function () { Exporter.exportPNG(P, ASPECTS[P.aspect]); });
|
||||
document.getElementById("btn-export-video").addEventListener("click", function () { Exporter.exportVideo(P, ASPECTS[P.aspect]); });
|
||||
document.getElementById("btn-export-gif").addEventListener("click", function () { Exporter.exportGIF(P, ASPECTS[P.aspect]); });
|
||||
document.getElementById("btn-export-png").addEventListener("click", function () { Modals.openExport("png"); });
|
||||
document.getElementById("btn-export-video").addEventListener("click", function () { Modals.openExport("video"); });
|
||||
document.getElementById("btn-export-gif").addEventListener("click", function () { Modals.openExport("gif"); });
|
||||
document.getElementById("btn-set").addEventListener("click", function () { Modals.openSetGenerator(); });
|
||||
|
||||
document.addEventListener("keydown", function (e) {
|
||||
var tag = (e.target.tagName || "").toLowerCase();
|
||||
|
||||
+261
@@ -0,0 +1,261 @@
|
||||
/* Modal system: export dialog with preview and settings, and the set
|
||||
generator that produces N consistent variations of the current design. */
|
||||
|
||||
var Modals = (function () {
|
||||
|
||||
function el(tag, cls, parent) {
|
||||
var e = document.createElement(tag);
|
||||
if (cls) e.className = cls;
|
||||
if (parent) parent.appendChild(e);
|
||||
return e;
|
||||
}
|
||||
|
||||
var root = null;
|
||||
|
||||
function open(title, subtitle) {
|
||||
close();
|
||||
root = el("div", "modal-backdrop", document.body);
|
||||
var card = el("div", "modal-card", root);
|
||||
var head = el("div", "modal-head", card);
|
||||
var tWrap = el("div", null, head);
|
||||
var t = el("div", "modal-title", tWrap);
|
||||
t.textContent = title;
|
||||
if (subtitle) {
|
||||
var s = el("div", "modal-sub mono", tWrap);
|
||||
s.textContent = subtitle;
|
||||
}
|
||||
var x = el("button", "modal-close", head);
|
||||
x.innerHTML = '<svg viewBox="0 0 12 12"><path d="M2 2 L10 10 M10 2 L2 10" stroke="currentColor" stroke-width="1.6" fill="none"/></svg>';
|
||||
x.addEventListener("click", close);
|
||||
root.addEventListener("click", function (e) { if (e.target === root) close(); });
|
||||
var body = el("div", "modal-body", card);
|
||||
return body;
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (root) { root.remove(); root = null; }
|
||||
}
|
||||
|
||||
function snapshotInto(canvas2d, aspect) {
|
||||
var src = Engine.canvas();
|
||||
var ctx = canvas2d.getContext("2d");
|
||||
ctx.fillStyle = "#000";
|
||||
ctx.fillRect(0, 0, canvas2d.width, canvas2d.height);
|
||||
ctx.drawImage(src, 0, 0, canvas2d.width, canvas2d.height);
|
||||
}
|
||||
|
||||
/* ---------- export dialog ---------- */
|
||||
|
||||
function selectField(parent, label, options, value, onChange) {
|
||||
var row = el("div", "field-row", parent);
|
||||
var lab = el("span", "ctl-label", row);
|
||||
lab.textContent = label;
|
||||
var sel = el("select", null, row);
|
||||
options.forEach(function (o) {
|
||||
var op = el("option", null, sel);
|
||||
op.value = o[0]; op.textContent = o[1];
|
||||
});
|
||||
sel.value = value;
|
||||
sel.addEventListener("change", function () { onChange(sel.value); });
|
||||
return sel;
|
||||
}
|
||||
|
||||
function toggleField(parent, label, value, onChange) {
|
||||
var row = el("div", "toggle-row", parent);
|
||||
var lab = el("span", "ctl-label", row);
|
||||
lab.textContent = label;
|
||||
var tg = el("button", "toggle" + (value ? " on" : ""), row);
|
||||
tg.addEventListener("click", function () {
|
||||
var on = !tg.classList.contains("on");
|
||||
tg.classList.toggle("on", on);
|
||||
onChange(on);
|
||||
});
|
||||
}
|
||||
|
||||
function openExport(kind) {
|
||||
var titles = { png: "Export image", video: "Export video", gif: "Export GIF" };
|
||||
var body = open(titles[kind], MODES[P.mode].full + " \u00b7 seed " + Math.round(P.seed));
|
||||
|
||||
/* live preview */
|
||||
var prevWrap = el("div", "modal-preview", body);
|
||||
var pv = el("canvas", null, prevWrap);
|
||||
var ar = ASPECTS[P.aspect];
|
||||
pv.width = 480; pv.height = Math.round(480 / ar);
|
||||
snapshotInto(pv, ar);
|
||||
var pvTimer = setInterval(function () {
|
||||
if (!document.body.contains(pv)) { clearInterval(pvTimer); return; }
|
||||
snapshotInto(pv, ar);
|
||||
}, 120);
|
||||
var pvMeta = el("div", "modal-preview-meta mono", prevWrap);
|
||||
|
||||
var form = el("div", "modal-form", body);
|
||||
|
||||
function metaText() {
|
||||
if (kind === "png") {
|
||||
var h = parseInt(P.imgRes, 10);
|
||||
return Math.round(h * ar) + " \u00d7 " + h + " px";
|
||||
}
|
||||
if (kind === "video") {
|
||||
var vh = parseInt(P.vidRes, 10);
|
||||
var fps = parseInt(P.vidFps, 10);
|
||||
var sec = (String(P.vidLen).charAt(0) === "s")
|
||||
? parseInt(String(P.vidLen).slice(1), 10)
|
||||
: P.loop * parseInt(String(P.vidLen).slice(1), 10);
|
||||
return Math.round(vh * ar) + " \u00d7 " + vh + " \u00b7 " + fps + " fps \u00b7 " +
|
||||
sec.toFixed(1) + "s \u00b7 " + Math.round(sec * fps) + " frames";
|
||||
}
|
||||
var gw = parseInt(P.gifW, 10);
|
||||
return gw + " \u00d7 " + Math.round(gw / ar) + " \u00b7 " + P.gifFps + " fps \u00b7 " +
|
||||
Math.round(P.loop * P.gifFps) + " frames \u00b7 " + P.loop.toFixed(1) + "s loop";
|
||||
}
|
||||
function refreshMeta() { pvMeta.textContent = metaText(); }
|
||||
refreshMeta();
|
||||
|
||||
if (kind === "png") {
|
||||
selectField(form, "Resolution", [["1080", "1920 \u00d7 1080"], ["1440", "2560 \u00d7 1440"], ["2160", "3840 \u00d7 2160"]],
|
||||
P.imgRes, function (v) { P.imgRes = v; refreshMeta(); });
|
||||
} else if (kind === "video") {
|
||||
selectField(form, "Resolution", [["720", "720p"], ["1080", "1080p"], ["1440", "1440p"]],
|
||||
P.vidRes, function (v) { P.vidRes = v; refreshMeta(); });
|
||||
selectField(form, "Frame rate", [["24", "24 fps"], ["30", "30 fps"], ["60", "60 fps"]],
|
||||
P.vidFps, function (v) { P.vidFps = v; refreshMeta(); });
|
||||
selectField(form, "Length", [
|
||||
["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"]
|
||||
], P.vidLen, function (v) { P.vidLen = v; refreshMeta(); });
|
||||
} else {
|
||||
selectField(form, "Width", [["360", "360 px"], ["480", "480 px"], ["640", "640 px"], ["800", "800 px"]],
|
||||
P.gifW, function (v) { P.gifW = v; refreshMeta(); });
|
||||
selectField(form, "Frame rate", [["15", "15 fps"], ["20", "20 fps"], ["25", "25 fps"], ["30", "30 fps"]],
|
||||
String(P.gifFps), function (v) { P.gifFps = parseInt(v, 10); refreshMeta(); });
|
||||
toggleField(form, "Dithering", P.gifDither, function (v) { P.gifDither = v; });
|
||||
toggleField(form, "Loop forever", P.gifLoop, function (v) { P.gifLoop = v; });
|
||||
}
|
||||
|
||||
var actions = el("div", "modal-actions", body);
|
||||
var dl = el("button", "btn btn-primary modal-dl", actions);
|
||||
dl.innerHTML = '<svg viewBox="0 0 16 16"><path d="M8 2 V10 M4.5 7 L8 10.5 L11.5 7" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M3 13.5 H13" stroke="currentColor" stroke-width="1.6"/></svg>Download ' +
|
||||
(kind === "png" ? "PNG" : kind === "video" ? "WebM" : "GIF");
|
||||
dl.addEventListener("click", function () {
|
||||
close();
|
||||
var a = ASPECTS[P.aspect];
|
||||
if (kind === "png") Exporter.exportPNG(P, a);
|
||||
else if (kind === "video") Exporter.exportVideo(P, a);
|
||||
else Exporter.exportGIF(P, a);
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- set generator ---------- */
|
||||
|
||||
function setSeeds(base, n) {
|
||||
var seeds = [];
|
||||
for (var i = 0; i < n; i++) seeds.push((base + 73 + i * 911) % 10000);
|
||||
return seeds;
|
||||
}
|
||||
|
||||
async function openSetGenerator() {
|
||||
var body = open("Gradient set", "consistent variations of the current design");
|
||||
var info = el("div", "modal-note", body);
|
||||
info.textContent = "Same style, palette and settings with different seeds. Use a set for hero, cards and section backgrounds that visually belong together.";
|
||||
|
||||
var form = el("div", "modal-form", body);
|
||||
var state = { count: 6, res: "1080" };
|
||||
selectField(form, "Variations", [["4", "4"], ["6", "6"], ["8", "8"], ["12", "12"]],
|
||||
"6", function (v) { state.count = parseInt(v, 10); build(); });
|
||||
selectField(form, "PNG size", [["720", "1280 \u00d7 720"], ["1080", "1920 \u00d7 1080"], ["2160", "3840 \u00d7 2160"]],
|
||||
"1080", function (v) { state.res = v; });
|
||||
|
||||
var grid = el("div", "set-grid", body);
|
||||
var actions = el("div", "modal-actions", body);
|
||||
var dl = el("button", "btn btn-primary modal-dl", actions);
|
||||
dl.innerHTML = '<svg viewBox="0 0 16 16"><path d="M8 2 V10 M4.5 7 L8 10.5 L11.5 7" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M3 13.5 H13" stroke="currentColor" stroke-width="1.6"/></svg>Download set as ZIP';
|
||||
|
||||
var seeds = [];
|
||||
|
||||
async function build() {
|
||||
grid.innerHTML = "";
|
||||
seeds = setSeeds(Math.round(P.seed), state.count);
|
||||
var ar = ASPECTS[P.aspect];
|
||||
|
||||
var origSeed = P.seed;
|
||||
var prev = Engine.size();
|
||||
var wasPlaying = Engine.isPlaying();
|
||||
Engine.suspend();
|
||||
Engine.setPlaying(false);
|
||||
Engine.setSize(320, 2 * Math.round(320 / ar / 2));
|
||||
|
||||
for (var i = 0; i < seeds.length; i++) {
|
||||
P.seed = seeds[i];
|
||||
Engine.renderAt(0.3);
|
||||
var tile = el("button", "set-tile", grid);
|
||||
var c = el("canvas", null, tile);
|
||||
c.width = 320; c.height = Math.round(320 / ar);
|
||||
c.getContext("2d").drawImage(Engine.canvas(), 0, 0, c.width, c.height);
|
||||
var lab = el("span", "set-tile-label mono", tile);
|
||||
lab.textContent = "#" + String(seeds[i]).padStart(4, "0");
|
||||
(function (sd) {
|
||||
tile.addEventListener("click", function () {
|
||||
P.seed = sd;
|
||||
refreshAll();
|
||||
UI.toast("Applied seed " + sd);
|
||||
close();
|
||||
});
|
||||
})(seeds[i]);
|
||||
await new Promise(function (r) { setTimeout(r, 0); });
|
||||
}
|
||||
|
||||
P.seed = origSeed;
|
||||
Engine.setSize(prev[0], prev[1]);
|
||||
Engine.setPlaying(wasPlaying);
|
||||
Engine.resume();
|
||||
}
|
||||
|
||||
dl.addEventListener("click", async function () {
|
||||
dl.disabled = true;
|
||||
dl.textContent = "Rendering\u2026";
|
||||
var ar = ASPECTS[P.aspect];
|
||||
var h = parseInt(state.res, 10);
|
||||
var w = 2 * Math.round(h * ar / 2);
|
||||
|
||||
var origSeed = P.seed;
|
||||
var prev = Engine.size();
|
||||
var wasPlaying = Engine.isPlaying();
|
||||
Engine.suspend();
|
||||
Engine.setPlaying(false);
|
||||
Engine.setSize(w, h);
|
||||
|
||||
var entries = [];
|
||||
for (var i = 0; i < seeds.length; i++) {
|
||||
P.seed = seeds[i];
|
||||
Engine.renderAt(0.3);
|
||||
var blob = await new Promise(function (res) { Engine.canvas().toBlob(res, "image/png"); });
|
||||
if (blob) {
|
||||
entries.push({
|
||||
name: "lumen-set-" + String(i + 1).padStart(2, "0") + "-seed" + String(seeds[i]).padStart(4, "0") + ".png",
|
||||
data: new Uint8Array(await blob.arrayBuffer())
|
||||
});
|
||||
}
|
||||
dl.textContent = "Rendering " + (i + 1) + "/" + seeds.length + "\u2026";
|
||||
await new Promise(function (r) { setTimeout(r, 0); });
|
||||
}
|
||||
|
||||
P.seed = origSeed;
|
||||
Engine.setSize(prev[0], prev[1]);
|
||||
Engine.setPlaying(wasPlaying);
|
||||
Engine.resume();
|
||||
|
||||
var zip = ZipWriter.build(entries);
|
||||
var a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(zip);
|
||||
a.download = "lumen-set-" + MODES[P.mode].key + ".zip";
|
||||
a.click();
|
||||
setTimeout(function () { URL.revokeObjectURL(a.href); }, 4000);
|
||||
UI.toast("Set saved: " + entries.length + " PNGs (" + w + "\u00d7" + h + ")");
|
||||
close();
|
||||
});
|
||||
|
||||
await build();
|
||||
}
|
||||
|
||||
return { openExport: openExport, openSetGenerator: openSetGenerator, close: close };
|
||||
})();
|
||||
+48
-10
@@ -29,6 +29,12 @@ uniform float u_light, u_gloss, u_lightAngle, u_irid, u_glow;
|
||||
uniform float u_grain, u_cell, u_lines, u_ca, u_vig, u_soft;
|
||||
uniform float u_travel;
|
||||
|
||||
/* synth: procedural style combinator */
|
||||
uniform int u_synth; // 0 = single mode, 1 = blend two modes
|
||||
uniform int u_modeB;
|
||||
uniform int u_mixOp; // 0 noise mask, 1 screen, 2 multiply, 3 radial, 4 diagonal
|
||||
uniform float u_blend;
|
||||
|
||||
out vec4 fragColor;
|
||||
|
||||
#define TAU 6.28318530718
|
||||
@@ -404,23 +410,55 @@ vec3 sceneMosaic(vec2 uv){
|
||||
|
||||
/* ---------------- dispatch + post ---------------- */
|
||||
|
||||
vec3 scene(vec2 uv){
|
||||
if (u_mode == 0) return sceneChrome(uv);
|
||||
if (u_mode == 1) return sceneSilk(uv);
|
||||
if (u_mode == 2) return sceneBloom(uv);
|
||||
if (u_mode == 3) return sceneAura(uv);
|
||||
if (u_mode == 4) return sceneRays(uv);
|
||||
if (u_mode == 5) return sceneHalftone(uv);
|
||||
if (u_mode == 6) return sceneGlyphs(uv);
|
||||
if (u_mode == 7) return sceneReeded(uv);
|
||||
vec3 sceneFor(int m, vec2 uv){
|
||||
if (m == 0) return sceneChrome(uv);
|
||||
if (m == 1) return sceneSilk(uv);
|
||||
if (m == 2) return sceneBloom(uv);
|
||||
if (m == 3) return sceneAura(uv);
|
||||
if (m == 4) return sceneRays(uv);
|
||||
if (m == 5) return sceneHalftone(uv);
|
||||
if (m == 6) return sceneGlyphs(uv);
|
||||
if (m == 7) return sceneReeded(uv);
|
||||
return sceneMosaic(uv);
|
||||
}
|
||||
|
||||
vec3 scene(vec2 uv){
|
||||
vec3 a = sceneFor(u_mode, uv);
|
||||
if (u_synth == 0) return a;
|
||||
|
||||
vec3 b = sceneFor(u_modeB, uv);
|
||||
float asp = u_res.x/u_res.y;
|
||||
vec2 c = (uv - 0.5)*vec2(asp, 1.0);
|
||||
|
||||
if (u_mixOp == 1) { /* screen: layered light */
|
||||
return mix(a, 1.0 - (1.0 - a)*(1.0 - b), u_blend);
|
||||
}
|
||||
if (u_mixOp == 2) { /* multiply with lift */
|
||||
return mix(a, a*b*1.6 + a*0.12, u_blend);
|
||||
}
|
||||
if (u_mixOp == 3) { /* radial: B grows from center */
|
||||
float m = smoothstep(0.15, 0.85, length(c)*1.15);
|
||||
return mix(b, a, mix(1.0, m, u_blend));
|
||||
}
|
||||
if (u_mixOp == 4) { /* soft diagonal split */
|
||||
float ang = TAU*hash11(u_seed*0.091 + 5.0);
|
||||
float m = smoothstep(-0.45, 0.45, cos(ang)*c.x + sin(ang)*c.y);
|
||||
return mix(a, b, m*u_blend);
|
||||
}
|
||||
/* default: organic noise mask */
|
||||
float m = fbm(c*1.6 + SO()*0.7 + LT()*0.5);
|
||||
m = smoothstep(0.32, 0.68, m);
|
||||
return mix(a, b, m*u_blend);
|
||||
}
|
||||
|
||||
void main(){
|
||||
vec2 uv = gl_FragCoord.xy/u_res;
|
||||
vec3 col;
|
||||
|
||||
if (u_ca > 0.004){
|
||||
/* CA re-renders the scene per channel; with synth blending that would
|
||||
inline the full mode dispatch 6 times and break the D3D linker, so
|
||||
aberration only applies to single-mode renders */
|
||||
if (u_ca > 0.004 && u_synth == 0){
|
||||
vec2 off = (uv-0.5)*u_ca*0.016;
|
||||
col = vec3(scene(uv-off).r, scene(uv).g, scene(uv+off).b);
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/* Minimal store-only (no compression) ZIP writer for batch PNG export. */
|
||||
|
||||
var ZipWriter = (function () {
|
||||
|
||||
var CRC_TABLE = (function () {
|
||||
var t = new Uint32Array(256);
|
||||
for (var n = 0; n < 256; n++) {
|
||||
var c = n;
|
||||
for (var k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
|
||||
t[n] = c >>> 0;
|
||||
}
|
||||
return t;
|
||||
})();
|
||||
|
||||
function crc32(buf) {
|
||||
var c = 0xFFFFFFFF;
|
||||
for (var i = 0; i < buf.length; i++) {
|
||||
c = CRC_TABLE[(c ^ buf[i]) & 0xFF] ^ (c >>> 8);
|
||||
}
|
||||
return (c ^ 0xFFFFFFFF) >>> 0;
|
||||
}
|
||||
|
||||
function dosDateTime() {
|
||||
var d = new Date();
|
||||
var time = (d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1);
|
||||
var date = ((d.getFullYear() - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate();
|
||||
return { time: time, date: date };
|
||||
}
|
||||
|
||||
/* entries: [{ name: string, data: Uint8Array }] -> Blob */
|
||||
function build(entries) {
|
||||
var parts = [];
|
||||
var central = [];
|
||||
var offset = 0;
|
||||
var dt = dosDateTime();
|
||||
|
||||
entries.forEach(function (e) {
|
||||
var nameBytes = new TextEncoder().encode(e.name);
|
||||
var crc = crc32(e.data);
|
||||
var local = new Uint8Array(30 + nameBytes.length);
|
||||
var v = new DataView(local.buffer);
|
||||
v.setUint32(0, 0x04034b50, true);
|
||||
v.setUint16(4, 20, true); // version needed
|
||||
v.setUint16(6, 0x0800, true); // utf8 flag
|
||||
v.setUint16(8, 0, true); // store
|
||||
v.setUint16(10, dt.time, true);
|
||||
v.setUint16(12, dt.date, true);
|
||||
v.setUint32(14, crc, true);
|
||||
v.setUint32(18, e.data.length, true);
|
||||
v.setUint32(22, e.data.length, true);
|
||||
v.setUint16(26, nameBytes.length, true);
|
||||
v.setUint16(28, 0, true);
|
||||
local.set(nameBytes, 30);
|
||||
parts.push(local, e.data);
|
||||
|
||||
var cd = new Uint8Array(46 + nameBytes.length);
|
||||
var c = new DataView(cd.buffer);
|
||||
c.setUint32(0, 0x02014b50, true);
|
||||
c.setUint16(4, 20, true);
|
||||
c.setUint16(6, 20, true);
|
||||
c.setUint16(8, 0x0800, true);
|
||||
c.setUint16(10, 0, true);
|
||||
c.setUint16(12, dt.time, true);
|
||||
c.setUint16(14, dt.date, true);
|
||||
c.setUint32(16, crc, true);
|
||||
c.setUint32(20, e.data.length, true);
|
||||
c.setUint32(24, e.data.length, true);
|
||||
c.setUint16(28, nameBytes.length, true);
|
||||
c.setUint32(42, offset, true);
|
||||
cd.set(nameBytes, 46);
|
||||
central.push(cd);
|
||||
|
||||
offset += local.length + e.data.length;
|
||||
});
|
||||
|
||||
var cdSize = 0;
|
||||
central.forEach(function (c) { cdSize += c.length; });
|
||||
|
||||
var end = new Uint8Array(22);
|
||||
var ev = new DataView(end.buffer);
|
||||
ev.setUint32(0, 0x06054b50, true);
|
||||
ev.setUint16(8, entries.length, true);
|
||||
ev.setUint16(10, entries.length, true);
|
||||
ev.setUint32(12, cdSize, true);
|
||||
ev.setUint32(16, offset, true);
|
||||
|
||||
return new Blob(parts.concat(central, [end]), { type: "application/zip" });
|
||||
}
|
||||
|
||||
return { build: build };
|
||||
})();
|
||||
Reference in New Issue
Block a user