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:
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Leonxlnx
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,44 @@
|
||||
# LUMEN — Generative Shader Studio
|
||||
|
||||
A self-contained web tool that generates looping abstract shader art: liquid chrome, silk ribbons, soft gradient blooms, aura rings, light rays, halftone fields, data glyphs, reeded glass and pixel mosaics. Everything is rendered in real time with WebGL2 and every animation is a mathematically perfect loop.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## Run it
|
||||
|
||||
No build step, no dependencies. Either:
|
||||
|
||||
- double-click `index.html`, or
|
||||
- serve the folder: `npx http-server . -p 8080` and open `http://localhost:8080`
|
||||
|
||||
Requires a browser with WebGL2 (Chrome, Edge, Firefox).
|
||||
|
||||
## Controls
|
||||
|
||||
- **Randomize** (or press `R`): new style, palette, form, lighting and seed, tuned per art style so results stay good.
|
||||
- **Style**: 9 art modes, each with its own renderer. "Keep style" locks the mode while randomizing.
|
||||
- **Color**: 16 curated palettes plus a harmonic palette generator, 4 editable colors + background, hue / saturation / exposure.
|
||||
- **Form**: seed, zoom, fbm detail, domain warp, turbulence, anisotropic stretch.
|
||||
- **Lighting**: intensity, gloss, light angle, iridescence, glow, contrast.
|
||||
- **Texture**: film grain, halftone/glyph density, ridge count, chromatic aberration, vignette, softness.
|
||||
- **Motion**: loop length and travel distance. Loops are seamless by construction (the noise field is sampled along a closed circle).
|
||||
- `Space` pauses, `S` saves a PNG.
|
||||
|
||||
## Export
|
||||
|
||||
- **PNG** up to 3840×2160, rendered offscreen at full quality.
|
||||
- **Video** (WebM VP9/VP8, MP4 where supported) recorded at 60 fps for 1–4 loops.
|
||||
- **GIF** rendered deterministically frame by frame, encoded in-page with a dependency-free GIF89a encoder (median-cut palette + ordered dithering), infinite loop flag set.
|
||||
|
||||
## Files
|
||||
|
||||
- `index.html` / `styles.css` — app shell and design system
|
||||
- `js/shaders.js` — the uber fragment shader with all 9 modes
|
||||
- `js/engine.js` — WebGL2 engine and render loop
|
||||
- `js/gifenc.js` — GIF encoder (quantizer + LZW)
|
||||
- `js/exporter.js` — PNG / video / GIF pipelines
|
||||
- `js/palettes.js`, `js/ui.js`, `js/main.js` — palettes, control builders, state and randomizer
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.6 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 955 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.4 MiB |
+96
@@ -0,0 +1,96 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>LUMEN — Generative Shader Studio</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Space+Grotesk:wght@500;600&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<svg class="brand-mark" viewBox="0 0 20 20" aria-hidden="true">
|
||||
<path d="M10 1 L19 10 L10 19 L1 10 Z" fill="none" stroke="currentColor" stroke-width="1.4"/>
|
||||
<path d="M10 5.5 L14.5 10 L10 14.5 L5.5 10 Z" fill="currentColor"/>
|
||||
</svg>
|
||||
<span class="brand-name">LUMEN</span>
|
||||
<span class="brand-sub">generative shader studio</span>
|
||||
</div>
|
||||
|
||||
<div class="topbar-center">
|
||||
<div class="segmented" id="aspect-seg" role="group" aria-label="Canvas aspect ratio"></div>
|
||||
</div>
|
||||
|
||||
<div class="topbar-actions">
|
||||
<button class="btn btn-primary" id="btn-random" title="Randomize everything (R)">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><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>
|
||||
Randomize
|
||||
</button>
|
||||
<div class="topbar-divider"></div>
|
||||
<button class="btn" id="btn-export-png" title="Save still image (S)">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><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>
|
||||
Image
|
||||
</button>
|
||||
<button class="btn" id="btn-export-video" title="Record looping video">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><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>
|
||||
Video
|
||||
</button>
|
||||
<button class="btn" id="btn-export-gif" title="Render seamless looping GIF">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><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>
|
||||
GIF
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="app">
|
||||
<section class="stage" id="stage">
|
||||
<div class="canvas-frame" id="canvas-frame">
|
||||
<canvas id="view"></canvas>
|
||||
</div>
|
||||
<div class="stage-meta">
|
||||
<div class="meta-left">
|
||||
<button class="icon-btn" id="btn-play" title="Play / pause (Space)">
|
||||
<svg id="icon-pause" viewBox="0 0 14 14"><rect x="2.5" y="2" width="3" height="10" rx="1" fill="currentColor"/><rect x="8.5" y="2" width="3" height="10" rx="1" fill="currentColor"/></svg>
|
||||
<svg id="icon-play" viewBox="0 0 14 14" style="display:none"><path d="M3.5 2 L12 7 L3.5 12 Z" fill="currentColor"/></svg>
|
||||
</button>
|
||||
<span class="meta-item mono" id="meta-mode">—</span>
|
||||
<span class="meta-sep"></span>
|
||||
<span class="meta-item mono dim" id="meta-seed">seed 0000</span>
|
||||
</div>
|
||||
<div class="meta-right">
|
||||
<span class="meta-item mono dim" id="meta-loop">4.0s loop</span>
|
||||
<span class="meta-sep"></span>
|
||||
<span class="meta-item mono dim" id="meta-res">0×0</span>
|
||||
<span class="meta-sep"></span>
|
||||
<span class="meta-item mono dim" id="meta-fps">60 fps</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="rail" id="rail"><!-- built by ui.js --></aside>
|
||||
</main>
|
||||
|
||||
<div class="overlay" id="overlay" hidden>
|
||||
<div class="overlay-card">
|
||||
<div class="overlay-title" id="overlay-title">Rendering</div>
|
||||
<div class="overlay-detail mono" id="overlay-detail">—</div>
|
||||
<div class="progress"><div class="progress-fill" id="overlay-bar"></div></div>
|
||||
<button class="btn overlay-cancel" id="overlay-cancel">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast mono" id="toast" hidden></div>
|
||||
|
||||
<script src="js/palettes.js"></script>
|
||||
<script src="js/shaders.js"></script>
|
||||
<script src="js/engine.js"></script>
|
||||
<script src="js/gifenc.js"></script>
|
||||
<script src="js/exporter.js"></script>
|
||||
<script src="js/ui.js"></script>
|
||||
<script src="js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+166
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
`;
|
||||
@@ -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
|
||||
};
|
||||
})();
|
||||
+583
@@ -0,0 +1,583 @@
|
||||
/* ============================================================
|
||||
LUMEN — generative shader studio
|
||||
dark precision-instrument UI
|
||||
============================================================ */
|
||||
|
||||
:root {
|
||||
--bg: #09090b;
|
||||
--bg-stage: #0c0c0f;
|
||||
--bg-rail: #101013;
|
||||
--bg-raised: #16161a;
|
||||
--bg-hover: #1c1c21;
|
||||
--line: #222228;
|
||||
--line-soft: #1a1a1f;
|
||||
--text: #ececef;
|
||||
--text-mid: #9b9ba4;
|
||||
--text-dim: #5e5e68;
|
||||
--focus: #d7d7de;
|
||||
--radius-s: 6px;
|
||||
--radius-m: 9px;
|
||||
--font-ui: "Inter", -apple-system, "Segoe UI", sans-serif;
|
||||
--font-display: "Space Grotesk", var(--font-ui);
|
||||
--font-mono: "IBM Plex Mono", ui-monospace, "Cascadia Mono", monospace;
|
||||
--topbar-h: 52px;
|
||||
--rail-w: 308px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html, body { height: 100%; }
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 13px;
|
||||
overflow: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.mono { font-family: var(--font-mono); }
|
||||
.dim { color: var(--text-dim); }
|
||||
|
||||
button { font: inherit; color: inherit; cursor: pointer; background: none; border: none; }
|
||||
|
||||
/* ---------- top bar ---------- */
|
||||
|
||||
.topbar {
|
||||
height: var(--topbar-h);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 14px 0 18px;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
background: var(--bg);
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.brand { display: flex; align-items: baseline; gap: 10px; min-width: 260px; }
|
||||
.brand-mark { width: 17px; height: 17px; color: var(--text); align-self: center; }
|
||||
.brand-name {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
letter-spacing: 0.22em;
|
||||
}
|
||||
.brand-sub {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
color: var(--text-dim);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.topbar-center { display: flex; justify-content: center; }
|
||||
|
||||
.topbar-actions { display: flex; align-items: center; gap: 8px; min-width: 260px; justify-content: flex-end; }
|
||||
.topbar-divider { width: 1px; height: 22px; background: var(--line); margin: 0 4px; }
|
||||
|
||||
/* ---------- buttons ---------- */
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
height: 32px;
|
||||
padding: 0 13px;
|
||||
border-radius: var(--radius-s);
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-raised);
|
||||
color: var(--text);
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
transition: background 130ms ease, border-color 130ms ease, transform 80ms ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn svg { width: 14px; height: 14px; flex: none; }
|
||||
.btn:hover { background: var(--bg-hover); border-color: #2e2e36; }
|
||||
.btn:active { transform: translateY(1px); }
|
||||
|
||||
.btn-primary {
|
||||
background: var(--text);
|
||||
border-color: var(--text);
|
||||
color: #0a0a0c;
|
||||
}
|
||||
.btn-primary:hover { background: #fff; border-color: #fff; }
|
||||
|
||||
.icon-btn {
|
||||
width: 28px; height: 28px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
border-radius: var(--radius-s);
|
||||
color: var(--text-mid);
|
||||
transition: background 130ms ease, color 130ms ease;
|
||||
}
|
||||
.icon-btn svg { width: 13px; height: 13px; }
|
||||
.icon-btn:hover { background: var(--bg-hover); color: var(--text); }
|
||||
|
||||
/* ---------- segmented control ---------- */
|
||||
|
||||
.segmented {
|
||||
display: inline-flex;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-s);
|
||||
padding: 2px;
|
||||
gap: 2px;
|
||||
}
|
||||
.segmented button {
|
||||
height: 24px;
|
||||
padding: 0 10px;
|
||||
border-radius: 4px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
transition: background 120ms ease, color 120ms ease;
|
||||
}
|
||||
.segmented button:hover { color: var(--text-mid); }
|
||||
.segmented button.active { background: #26262d; color: var(--text); }
|
||||
|
||||
/* ---------- layout ---------- */
|
||||
|
||||
.app {
|
||||
display: flex;
|
||||
height: calc(100vh - var(--topbar-h));
|
||||
}
|
||||
|
||||
.stage {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background:
|
||||
radial-gradient(ellipse 90% 70% at 50% 42%, #101016 0%, var(--bg-stage) 70%);
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.canvas-frame {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 34px 40px 18px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
#view {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
border-radius: 10px;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255,255,255,0.055),
|
||||
0 24px 70px -18px rgba(0,0,0,0.85),
|
||||
0 6px 22px -8px rgba(0,0,0,0.6);
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.stage-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 22px 14px;
|
||||
flex: none;
|
||||
}
|
||||
.meta-left, .meta-right { display: flex; align-items: center; gap: 10px; }
|
||||
.meta-item { font-size: 11px; letter-spacing: 0.03em; }
|
||||
.meta-sep { width: 3px; height: 3px; border-radius: 50%; background: var(--line); }
|
||||
|
||||
/* ---------- control rail ---------- */
|
||||
|
||||
.rail {
|
||||
width: var(--rail-w);
|
||||
flex: none;
|
||||
background: var(--bg-rail);
|
||||
border-left: 1px solid var(--line-soft);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #26262d transparent;
|
||||
}
|
||||
.rail::-webkit-scrollbar { width: 8px; }
|
||||
.rail::-webkit-scrollbar-thumb { background: #26262d; border-radius: 4px; border: 2px solid var(--bg-rail); }
|
||||
|
||||
.rail-section { border-bottom: 1px solid var(--line-soft); padding: 14px 16px 17px; }
|
||||
.rail-section:last-child { border-bottom: none; padding-bottom: 28px; }
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.section-title {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.section-dice {
|
||||
width: 24px; height: 24px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
border-radius: 5px;
|
||||
color: var(--text-dim);
|
||||
transition: background 120ms ease, color 120ms ease, transform 80ms ease;
|
||||
}
|
||||
.section-dice svg { width: 12px; height: 12px; }
|
||||
.section-dice:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.section-dice:active { transform: rotate(90deg); }
|
||||
|
||||
/* ---------- mode grid ---------- */
|
||||
|
||||
.mode-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
.mode-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 9px 2px 8px;
|
||||
border-radius: var(--radius-s);
|
||||
border: 1px solid var(--line-soft);
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-dim);
|
||||
transition: border-color 130ms ease, color 130ms ease, background 130ms ease;
|
||||
}
|
||||
.mode-card svg { width: 26px; height: 18px; }
|
||||
.mode-card span { font-size: 10px; font-weight: 500; letter-spacing: 0.02em; }
|
||||
.mode-card:hover { color: var(--text-mid); border-color: #2c2c33; }
|
||||
.mode-card.active {
|
||||
border-color: #4a4a55;
|
||||
background: #1d1d23;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.style-lock {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
color: var(--text-dim);
|
||||
font-size: 11.5px;
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.style-lock input { display: none; }
|
||||
.lock-box {
|
||||
width: 14px; height: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
transition: background 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
.lock-box svg { width: 8px; height: 8px; opacity: 0; transition: opacity 120ms ease; }
|
||||
.style-lock input:checked + .lock-box { background: var(--text); border-color: var(--text); }
|
||||
.style-lock input:checked + .lock-box svg { opacity: 1; }
|
||||
|
||||
/* ---------- palette ---------- */
|
||||
|
||||
.preset-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.preset-chip {
|
||||
height: 22px;
|
||||
width: 46px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
padding: 0;
|
||||
transition: transform 100ms ease, border-color 120ms ease;
|
||||
}
|
||||
.preset-chip i { flex: 1; display: block; }
|
||||
.preset-chip:hover { transform: translateY(-1px); border-color: rgba(255,255,255,0.3); }
|
||||
.preset-chip.active { border-color: #fff; box-shadow: 0 0 0 1px #fff inset; }
|
||||
|
||||
.swatch-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.swatch {
|
||||
position: relative;
|
||||
width: 34px; height: 34px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255,255,255,0.10);
|
||||
overflow: hidden;
|
||||
flex: none;
|
||||
cursor: pointer;
|
||||
transition: transform 100ms ease;
|
||||
}
|
||||
.swatch:hover { transform: scale(1.06); }
|
||||
.swatch input {
|
||||
position: absolute;
|
||||
inset: -8px;
|
||||
width: calc(100% + 16px);
|
||||
height: calc(100% + 16px);
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
}
|
||||
.swatch-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
text-align: center;
|
||||
margin-top: 4px;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.swatch-wrap { display: flex; flex-direction: column; }
|
||||
.swatch-gap { width: 1px; height: 30px; background: var(--line); margin: 0 3px; align-self: flex-start; margin-top: 2px; }
|
||||
|
||||
/* ---------- sliders ---------- */
|
||||
|
||||
.ctl { margin-bottom: 11px; }
|
||||
.ctl:last-child { margin-bottom: 0; }
|
||||
|
||||
.ctl-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.ctl-label { font-size: 11.5px; font-weight: 500; color: var(--text-mid); }
|
||||
.ctl-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
color: var(--text-dim);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 18px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
}
|
||||
input[type="range"]::-webkit-slider-runnable-track {
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(to right, #62626e var(--fill, 0%), #232329 var(--fill, 0%));
|
||||
}
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 12px; height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #e8e8ec;
|
||||
border: none;
|
||||
margin-top: -4.5px;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.5);
|
||||
transition: transform 100ms ease;
|
||||
}
|
||||
input[type="range"]:hover::-webkit-slider-thumb { transform: scale(1.15); }
|
||||
input[type="range"]:active::-webkit-slider-thumb { transform: scale(1.25); background: #fff; }
|
||||
input[type="range"]::-moz-range-track {
|
||||
height: 3px; border-radius: 2px; background: #232329;
|
||||
}
|
||||
input[type="range"]::-moz-range-progress { height: 3px; border-radius: 2px; background: #62626e; }
|
||||
input[type="range"]::-moz-range-thumb {
|
||||
width: 12px; height: 12px; border-radius: 50%;
|
||||
background: #e8e8ec; border: none;
|
||||
}
|
||||
|
||||
/* ---------- selects / small inputs ---------- */
|
||||
|
||||
.field-row { display: flex; gap: 8px; align-items: center; margin-bottom: 11px; }
|
||||
.field-row:last-child { margin-bottom: 0; }
|
||||
.field-row .ctl-label { flex: 1; }
|
||||
|
||||
select, .num-input {
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-s);
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
outline: none;
|
||||
}
|
||||
select:focus, .num-input:focus { border-color: #3c3c46; }
|
||||
select { cursor: pointer; appearance: none; padding-right: 22px;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5'%3E%3Cpath d='M0 0l4 5 4-5z' fill='%235e5e68'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 8px center;
|
||||
}
|
||||
|
||||
.num-input { width: 74px; text-align: right; }
|
||||
|
||||
.seed-row { display: flex; gap: 6px; align-items: center; }
|
||||
.seed-row .num-input { flex: 1; text-align: left; }
|
||||
|
||||
.mini-btn {
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: var(--radius-s);
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-mid);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: background 120ms ease, color 120ms ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mini-btn svg { width: 11px; height: 11px; }
|
||||
.mini-btn:hover { background: var(--bg-hover); color: var(--text); }
|
||||
|
||||
/* ---------- toggle ---------- */
|
||||
|
||||
.toggle-row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 11px;
|
||||
}
|
||||
.toggle-row:last-child { margin-bottom: 0; }
|
||||
.toggle {
|
||||
position: relative;
|
||||
width: 30px; height: 17px;
|
||||
border-radius: 9px;
|
||||
background: #26262d;
|
||||
transition: background 140ms ease;
|
||||
flex: none;
|
||||
}
|
||||
.toggle::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 2.5px; left: 2.5px;
|
||||
width: 12px; height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #9b9ba4;
|
||||
transition: transform 140ms ease, background 140ms ease;
|
||||
}
|
||||
.toggle.on { background: var(--text); }
|
||||
.toggle.on::after { transform: translateX(13px); background: #0a0a0c; }
|
||||
|
||||
/* ---------- export section ---------- */
|
||||
|
||||
.export-grid { display: flex; flex-direction: column; gap: 8px; margin-top: 4px; }
|
||||
.export-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
padding: 0 13px;
|
||||
border-radius: var(--radius-s);
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-raised);
|
||||
color: var(--text);
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
transition: background 130ms ease, border-color 130ms ease;
|
||||
}
|
||||
.export-btn svg { width: 14px; height: 14px; color: var(--text-mid); }
|
||||
.export-btn:hover { background: var(--bg-hover); border-color: #2e2e36; }
|
||||
.export-btn .ext {
|
||||
margin-left: auto;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
/* ---------- overlay ---------- */
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(5,5,7,0.78);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 50;
|
||||
}
|
||||
.overlay[hidden] { display: none; }
|
||||
.overlay-card {
|
||||
width: 320px;
|
||||
background: var(--bg-rail);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 22px 22px 18px;
|
||||
box-shadow: 0 30px 80px -20px rgba(0,0,0,0.9);
|
||||
}
|
||||
.overlay-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.02em;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.overlay-detail { font-size: 11px; color: var(--text-dim); margin-bottom: 14px; }
|
||||
.progress {
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: #1d1d23;
|
||||
overflow: hidden;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
border-radius: 2px;
|
||||
background: var(--text);
|
||||
transition: width 160ms ease;
|
||||
}
|
||||
.overlay-cancel { width: 100%; justify-content: center; }
|
||||
|
||||
/* ---------- toast ---------- */
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 22px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--text);
|
||||
font-size: 11.5px;
|
||||
padding: 9px 16px;
|
||||
border-radius: 8px;
|
||||
z-index: 60;
|
||||
box-shadow: 0 10px 30px -8px rgba(0,0,0,0.7);
|
||||
animation: toast-in 200ms ease;
|
||||
}
|
||||
@keyframes toast-in {
|
||||
from { opacity: 0; transform: translateX(-50%) translateY(8px); }
|
||||
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
}
|
||||
|
||||
/* ---------- responsive ---------- */
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.brand-sub { display: none; }
|
||||
.topbar-center { display: none; }
|
||||
.brand { min-width: 0; }
|
||||
.topbar-actions { min-width: 0; }
|
||||
.btn { padding: 0 10px; }
|
||||
.btn span { display: none; }
|
||||
}
|
||||
@media (max-width: 820px) {
|
||||
body { overflow: auto; }
|
||||
.app { flex-direction: column; height: auto; }
|
||||
.stage { height: 58vh; }
|
||||
.rail { width: 100%; border-left: none; border-top: 1px solid var(--line-soft); }
|
||||
.canvas-frame { padding: 18px; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* { transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; }
|
||||
}
|
||||
Reference in New Issue
Block a user