LUMEN: generative shader studio with 9 looping art modes and PNG/video/GIF export

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonxlnx
2026-06-10 00:31:31 +02:00
commit 10d8bd9902
14 changed files with 2403 additions and 0 deletions
+166
View File
@@ -0,0 +1,166 @@
/* WebGL2 engine: compiles the uber shader, owns the render loop,
exposes deterministic renderAt(phase) for exporters. */
var Engine = (function () {
var canvas, gl, program, uniforms = {};
var playing = true;
var loopT = 0; // seconds into current loop
var lastTick = 0;
var fps = 60, fpsAcc = 0, fpsN = 0, fpsCb = null;
var getParams = null; // injected: () => P
var UNIFORM_NAMES = [
"u_res", "u_phase", "u_seed", "u_mode",
"u_c1", "u_c2", "u_c3", "u_c4", "u_bg",
"u_hue", "u_sat", "u_exposure", "u_contrast",
"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"
];
function compile(type, src) {
var sh = gl.createShader(type);
gl.shaderSource(sh, src);
gl.compileShader(sh);
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
throw new Error("Shader compile error:\n" + gl.getShaderInfoLog(sh));
}
return sh;
}
function init(canvasEl, paramsGetter) {
canvas = canvasEl;
getParams = paramsGetter;
gl = canvas.getContext("webgl2", {
antialias: false,
preserveDrawingBuffer: true,
powerPreference: "high-performance"
});
if (!gl) throw new Error("WebGL2 not available");
program = gl.createProgram();
gl.attachShader(program, compile(gl.VERTEX_SHADER, VERT_SRC));
gl.attachShader(program, compile(gl.FRAGMENT_SHADER, FRAG_SRC));
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
throw new Error("Program link error:\n" + gl.getProgramInfoLog(program));
}
gl.useProgram(program);
var buf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
UNIFORM_NAMES.forEach(function (n) {
uniforms[n] = gl.getUniformLocation(program, n);
});
lastTick = performance.now();
requestAnimationFrame(tick);
}
function setSize(w, h) {
canvas.width = w;
canvas.height = h;
gl.viewport(0, 0, w, h);
}
function pushUniforms(P, phase) {
gl.uniform2f(uniforms.u_res, canvas.width, canvas.height);
gl.uniform1f(uniforms.u_phase, phase);
gl.uniform1f(uniforms.u_seed, P.seed);
gl.uniform1i(uniforms.u_mode, P.mode);
gl.uniform3fv(uniforms.u_c1, hexToRgb01(P.c1));
gl.uniform3fv(uniforms.u_c2, hexToRgb01(P.c2));
gl.uniform3fv(uniforms.u_c3, hexToRgb01(P.c3));
gl.uniform3fv(uniforms.u_c4, hexToRgb01(P.c4));
gl.uniform3fv(uniforms.u_bg, hexToRgb01(P.bg));
gl.uniform1f(uniforms.u_hue, P.hue);
gl.uniform1f(uniforms.u_sat, P.sat);
gl.uniform1f(uniforms.u_exposure, P.exposure);
gl.uniform1f(uniforms.u_contrast, P.contrast);
gl.uniform1f(uniforms.u_scale, P.scale);
gl.uniform1f(uniforms.u_complex, P.complex);
gl.uniform1f(uniforms.u_warp, P.warp);
gl.uniform1f(uniforms.u_flow, P.flow);
gl.uniform1f(uniforms.u_stretch, P.stretch);
gl.uniform1f(uniforms.u_light, P.light);
gl.uniform1f(uniforms.u_gloss, P.gloss);
gl.uniform1f(uniforms.u_lightAngle, P.lightAngle);
gl.uniform1f(uniforms.u_irid, P.irid);
gl.uniform1f(uniforms.u_glow, P.glow);
gl.uniform1f(uniforms.u_grain, P.grain);
gl.uniform1f(uniforms.u_cell, P.cell);
gl.uniform1f(uniforms.u_lines, P.lines);
gl.uniform1f(uniforms.u_ca, P.ca);
gl.uniform1f(uniforms.u_vig, P.vig);
gl.uniform1f(uniforms.u_soft, P.soft);
gl.uniform1f(uniforms.u_travel, P.travel);
}
function renderAt(phase) {
var P = getParams();
pushUniforms(P, phase);
gl.drawArrays(gl.TRIANGLES, 0, 3);
}
function currentPhase() {
var P = getParams();
return (loopT / P.loop) % 1;
}
function tick(now) {
var dt = Math.min((now - lastTick) / 1000, 0.1);
lastTick = now;
if (playing) {
var P = getParams();
loopT = (loopT + dt) % P.loop;
}
renderAt(currentPhase());
fpsAcc += dt; fpsN++;
if (fpsAcc >= 0.5) {
fps = Math.round(fpsN / fpsAcc);
fpsAcc = 0; fpsN = 0;
if (fpsCb) fpsCb(fps);
}
requestAnimationFrame(tick);
}
function readPixels() {
var w = canvas.width, h = canvas.height;
var buf = new Uint8Array(w * h * 4);
gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, buf);
/* flip vertically: GL origin is bottom-left */
var flipped = new Uint8Array(w * h * 4);
var row = w * 4;
for (var y = 0; y < h; y++) {
flipped.set(buf.subarray(y * row, (y + 1) * row), (h - 1 - y) * row);
}
return flipped;
}
return {
init: init,
setSize: setSize,
renderAt: renderAt,
readPixels: readPixels,
currentPhase: currentPhase,
resetTime: function () { loopT = 0; },
setPlaying: function (v) { playing = v; lastTick = performance.now(); },
isPlaying: function () { return playing; },
onFps: function (cb) { fpsCb = cb; },
canvas: function () { return canvas; },
size: function () { return [canvas.width, canvas.height]; }
};
})();
+174
View File
@@ -0,0 +1,174 @@
/* 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 (realtime capture) ---------- */
function pickVideoMime() {
var candidates = [
"video/webm;codecs=vp9",
"video/webm;codecs=vp8",
"video/webm",
"video/mp4"
];
for (var i = 0; i < candidates.length; i++) {
if (window.MediaRecorder && MediaRecorder.isTypeSupported(candidates[i])) return candidates[i];
}
return null;
}
function exportVideo(P, aspect) {
if (busy) return;
var mime = pickVideoMime();
if (!mime) { UI.toast("Video recording not supported in this browser"); return; }
busy = true;
var prev = Engine.size();
var h = parseInt(P.vidRes, 10);
var w = evenRound(h * aspect);
Engine.setSize(w, h);
var wasPlaying = Engine.isPlaying();
Engine.setPlaying(true);
Engine.resetTime();
var durMs = P.loop * P.vidLoops * 1000;
var ext = mime.indexOf("mp4") >= 0 ? "mp4" : "webm";
showOverlay("Recording video");
var stream = Engine.canvas().captureStream(60);
var rec = new MediaRecorder(stream, { mimeType: mime, videoBitsPerSecond: 18000000 });
var parts = [];
rec.ondataavailable = function (e) { if (e.data.size) parts.push(e.data); };
rec.onstop = function () {
Engine.setSize(prev[0], prev[1]);
Engine.setPlaying(wasPlaying);
hideOverlay();
busy = false;
if (!cancelled) {
download(new Blob(parts, { type: mime }), stamp(P, ext));
UI.toast("Saved " + (durMs / 1000).toFixed(1) + "s " + ext.toUpperCase() + " (" + w + "\u00d7" + h + ")");
}
};
rec.start(200);
var t0 = performance.now();
(function poll() {
var el = performance.now() - t0;
if (cancelled) { rec.stop(); return; }
setProgress(Math.min(el / durMs, 1), (el / 1000).toFixed(1) + "s / " + (durMs / 1000).toFixed(1) + "s \u00b7 " + w + "\u00d7" + h);
if (el >= durMs) { rec.stop(); return; }
requestAnimationFrame(poll);
})();
}
/* ---------- 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,
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; }
};
})();
+241
View File
@@ -0,0 +1,241 @@
/* Dependency-free animated GIF encoder.
- global 256-color palette via median cut over sampled frame pixels
- optional 4x4 Bayer ordered dithering (kills gradient banding)
- standard GIF LZW (jsgif-compatible code-size handling)
- NETSCAPE2.0 extension for infinite looping */
var GIFEnc = (function () {
/* ---------- byte writer ---------- */
function ByteWriter() {
this.chunks = [];
this.cur = new Uint8Array(1 << 16);
this.len = 0;
}
ByteWriter.prototype.byte = function (b) {
if (this.len === this.cur.length) { this.chunks.push(this.cur); this.cur = new Uint8Array(1 << 16); this.len = 0; }
this.cur[this.len++] = b & 0xff;
};
ByteWriter.prototype.bytes = function (arr) {
for (var i = 0; i < arr.length; i++) this.byte(arr[i]);
};
ByteWriter.prototype.short = function (v) { this.byte(v & 0xff); this.byte((v >> 8) & 0xff); };
ByteWriter.prototype.string = function (s) { for (var i = 0; i < s.length; i++) this.byte(s.charCodeAt(i)); };
ByteWriter.prototype.result = function () {
var total = this.chunks.length * (1 << 16) + this.len;
var out = new Uint8Array(total);
var o = 0;
for (var i = 0; i < this.chunks.length; i++) { out.set(this.chunks[i], o); o += this.chunks[i].length; }
out.set(this.cur.subarray(0, this.len), o);
return out;
};
/* ---------- median cut quantization ---------- */
function buildPalette(samples, maxColors) {
// samples: array of [r,g,b]
var boxes = [samples];
while (boxes.length < maxColors) {
var bi = -1, bRange = -1, bCh = 0;
for (var i = 0; i < boxes.length; i++) {
var box = boxes[i];
if (box.length < 2) continue;
var mins = [255, 255, 255], maxs = [0, 0, 0];
for (var j = 0; j < box.length; j++) {
for (var c = 0; c < 3; c++) {
if (box[j][c] < mins[c]) mins[c] = box[j][c];
if (box[j][c] > maxs[c]) maxs[c] = box[j][c];
}
}
for (var c2 = 0; c2 < 3; c2++) {
var r = maxs[c2] - mins[c2];
if (r > bRange) { bRange = r; bi = i; bCh = c2; }
}
}
if (bi < 0 || bRange === 0) break;
var target = boxes[bi];
target.sort(function (a, b) { return a[bCh] - b[bCh]; });
var mid = target.length >> 1;
boxes.splice(bi, 1, target.slice(0, mid), target.slice(mid));
}
var palette = [];
for (var k = 0; k < boxes.length; k++) {
var bx = boxes[k];
if (!bx.length) continue;
var rs = 0, gs = 0, bs = 0;
for (var m = 0; m < bx.length; m++) { rs += bx[m][0]; gs += bx[m][1]; bs += bx[m][2]; }
palette.push([Math.round(rs / bx.length), Math.round(gs / bx.length), Math.round(bs / bx.length)]);
}
while (palette.length < maxColors) palette.push([0, 0, 0]);
return palette;
}
function makeNearest(palette) {
var cache = new Int16Array(32768).fill(-1);
return function (r, g, b) {
var key = ((r >> 3) << 10) | ((g >> 3) << 5) | (b >> 3);
var hit = cache[key];
if (hit >= 0) return hit;
var best = 0, bd = 1e9;
for (var i = 0; i < palette.length; i++) {
var dr = palette[i][0] - r, dg = palette[i][1] - g, db = palette[i][2] - b;
var d = dr * dr + dg * dg + db * db;
if (d < bd) { bd = d; best = i; }
}
cache[key] = best;
return best;
};
}
var BAYER = [
0, 8, 2, 10,
12, 4, 14, 6,
3, 11, 1, 9,
15, 7, 13, 5
];
/* ---------- LZW (GIF variant) ---------- */
function lzwEncode(minCodeSize, indices, out) {
var clearCode = 1 << minCodeSize;
var eoiCode = clearCode + 1;
var nextCode = eoiCode + 1;
var curBits = minCodeSize + 1;
var maxCode = (1 << curBits) - 1;
var dict = new Map();
var bitAcc = 0, bitCnt = 0;
var sub = new Uint8Array(255), subLen = 0;
function flushSub() {
if (subLen > 0) { out.byte(subLen); for (var i = 0; i < subLen; i++) out.byte(sub[i]); subLen = 0; }
}
function pushByte(b) {
sub[subLen++] = b;
if (subLen === 255) flushSub();
}
function emit(code) {
bitAcc |= code << bitCnt;
bitCnt += curBits;
while (bitCnt >= 8) { pushByte(bitAcc & 0xff); bitAcc >>= 8; bitCnt -= 8; }
if (nextCode > maxCode && curBits < 12) {
curBits++;
maxCode = (1 << curBits) - 1;
}
}
out.byte(minCodeSize);
emit(clearCode);
var prev = indices[0];
for (var i = 1; i < indices.length; i++) {
var k = indices[i];
var key = (prev << 8) | k;
var hit = dict.get(key);
if (hit !== undefined) { prev = hit; continue; }
emit(prev);
if (nextCode < 4096) {
dict.set(key, nextCode++);
} else {
emit(clearCode);
dict.clear();
nextCode = eoiCode + 1;
curBits = minCodeSize + 1;
maxCode = (1 << curBits) - 1;
}
prev = k;
}
emit(prev);
emit(eoiCode);
if (bitCnt > 0) pushByte(bitAcc & 0xff);
flushSub();
out.byte(0); /* block terminator */
}
/* ---------- main encode ---------- */
/* frames: array of Uint8Array RGBA (top-down), all width*height*4 */
async function encode(opts) {
var frames = opts.frames, w = opts.width, h = opts.height;
var fps = opts.fps || 25;
var dither = opts.dither !== false;
var onProgress = opts.onProgress || function () {};
var isCancelled = opts.isCancelled || function () { return false; };
/* sample pixels across all frames for the global palette */
var samples = [];
var targetSamples = 42000;
var totalPx = frames.length * w * h;
var stride = Math.max(1, Math.floor(totalPx / targetSamples));
for (var f = 0; f < frames.length; f++) {
var d = frames[f];
for (var p = (f * 7919) % stride; p < w * h; p += stride) {
samples.push([d[p * 4], d[p * 4 + 1], d[p * 4 + 2]]);
}
}
onProgress(0.02, "building palette");
await microtask();
var palette = buildPalette(samples, 256);
var nearest = makeNearest(palette);
var out = new ByteWriter();
out.string("GIF89a");
out.short(w); out.short(h);
out.byte(0xF7); /* global table, 256 colors, 8-bit res */
out.byte(0); /* bg index */
out.byte(0); /* aspect */
for (var c = 0; c < 256; c++) {
var col = palette[c] || [0, 0, 0];
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);
var delay = Math.max(2, Math.round(100 / fps));
var indices = new Uint8Array(w * h);
for (var fi = 0; fi < frames.length; fi++) {
if (isCancelled()) return null;
var data = frames[fi];
var di = 0;
for (var y = 0; y < h; y++) {
for (var x = 0; x < w; x++) {
var o = (y * w + x) * 4;
var r = data[o], g = data[o + 1], b = data[o + 2];
if (dither) {
var dth = (BAYER[(y & 3) * 4 + (x & 3)] / 16 - 0.5) * 14;
r = Math.max(0, Math.min(255, r + dth));
g = Math.max(0, Math.min(255, g + dth));
b = Math.max(0, Math.min(255, b + dth));
}
indices[di++] = nearest(r | 0, g | 0, b | 0);
}
}
/* graphic control extension */
out.byte(0x21); out.byte(0xF9); out.byte(4);
out.byte(0x04); /* disposal: do not dispose */
out.short(delay);
out.byte(0); out.byte(0);
/* image descriptor */
out.byte(0x2C);
out.short(0); out.short(0); out.short(w); out.short(h);
out.byte(0); /* no local table */
lzwEncode(8, indices, out);
onProgress(0.05 + 0.95 * (fi + 1) / frames.length, "frame " + (fi + 1) + "/" + frames.length);
await microtask();
}
out.byte(0x3B); /* trailer */
return out.result();
}
function microtask() {
return new Promise(function (res) { setTimeout(res, 0); });
}
return { encode: encode };
})();
+323
View File
@@ -0,0 +1,323 @@
/* App state, parameter schema, randomizer recipes, and wiring. */
/* ---------------- modes ---------------- */
var MODES = [
{ id: 0, key: "chrome", name: "Chrome", full: "Liquid Chrome",
icon: '<svg viewBox="0 0 26 18"><path d="M1 12 C5 4 9 15 13 9 C17 3 21 13 25 7" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M1 15 C5 9 10 17 14 12 C18 8 22 15 25 11" fill="none" stroke="currentColor" stroke-width="1.5" opacity="0.45"/></svg>' },
{ id: 1, key: "silk", name: "Silk", full: "Silk Ribbons",
icon: '<svg viewBox="0 0 26 18"><path d="M1 13 C8 11 12 3 25 4" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M1 15.5 C8 13.5 12 5.5 25 6.5" fill="none" stroke="currentColor" stroke-width="1.5" opacity="0.65"/><path d="M1 18 C8 16 12 8 25 9" fill="none" stroke="currentColor" stroke-width="1.5" opacity="0.35"/></svg>' },
{ id: 2, key: "bloom", name: "Bloom", full: "Soft Bloom",
icon: '<svg viewBox="0 0 26 18"><circle cx="9" cy="8" r="5.5" fill="currentColor" opacity="0.35"/><circle cx="17" cy="11" r="4" fill="currentColor" opacity="0.6"/></svg>' },
{ id: 3, key: "aura", name: "Aura", full: "Aura Rings",
icon: '<svg viewBox="0 0 26 18"><circle cx="13" cy="9" r="7" fill="none" stroke="currentColor" stroke-width="1.4" opacity="0.35"/><circle cx="13" cy="9" r="4.2" fill="none" stroke="currentColor" stroke-width="1.4" opacity="0.7"/><circle cx="13" cy="9" r="1.6" fill="currentColor"/></svg>' },
{ id: 4, key: "rays", name: "Rays", full: "Light Rays",
icon: '<svg viewBox="0 0 26 18"><path d="M13 1 L7 17 M13 1 L13 17 M13 1 L19 17 M13 1 L2 13 M13 1 L24 13" fill="none" stroke="currentColor" stroke-width="1.3" opacity="0.8"/></svg>' },
{ id: 5, key: "halftone", name: "Halftone", full: "Halftone",
icon: '<svg viewBox="0 0 26 18"><circle cx="4" cy="5" r="2.4" fill="currentColor"/><circle cx="11" cy="5" r="1.8" fill="currentColor"/><circle cx="18" cy="5" r="1.2" fill="currentColor"/><circle cx="24" cy="5" r="0.7" fill="currentColor"/><circle cx="4" cy="12" r="1.6" fill="currentColor"/><circle cx="11" cy="12" r="2.2" fill="currentColor"/><circle cx="18" cy="12" r="1.5" fill="currentColor"/><circle cx="24" cy="12" r="0.9" fill="currentColor"/></svg>' },
{ id: 6, key: "glyphs", name: "Glyphs", full: "Data Glyphs",
icon: '<svg viewBox="0 0 26 18"><g fill="currentColor"><rect x="2" y="2" width="2" height="3"/><rect x="7" y="2" width="2" height="3" opacity="0.5"/><rect x="12" y="2" width="2" height="3"/><rect x="17" y="2" width="2" height="3" opacity="0.3"/><rect x="22" y="2" width="2" height="3" opacity="0.7"/><rect x="2" y="8" width="2" height="3" opacity="0.4"/><rect x="7" y="8" width="2" height="3"/><rect x="12" y="8" width="2" height="3" opacity="0.6"/><rect x="17" y="8" width="2" height="3"/><rect x="22" y="8" width="2" height="3" opacity="0.4"/><rect x="2" y="14" width="2" height="3" opacity="0.7"/><rect x="7" y="14" width="2" height="3" opacity="0.3"/><rect x="12" y="14" width="2" height="3" opacity="0.8"/><rect x="17" y="14" width="2" height="3" opacity="0.5"/><rect x="22" y="14" width="2" height="3"/></g></svg>' },
{ id: 7, key: "reeded", name: "Reeded", full: "Reeded Glass",
icon: '<svg viewBox="0 0 26 18"><g stroke="currentColor" stroke-width="1.8" fill="none"><path d="M3 1 V17" opacity="0.9"/><path d="M8 1 V17" opacity="0.5"/><path d="M13 1 V17" opacity="0.9"/><path d="M18 1 V17" opacity="0.5"/><path d="M23 1 V17" opacity="0.9"/></g></svg>' },
{ id: 8, key: "mosaic", name: "Mosaic", full: "Pixel Bloom",
icon: '<svg viewBox="0 0 26 18"><g fill="currentColor"><rect x="2" y="2" width="6" height="6" opacity="0.9"/><rect x="9" y="2" width="6" height="6" opacity="0.4"/><rect x="16" y="2" width="6" height="6" opacity="0.7"/><rect x="2" y="9" width="6" height="6" opacity="0.3"/><rect x="9" y="9" width="6" height="6" opacity="0.8"/><rect x="16" y="9" width="6" height="6" opacity="0.5"/></g></svg>' }
];
var ASPECTS = { "16:9": 16 / 9, "3:2": 1.5, "1:1": 1, "4:5": 0.8, "21:9": 21 / 9 };
/* ---------------- state ---------------- */
var P = {
mode: 0, seed: Math.floor(Math.random() * 10000),
c1: "#e0220a", c2: "#ff5a1f", c3: "#1f8cff", c4: "#bfe7ff", bg: "#050507",
hue: 0, sat: 1.2, exposure: 1.0, contrast: 1.15,
scale: 1.7, complex: 3.8, warp: 1.1, flow: 0.5, stretch: 0.45,
light: 1.6, gloss: 64, lightAngle: 130, irid: 0.55, glow: 0.35,
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,
aspect: "16:9"
};
/* randomizer ranges; missing keys fall back to DEF_RANGE */
var DEF_RANGE = {
scale: [0.8, 1.8], complex: [3, 6], warp: [0.3, 1.5], flow: [0, 1], stretch: [-0.3, 0.5],
light: [0.6, 1.6], gloss: [16, 80], lightAngle: [0, 360], irid: [0, 0.8], glow: [0, 0.5],
contrast: [0.95, 1.2], grain: [0, 0.12], cell: [40, 140], lines: [20, 100], ca: [0, 0.4],
vig: [0, 0.4], soft: [0.6, 1.4], sat: [0.95, 1.35], exposure: [0.95, 1.1], hue: [0, 0],
travel: [0.3, 1.0], loop: [4, 8]
};
var RECIPES = {
chrome: { tone: ["dark"], scale: [1.2, 2.4], complex: [2.8, 5], warp: [0.7, 1.9], flow: [0.2, 1.0],
stretch: [0.25, 0.7], light: [1.2, 2.0], gloss: [36, 100], irid: [0.2, 0.85], glow: [0.15, 0.55],
contrast: [1.0, 1.3], grain: [0.02, 0.1], ca: [0, 0.45], vig: [0.15, 0.5], sat: [1.0, 1.4],
travel: [0.4, 1.0], loop: [4, 8] },
silk: { tone: ["dark"], scale: [1.2, 2.2], warp: [0.3, 1.0], lines: [40, 90], gloss: [24, 80],
light: [1.0, 1.9], stretch: [0, 0.4], irid: [0.2, 0.7], glow: [0.2, 0.6], grain: [0.02, 0.08],
vig: [0.1, 0.4], sat: [1.0, 1.45], contrast: [1.0, 1.25], travel: [0.3, 0.8], loop: [4, 9] },
bloom: { tone: ["light", "light", "dark"], scale: [0.8, 1.6], warp: [0.3, 1.4], soft: [0.8, 1.5],
light: [0, 0.6], glow: [0, 0.3], grain: [0, 0.07], ca: [0, 0.25], vig: [0, 0.25],
contrast: [0.9, 1.05], sat: [0.9, 1.3], travel: [0.5, 1.2], loop: [5, 10] },
aura: { tone: ["light", "light", "dark"], scale: [0.9, 1.6], warp: [0.2, 1.0], soft: [0.9, 1.6],
grain: [0, 0.06], contrast: [0.9, 1.05], sat: [0.85, 1.15], vig: [0, 0.2], glow: [0, 0.35],
ca: [0, 0.08], travel: [0.3, 0.9], loop: [5, 10] },
rays: { tone: ["light", "light", "dark"], warp: [0.3, 1.2], lines: [20, 90], grain: [0, 0.1],
glow: [0.1, 0.5], contrast: [0.95, 1.2], sat: [1.0, 1.4], vig: [0, 0.3], travel: [0.2, 0.7],
loop: [5, 10], scale: [0.9, 1.5] },
halftone: { tone: ["light", "light", "dark"], cell: [60, 150], scale: [0.9, 1.8], warp: [0.4, 1.4],
grain: [0, 0.05], contrast: [0.95, 1.15], sat: [0.95, 1.3], ca: [0, 0.2], vig: [0, 0.2],
travel: [0.4, 1.0], loop: [4, 9] },
glyphs: { tone: ["dark"], cell: [70, 150], scale: [0.8, 1.6], warp: [0.5, 1.5], grain: [0.02, 0.12],
glow: [0.3, 0.8], contrast: [1.0, 1.3], sat: [1.0, 1.5], ca: [0, 0.5], vig: [0.2, 0.6],
travel: [0.4, 1.0], loop: [3, 7] },
reeded: { tone: ["light", "dark"], lines: [36, 90], warp: [0.6, 1.6], scale: [0.8, 1.5],
grain: [0, 0.07], light: [0.4, 1.2], contrast: [0.95, 1.2], sat: [1.0, 1.35], vig: [0, 0.18],
travel: [0.5, 1.1], loop: [5, 10] },
mosaic: { tone: ["light", "dark"], cell: [40, 120], warp: [0.3, 1.2], scale: [0.8, 1.5],
grain: [0, 0.04], contrast: [0.95, 1.15], sat: [0.95, 1.3], vig: [0, 0.15],
travel: [0.5, 1.2], loop: [5, 10], soft: [0.8, 1.4] }
};
var FORM_KEYS = ["scale", "complex", "warp", "flow", "stretch"];
var LIGHT_KEYS = ["light", "gloss", "lightAngle", "irid", "glow", "contrast"];
var TEXTURE_KEYS = ["grain", "cell", "lines", "ca", "vig", "soft"];
var MOTION_KEYS = ["loop", "travel"];
var GRADE_KEYS = ["sat", "exposure"];
/* ---------------- randomizer ---------------- */
function rnd() { return Math.random(); }
function randIn(range) { return range[0] + rnd() * (range[1] - range[0]); }
function rangeFor(key) {
var rec = RECIPES[MODES[P.mode].key] || {};
return rec[key] || DEF_RANGE[key];
}
var activePreset = 0;
var setPresetActive = function () {};
function applyPalette(pal, presetIdx) {
P.c1 = pal.colors[0]; P.c2 = pal.colors[1];
P.c3 = pal.colors[2]; P.c4 = pal.colors[3];
P.bg = pal.bg;
activePreset = presetIdx;
setPresetActive(presetIdx);
}
function randomizePalette() {
var rec = RECIPES[MODES[P.mode].key] || { tone: ["dark", "light"] };
var tone = rec.tone[Math.floor(rnd() * rec.tone.length)];
if (rnd() < 0.25) {
applyPalette(generateRandomPalette(rnd, tone), -1);
} else {
var pool = [];
PALETTES.forEach(function (p, i) { if (p.tone === tone) pool.push(i); });
var idx = pool[Math.floor(rnd() * pool.length)];
applyPalette(PALETTES[idx], idx);
}
P.hue = 0;
GRADE_KEYS.forEach(function (k) { P[k] = randIn(rangeFor(k)); });
}
function randomizeKeys(keys) {
keys.forEach(function (k) {
var v = randIn(rangeFor(k));
if (k === "gloss" || k === "cell" || k === "lines" || k === "lightAngle") v = Math.round(v);
if (k === "loop") v = Math.round(v * 2) / 2;
P[k] = v;
});
}
function newSeed() { P.seed = Math.floor(rnd() * 10000); }
function randomizeAll() {
if (!P.lockStyle) P.mode = Math.floor(rnd() * MODES.length);
randomizePalette();
randomizeKeys(FORM_KEYS.concat(LIGHT_KEYS, TEXTURE_KEYS, MOTION_KEYS));
newSeed();
refreshAll();
}
/* ---------------- UI wiring ---------------- */
var refreshers = [];
function reg(fn) { refreshers.push(fn); return fn; }
function refreshAll() {
refreshers.forEach(function (f) { f(); });
updateMeta();
}
function get(k) { return function () { return P[k]; }; }
function set(k) { return function (v) { P[k] = v; updateMeta(); }; }
function fmt2(v) { return (+v).toFixed(2); }
function fmt1(v) { return (+v).toFixed(1); }
function fmtInt(v) { return String(Math.round(v)); }
function fmtDeg(v) { return Math.round(v) + "\u00b0"; }
function fmtSec(v) { return (+v).toFixed(1) + "s"; }
function buildRail() {
var rail = document.getElementById("rail");
/* STYLE */
var sStyle = UI.section(rail, "Style", function () {
P.mode = Math.floor(rnd() * MODES.length);
refreshAll();
});
reg(UI.modeGrid(sStyle, MODES, get("mode"), function (v) { P.mode = v; updateMeta(); }));
reg(UI.lockRow(sStyle, { label: "Keep style when randomizing", get: get("lockStyle"), set: set("lockStyle") }));
/* COLOR */
var sColor = UI.section(rail, "Color", function () { randomizePalette(); refreshAll(); });
setPresetActive = UI.presetChips(sColor, PALETTES, function (i) {
applyPalette(PALETTES[i], i);
refreshAll();
});
setPresetActive(activePreset);
reg(UI.colorSwatches(sColor,
[{ key: "c1", label: "A" }, { key: "c2", label: "B" }, { key: "c3", label: "C" }, { key: "c4", label: "D" },
{ gap: true }, { key: "bg", label: "BG" }],
function (k) { return P[k]; },
function (k, v) { P[k] = v; activePreset = -1; setPresetActive(-1); }));
reg(UI.slider(sColor, { label: "Hue shift", min: -180, max: 180, step: 1, fmt: fmtDeg, get: get("hue"), set: set("hue") }));
reg(UI.slider(sColor, { label: "Saturation", min: 0, max: 2, step: 0.01, fmt: fmt2, get: get("sat"), set: set("sat") }));
reg(UI.slider(sColor, { label: "Exposure", min: 0.5, max: 1.6, step: 0.01, fmt: fmt2, get: get("exposure"), set: set("exposure") }));
/* FORM */
var sForm = UI.section(rail, "Form", function () { randomizeKeys(FORM_KEYS); newSeed(); refreshAll(); });
reg(UI.seedRow(sForm, { get: get("seed"), set: function (v) { P.seed = v; updateMeta(); refreshAll(); }, onDice: function () { newSeed(); refreshAll(); } }));
reg(UI.slider(sForm, { label: "Zoom", min: 0.5, max: 3, step: 0.01, fmt: fmt2, get: get("scale"), set: set("scale") }));
reg(UI.slider(sForm, { label: "Detail", min: 1, max: 8, step: 0.1, fmt: fmt1, get: get("complex"), set: set("complex") }));
reg(UI.slider(sForm, { label: "Warp", min: 0, max: 2.5, step: 0.01, fmt: fmt2, get: get("warp"), set: set("warp") }));
reg(UI.slider(sForm, { label: "Turbulence", min: 0, max: 2, step: 0.01, fmt: fmt2, get: get("flow"), set: set("flow") }));
reg(UI.slider(sForm, { label: "Stretch", min: -1, max: 1, step: 0.01, fmt: fmt2, get: get("stretch"), set: set("stretch") }));
/* LIGHTING */
var sLight = UI.section(rail, "Lighting", function () { randomizeKeys(LIGHT_KEYS); refreshAll(); });
reg(UI.slider(sLight, { label: "Intensity", min: 0, max: 2.2, step: 0.01, fmt: fmt2, get: get("light"), set: set("light") }));
reg(UI.slider(sLight, { label: "Gloss", min: 4, max: 120, step: 1, fmt: fmtInt, get: get("gloss"), set: set("gloss") }));
reg(UI.slider(sLight, { label: "Angle", min: 0, max: 360, step: 1, fmt: fmtDeg, get: get("lightAngle"), set: set("lightAngle") }));
reg(UI.slider(sLight, { label: "Iridescence", min: 0, max: 1, step: 0.01, fmt: fmt2, get: get("irid"), set: set("irid") }));
reg(UI.slider(sLight, { label: "Glow", min: 0, max: 1, step: 0.01, fmt: fmt2, get: get("glow"), set: set("glow") }));
reg(UI.slider(sLight, { label: "Contrast", min: 0.6, max: 1.6, step: 0.01, fmt: fmt2, get: get("contrast"), set: set("contrast") }));
/* TEXTURE */
var sTex = UI.section(rail, "Texture", function () { randomizeKeys(TEXTURE_KEYS); refreshAll(); });
reg(UI.slider(sTex, { label: "Grain", min: 0, max: 0.4, step: 0.005, fmt: fmt2, get: get("grain"), set: set("grain") }));
reg(UI.slider(sTex, { label: "Density", min: 14, max: 180, step: 1, fmt: fmtInt, get: get("cell"), set: set("cell") }));
reg(UI.slider(sTex, { label: "Ridges", min: 8, max: 160, step: 1, fmt: fmtInt, get: get("lines"), set: set("lines") }));
reg(UI.slider(sTex, { label: "Aberration", min: 0, max: 1, step: 0.01, fmt: fmt2, get: get("ca"), set: set("ca") }));
reg(UI.slider(sTex, { label: "Vignette", min: 0, max: 1, step: 0.01, fmt: fmt2, get: get("vig"), set: set("vig") }));
reg(UI.slider(sTex, { label: "Softness", min: 0.3, max: 1.6, step: 0.01, fmt: fmt2, get: get("soft"), set: set("soft") }));
/* MOTION */
var sMotion = UI.section(rail, "Motion", function () { randomizeKeys(MOTION_KEYS); refreshAll(); });
reg(UI.slider(sMotion, { label: "Loop length", min: 2, max: 12, step: 0.5, fmt: fmtSec, get: get("loop"), set: set("loop") }));
reg(UI.slider(sMotion, { label: "Travel", min: 0, max: 1.5, step: 0.01, fmt: fmt2, get: get("travel"), set: set("travel") }));
/* EXPORT */
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: "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") }));
var grid = UI.el("div", "export-grid", sExp);
UI.exportButton(grid, "Save 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",
'<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",
'<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]); });
}
/* ---------------- stage / meta ---------------- */
function updateMeta() {
document.getElementById("meta-mode").textContent = 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();
document.getElementById("meta-res").textContent = s[0] + "\u00d7" + s[1];
}
function fitCanvas() {
var frame = document.getElementById("canvas-frame");
var availW = frame.clientWidth - 80;
var availH = frame.clientHeight - 52;
if (availW <= 0 || availH <= 0) return;
var ar = ASPECTS[P.aspect];
var w = availW, h = w / ar;
if (h > availH) { h = availH; w = h * ar; }
var canvas = Engine.canvas();
canvas.style.width = Math.round(w) + "px";
canvas.style.height = Math.round(h) + "px";
var dpr = Math.min(window.devicePixelRatio || 1, 1.35);
Engine.setSize(2 * Math.round(w * dpr / 2), 2 * Math.round(h * dpr / 2));
updateMeta();
}
function setPlayingUI(v) {
Engine.setPlaying(v);
document.getElementById("icon-pause").style.display = v ? "" : "none";
document.getElementById("icon-play").style.display = v ? "none" : "";
}
/* ---------------- boot ---------------- */
document.addEventListener("DOMContentLoaded", function () {
var canvas = document.getElementById("view");
try {
Engine.init(canvas, function () { return P; });
} catch (e) {
document.querySelector(".canvas-frame").innerHTML =
'<div style="color:#9b9ba4;font-size:13px;max-width:380px;text-align:center;line-height:1.6">' +
"WebGL2 is required. Please use a recent Chrome, Edge or Firefox.<br><span style=\"color:#5e5e68;font-size:11px\">" +
String(e.message).split("\n")[0] + "</span></div>";
return;
}
buildRail();
/* aspect segmented control */
var segRefresh = UI.segmented(
document.getElementById("aspect-seg"),
Object.keys(ASPECTS).map(function (k) { return [k, k]; }),
get("aspect"),
function (v) { P.aspect = v; fitCanvas(); }
);
reg(segRefresh);
fitCanvas();
new ResizeObserver(fitCanvas).observe(document.getElementById("canvas-frame"));
Engine.onFps(function (fps) {
document.getElementById("meta-fps").textContent = fps + " fps";
});
document.getElementById("btn-random").addEventListener("click", randomizeAll);
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.addEventListener("keydown", function (e) {
var tag = (e.target.tagName || "").toLowerCase();
if (tag === "input" || tag === "select" || tag === "textarea") return;
if (e.code === "Space") { e.preventDefault(); setPlayingUI(!Engine.isPlaying()); }
else if (e.key === "r" || e.key === "R") { randomizeAll(); }
else if (e.key === "s" || e.key === "S") { Exporter.exportPNG(P, ASPECTS[P.aspect]); }
});
updateMeta();
});
+85
View File
@@ -0,0 +1,85 @@
/* Curated palettes derived from the reference moods.
tone: 'dark' | 'light' decides which modes prefer them. */
var PALETTES = [
{ name: "Inferno Chrome", tone: "dark",
bg: "#050507", colors: ["#e0220a", "#ff5a1f", "#1f8cff", "#bfe7ff"] },
{ name: "Neon Silk", tone: "dark",
bg: "#040406", colors: ["#19e3e3", "#ff2d78", "#ff7a1a", "#7a2dff"] },
{ name: "Ultraviolet", tone: "dark",
bg: "#06040c", colors: ["#2440ff", "#8a2bff", "#e22bd0", "#ff5470"] },
{ name: "Ember", tone: "dark",
bg: "#070403", colors: ["#ff6a00", "#ffb347", "#a81c00", "#3d0c02"] },
{ name: "Deep Signal", tone: "dark",
bg: "#030608", colors: ["#0e3a5c", "#2e7fb8", "#9fd4e8", "#16222e"] },
{ name: "Red Telemetry", tone: "dark",
bg: "#0a0202", colors: ["#ff2414", "#c81204", "#ff7a5c", "#5c0a02"] },
{ name: "Ghost Mono", tone: "dark",
bg: "#030304", colors: ["#f2f2f4", "#9a9aa6", "#3c3c46", "#c8c8d2"] },
{ name: "Acid Garden", tone: "dark",
bg: "#04070a", colors: ["#b8ff2e", "#1fd9a4", "#0a7a5c", "#eaffd0"] },
{ name: "Blush", tone: "light",
bg: "#fbf6f2", colors: ["#d4607a", "#f0b890", "#fde8d8", "#b8434f"] },
{ name: "Prism Pastel", tone: "light",
bg: "#f4f1fa", colors: ["#ffb340", "#2b3bd4", "#ff4f9a", "#9a8cff"] },
{ name: "Sky Aura", tone: "light",
bg: "#e8edfb", colors: ["#2451e8", "#6fa3f5", "#f0a8c8", "#fdfdff"] },
{ name: "Halo", tone: "light",
bg: "#f4f6ff", colors: ["#4a30e0", "#7a8cff", "#e89ab8", "#c2d4ff"] },
{ name: "Solar Flare", tone: "light",
bg: "#fefcf8", colors: ["#e8401c", "#ff8a2a", "#ffc04a", "#a82408"] },
{ name: "Glacier", tone: "light",
bg: "#f8fafc", colors: ["#1a56d6", "#4a9af0", "#a8d4ff", "#0a2a6e"] },
{ name: "Tangerine Glass", tone: "dark",
bg: "#1c1e22", colors: ["#ff7a14", "#e8e4dc", "#3a3e46", "#ffb066"] },
{ name: "Velvet Dusk", tone: "dark",
bg: "#08050c", colors: ["#ff3d2e", "#ff8c5a", "#5a1eb8", "#1a0a3c"] }
];
/* Generates a harmonised random palette: pick base hue + scheme. */
function generateRandomPalette(rand, tone) {
var h = rand() * 360;
var schemes = [
[0, 22, 48, 180], // analogous + complement pop
[0, 150, 180, 210], // split complementary
[0, 120, 240, 60], // triad + bridge
[0, 30, -30, 200], // warm cluster + cold accent
[0, 8, 16, 24] // tight monochrome drift
];
var sch = schemes[Math.floor(rand() * schemes.length)];
var dark = tone === "dark";
var colors = sch.map(function (off, i) {
var hue = ((h + off) % 360 + 360) % 360;
var s = dark ? 0.75 + rand() * 0.25 : 0.55 + rand() * 0.35;
var l = dark
? (i === 0 ? 0.52 : 0.28 + rand() * 0.45)
: (i === 0 ? 0.5 : 0.45 + rand() * 0.4);
return hslToHex(hue, s, l);
});
var bg = dark
? hslToHex(h + (rand() - 0.5) * 60, 0.3 + rand() * 0.4, 0.015 + rand() * 0.035)
: hslToHex(h + (rand() - 0.5) * 60, 0.25 + rand() * 0.35, 0.93 + rand() * 0.05);
return { name: "Generated", tone: tone, bg: bg, colors: colors };
}
function hslToHex(h, s, l) {
h = ((h % 360) + 360) % 360;
var c = (1 - Math.abs(2 * l - 1)) * s;
var x = c * (1 - Math.abs(((h / 60) % 2) - 1));
var m = l - c / 2;
var r = 0, g = 0, b = 0;
if (h < 60) { r = c; g = x; }
else if (h < 120) { r = x; g = c; }
else if (h < 180) { g = c; b = x; }
else if (h < 240) { g = x; b = c; }
else if (h < 300) { r = x; b = c; }
else { r = c; b = x; }
return "#" + [r, g, b].map(function (v) {
return Math.round((v + m) * 255).toString(16).padStart(2, "0");
}).join("");
}
function hexToRgb01(hex) {
var v = parseInt(hex.slice(1), 16);
return [((v >> 16) & 255) / 255, ((v >> 8) & 255) / 255, (v & 255) / 255];
}
+453
View File
@@ -0,0 +1,453 @@
/* GLSL sources. One uber fragment shader, mode switched by uniform.
All motion is driven by a phase in [0,1) sampled on a circle in noise
space, so every animation is a mathematically perfect loop. */
var VERT_SRC = "#version 300 es\n" +
"layout(location=0) in vec2 a_pos;\n" +
"void main(){ gl_Position = vec4(a_pos, 0.0, 1.0); }\n";
var FRAG_SRC = `#version 300 es
precision highp float;
precision highp int;
uniform vec2 u_res;
uniform float u_phase; // 0..1 loop phase
uniform float u_seed;
uniform int u_mode;
uniform vec3 u_c1, u_c2, u_c3, u_c4, u_bg;
uniform float u_hue, u_sat, u_exposure, u_contrast;
uniform float u_scale; // zoom: higher = larger forms
uniform float u_complex; // fbm octaves 1..8
uniform float u_warp;
uniform float u_flow; // extra turbulence
uniform float u_stretch; // -1..1 anisotropy
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;
out vec4 fragColor;
#define TAU 6.28318530718
#define PI 3.14159265359
/* ---------------- noise ---------------- */
/* fract-first hashes stay precise for large inputs (big seeds, far cells) */
float hash11(float n){
n = fract(n * 0.1031);
n *= n + 33.33;
n *= n + n;
return fract(n);
}
float hash21(vec2 p){
vec3 p3 = fract(vec3(p.xyx) * 0.1031);
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.x + p3.y) * p3.z);
}
vec2 hash22(vec2 p){
float n = hash21(p);
return vec2(n, hash21(p+n+17.13));
}
float vnoise(vec2 p){
vec2 i = floor(p), f = fract(p);
vec2 u = f*f*(3.0-2.0*f);
float a = hash21(i);
float b = hash21(i+vec2(1,0));
float c = hash21(i+vec2(0,1));
float d = hash21(i+vec2(1,1));
return mix(mix(a,b,u.x), mix(c,d,u.x), u.y);
}
mat2 rot(float a){ float c=cos(a), s=sin(a); return mat2(c,-s,s,c); }
float fbm(vec2 p){
float v = 0.0, a = 0.5, tot = 0.0;
mat2 R = rot(0.62);
for (int i = 0; i < 8; i++){
float w = clamp(u_complex - float(i), 0.0, 1.0);
if (w <= 0.0) break;
v += a*w*vnoise(p);
tot += a*w;
a *= 0.55;
p = R*p*2.03 + 11.7;
}
return v/max(tot, 1e-4);
}
/* loop offset: orbit in noise space -> perfect loop */
vec2 LT(){ return vec2(cos(TAU*u_phase), sin(TAU*u_phase)) * u_travel; }
vec2 SO(){ return vec2(hash11(u_seed*0.137 + 0.731)*61.7, hash11(u_seed*0.213 + 7.0)*47.3); }
/* ---------------- color ---------------- */
vec3 palette(float t){
t = clamp(t, 0.0, 1.0);
float x = t*3.0;
vec3 c = mix(u_c1, u_c2, smoothstep(0.0,1.0,x));
c = mix(c, u_c3, smoothstep(1.0,2.0,x));
c = mix(c, u_c4, smoothstep(2.0,3.0,x));
return c;
}
vec3 paletteCyc(float t){
t = fract(t);
float x = t*4.0;
vec3 c = mix(u_c1, u_c2, smoothstep(0.0,1.0,x));
c = mix(c, u_c3, smoothstep(1.0,2.0,x));
c = mix(c, u_c4, smoothstep(2.0,3.0,x));
c = mix(c, u_c1, smoothstep(3.0,4.0,x));
return c;
}
vec3 hueRotate(vec3 c, float deg){
float a = deg*PI/180.0;
float cs = cos(a), sn = sin(a);
mat3 m = mat3(
0.299+0.701*cs+0.168*sn, 0.587-0.587*cs+0.330*sn, 0.114-0.114*cs-0.497*sn,
0.299-0.299*cs-0.328*sn, 0.587+0.413*cs+0.035*sn, 0.114-0.114*cs+0.292*sn,
0.299-0.300*cs+1.250*sn, 0.587-0.588*cs-1.050*sn, 0.114+0.886*cs-0.203*sn);
return c*m;
}
/* ---------------- shared coords ---------------- */
vec2 toP(vec2 uv){
float asp = u_res.x/u_res.y;
vec2 p = (uv - 0.5) * vec2(asp, 1.0) * (3.0/max(u_scale, 0.15));
p.x *= mix(1.0, 0.38, clamp(u_stretch, 0.0, 1.0));
p.y *= mix(1.0, 0.38, clamp(-u_stretch, 0.0, 1.0));
return p;
}
/* =========================================================
MODE 0 — LIQUID CHROME
========================================================= */
float chromeH(vec2 p, vec2 w){
vec2 so = SO(), lt = LT();
return fbm((p + w)*0.85 + so*0.5 + u_flow*0.6*lt);
}
vec3 sceneChrome(vec2 uv){
vec2 p = toP(uv);
p.x *= 0.48;
vec2 so = SO(), lt = LT();
/* slow warp field, computed once and reused for all height taps */
vec2 w = u_warp*0.9*vec2(
fbm(p*0.5 + so + lt) - 0.5,
fbm(p*0.5 + so + 7.31 - lt) - 0.5) * 2.4;
float e = 0.06;
float h = chromeH(p, w);
float hx = chromeH(p + vec2(e,0.0), w);
float hy = chromeH(p + vec2(0.0,e), w);
float relief = 3.4 + u_warp*1.6;
vec3 n = normalize(vec3(-(hx-h)/e*relief, -(hy-h)/e*relief, 1.0));
float la = u_lightAngle*PI/180.0;
vec3 L = normalize(vec3(cos(la), sin(la), 0.55));
float diff = max(dot(n, L), 0.0);
vec3 Hv = normalize(L + vec3(0.0,0.0,1.0));
float spec = pow(max(dot(n, Hv), 0.0), u_gloss);
float spec2 = pow(max(dot(n, normalize(vec3(-L.xy, 0.9))), 0.0), u_gloss*0.45);
float fres = pow(1.0 - max(n.z, 0.0), 2.4);
vec3 alb = palette(clamp(h*1.1 + u_irid*n.x*0.7, 0.0, 1.0));
vec3 alb2 = palette(clamp(0.55 - n.x*0.7 + h*0.25, 0.0, 1.0));
/* dark metal base; nearly all color arrives via the lights */
vec3 col = u_bg*(0.55 + 0.45*diff);
col += alb * pow(diff, 2.4) * 0.30;
col += alb * spec * u_light * 3.0;
col += alb2 * spec2 * u_light * 1.35;
col += palette(clamp(fres*0.85 + u_irid*n.y*0.4, 0.0, 1.0)) * fres * u_light * 0.55;
col += vec3(1.0) * pow(spec, 3.0) * u_light * 0.5;
return col;
}
/* =========================================================
MODE 1 — SILK RIBBONS
========================================================= */
vec3 sceneSilk(vec2 uv){
vec2 p = toP(uv);
vec2 so = SO(), lt = LT();
p = rot(-0.30 + 0.6*(hash11(u_seed*0.31+3.0)-0.5)) * p;
/* one smooth guide curve: low-frequency two-octave noise */
vec2 wq = p*vec2(0.42, 0.50) + so + lt*0.55;
float wave = vnoise(wq)*0.70 + vnoise(wq*2.13 + 5.0)*0.30;
float freq = u_lines*0.16;
float tt = p.y*freq + (wave-0.5)*(4.5 + u_warp*3.5) + p.x*0.30;
float ft = fract(tt)-0.5; /* signed position across strand */
float band = abs(ft)*2.0;
float prof = sqrt(max(1.0-band*band, 0.0));
/* true cylinder normal across the strand */
vec3 n = normalize(vec3(0.35*(wave-0.5), ft*2.0, max(prof, 0.05)));
float la = u_lightAngle*PI/180.0;
vec3 L = normalize(vec3(cos(la), sin(la), 0.62));
float diff = max(dot(n, L), 0.0);
float spec = pow(max(dot(n, normalize(L+vec3(0,0,1))), 0.0), u_gloss);
float id = hash11(floor(tt)*7.77 + hash11(u_seed*0.171)*43.0);
vec3 alb = paletteCyc(id*0.97 + wave*0.22 + u_irid*0.25*n.y);
vec3 col = alb*(0.05 + 0.95*pow(diff, 1.7));
col += alb * spec * u_light * 1.9;
col += vec3(1.0) * pow(spec, 2.5) * u_light * 0.6;
col *= 0.45 + 0.55*prof; /* crevice shadow between strands */
float env = smoothstep(1.8, 0.55, abs(p.y*0.7 + (wave-0.5)*3.4));
return mix(u_bg, col, env);
}
/* =========================================================
MODE 2 — SOFT BLOOM
========================================================= */
vec3 blobField(vec2 p, float warpAmt){
vec2 so = SO();
p += warpAmt*0.55*vec2(fbm(p*0.8+so)-0.5, fbm(p*0.8-so)-0.5)*2.0;
vec3 col = u_bg;
for (int i = 0; i < 5; i++){
float fi = float(i);
vec2 hc = hash22(vec2(fi*3.17, u_seed*0.731 + fi));
vec2 base = (hc - 0.5)*vec2(2.2, 1.6);
float orbR = 0.18 + 0.4*hash11(u_seed*0.117 + fi*9.1);
float ph = u_phase + hash11(fi + u_seed*0.291);
float dir = hash11(fi*5.0 + u_seed*0.49) > 0.5 ? 1.0 : -1.0;
vec2 pos = base + orbR*u_travel*vec2(cos(TAU*ph*dir), sin(TAU*ph*dir));
float rad = (0.45 + 0.6*hash11(fi*2.3 + u_seed*0.371 + 4.0)) * u_soft;
float d = length(p - pos);
float g = exp(-(d*d)/(rad*rad));
/* spread blob hues across the whole palette so c1..c4 all show up */
vec3 bc = palette(fract(fi*0.249 + hash11(fi + u_seed*0.523)*0.18));
col = mix(col, bc, g*0.92);
}
return col;
}
vec3 sceneBloom(vec2 uv){
return blobField(toP(uv), u_warp);
}
/* =========================================================
MODE 3 — AURA RINGS
========================================================= */
vec3 sceneAura(vec2 uv){
vec2 p = toP(uv);
vec2 so = SO();
vec2 c = (hash22(vec2(u_seed*0.37, 8.8)) - 0.5)*vec2(0.5, 0.6);
vec2 d2 = p - c;
float ang = atan(d2.y, d2.x);
float d = length(d2);
d += (0.06 + 0.08*u_warp)*fbm(vec2(ang*1.2, d*1.4) + so + LT()*0.5)
* smoothstep(0.0, 0.3, d) - 0.05; /* fade wobble near center */
d += 0.045*u_travel*sin(TAU*u_phase);
float t = pow(max(d*0.66, 0.0), mix(1.55, 0.8, clamp(u_soft*0.65, 0.0, 1.0)));
/* gentle radial ramp: palette through the middle, bg at both poles */
vec3 col = palette(smoothstep(0.04, 0.96, t));
col = mix(col, u_bg, smoothstep(0.68, 1.18, t));
col = mix(col, mix(u_bg, vec3(1.0), 0.5), smoothstep(0.26, 0.0, t)*0.45);
/* one soft luminous ring accent */
float ring = exp(-pow((t - 0.46)*4.6, 2.0));
col = mix(col, col*1.18 + 0.06, ring*0.5);
return col;
}
/* =========================================================
MODE 4 — LIGHT RAYS
========================================================= */
vec3 sceneRays(vec2 uv){
vec2 p = toP(uv);
vec2 so = SO();
vec2 O = vec2((hash11(u_seed+1.7)-0.5)*0.8, 1.9);
vec2 dir = p - O;
float ang = atan(dir.x, -dir.y);
float r = length(dir);
float beams = fbm(vec2(ang*(2.0 + u_lines*0.12), 0.0) + so + LT()*0.5);
beams = pow(clamp(beams*1.25, 0.0, 1.0), 2.0 + u_warp*2.0);
float fall = smoothstep(3.4, 0.7, r);
float glowB = beams*fall;
vec3 col = u_bg;
vec3 beamCol = palette(clamp(0.85 - glowB*0.9, 0.0, 1.0));
col = mix(col, beamCol, clamp(glowB*1.7, 0.0, 1.0));
col = mix(col, palette(0.92), smoothstep(1.2, 3.2, r)*0.85);
return col;
}
/* =========================================================
MODE 5 — HALFTONE
========================================================= */
vec3 sceneHalftone(vec2 uv){
float asp = u_res.x/u_res.y;
vec2 so = SO(), lt = LT();
vec2 guv = uv*vec2(asp,1.0)*u_cell*0.55;
vec2 gp = floor(guv);
vec2 gf = fract(guv)-0.5;
vec2 cuv = (gp+0.5)/(u_cell*0.55)/vec2(asp,1.0);
vec2 cp = toP(cuv);
vec2 q = cp + u_warp*0.9*vec2(fbm(cp*0.7+so+lt)-0.5, fbm(cp*0.7-so-lt)-0.5)*2.0;
float f = fbm(q + so);
f = smoothstep(0.30, 0.80, f); /* large breathing-room voids */
float radius = sqrt(f)*0.62;
float dotm = smoothstep(radius, radius-0.12, length(gf));
float hueF = fbm(q*0.55 + so + 31.7);
vec3 ink = palette(clamp(hueF*1.5 - 0.22, 0.0, 1.0));
return mix(u_bg, ink, dotm*(0.30 + 0.70*f));
}
/* =========================================================
MODE 6 — DATA GLYPHS
========================================================= */
const int GLYPHS[8] = int[8](31599, 11415, 29330, 31727, 1488, 448, 128, 9362);
vec3 sceneGlyphs(vec2 uv){
float asp = u_res.x/u_res.y;
vec2 so = SO(), lt = LT();
vec2 guv = uv*vec2(asp,1.0)*vec2(u_cell*0.5, u_cell*0.5/1.55);
vec2 gp = floor(guv);
vec2 gf = fract(guv);
vec2 cuv = (gp+0.5)/vec2(u_cell*0.5, u_cell*0.5/1.55)/vec2(asp,1.0);
vec2 cp = toP(cuv);
float b = fbm(cp*0.8 + so + lt);
b = pow(clamp(b*1.65 - 0.30, 0.0, 1.0), 2.3); /* deep dark voids */
/* columnar shimmer, quantized so it still loops */
float step8 = floor(u_phase*8.0);
b *= 0.55 + 0.9*hash21(vec2(gp.x*1.31, step8));
b += 0.018; /* faint base field so the grid breathes */
float swap = hash21(gp + vec2(floor(u_phase*8.0)*13.0, u_seed));
int gi = int(floor(swap*7.999));
int glyph = GLYPHS[gi];
vec2 cell = gf;
cell = (cell - 0.5)/0.74 + 0.5; /* padding */
vec3 col = u_bg;
if (cell.x > 0.0 && cell.x < 1.0 && cell.y > 0.0 && cell.y < 1.0){
int px = int(floor(cell.x*3.0));
int py = int(floor((1.0-cell.y)*5.0));
int bit = (glyph >> ((4-py)*3 + (2-px))) & 1;
vec3 ink = palette(clamp(b*1.3, 0.0, 1.0));
col += ink * float(bit) * b * 2.2;
}
return col;
}
/* =========================================================
MODE 7 — REEDED GLASS
========================================================= */
/* bold smooth color regions for the glass to refract:
a seeded diagonal palette sweep + noise, grounded by bg in the lows */
vec3 boldField(vec2 p){
vec2 so = SO();
float f1 = fbm(p*0.40 + so + LT()*0.7);
float ang = TAU*hash11(u_seed*0.071 + 2.0);
float diag = 0.5 + 0.30*(cos(ang)*p.x + sin(ang)*p.y);
vec3 col = palette(clamp(diag + (f1-0.5)*1.5, 0.0, 1.0));
col = mix(col, u_bg, smoothstep(0.60, 0.18, f1)*0.85);
return col;
}
vec3 sceneReeded(vec2 uv){
float nx = uv.x * u_lines*0.55;
float ci = floor(nx);
float lx = fract(nx)-0.5;
float srcX = (ci + 0.5 + lx*0.20 + sin(lx*PI)*0.34*u_warp) / (u_lines*0.55);
vec2 suv = vec2(srcX, uv.y);
vec3 col = boldField(toP(suv)*0.8);
float shade = 0.86 + 0.26*cos(lx*PI);
float edge = smoothstep(0.5, 0.465, abs(lx));
col *= mix(0.62, shade, edge);
col += vec3(1.0)*pow(max(cos(lx*PI), 0.0), 30.0)*0.12*u_light;
return col;
}
/* =========================================================
MODE 8 — PIXEL BLOOM
========================================================= */
vec3 sceneMosaic(vec2 uv){
float asp = u_res.x/u_res.y;
float cells = max(u_cell*0.22, 3.0);
vec2 g = vec2(cells*asp, cells);
vec2 q = (floor(uv*g)+0.5)/g;
vec3 col = blobField(toP(q), u_warp*0.5);
float h = hash21(floor(uv*g)+u_seed);
col *= 0.97 + 0.05*h;
return col;
}
/* ---------------- 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);
return sceneMosaic(uv);
}
void main(){
vec2 uv = gl_FragCoord.xy/u_res;
vec3 col;
if (u_ca > 0.004){
vec2 off = (uv-0.5)*u_ca*0.016;
col = vec3(scene(uv-off).r, scene(uv).g, scene(uv+off).b);
} else {
col = scene(uv);
}
/* glow: soft luminance knee */
float lum = dot(col, vec3(0.299,0.587,0.114));
col += u_glow * col * lum * 0.85;
/* grade */
if (abs(u_hue) > 0.5) col = hueRotate(col, u_hue);
float l2 = dot(col, vec3(0.299,0.587,0.114));
col = mix(vec3(l2), col, u_sat);
col *= u_exposure;
col = (col - 0.5)*u_contrast + 0.5;
/* vignette */
float asp = u_res.x/u_res.y;
vec2 vc = (uv-0.5)*vec2(asp,1.0);
col *= 1.0 - u_vig*smoothstep(0.35, 1.05, length(vc));
/* film grain — quantized steps keep the loop perfect */
float gstep = floor(u_phase*24.0);
float gr = hash21(gl_FragCoord.xy*0.71 + vec2(gstep*3.1, gstep*7.7));
col += (gr-0.5)*u_grain*0.55;
fragColor = vec4(clamp(col, 0.0, 1.0), 1.0);
}
`;
+217
View File
@@ -0,0 +1,217 @@
/* Small builder library for the control rail. main.js wires it to state. */
var UI = (function () {
var DICE_SVG = '<svg viewBox="0 0 16 16"><rect x="1.5" y="1.5" width="13" height="13" rx="3" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="5.4" cy="5.4" r="1.25" fill="currentColor"/><circle cx="10.6" cy="10.6" r="1.25" fill="currentColor"/><circle cx="10.6" cy="5.4" r="1.25" fill="currentColor"/><circle cx="5.4" cy="10.6" r="1.25" fill="currentColor"/></svg>';
function el(tag, cls, parent) {
var e = document.createElement(tag);
if (cls) e.className = cls;
if (parent) parent.appendChild(e);
return e;
}
function section(rail, title, onDice) {
var sec = el("div", "rail-section", rail);
var head = el("div", "section-head", sec);
var t = el("div", "section-title", head);
t.textContent = title;
if (onDice) {
var d = el("button", "section-dice", head);
d.innerHTML = DICE_SVG;
d.title = "Randomize " + title.toLowerCase();
d.addEventListener("click", onDice);
}
return sec;
}
function fmtDefault(v) { return (+v).toFixed(2); }
function slider(parent, opts) {
var wrap = el("div", "ctl", parent);
var head = el("div", "ctl-head", wrap);
var lab = el("span", "ctl-label", head);
lab.textContent = opts.label;
var val = el("span", "ctl-value", head);
var input = el("input", null, wrap);
input.type = "range";
input.min = opts.min; input.max = opts.max; input.step = opts.step;
var fmt = opts.fmt || fmtDefault;
function paint(v) {
var pct = ((v - opts.min) / (opts.max - opts.min)) * 100;
input.style.setProperty("--fill", pct + "%");
val.textContent = fmt(v);
}
input.addEventListener("input", function () {
var v = parseFloat(input.value);
paint(v);
opts.set(v);
});
function refresh() {
var v = opts.get();
input.value = v;
paint(v);
}
refresh();
return refresh;
}
function selectRow(parent, opts) {
var row = el("div", "field-row", parent);
var lab = el("span", "ctl-label", row);
lab.textContent = opts.label;
var sel = el("select", null, row);
opts.options.forEach(function (o) {
var op = el("option", null, sel);
op.value = o[0]; op.textContent = o[1];
});
sel.addEventListener("change", function () { opts.set(sel.value); });
function refresh() { sel.value = opts.get(); }
refresh();
return refresh;
}
function toggleRow(parent, opts) {
var row = el("div", "toggle-row", parent);
var lab = el("span", "ctl-label", row);
lab.textContent = opts.label;
var tg = el("button", "toggle", row);
tg.setAttribute("role", "switch");
tg.addEventListener("click", function () { opts.set(!opts.get()); refresh(); });
function refresh() { tg.classList.toggle("on", !!opts.get()); }
refresh();
return refresh;
}
function colorSwatches(parent, defs, get, set) {
var row = el("div", "swatch-row", parent);
var refreshers = [];
defs.forEach(function (d, i) {
if (d.gap) { el("div", "swatch-gap", row); return; }
var wrap = el("div", "swatch-wrap", row);
var sw = el("div", "swatch", wrap);
var input = el("input", null, sw);
input.type = "color";
input.addEventListener("input", function () {
sw.style.background = input.value;
set(d.key, input.value);
});
var lab = el("div", "swatch-label", wrap);
lab.textContent = d.label;
refreshers.push(function () {
var v = get(d.key);
input.value = v;
sw.style.background = v;
});
});
function refresh() { refreshers.forEach(function (r) { r(); }); }
refresh();
return refresh;
}
function presetChips(parent, palettes, onPick) {
var row = el("div", "preset-row", parent);
var chips = [];
palettes.forEach(function (pal, i) {
var chip = el("button", "preset-chip", row);
chip.title = pal.name;
pal.colors.forEach(function (c) {
var b = el("i", null, chip);
b.style.background = c;
});
chip.addEventListener("click", function () { onPick(i); });
chips.push(chip);
});
return function setActive(idx) {
chips.forEach(function (c, i) { c.classList.toggle("active", i === idx); });
};
}
function modeGrid(parent, modes, get, set) {
var grid = el("div", "mode-grid", parent);
var cards = [];
modes.forEach(function (m) {
var card = el("button", "mode-card", grid);
card.innerHTML = m.icon + "<span>" + m.name + "</span>";
card.title = m.full;
card.addEventListener("click", function () { set(m.id); refresh(); });
cards.push(card);
});
function refresh() {
var cur = get();
cards.forEach(function (c, i) { c.classList.toggle("active", modes[i].id === cur); });
}
refresh();
return refresh;
}
function lockRow(parent, opts) {
var lab = el("label", "style-lock", parent);
var input = el("input", null, lab);
input.type = "checkbox";
var box = el("span", "lock-box", lab);
box.innerHTML = '<svg viewBox="0 0 10 10"><path d="M1.5 5.5 L4 8 L8.5 2.5" fill="none" stroke="#0a0a0c" stroke-width="2"/></svg>';
var txt = el("span", null, lab);
txt.textContent = opts.label;
input.addEventListener("change", function () { opts.set(input.checked); });
function refresh() { input.checked = !!opts.get(); }
refresh();
return refresh;
}
function seedRow(parent, opts) {
var row = el("div", "seed-row ctl", parent);
var input = el("input", "num-input mono", row);
input.type = "number"; input.min = 0; input.max = 9999;
input.addEventListener("change", function () {
opts.set(Math.max(0, Math.min(9999, parseInt(input.value, 10) || 0)));
});
var btn = el("button", "mini-btn", row);
btn.innerHTML = DICE_SVG + "New seed";
btn.addEventListener("click", opts.onDice);
function refresh() { input.value = Math.round(opts.get()); }
refresh();
return refresh;
}
function exportButton(parent, label, ext, svg, onClick) {
var b = el("button", "export-btn", parent);
b.innerHTML = svg + "<span>" + label + '</span><span class="ext">' + ext + "</span>";
b.addEventListener("click", onClick);
return b;
}
function segmented(container, options, get, set) {
options.forEach(function (o) {
var b = el("button", null, container);
b.textContent = o[1];
b.dataset.v = o[0];
b.addEventListener("click", function () { set(o[0]); refresh(); });
});
function refresh() {
var cur = String(get());
Array.prototype.forEach.call(container.children, function (b) {
b.classList.toggle("active", b.dataset.v === cur);
});
}
refresh();
return refresh;
}
var toastTimer = null;
function toast(msg) {
var t = document.getElementById("toast");
t.textContent = msg;
t.hidden = false;
clearTimeout(toastTimer);
toastTimer = setTimeout(function () { t.hidden = true; }, 2600);
}
return {
el: el, section: section, slider: slider, selectRow: selectRow,
toggleRow: toggleRow, colorSwatches: colorSwatches, presetChips: presetChips,
modeGrid: modeGrid, lockRow: lockRow, seedRow: seedRow,
exportButton: exportButton, segmented: segmented, toast: toast
};
})();