Major update: synth styles, gradient sets, export dialogs, glass UI, docs

- Synth styles: blend any two renderers with five mix operations into
  brand-new generated looks, savable to localStorage as style chips
- Share codes bumped to v2 (synth fields), v1 codes still decode
- Gradient set generator: 4-12 seed variations with preview grid and
  ZIP batch download via a dependency-free store-only ZIP writer
- Export buttons now open dialogs with live preview and settings
- UI redesign: glassy translucent surfaces, rounder geometry, staggered
  intro animation, hover micro-interactions, refined scrollbars
- Docs page, generated favicon/touch icons and OG image, meta tags
- Skip chromatic aberration during synth blends to keep the D3D shader
  linker under its inlining limit

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonxlnx
2026-06-10 16:13:52 +02:00
parent dcd29a97d5
commit 6d8c96d1db
14 changed files with 1041 additions and 93 deletions
+17 -11
View File
@@ -1,4 +1,4 @@
# LUMEN — Generative Shader Studio
# LUMEN, the generative shader studio
**[Try it live →](https://leonxlnx.github.io/lumenshaders/)**
@@ -21,28 +21,34 @@ 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.
- **Randomize** (or press `R`): new style, palette, form, lighting and seed, tuned per art style so results stay good. Sometimes it invents a brand-new synth style.
- **Style**: 9 art modes, each with its own renderer. "Keep style" locks the mode while randomizing.
- **Synth styles**: "New synth style" blends two renderers with a random mix operation (organic mask, screen, multiply, radial, diagonal) into a completely new look, with a blend slider. "Save style" keeps favorites in your browser as one-click chips.
- **Gradient sets**: the Set button renders 4 to 12 variations of the current design (same style and palette, different seeds) and downloads them as a ZIP of PNGs, ideal for using consistent art across one website.
- **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).
- **Share**: every design serializes to a compact code (`LMN1.…`). Copy it, send it to anyone, and pasting it into the Share box recreates the exact design style, colors, seed, all parameters. "Copy link" produces a URL that loads the design directly.
- **Share**: every design serializes to a compact code (`LMN1.…`). Copy it, send it to anyone, and pasting it into the Share box recreates the exact design: style, colors, seed, all parameters. "Copy link" produces a URL that loads the design directly.
- `Space` pauses, `S` saves a PNG.
Full guide: [docs page](https://leonxlnx.github.io/lumenshaders/docs.html).
## Export
Every export button opens a dialog with a live preview and its settings before downloading.
- **PNG** up to 3840×2160, rendered offscreen at full quality.
- **Video** (WebM VP9/VP8) encoded offline with **WebCodecs** (`VideoEncoder`) and muxed by a built-in dependency-free WebM writer no `MediaRecorder`, no realtime capture, no tab crashes. Configurable fps (24/30/60), resolution (720p1440p) and exact length (18 loops or 560 seconds).
- **Video** (WebM VP9/VP8) encoded offline with **WebCodecs** (`VideoEncoder`) and muxed by a built-in dependency-free WebM writer: no `MediaRecorder`, no realtime capture, no tab crashes. Configurable fps (24/30/60), resolution (720p1440p) and exact length (18 loops or 560 seconds).
- **GIF** rendered deterministically frame by frame, encoded in-page with a dependency-free GIF89a encoder (median-cut palette + ordered dithering). Loop forever or play once.
## 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/webmmux.js` WebM/Matroska muxer for WebCodecs output
- `js/exporter.js` PNG / video / GIF pipelines
- `js/palettes.js`, `js/ui.js`, `js/main.js` palettes, control builders, state and randomizer
- `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/webmmux.js`: WebM/Matroska muxer for WebCodecs output
- `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: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 388 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

+174
View File
@@ -0,0 +1,174 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Docs, LUMEN generative shader studio</title>
<meta name="description" content="How to use LUMEN: styles, synth styles, color, motion, share codes, gradient sets and exporting PNG, video or GIF." />
<link rel="icon" type="image/png" sizes="64x64" href="assets/favicon.png" />
<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" />
<style>
body { overflow: auto; }
.docs-wrap {
max-width: 720px;
margin: 0 auto;
padding: 48px 24px 110px;
}
.docs-hero {
margin-bottom: 44px;
}
.docs-hero h1 {
font-family: var(--font-display);
font-size: 30px;
font-weight: 600;
letter-spacing: 0.01em;
margin-bottom: 10px;
}
.docs-hero p { color: var(--text-mid); font-size: 14px; line-height: 1.65; max-width: 56ch; }
.docs-hero .btn { margin-top: 18px; }
.docs-section { margin-bottom: 38px; }
.docs-section h2 {
font-family: var(--font-display);
font-size: 17px;
font-weight: 600;
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid var(--line-soft);
}
.docs-section p, .docs-section li {
font-size: 13.5px;
line-height: 1.7;
color: var(--text-mid);
}
.docs-section p + p { margin-top: 8px; }
.docs-section ul { padding-left: 18px; margin-top: 8px; display: flex; flex-direction: column; gap: 5px; }
.docs-section strong { color: var(--text); font-weight: 600; }
.docs-section code {
font-family: var(--font-mono);
font-size: 11.5px;
background: var(--bg-raised);
border: 1px solid var(--line-soft);
padding: 1.5px 6px;
border-radius: 6px;
color: var(--text);
}
.kbd {
font-family: var(--font-mono);
font-size: 11px;
background: linear-gradient(180deg, rgba(255,255,255,0.09), rgba(255,255,255,0.03));
border: 1px solid var(--line);
box-shadow: inset 0 -1.5px 0 rgba(0,0,0,0.4);
padding: 2px 7px;
border-radius: 6px;
color: var(--text);
}
.docs-table { width: 100%; border-collapse: collapse; margin-top: 10px; }
.docs-table td {
padding: 7px 10px;
border-bottom: 1px solid var(--line-soft);
font-size: 13px;
color: var(--text-mid);
vertical-align: top;
}
.docs-table td:first-child { color: var(--text); font-weight: 500; white-space: nowrap; width: 140px; }
</style>
</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">documentation</span>
</div>
<div class="topbar-actions">
<a class="btn btn-primary" href="index.html">Open the studio</a>
</div>
</header>
<div class="docs-wrap">
<div class="docs-hero">
<h1>How LUMEN works</h1>
<p>LUMEN generates looping abstract shader art in your browser: liquid chrome, silk, soft gradients, halftone fields and more. Everything renders in real time with WebGL2, every animation is a perfect loop, and everything you make can be exported or shared as a code.</p>
</div>
<div class="docs-section">
<h2>Styles</h2>
<p>Pick one of the 9 base styles in the <strong>Style</strong> section: Chrome, Silk, Bloom, Aura, Rays, Halftone, Glyphs, Reeded and Mosaic. Each one is its own renderer with its own character.</p>
<p><strong>Synth styles</strong> go beyond the base set: <code>New synth style</code> blends two renderers with a random mix operation (organic mask, screen, multiply, radial or diagonal split) into a completely new look. The <strong>Synth blend</strong> slider controls how strongly the second layer shows. The big <strong>Randomize</strong> button also produces synth styles some of the time.</p>
<p>Found something you love? <code>Save style</code> stores it in your browser. Saved styles appear as chips you can reload with one click, and they survive page reloads.</p>
</div>
<div class="docs-section">
<h2>Color</h2>
<ul>
<li><strong>Presets:</strong> 16 curated palettes tuned for the renderers.</li>
<li><strong>Swatches A to D:</strong> the four palette colors every style draws from, plus <strong>BG</strong> for the background.</li>
<li><strong>Hue shift, saturation, exposure:</strong> grade the final image without touching the palette.</li>
<li>The dice in the section header rolls a new harmonised palette that fits the current style.</li>
</ul>
</div>
<div class="docs-section">
<h2>Form, lighting, texture, motion</h2>
<table class="docs-table">
<tr><td>Seed</td><td>Every design is deterministic. Same seed, same image. Type a number or roll a new one.</td></tr>
<tr><td>Zoom / Detail</td><td>Size of the forms and the number of noise octaves.</td></tr>
<tr><td>Warp / Turbulence</td><td>How much the field folds into itself and how much it churns while moving.</td></tr>
<tr><td>Stretch</td><td>Anisotropy. Positive stretches horizontally, negative vertically.</td></tr>
<tr><td>Lighting</td><td>Intensity, gloss tightness, light angle, iridescence and bloom glow.</td></tr>
<tr><td>Texture</td><td>Film grain, halftone and glyph density, ridge count, chromatic aberration, vignette, softness.</td></tr>
<tr><td>Loop length</td><td>Duration of one seamless cycle, 2 to 12 seconds.</td></tr>
<tr><td>Travel</td><td>How far the field moves during one loop.</td></tr>
</table>
</div>
<div class="docs-section">
<h2>Share codes</h2>
<p>Every design serializes to a compact code starting with <code>LMN1.</code> It contains the style, all five colors, the seed and every parameter.</p>
<ul>
<li><strong>Copy design code</strong> puts the code on your clipboard. Anyone can paste it into the Share box to get the exact same design.</li>
<li><strong>Copy link</strong> creates a URL with the code in the hash. Opening it loads the design directly.</li>
</ul>
</div>
<div class="docs-section">
<h2>Gradient sets</h2>
<p>The <strong>Set</strong> button generates 4 to 12 variations of your current design that share the style, palette and all settings but use different seeds. They look like one family, which makes them perfect for a website: one for the hero, others for cards or section backgrounds.</p>
<p>Click a tile to apply that seed to the editor, or download the whole set as a ZIP of PNGs in one click.</p>
</div>
<div class="docs-section">
<h2>Export</h2>
<p>Each export button opens a dialog with a live preview and its settings, then downloads on confirm.</p>
<table class="docs-table">
<tr><td>PNG</td><td>Stills up to 3840 x 2160, rendered offscreen at full quality.</td></tr>
<tr><td>WebM video</td><td>Encoded offline with WebCodecs, VP9 or VP8, muxed by a built-in WebM writer. 24, 30 or 60 fps, exact length in loops or seconds.</td></tr>
<tr><td>GIF</td><td>Frame-exact seamless loop, encoded in-page with a dependency-free GIF89a encoder. Optional dithering, loop forever or play once.</td></tr>
</table>
<p>Loops are seamless by construction: the noise field is sampled along a closed circle, so the last frame flows exactly into the first.</p>
</div>
<div class="docs-section">
<h2>Shortcuts</h2>
<table class="docs-table">
<tr><td><span class="kbd">R</span></td><td>Randomize everything</td></tr>
<tr><td><span class="kbd">Space</span></td><td>Play or pause</td></tr>
<tr><td><span class="kbd">S</span></td><td>Save a PNG with current settings</td></tr>
</table>
</div>
<div class="docs-section">
<h2>Tech notes</h2>
<p>LUMEN is a static page with zero dependencies: one WebGL2 uber-shader, a hand-written GIF encoder, a WebM muxer and a store-only ZIP writer. The source is on <a href="https://github.com/Leonxlnx/lumenshaders" style="color: var(--text);">GitHub</a> under the MIT license.</p>
</div>
</div>
</body>
</html>
+24 -3
View File
@@ -3,7 +3,17 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>LUMEN — Generative Shader Studio</title>
<title>LUMEN, the generative shader studio</title>
<meta name="description" content="Generate looping abstract shader art: liquid chrome, silk, halftone, aura and more. Randomize, fine-tune, share design codes and export PNG, video or GIF." />
<link rel="icon" type="image/png" sizes="64x64" href="assets/favicon.png" />
<link rel="apple-touch-icon" href="assets/apple-touch-icon.png" />
<meta property="og:title" content="LUMEN, the generative shader studio" />
<meta property="og:description" content="Looping abstract shader art in your browser. Randomize, fine-tune, share design codes and export PNG, video or GIF. No dependencies." />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://leonxlnx.github.io/lumenshaders/" />
<meta property="og:image" content="https://leonxlnx.github.io/lumenshaders/assets/og.png" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content="https://leonxlnx.github.io/lumenshaders/assets/og.png" />
<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" />
@@ -43,6 +53,15 @@
<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>
<button class="btn" id="btn-set" title="Generate a consistent set of variations">
<svg viewBox="0 0 16 16" aria-hidden="true"><rect x="1.5" y="1.5" width="5.5" height="5.5" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.4"/><rect x="9" y="1.5" width="5.5" height="5.5" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.4"/><rect x="1.5" y="9" width="5.5" height="5.5" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.4"/><rect x="9" y="9" width="5.5" height="5.5" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.4"/></svg>
Set
</button>
<div class="topbar-divider"></div>
<a class="btn btn-ghost" href="docs.html" title="Documentation">
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3 2 H10 L13 5 V14 H3 Z" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="M5.5 7.5 H10.5 M5.5 10 H10.5" stroke="currentColor" stroke-width="1.2"/></svg>
Docs
</a>
</div>
</header>
@@ -57,7 +76,7 @@
<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-item mono" id="meta-mode">...</span>
<span class="meta-sep"></span>
<span class="meta-item mono dim" id="meta-seed">seed 0000</span>
</div>
@@ -77,7 +96,7 @@
<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="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>
@@ -90,7 +109,9 @@
<script src="js/engine.js"></script>
<script src="js/gifenc.js"></script>
<script src="js/webmmux.js"></script>
<script src="js/zip.js"></script>
<script src="js/exporter.js"></script>
<script src="js/modals.js"></script>
<script src="js/ui.js"></script>
<script src="js/main.js"></script>
</body>
+7 -1
View File
@@ -17,7 +17,8 @@ var Engine = (function () {
"u_scale", "u_complex", "u_warp", "u_flow", "u_stretch",
"u_light", "u_gloss", "u_lightAngle", "u_irid", "u_glow",
"u_grain", "u_cell", "u_lines", "u_ca", "u_vig", "u_soft",
"u_travel"
"u_travel",
"u_synth", "u_modeB", "u_mixOp", "u_blend"
];
function compile(type, src) {
@@ -106,6 +107,11 @@ var Engine = (function () {
gl.uniform1f(uniforms.u_soft, P.soft);
gl.uniform1f(uniforms.u_travel, P.travel);
gl.uniform1i(uniforms.u_synth, P.synthOn ? 1 : 0);
gl.uniform1i(uniforms.u_modeB, P.modeB | 0);
gl.uniform1i(uniforms.u_mixOp, P.mixOp | 0);
gl.uniform1f(uniforms.u_blend, P.blend);
}
function renderAt(phase) {
+1 -1
View File
@@ -91,7 +91,7 @@ var Exporter = (function () {
async function exportVideo(P, aspect) {
if (busy) return;
if (typeof VideoEncoder === "undefined" || typeof VideoFrame === "undefined") {
UI.toast("This browser has no WebCodecs support \u2014 use a current Chrome, Edge or Firefox");
UI.toast("This browser has no WebCodecs support, use a current Chrome, Edge or Firefox");
return;
}
busy = true;
+146 -28
View File
@@ -36,6 +36,7 @@ var P = {
light: 1.175, gloss: 44, lightAngle: 235, irid: 0.012, glow: 0.471,
grain: 0.024, cell: 113, lines: 67, ca: 0.018, vig: 0.079, soft: 1.14,
travel: 0.72, loop: 7.5,
synthOn: false, modeB: 2, mixOp: 0, blend: 0.6,
lockStyle: false,
imgRes: "2160", vidRes: "1080", vidFps: "30", vidLen: "l2",
gifW: "640", gifFps: 25, gifDither: true, gifLoop: true,
@@ -134,14 +135,65 @@ function randomizeKeys(keys) {
function newSeed() { P.seed = Math.floor(rnd() * 10000); }
/* pairs that blend into something coherent instead of mud */
var SYNTH_PAIRS = [
[0, 2], [0, 3], [0, 5], [1, 2], [1, 4], [2, 5], [2, 6], [3, 4],
[3, 5], [4, 5], [4, 6], [2, 7], [3, 8], [0, 8], [1, 5], [4, 8]
];
function generateSynthStyle() {
var pair = SYNTH_PAIRS[Math.floor(rnd() * SYNTH_PAIRS.length)];
var flip = rnd() < 0.5;
P.mode = flip ? pair[1] : pair[0];
P.modeB = flip ? pair[0] : pair[1];
P.mixOp = Math.floor(rnd() * 5);
P.blend = 0.4 + rnd() * 0.55;
P.synthOn = true;
}
function synthName() {
return "SYN " + MODES[P.mode].name.slice(0, 3).toUpperCase() + "+" +
MODES[P.modeB].name.slice(0, 3).toUpperCase();
}
function randomizeAll() {
if (!P.lockStyle) P.mode = Math.floor(rnd() * MODES.length);
if (!P.lockStyle) {
if (rnd() < 0.38) {
generateSynthStyle();
} else {
P.synthOn = false;
P.mode = Math.floor(rnd() * MODES.length);
}
}
randomizePalette();
randomizeKeys(FORM_KEYS.concat(LIGHT_KEYS, TEXTURE_KEYS, MOTION_KEYS));
newSeed();
refreshAll();
}
/* ---------------- saved styles (localStorage) ---------------- */
var STYLES_KEY = "lumen-styles-v1";
function loadSavedStyles() {
try { return JSON.parse(localStorage.getItem(STYLES_KEY)) || []; }
catch (e) { return []; }
}
function persistSavedStyles(list) {
try { localStorage.setItem(STYLES_KEY, JSON.stringify(list)); } catch (e) {}
}
function saveCurrentStyle() {
var list = loadSavedStyles();
var base = P.synthOn ? synthName() : MODES[P.mode].name;
var name = base + " " + String(Math.round(P.seed)).padStart(4, "0");
list.unshift({ name: name, code: encodeDesign(), ts: Date.now() });
if (list.length > 24) list.length = 24;
persistSavedStyles(list);
renderSavedStyles();
UI.toast("Style saved: " + name);
}
var renderSavedStyles = function () {};
/* ---------------- design codes (share) ---------------- */
var SHARE_NUMS = [
@@ -153,10 +205,11 @@ var SHARE_NUMS = [
];
function encodeDesign() {
var arr = [1, P.mode, Math.round(P.seed),
var arr = [2, P.mode, Math.round(P.seed),
P.c1.slice(1), P.c2.slice(1), P.c3.slice(1), P.c4.slice(1), P.bg.slice(1),
P.aspect];
SHARE_NUMS.forEach(function (k) { arr.push(Math.round(P[k] * 1000) / 1000); });
arr.push(P.synthOn ? 1 : 0, P.modeB | 0, P.mixOp | 0, Math.round(P.blend * 1000) / 1000);
var b64 = btoa(JSON.stringify(arr))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
return "LMN1." + b64;
@@ -169,7 +222,10 @@ function decodeDesign(code) {
var b64 = code.slice(5).replace(/-/g, "+").replace(/_/g, "/");
while (b64.length % 4) b64 += "=";
var arr = JSON.parse(atob(b64));
if (!Array.isArray(arr) || arr[0] !== 1 || arr.length !== 9 + SHARE_NUMS.length) return false;
var baseLen = 9 + SHARE_NUMS.length;
var isV1 = Array.isArray(arr) && arr[0] === 1 && arr.length === baseLen;
var isV2 = Array.isArray(arr) && arr[0] === 2 && arr.length === baseLen + 4;
if (!isV1 && !isV2) return false;
var hexOk = function (h) { return /^[0-9a-fA-F]{6}$/.test(h); };
if (![arr[3], arr[4], arr[5], arr[6], arr[7]].every(hexOk)) return false;
@@ -183,6 +239,15 @@ function decodeDesign(code) {
var v = Number(arr[9 + i]);
if (isFinite(v)) P[k] = v;
});
if (isV2) {
P.synthOn = !!arr[baseLen];
P.modeB = Math.min(Math.max(Math.round(arr[baseLen + 1]) || 0, 0), MODES.length - 1);
P.mixOp = Math.min(Math.max(Math.round(arr[baseLen + 2]) || 0, 0), 4);
var bl = Number(arr[baseLen + 3]);
P.blend = isFinite(bl) ? Math.min(Math.max(bl, 0), 1) : 0.6;
} else {
P.synthOn = false;
}
activePreset = -1;
setPresetActive(-1);
refreshAll();
@@ -226,10 +291,56 @@ function buildRail() {
/* STYLE */
var sStyle = UI.section(rail, "Style", function () {
P.synthOn = false;
P.mode = Math.floor(rnd() * MODES.length);
refreshAll();
});
reg(UI.modeGrid(sStyle, MODES, get("mode"), function (v) { P.mode = v; updateMeta(); }));
reg(UI.modeGrid(sStyle, MODES, function () { return P.synthOn ? -1 : P.mode; },
function (v) { P.mode = v; P.synthOn = false; refreshAll(); }));
/* synth: generated styles */
var synthRow = UI.el("div", "share-row synth-row", sStyle);
var btnSynth = UI.el("button", "mini-btn", synthRow);
btnSynth.innerHTML = '<svg viewBox="0 0 16 16"><path d="M2 8 C4 3 7 13 9 8 C11 3 13 11 14 8" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="8" cy="2.5" r="1.2" fill="currentColor"/></svg>New synth style';
btnSynth.addEventListener("click", function () {
generateSynthStyle();
newSeed();
refreshAll();
UI.toast("Generated " + synthName());
});
var btnSave = UI.el("button", "mini-btn", synthRow);
btnSave.innerHTML = '<svg viewBox="0 0 16 16"><path d="M3 2 H11 L14 5 V14 H3 Z" fill="none" stroke="currentColor" stroke-width="1.5"/><rect x="5.5" y="9" width="5" height="4" fill="currentColor"/></svg>Save style';
btnSave.addEventListener("click", saveCurrentStyle);
var synthCtl = UI.el("div", "synth-ctl", sStyle);
reg(UI.slider(synthCtl, { label: "Synth blend", min: 0, max: 1, step: 0.01, fmt: fmt2,
get: get("blend"), set: function (v) { P.blend = v; updateMeta(); } }));
reg(function () { synthCtl.style.display = P.synthOn ? "" : "none"; });
var savedWrap = UI.el("div", "saved-styles", sStyle);
renderSavedStyles = function () {
savedWrap.innerHTML = "";
var list = loadSavedStyles();
list.forEach(function (st, i) {
var chip = UI.el("button", "saved-chip", savedWrap);
var label = UI.el("span", null, chip);
label.textContent = st.name;
var del = UI.el("span", "saved-del", chip);
del.innerHTML = "&times;";
del.addEventListener("click", function (ev) {
ev.stopPropagation();
var l = loadSavedStyles();
l.splice(i, 1);
persistSavedStyles(l);
renderSavedStyles();
});
chip.addEventListener("click", function () {
if (decodeDesign(st.code)) UI.toast("Loaded " + st.name);
});
});
};
renderSavedStyles();
reg(UI.lockRow(sStyle, { label: "Keep style when randomizing", get: get("lockStyle"), set: set("lockStyle") }));
/* COLOR */
@@ -286,7 +397,7 @@ function buildRail() {
var btnCopyCode = UI.el("button", "mini-btn", shareRow);
btnCopyCode.textContent = "Copy design code";
btnCopyCode.addEventListener("click", function () {
copyText(encodeDesign(), "Design code copied \u2014 paste it anywhere");
copyText(encodeDesign(), "Design code copied, paste it anywhere");
});
var btnCopyLink = UI.el("button", "mini-btn", shareRow);
btnCopyLink.textContent = "Copy link";
@@ -315,36 +426,28 @@ function buildRail() {
e.stopPropagation();
});
/* EXPORT */
/* EXPORT: buttons open a dialog with preview and settings */
var sExp = UI.section(rail, "Export", null);
reg(UI.selectRow(sExp, { label: "Image size", options: [["1080", "1920 \u00d7 1080"], ["1440", "2560 \u00d7 1440"], ["2160", "3840 \u00d7 2160"]], get: get("imgRes"), set: set("imgRes") }));
reg(UI.selectRow(sExp, { label: "Video size", options: [["720", "720p"], ["1080", "1080p"], ["1440", "1440p"]], get: get("vidRes"), set: set("vidRes") }));
reg(UI.selectRow(sExp, { label: "Video fps", options: [["24", "24 fps"], ["30", "30 fps"], ["60", "60 fps"]], get: get("vidFps"), set: set("vidFps") }));
reg(UI.selectRow(sExp, { label: "Video length", options: [
["l1", "1 loop"], ["l2", "2 loops"], ["l3", "3 loops"], ["l4", "4 loops"], ["l6", "6 loops"], ["l8", "8 loops"],
["s5", "5 seconds"], ["s10", "10 seconds"], ["s15", "15 seconds"], ["s30", "30 seconds"], ["s60", "60 seconds"]
], get: get("vidLen"), set: set("vidLen") }));
reg(UI.selectRow(sExp, { label: "GIF width", options: [["360", "360 px"], ["480", "480 px"], ["640", "640 px"], ["800", "800 px"]], get: get("gifW"), set: set("gifW") }));
reg(UI.selectRow(sExp, { label: "GIF fps", options: [["15", "15 fps"], ["20", "20 fps"], ["25", "25 fps"], ["30", "30 fps"]], get: function () { return String(P.gifFps); }, set: function (v) { P.gifFps = parseInt(v, 10); } }));
reg(UI.toggleRow(sExp, { label: "GIF dithering", get: get("gifDither"), set: set("gifDither") }));
reg(UI.toggleRow(sExp, { label: "GIF loop forever", get: get("gifLoop"), set: set("gifLoop") }));
var grid = UI.el("div", "export-grid", sExp);
UI.exportButton(grid, "Save image", "PNG",
UI.exportButton(grid, "Image", "PNG",
'<svg viewBox="0 0 16 16"><rect x="1.5" y="1.5" width="13" height="13" rx="2" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="5.5" cy="5.5" r="1.5" fill="currentColor"/><path d="M2 12 L6 8 L9 11 L11.5 8.5 L14 11" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>',
function () { Exporter.exportPNG(P, ASPECTS[P.aspect]); });
UI.exportButton(grid, "Record video", "WEBM",
function () { Modals.openExport("png"); });
UI.exportButton(grid, "Video", "WEBM",
'<svg viewBox="0 0 16 16"><rect x="1.5" y="3.5" width="9" height="9" rx="2" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M10.5 7 L14.5 4.5 V11.5 L10.5 9" fill="currentColor"/></svg>',
function () { Exporter.exportVideo(P, ASPECTS[P.aspect]); });
UI.exportButton(grid, "Render loop GIF", "GIF",
function () { Modals.openExport("video"); });
UI.exportButton(grid, "Looping GIF", "GIF",
'<svg viewBox="0 0 16 16"><path d="M13.5 8 a5.5 5.5 0 1 1 -1.6 -3.9" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M13.8 1.6 V4.4 H11" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>',
function () { Exporter.exportGIF(P, ASPECTS[P.aspect]); });
function () { Modals.openExport("gif"); });
UI.exportButton(grid, "Gradient set", "ZIP",
'<svg viewBox="0 0 16 16"><rect x="1.5" y="1.5" width="5.5" height="5.5" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.4"/><rect x="9" y="1.5" width="5.5" height="5.5" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.4"/><rect x="1.5" y="9" width="5.5" height="5.5" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.4"/><rect x="9" y="9" width="5.5" height="5.5" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.4"/></svg>',
function () { Modals.openSetGenerator(); });
}
/* ---------------- stage / meta ---------------- */
function updateMeta() {
document.getElementById("meta-mode").textContent = MODES[P.mode].full;
document.getElementById("meta-mode").textContent =
P.synthOn ? synthName() : MODES[P.mode].full;
document.getElementById("meta-seed").textContent = "seed " + String(Math.round(P.seed)).padStart(4, "0");
document.getElementById("meta-loop").textContent = P.loop.toFixed(1) + "s loop";
var s = Engine.size();
@@ -414,6 +517,20 @@ document.addEventListener("DOMContentLoaded", function () {
}
}
/* intro: elements pop in big and settle into place, one after another */
(function intro() {
var items = [];
document.querySelectorAll(".topbar > *").forEach(function (n) { items.push(n); });
items.push(document.querySelector(".canvas-frame"));
items.push(document.querySelector(".stage-meta"));
document.querySelectorAll(".rail-section").forEach(function (n) { items.push(n); });
items.forEach(function (n, i) {
if (n) n.style.setProperty("--intro-d", (i * 65) + "ms");
});
document.body.classList.add("intro");
setTimeout(function () { document.body.classList.remove("intro"); }, items.length * 65 + 1100);
})();
Engine.onFps(function (fps) {
document.getElementById("meta-fps").textContent = fps + " fps";
});
@@ -422,9 +539,10 @@ document.addEventListener("DOMContentLoaded", function () {
document.getElementById("btn-play").addEventListener("click", function () {
setPlayingUI(!Engine.isPlaying());
});
document.getElementById("btn-export-png").addEventListener("click", function () { Exporter.exportPNG(P, ASPECTS[P.aspect]); });
document.getElementById("btn-export-video").addEventListener("click", function () { Exporter.exportVideo(P, ASPECTS[P.aspect]); });
document.getElementById("btn-export-gif").addEventListener("click", function () { Exporter.exportGIF(P, ASPECTS[P.aspect]); });
document.getElementById("btn-export-png").addEventListener("click", function () { Modals.openExport("png"); });
document.getElementById("btn-export-video").addEventListener("click", function () { Modals.openExport("video"); });
document.getElementById("btn-export-gif").addEventListener("click", function () { Modals.openExport("gif"); });
document.getElementById("btn-set").addEventListener("click", function () { Modals.openSetGenerator(); });
document.addEventListener("keydown", function (e) {
var tag = (e.target.tagName || "").toLowerCase();
+261
View File
@@ -0,0 +1,261 @@
/* Modal system: export dialog with preview and settings, and the set
generator that produces N consistent variations of the current design. */
var Modals = (function () {
function el(tag, cls, parent) {
var e = document.createElement(tag);
if (cls) e.className = cls;
if (parent) parent.appendChild(e);
return e;
}
var root = null;
function open(title, subtitle) {
close();
root = el("div", "modal-backdrop", document.body);
var card = el("div", "modal-card", root);
var head = el("div", "modal-head", card);
var tWrap = el("div", null, head);
var t = el("div", "modal-title", tWrap);
t.textContent = title;
if (subtitle) {
var s = el("div", "modal-sub mono", tWrap);
s.textContent = subtitle;
}
var x = el("button", "modal-close", head);
x.innerHTML = '<svg viewBox="0 0 12 12"><path d="M2 2 L10 10 M10 2 L2 10" stroke="currentColor" stroke-width="1.6" fill="none"/></svg>';
x.addEventListener("click", close);
root.addEventListener("click", function (e) { if (e.target === root) close(); });
var body = el("div", "modal-body", card);
return body;
}
function close() {
if (root) { root.remove(); root = null; }
}
function snapshotInto(canvas2d, aspect) {
var src = Engine.canvas();
var ctx = canvas2d.getContext("2d");
ctx.fillStyle = "#000";
ctx.fillRect(0, 0, canvas2d.width, canvas2d.height);
ctx.drawImage(src, 0, 0, canvas2d.width, canvas2d.height);
}
/* ---------- export dialog ---------- */
function selectField(parent, label, options, value, onChange) {
var row = el("div", "field-row", parent);
var lab = el("span", "ctl-label", row);
lab.textContent = label;
var sel = el("select", null, row);
options.forEach(function (o) {
var op = el("option", null, sel);
op.value = o[0]; op.textContent = o[1];
});
sel.value = value;
sel.addEventListener("change", function () { onChange(sel.value); });
return sel;
}
function toggleField(parent, label, value, onChange) {
var row = el("div", "toggle-row", parent);
var lab = el("span", "ctl-label", row);
lab.textContent = label;
var tg = el("button", "toggle" + (value ? " on" : ""), row);
tg.addEventListener("click", function () {
var on = !tg.classList.contains("on");
tg.classList.toggle("on", on);
onChange(on);
});
}
function openExport(kind) {
var titles = { png: "Export image", video: "Export video", gif: "Export GIF" };
var body = open(titles[kind], MODES[P.mode].full + " \u00b7 seed " + Math.round(P.seed));
/* live preview */
var prevWrap = el("div", "modal-preview", body);
var pv = el("canvas", null, prevWrap);
var ar = ASPECTS[P.aspect];
pv.width = 480; pv.height = Math.round(480 / ar);
snapshotInto(pv, ar);
var pvTimer = setInterval(function () {
if (!document.body.contains(pv)) { clearInterval(pvTimer); return; }
snapshotInto(pv, ar);
}, 120);
var pvMeta = el("div", "modal-preview-meta mono", prevWrap);
var form = el("div", "modal-form", body);
function metaText() {
if (kind === "png") {
var h = parseInt(P.imgRes, 10);
return Math.round(h * ar) + " \u00d7 " + h + " px";
}
if (kind === "video") {
var vh = parseInt(P.vidRes, 10);
var fps = parseInt(P.vidFps, 10);
var sec = (String(P.vidLen).charAt(0) === "s")
? parseInt(String(P.vidLen).slice(1), 10)
: P.loop * parseInt(String(P.vidLen).slice(1), 10);
return Math.round(vh * ar) + " \u00d7 " + vh + " \u00b7 " + fps + " fps \u00b7 " +
sec.toFixed(1) + "s \u00b7 " + Math.round(sec * fps) + " frames";
}
var gw = parseInt(P.gifW, 10);
return gw + " \u00d7 " + Math.round(gw / ar) + " \u00b7 " + P.gifFps + " fps \u00b7 " +
Math.round(P.loop * P.gifFps) + " frames \u00b7 " + P.loop.toFixed(1) + "s loop";
}
function refreshMeta() { pvMeta.textContent = metaText(); }
refreshMeta();
if (kind === "png") {
selectField(form, "Resolution", [["1080", "1920 \u00d7 1080"], ["1440", "2560 \u00d7 1440"], ["2160", "3840 \u00d7 2160"]],
P.imgRes, function (v) { P.imgRes = v; refreshMeta(); });
} else if (kind === "video") {
selectField(form, "Resolution", [["720", "720p"], ["1080", "1080p"], ["1440", "1440p"]],
P.vidRes, function (v) { P.vidRes = v; refreshMeta(); });
selectField(form, "Frame rate", [["24", "24 fps"], ["30", "30 fps"], ["60", "60 fps"]],
P.vidFps, function (v) { P.vidFps = v; refreshMeta(); });
selectField(form, "Length", [
["l1", "1 loop"], ["l2", "2 loops"], ["l3", "3 loops"], ["l4", "4 loops"], ["l6", "6 loops"], ["l8", "8 loops"],
["s5", "5 seconds"], ["s10", "10 seconds"], ["s15", "15 seconds"], ["s30", "30 seconds"], ["s60", "60 seconds"]
], P.vidLen, function (v) { P.vidLen = v; refreshMeta(); });
} else {
selectField(form, "Width", [["360", "360 px"], ["480", "480 px"], ["640", "640 px"], ["800", "800 px"]],
P.gifW, function (v) { P.gifW = v; refreshMeta(); });
selectField(form, "Frame rate", [["15", "15 fps"], ["20", "20 fps"], ["25", "25 fps"], ["30", "30 fps"]],
String(P.gifFps), function (v) { P.gifFps = parseInt(v, 10); refreshMeta(); });
toggleField(form, "Dithering", P.gifDither, function (v) { P.gifDither = v; });
toggleField(form, "Loop forever", P.gifLoop, function (v) { P.gifLoop = v; });
}
var actions = el("div", "modal-actions", body);
var dl = el("button", "btn btn-primary modal-dl", actions);
dl.innerHTML = '<svg viewBox="0 0 16 16"><path d="M8 2 V10 M4.5 7 L8 10.5 L11.5 7" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M3 13.5 H13" stroke="currentColor" stroke-width="1.6"/></svg>Download ' +
(kind === "png" ? "PNG" : kind === "video" ? "WebM" : "GIF");
dl.addEventListener("click", function () {
close();
var a = ASPECTS[P.aspect];
if (kind === "png") Exporter.exportPNG(P, a);
else if (kind === "video") Exporter.exportVideo(P, a);
else Exporter.exportGIF(P, a);
});
}
/* ---------- set generator ---------- */
function setSeeds(base, n) {
var seeds = [];
for (var i = 0; i < n; i++) seeds.push((base + 73 + i * 911) % 10000);
return seeds;
}
async function openSetGenerator() {
var body = open("Gradient set", "consistent variations of the current design");
var info = el("div", "modal-note", body);
info.textContent = "Same style, palette and settings with different seeds. Use a set for hero, cards and section backgrounds that visually belong together.";
var form = el("div", "modal-form", body);
var state = { count: 6, res: "1080" };
selectField(form, "Variations", [["4", "4"], ["6", "6"], ["8", "8"], ["12", "12"]],
"6", function (v) { state.count = parseInt(v, 10); build(); });
selectField(form, "PNG size", [["720", "1280 \u00d7 720"], ["1080", "1920 \u00d7 1080"], ["2160", "3840 \u00d7 2160"]],
"1080", function (v) { state.res = v; });
var grid = el("div", "set-grid", body);
var actions = el("div", "modal-actions", body);
var dl = el("button", "btn btn-primary modal-dl", actions);
dl.innerHTML = '<svg viewBox="0 0 16 16"><path d="M8 2 V10 M4.5 7 L8 10.5 L11.5 7" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M3 13.5 H13" stroke="currentColor" stroke-width="1.6"/></svg>Download set as ZIP';
var seeds = [];
async function build() {
grid.innerHTML = "";
seeds = setSeeds(Math.round(P.seed), state.count);
var ar = ASPECTS[P.aspect];
var origSeed = P.seed;
var prev = Engine.size();
var wasPlaying = Engine.isPlaying();
Engine.suspend();
Engine.setPlaying(false);
Engine.setSize(320, 2 * Math.round(320 / ar / 2));
for (var i = 0; i < seeds.length; i++) {
P.seed = seeds[i];
Engine.renderAt(0.3);
var tile = el("button", "set-tile", grid);
var c = el("canvas", null, tile);
c.width = 320; c.height = Math.round(320 / ar);
c.getContext("2d").drawImage(Engine.canvas(), 0, 0, c.width, c.height);
var lab = el("span", "set-tile-label mono", tile);
lab.textContent = "#" + String(seeds[i]).padStart(4, "0");
(function (sd) {
tile.addEventListener("click", function () {
P.seed = sd;
refreshAll();
UI.toast("Applied seed " + sd);
close();
});
})(seeds[i]);
await new Promise(function (r) { setTimeout(r, 0); });
}
P.seed = origSeed;
Engine.setSize(prev[0], prev[1]);
Engine.setPlaying(wasPlaying);
Engine.resume();
}
dl.addEventListener("click", async function () {
dl.disabled = true;
dl.textContent = "Rendering\u2026";
var ar = ASPECTS[P.aspect];
var h = parseInt(state.res, 10);
var w = 2 * Math.round(h * ar / 2);
var origSeed = P.seed;
var prev = Engine.size();
var wasPlaying = Engine.isPlaying();
Engine.suspend();
Engine.setPlaying(false);
Engine.setSize(w, h);
var entries = [];
for (var i = 0; i < seeds.length; i++) {
P.seed = seeds[i];
Engine.renderAt(0.3);
var blob = await new Promise(function (res) { Engine.canvas().toBlob(res, "image/png"); });
if (blob) {
entries.push({
name: "lumen-set-" + String(i + 1).padStart(2, "0") + "-seed" + String(seeds[i]).padStart(4, "0") + ".png",
data: new Uint8Array(await blob.arrayBuffer())
});
}
dl.textContent = "Rendering " + (i + 1) + "/" + seeds.length + "\u2026";
await new Promise(function (r) { setTimeout(r, 0); });
}
P.seed = origSeed;
Engine.setSize(prev[0], prev[1]);
Engine.setPlaying(wasPlaying);
Engine.resume();
var zip = ZipWriter.build(entries);
var a = document.createElement("a");
a.href = URL.createObjectURL(zip);
a.download = "lumen-set-" + MODES[P.mode].key + ".zip";
a.click();
setTimeout(function () { URL.revokeObjectURL(a.href); }, 4000);
UI.toast("Set saved: " + entries.length + " PNGs (" + w + "\u00d7" + h + ")");
close();
});
await build();
}
return { openExport: openExport, openSetGenerator: openSetGenerator, close: close };
})();
+48 -10
View File
@@ -29,6 +29,12 @@ uniform float u_light, u_gloss, u_lightAngle, u_irid, u_glow;
uniform float u_grain, u_cell, u_lines, u_ca, u_vig, u_soft;
uniform float u_travel;
/* synth: procedural style combinator */
uniform int u_synth; // 0 = single mode, 1 = blend two modes
uniform int u_modeB;
uniform int u_mixOp; // 0 noise mask, 1 screen, 2 multiply, 3 radial, 4 diagonal
uniform float u_blend;
out vec4 fragColor;
#define TAU 6.28318530718
@@ -404,23 +410,55 @@ vec3 sceneMosaic(vec2 uv){
/* ---------------- dispatch + post ---------------- */
vec3 scene(vec2 uv){
if (u_mode == 0) return sceneChrome(uv);
if (u_mode == 1) return sceneSilk(uv);
if (u_mode == 2) return sceneBloom(uv);
if (u_mode == 3) return sceneAura(uv);
if (u_mode == 4) return sceneRays(uv);
if (u_mode == 5) return sceneHalftone(uv);
if (u_mode == 6) return sceneGlyphs(uv);
if (u_mode == 7) return sceneReeded(uv);
vec3 sceneFor(int m, vec2 uv){
if (m == 0) return sceneChrome(uv);
if (m == 1) return sceneSilk(uv);
if (m == 2) return sceneBloom(uv);
if (m == 3) return sceneAura(uv);
if (m == 4) return sceneRays(uv);
if (m == 5) return sceneHalftone(uv);
if (m == 6) return sceneGlyphs(uv);
if (m == 7) return sceneReeded(uv);
return sceneMosaic(uv);
}
vec3 scene(vec2 uv){
vec3 a = sceneFor(u_mode, uv);
if (u_synth == 0) return a;
vec3 b = sceneFor(u_modeB, uv);
float asp = u_res.x/u_res.y;
vec2 c = (uv - 0.5)*vec2(asp, 1.0);
if (u_mixOp == 1) { /* screen: layered light */
return mix(a, 1.0 - (1.0 - a)*(1.0 - b), u_blend);
}
if (u_mixOp == 2) { /* multiply with lift */
return mix(a, a*b*1.6 + a*0.12, u_blend);
}
if (u_mixOp == 3) { /* radial: B grows from center */
float m = smoothstep(0.15, 0.85, length(c)*1.15);
return mix(b, a, mix(1.0, m, u_blend));
}
if (u_mixOp == 4) { /* soft diagonal split */
float ang = TAU*hash11(u_seed*0.091 + 5.0);
float m = smoothstep(-0.45, 0.45, cos(ang)*c.x + sin(ang)*c.y);
return mix(a, b, m*u_blend);
}
/* default: organic noise mask */
float m = fbm(c*1.6 + SO()*0.7 + LT()*0.5);
m = smoothstep(0.32, 0.68, m);
return mix(a, b, m*u_blend);
}
void main(){
vec2 uv = gl_FragCoord.xy/u_res;
vec3 col;
if (u_ca > 0.004){
/* CA re-renders the scene per channel; with synth blending that would
inline the full mode dispatch 6 times and break the D3D linker, so
aberration only applies to single-mode renders */
if (u_ca > 0.004 && u_synth == 0){
vec2 off = (uv-0.5)*u_ca*0.016;
col = vec3(scene(uv-off).r, scene(uv).g, scene(uv+off).b);
} else {
+91
View File
@@ -0,0 +1,91 @@
/* Minimal store-only (no compression) ZIP writer for batch PNG export. */
var ZipWriter = (function () {
var CRC_TABLE = (function () {
var t = new Uint32Array(256);
for (var n = 0; n < 256; n++) {
var c = n;
for (var k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
t[n] = c >>> 0;
}
return t;
})();
function crc32(buf) {
var c = 0xFFFFFFFF;
for (var i = 0; i < buf.length; i++) {
c = CRC_TABLE[(c ^ buf[i]) & 0xFF] ^ (c >>> 8);
}
return (c ^ 0xFFFFFFFF) >>> 0;
}
function dosDateTime() {
var d = new Date();
var time = (d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1);
var date = ((d.getFullYear() - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate();
return { time: time, date: date };
}
/* entries: [{ name: string, data: Uint8Array }] -> Blob */
function build(entries) {
var parts = [];
var central = [];
var offset = 0;
var dt = dosDateTime();
entries.forEach(function (e) {
var nameBytes = new TextEncoder().encode(e.name);
var crc = crc32(e.data);
var local = new Uint8Array(30 + nameBytes.length);
var v = new DataView(local.buffer);
v.setUint32(0, 0x04034b50, true);
v.setUint16(4, 20, true); // version needed
v.setUint16(6, 0x0800, true); // utf8 flag
v.setUint16(8, 0, true); // store
v.setUint16(10, dt.time, true);
v.setUint16(12, dt.date, true);
v.setUint32(14, crc, true);
v.setUint32(18, e.data.length, true);
v.setUint32(22, e.data.length, true);
v.setUint16(26, nameBytes.length, true);
v.setUint16(28, 0, true);
local.set(nameBytes, 30);
parts.push(local, e.data);
var cd = new Uint8Array(46 + nameBytes.length);
var c = new DataView(cd.buffer);
c.setUint32(0, 0x02014b50, true);
c.setUint16(4, 20, true);
c.setUint16(6, 20, true);
c.setUint16(8, 0x0800, true);
c.setUint16(10, 0, true);
c.setUint16(12, dt.time, true);
c.setUint16(14, dt.date, true);
c.setUint32(16, crc, true);
c.setUint32(20, e.data.length, true);
c.setUint32(24, e.data.length, true);
c.setUint16(28, nameBytes.length, true);
c.setUint32(42, offset, true);
cd.set(nameBytes, 46);
central.push(cd);
offset += local.length + e.data.length;
});
var cdSize = 0;
central.forEach(function (c) { cdSize += c.length; });
var end = new Uint8Array(22);
var ev = new DataView(end.buffer);
ev.setUint32(0, 0x06054b50, true);
ev.setUint16(8, entries.length, true);
ev.setUint16(10, entries.length, true);
ev.setUint32(12, cdSize, true);
ev.setUint32(16, offset, true);
return new Blob(parts.concat(central, [end]), { type: "application/zip" });
}
return { build: build };
})();
+272 -39
View File
@@ -6,22 +6,24 @@
:root {
--bg: #09090b;
--bg-stage: #0c0c0f;
--bg-rail: #101013;
--bg-raised: #16161a;
--bg-hover: #1c1c21;
--line: #222228;
--line-soft: #1a1a1f;
--bg-rail: rgba(17, 17, 21, 0.82);
--bg-raised: rgba(255, 255, 255, 0.045);
--bg-hover: rgba(255, 255, 255, 0.09);
--line: rgba(255, 255, 255, 0.10);
--line-soft: rgba(255, 255, 255, 0.06);
--glass-hi: rgba(255, 255, 255, 0.14);
--text: #ececef;
--text-mid: #9b9ba4;
--text-dim: #5e5e68;
--focus: #d7d7de;
--radius-s: 6px;
--radius-m: 9px;
--radius-s: 9px;
--radius-m: 13px;
--radius-l: 18px;
--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;
--topbar-h: 54px;
--rail-w: 312px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
@@ -51,7 +53,9 @@ button { font: inherit; color: inherit; cursor: pointer; background: none; borde
justify-content: space-between;
padding: 0 14px 0 18px;
border-bottom: 1px solid var(--line-soft);
background: var(--bg);
background: rgba(12, 12, 15, 0.75);
backdrop-filter: blur(18px) saturate(1.2);
-webkit-backdrop-filter: blur(18px) saturate(1.2);
position: relative;
z-index: 5;
}
@@ -82,27 +86,48 @@ button { font: inherit; color: inherit; cursor: pointer; background: none; borde
display: inline-flex;
align-items: center;
gap: 7px;
height: 32px;
padding: 0 13px;
height: 33px;
padding: 0 14px;
border-radius: var(--radius-s);
border: 1px solid var(--line);
background: var(--bg-raised);
background: linear-gradient(180deg, rgba(255,255,255,0.07), rgba(255,255,255,0.025));
box-shadow: inset 0 1px 0 var(--glass-hi), 0 1px 3px rgba(0,0,0,0.3);
color: var(--text);
font-size: 12.5px;
font-weight: 500;
transition: background 130ms ease, border-color 130ms ease, transform 80ms ease;
text-decoration: none;
transition: background 160ms ease, border-color 160ms ease, transform 140ms cubic-bezier(.34,1.56,.64,1), box-shadow 160ms 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 svg { width: 14px; height: 14px; flex: none; transition: transform 220ms cubic-bezier(.34,1.56,.64,1); }
.btn:hover {
background: linear-gradient(180deg, rgba(255,255,255,0.12), rgba(255,255,255,0.05));
border-color: rgba(255,255,255,0.20);
transform: translateY(-1px);
box-shadow: inset 0 1px 0 var(--glass-hi), 0 6px 16px -6px rgba(0,0,0,0.55);
}
.btn:hover svg { transform: scale(1.18) rotate(-4deg); }
.btn:active { transform: translateY(0.5px) scale(0.98); }
.btn-primary {
background: var(--text);
border-color: var(--text);
background: linear-gradient(180deg, #ffffff, #dfdfe6);
border-color: rgba(255,255,255,0.85);
color: #0a0a0c;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.9), 0 2px 8px -2px rgba(255,255,255,0.18);
}
.btn-primary:hover { background: #fff; border-color: #fff; }
.btn-primary:hover {
background: linear-gradient(180deg, #ffffff, #f2f2f6);
border-color: #fff;
box-shadow: inset 0 1px 0 #fff, 0 8px 22px -6px rgba(255,255,255,0.28);
}
.btn-ghost {
background: transparent;
border-color: transparent;
box-shadow: none;
color: var(--text-mid);
}
.btn-ghost:hover { color: var(--text); background: var(--bg-hover); border-color: var(--line); }
.icon-btn {
width: 28px; height: 28px;
@@ -165,11 +190,12 @@ button { font: inherit; color: inherit; cursor: pointer; background: none; borde
#view {
max-width: 100%;
max-height: 100%;
border-radius: 10px;
border-radius: var(--radius-l);
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);
0 0 0 1px rgba(255,255,255,0.07),
inset 0 1px 0 rgba(255,255,255,0.08),
0 30px 80px -20px rgba(0,0,0,0.9),
0 8px 26px -10px rgba(0,0,0,0.65);
background: #000;
}
@@ -190,14 +216,23 @@ button { font: inherit; color: inherit; cursor: pointer; background: none; borde
width: var(--rail-w);
flex: none;
background: var(--bg-rail);
backdrop-filter: blur(22px) saturate(1.25);
-webkit-backdrop-filter: blur(22px) saturate(1.25);
border-left: 1px solid var(--line-soft);
box-shadow: inset 1px 0 0 rgba(255,255,255,0.03);
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: #26262d transparent;
scrollbar-color: rgba(255,255,255,0.14) transparent;
}
.rail::-webkit-scrollbar { width: 8px; }
.rail::-webkit-scrollbar-thumb { background: #26262d; border-radius: 4px; border: 2px solid var(--bg-rail); }
.rail::-webkit-scrollbar { width: 9px; }
.rail::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.12);
border-radius: 5px;
border: 3px solid transparent;
background-clip: content-box;
}
.rail::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.22); background-clip: content-box; }
.rail-section { border-bottom: 1px solid var(--line-soft); padding: 14px 16px 17px; }
.rail-section:last-child { border-bottom: none; padding-bottom: 28px; }
@@ -242,16 +277,21 @@ button { font: inherit; color: inherit; cursor: pointer; background: none; borde
padding: 9px 2px 8px;
border-radius: var(--radius-s);
border: 1px solid var(--line-soft);
background: var(--bg-raised);
background: linear-gradient(180deg, rgba(255,255,255,0.04), rgba(255,255,255,0.012));
box-shadow: inset 0 1px 0 rgba(255,255,255,0.05);
color: var(--text-dim);
transition: border-color 130ms ease, color 130ms ease, background 130ms ease;
transition: border-color 160ms ease, color 160ms ease, background 160ms ease,
transform 180ms cubic-bezier(.34,1.56,.64,1);
}
.mode-card svg { width: 26px; height: 18px; }
.mode-card svg { width: 26px; height: 18px; transition: transform 220ms cubic-bezier(.34,1.56,.64,1); }
.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:hover { color: var(--text-mid); border-color: rgba(255,255,255,0.16); transform: translateY(-1.5px); }
.mode-card:hover svg { transform: scale(1.15); }
.mode-card:active { transform: scale(0.96); }
.mode-card.active {
border-color: #4a4a55;
background: #1d1d23;
border-color: rgba(255,255,255,0.34);
background: linear-gradient(180deg, rgba(255,255,255,0.13), rgba(255,255,255,0.05));
box-shadow: inset 0 1px 0 rgba(255,255,255,0.2), 0 4px 14px -6px rgba(0,0,0,0.6);
color: var(--text);
}
@@ -494,18 +534,25 @@ select { cursor: pointer; appearance: none; padding-right: 22px;
align-items: center;
gap: 10px;
width: 100%;
height: 38px;
padding: 0 13px;
height: 40px;
padding: 0 14px;
border-radius: var(--radius-s);
border: 1px solid var(--line);
background: var(--bg-raised);
background: linear-gradient(180deg, rgba(255,255,255,0.06), rgba(255,255,255,0.02));
box-shadow: inset 0 1px 0 rgba(255,255,255,0.08);
color: var(--text);
font-size: 12.5px;
font-weight: 500;
transition: background 130ms ease, border-color 130ms ease;
transition: background 160ms ease, border-color 160ms ease,
transform 160ms cubic-bezier(.34,1.56,.64,1), box-shadow 160ms ease;
}
.export-btn svg { width: 14px; height: 14px; color: var(--text-mid); }
.export-btn:hover { background: var(--bg-hover); border-color: #2e2e36; }
.export-btn svg { width: 14px; height: 14px; color: var(--text-mid); transition: transform 220ms cubic-bezier(.34,1.56,.64,1), color 160ms ease; }
.export-btn:hover {
background: linear-gradient(180deg, rgba(255,255,255,0.11), rgba(255,255,255,0.04));
border-color: rgba(255,255,255,0.2);
transform: translateX(2px);
}
.export-btn:hover svg { transform: scale(1.2); color: var(--text); }
.export-btn .ext {
margin-left: auto;
font-family: var(--font-mono);
@@ -599,6 +646,192 @@ select { cursor: pointer; appearance: none; padding-right: 22px;
.canvas-frame { padding: 18px; }
}
/* ---------- synth + saved styles ---------- */
.synth-row { margin-top: 10px; }
.synth-ctl { margin-top: 8px; }
.mini-btn svg { width: 12px; height: 12px; margin-right: 2px; transition: transform 220ms cubic-bezier(.34,1.56,.64,1); }
.mini-btn:hover svg { transform: rotate(8deg) scale(1.15); }
.saved-styles { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
.saved-styles:empty { display: none; }
.saved-chip {
display: inline-flex;
align-items: center;
gap: 7px;
height: 25px;
padding: 0 5px 0 10px;
border-radius: 13px;
border: 1px solid var(--line);
background: linear-gradient(180deg, rgba(255,255,255,0.06), rgba(255,255,255,0.02));
color: var(--text-mid);
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.03em;
transition: color 150ms ease, border-color 150ms ease, transform 150ms cubic-bezier(.34,1.56,.64,1);
}
.saved-chip:hover { color: var(--text); border-color: rgba(255,255,255,0.22); transform: translateY(-1px); }
.saved-del {
width: 15px; height: 15px;
display: inline-flex; align-items: center; justify-content: center;
border-radius: 50%;
font-size: 11px;
color: var(--text-dim);
transition: background 130ms ease, color 130ms ease;
}
.saved-del:hover { background: rgba(255,80,80,0.18); color: #ff8d8d; }
/* ---------- modals ---------- */
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 40;
background: rgba(5,5,8,0.6);
backdrop-filter: blur(10px) saturate(1.1);
-webkit-backdrop-filter: blur(10px) saturate(1.1);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
animation: backdrop-in 220ms ease;
}
@keyframes backdrop-in { from { opacity: 0; } to { opacity: 1; } }
.modal-card {
width: min(560px, 100%);
max-height: 88vh;
overflow-y: auto;
background: rgba(20, 20, 25, 0.92);
backdrop-filter: blur(26px) saturate(1.3);
-webkit-backdrop-filter: blur(26px) saturate(1.3);
border: 1px solid rgba(255,255,255,0.12);
border-radius: var(--radius-l);
box-shadow:
inset 0 1px 0 rgba(255,255,255,0.12),
0 40px 110px -24px rgba(0,0,0,0.95);
padding: 20px 22px 22px;
animation: card-in 320ms cubic-bezier(.22,1.3,.36,1);
scrollbar-width: thin;
scrollbar-color: rgba(255,255,255,0.14) transparent;
}
@keyframes card-in {
from { opacity: 0; transform: scale(1.06) translateY(10px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
.modal-card::-webkit-scrollbar { width: 9px; }
.modal-card::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.12);
border-radius: 5px;
border: 3px solid transparent;
background-clip: content-box;
}
.modal-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 16px;
}
.modal-title {
font-family: var(--font-display);
font-weight: 600;
font-size: 16px;
letter-spacing: 0.01em;
}
.modal-sub { font-size: 10.5px; color: var(--text-dim); margin-top: 3px; }
.modal-close {
width: 28px; height: 28px;
display: inline-flex; align-items: center; justify-content: center;
border-radius: 8px;
color: var(--text-dim);
transition: background 130ms ease, color 130ms ease, transform 160ms ease;
}
.modal-close svg { width: 11px; height: 11px; }
.modal-close:hover { background: var(--bg-hover); color: var(--text); transform: rotate(90deg); }
.modal-preview {
position: relative;
border-radius: var(--radius-m);
overflow: hidden;
margin-bottom: 16px;
box-shadow: 0 0 0 1px rgba(255,255,255,0.08), 0 12px 30px -12px rgba(0,0,0,0.7);
}
.modal-preview canvas { display: block; width: 100%; height: auto; }
.modal-preview-meta {
position: absolute;
left: 10px; bottom: 8px;
font-size: 10px;
color: rgba(255,255,255,0.92);
background: rgba(8,8,12,0.55);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
padding: 4px 9px;
border-radius: 7px;
letter-spacing: 0.04em;
}
.modal-form { display: flex; flex-direction: column; gap: 2px; margin-bottom: 14px; }
.modal-note {
font-size: 11.5px;
color: var(--text-mid);
line-height: 1.55;
margin-bottom: 14px;
}
.modal-actions { display: flex; justify-content: flex-end; }
.modal-dl { height: 38px; padding: 0 18px; font-size: 13px; }
.modal-dl svg { width: 14px; height: 14px; }
.modal-dl:disabled { opacity: 0.55; pointer-events: none; }
/* ---------- set generator grid ---------- */
.set-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-bottom: 16px;
}
.set-tile {
position: relative;
border-radius: var(--radius-s);
overflow: hidden;
border: 1px solid rgba(255,255,255,0.1);
padding: 0;
transition: transform 180ms cubic-bezier(.34,1.56,.64,1), border-color 160ms ease, box-shadow 160ms ease;
}
.set-tile canvas { display: block; width: 100%; height: auto; }
.set-tile:hover {
transform: scale(1.045);
border-color: rgba(255,255,255,0.45);
box-shadow: 0 10px 24px -10px rgba(0,0,0,0.8);
z-index: 1;
}
.set-tile-label {
position: absolute;
left: 6px; bottom: 5px;
font-size: 9px;
color: rgba(255,255,255,0.9);
background: rgba(8,8,12,0.55);
padding: 2px 6px;
border-radius: 5px;
}
/* ---------- intro stagger animation ---------- */
@keyframes intro-pop {
0% { opacity: 0; transform: scale(1.5) translateY(-6px); filter: blur(5px); }
60% { opacity: 1; filter: blur(0); }
100% { opacity: 1; transform: scale(1) translateY(0); filter: blur(0); }
}
.intro .topbar > *,
.intro .rail-section,
.intro .stage-meta,
.intro .canvas-frame {
animation: intro-pop 640ms cubic-bezier(.22,1.25,.36,1) both;
animation-delay: var(--intro-d, 0ms);
}
.intro .canvas-frame { animation-duration: 800ms; }
@media (prefers-reduced-motion: reduce) {
* { transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; }
}