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