Compare commits
12 Commits
e39ee3d9ed
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3780832a0c | |||
| ef8e52cb85 | |||
| d3471018b4 | |||
| 3c54400025 | |||
| e309bc71d6 | |||
| 87e9af6b0f | |||
| bf0e0517e2 | |||
| 5f7d0c72f7 | |||
| b1de6a8e56 | |||
| 458cac66b7 | |||
| 6d8c96d1db | |||
| dcd29a97d5 |
@@ -1,48 +1,86 @@
|
||||
# LUMEN — Generative Shader Studio
|
||||
# LUMEN 生成式着色器工作室
|
||||
|
||||
**[Try it live →](https://leonxlnx.github.io/lumenshaders/)**
|
||||
**[在线体验 →](https://lumenshaders.vercel.app/)**
|
||||
|
||||
A self-contained web tool that generates looping abstract shader art: liquid chrome, silk ribbons, soft gradient blooms, aura rings, light rays, halftone fields, data glyphs, reeded glass and pixel mosaics. Everything is rendered in real time with WebGL2 and every animation is a mathematically perfect loop.
|
||||
一个自包含的 Web 工具,用于生成循环抽象着色器艺术:液态铬、丝带、柔和渐变绽放、光环、光线、半调场、数据字形、罗纹玻璃和像素马赛克。所有内容均通过 WebGL2 实时渲染,每段动画都是数学上的完美循环。
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
## Run it
|
||||
## 运行
|
||||
|
||||
No build step, no dependencies.
|
||||
无构建步骤,零依赖。
|
||||
|
||||
- **Live demo:** [leonxlnx.github.io/lumenshaders](https://leonxlnx.github.io/lumenshaders/)
|
||||
- **Local:** double-click `index.html`, or run `npx http-server . -p 8080`
|
||||
- **在线演示:** [lumenshaders.vercel.app](https://lumenshaders.vercel.app/)
|
||||
- **本地:** 双击 `index.html`,或运行 `npx http-server . -p 8080`
|
||||
|
||||
Requires a browser with WebGL2 (Chrome, Edge, Firefox).
|
||||
需要支持 WebGL2 的浏览器(Chrome、Edge、Firefox)。
|
||||
|
||||
## Controls
|
||||
## 部署(Caddy)
|
||||
|
||||
- **Randomize** (or press `R`): new style, palette, form, lighting and seed, tuned per art style so results stay good.
|
||||
- **Style**: 9 art modes, each with its own renderer. "Keep style" locks the mode while randomizing.
|
||||
- **Color**: 16 curated palettes plus a harmonic palette generator, 4 editable colors + background, hue / saturation / exposure.
|
||||
- **Form**: seed, zoom, fbm detail, domain warp, turbulence, anisotropic stretch.
|
||||
- **Lighting**: intensity, gloss, light angle, iridescence, glow, contrast.
|
||||
- **Texture**: film grain, halftone/glyph density, ridge count, chromatic aberration, vignette, softness.
|
||||
- **Motion**: loop length and travel distance. Loops are seamless by construction (the noise field is sampled along a closed circle).
|
||||
- **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.
|
||||
```bash
|
||||
mkdir -p /var/www/lumenshaders
|
||||
cp -r /root/lumenshaders/* /var/www/lumenshaders/
|
||||
sudo chown -R caddy:caddy /var/www/lumenshaders/
|
||||
sudo find /var/www/lumenshaders/ -type d -exec chmod 755 {} \;
|
||||
sudo find /var/www/lumenshaders/ -type f -exec chmod 644 {} \;
|
||||
```
|
||||
|
||||
## Export
|
||||
```Caddyfile
|
||||
yourdomain.com {
|
||||
root * /var/www/lumenshaders
|
||||
file_server
|
||||
encode {
|
||||
zstd
|
||||
gzip
|
||||
minimum_length 512
|
||||
}
|
||||
header {
|
||||
X-Content-Type-Options "nosniff"
|
||||
X-Frame-Options "DENY"
|
||||
Referrer-Policy "strict-origin-when-cross-origin"
|
||||
}
|
||||
@static {
|
||||
file
|
||||
path *.ico *.css *.js *.gif *.jpg *.jpeg *.png *.svg *.woff *.woff2
|
||||
}
|
||||
header @static Cache-Control "public, max-age=31536000, must-revalidate"
|
||||
}
|
||||
```
|
||||
|
||||
- **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).
|
||||
- **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
|
||||
- **随机化**(或按 `R`):新风格、调色板、形态、光照和种子,按艺术风格调校,确保结果始终出色。有时会发明全新的合成风格。
|
||||
- **风格**:9 种艺术模式,每种都有独立的渲染器。"随机化时保持风格"在随机化时锁定模式。
|
||||
- **生成风格**:"新风格"运行 12 基因风格合成器(6 种场类型 × 5 种域几何 × 4 种色彩映射 × 4 种着色模型 × 4 种表面叠加),产生基础集合中不存在的独立风格,如 FLUX(通量)、RIDGE(脊线)、WAVE(波)、RING(环)、CELL(细胞)和 FLOW(流)原型。"保存风格"将收藏保存在浏览器中,以一键标签形式显示。
|
||||
- **渐变套装**:套装按钮渲染当前设计的 4 至 12 个变体(相同风格和调色板,不同种子),并下载为 PNG 的 ZIP 文件,非常适合在网站上使用一致的视觉艺术。
|
||||
- **色彩**:16 种精选调色板加和谐调色板生成器,4 个可编辑颜色 + 背景,色相/饱和度/曝光。
|
||||
- **形态**:种子、缩放、fbm 细节、域扭曲、湍流、各向异性拉伸。
|
||||
- **光照**:强度、光泽、光照角度、虹彩、辉光、对比度。
|
||||
- **纹理**:胶片颗粒、半调/字形密度、脊线数量、色差、暗角、柔和度。
|
||||
- **运动**:循环长度和行程距离。循环在构造上是无缝的(噪声场沿闭合圆采样)。
|
||||
- **分享**:每个设计可序列化为紧凑代码(`LMN1.…`)。复制它,发送给任何人,粘贴到分享框即可重建完全相同的设计:风格、颜色、种子、所有参数。"复制链接"生成直接加载设计的 URL。
|
||||
- `空格`暂停,`S` 保存 PNG。
|
||||
|
||||
- `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
|
||||
完整指南:[文档页面](https://lumenshaders.vercel.app/docs.html)。
|
||||
|
||||
## 导出
|
||||
|
||||
每个导出按钮打开一个带有实时预览和设置的对话框,确认后下载。
|
||||
|
||||
- **PNG** 最高 3840×2160,离屏全质量渲染。
|
||||
- **视频**(WebM VP9/VP8)使用 **WebCodecs**(`VideoEncoder`)离线编码,由内置无依赖 WebM 封装器封装:无 `MediaRecorder`,无实时捕获,无标签页崩溃。可配置帧率(24/30/60 帧/秒)、分辨率(720p–1440p)和精确时长(1–8 个循环或 5–60 秒)。
|
||||
- **GIF** 逐帧确定性渲染,使用无依赖 GIF89a 编码器(中值切割调色板 + 有序抖动)在页面内编码。永久循环或播放一次。
|
||||
|
||||
## 文件
|
||||
|
||||
- `index.html` / `styles.css`:应用外壳和设计系统
|
||||
- `js/shaders.js`:包含全部 9 种模式的超级片段着色器
|
||||
- `js/engine.js`:WebGL2 引擎和渲染循环
|
||||
- `js/gifenc.js`:GIF 编码器(量化器 + LZW)
|
||||
- `js/webmmux.js`:WebCodecs 输出的 WebM/Matroska 封装器
|
||||
- `js/exporter.js`:PNG / 视频 / GIF 管线
|
||||
- `js/palettes.js`、`js/ui.js`、`js/main.js`:调色板、控件构建器、状态和随机化器
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 442 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
@@ -0,0 +1,184 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>文档 - LUMEN 生成式着色器工作室</title>
|
||||
<meta name="description" content="LUMEN 使用指南:风格、合成风格、色彩、运动、分享代码、渐变套装及导出 PNG、视频或 GIF。" />
|
||||
<link rel="canonical" href="https://lumenshaders.vercel.app/docs.html" />
|
||||
<meta property="og:url" content="https://lumenshaders.vercel.app/docs.html" />
|
||||
<meta property="og:image" content="https://lumenshaders.vercel.app/assets/og.jpg" />
|
||||
<meta property="og:image:type" content="image/jpeg" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<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">文档</span>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
<a class="btn btn-primary" href="index.html">打开工作室</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="docs-wrap">
|
||||
<div class="docs-hero">
|
||||
<h1>LUMEN 工作原理</h1>
|
||||
<p>LUMEN 在你的浏览器中生成循环抽象着色器艺术:液态铬、丝绸、柔和渐变、半调场等。所有内容均通过 WebGL2 实时渲染,每段动画都是完美循环,你的所有创作都可以导出或以代码形式分享。</p>
|
||||
</div>
|
||||
|
||||
<div class="docs-section">
|
||||
<h2>风格</h2>
|
||||
<p>在<strong>风格</strong>区域选择 9 种基础风格之一:铬、丝绸、绽放、光环、光线、半调、字形、罗纹和马赛克。每种风格都有独立的渲染器和独特的视觉特征。</p>
|
||||
<p><strong>生成风格</strong>远超基础集合:<code>新风格</code>运行一个 12 基因风格合成器,涵盖场类型、域几何、色彩映射、着色模型和表面叠加。每组基因都是一个独立风格,不存在于基础九种之中,以其场原型命名:FLUX(通量)、RIDGE(脊线)、WAVE(波)、RING(环)、CELL(细胞)或 FLOW(流)。<strong>随机化</strong>大按钮有时也会发现新风格。</p>
|
||||
<p>找到喜欢的了?<code>保存风格</code>将其存储在浏览器中。保存的风格以标签形式显示,一键即可重新加载,且页面刷新后依然保留。</p>
|
||||
</div>
|
||||
|
||||
<div class="docs-section">
|
||||
<h2>色彩</h2>
|
||||
<ul>
|
||||
<li><strong>预设:</strong>16 种为渲染器精心调校的调色板。</li>
|
||||
<li><strong>色板 A 至 D:</strong>每种风格绘制所用的四种调色板颜色,加上<strong>背景</strong>色。</li>
|
||||
<li><strong>色相偏移、饱和度、曝光:</strong>在不修改调色板的情况下调整最终图像。</li>
|
||||
<li>分区标题中的骰子按钮会随机生成一个适合当前风格的和谐调色板。</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="docs-section">
|
||||
<h2>形态、光照、纹理、运动</h2>
|
||||
<table class="docs-table">
|
||||
<tr><td>种子</td><td>每个设计都是确定性的。相同种子,相同图像。输入数字或随机生成。</td></tr>
|
||||
<tr><td>缩放/细节</td><td>形态大小和噪声八度数。</td></tr>
|
||||
<tr><td>扭曲/湍流</td><td>场向自身折叠的程度和运动时的搅动程度。</td></tr>
|
||||
<tr><td>拉伸</td><td>各向异性。正值水平拉伸,负值垂直拉伸。</td></tr>
|
||||
<tr><td>光照</td><td>强度、光泽紧度、光照角度、虹彩和辉光。</td></tr>
|
||||
<tr><td>纹理</td><td>胶片颗粒、半调和字形密度、脊线数量、色差、暗角、柔和度。</td></tr>
|
||||
<tr><td>循环长度</td><td>一个无缝循环的持续时间,2 至 12 秒。</td></tr>
|
||||
<tr><td>行程</td><td>场在一次循环中移动的距离。</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="docs-section">
|
||||
<h2>分享代码</h2>
|
||||
<p>每个设计可序列化为以 <code>LMN1.</code> 开头的紧凑代码。它包含风格、全部五种颜色、种子和所有参数。</p>
|
||||
<ul>
|
||||
<li><strong>复制设计代码</strong>将代码复制到剪贴板。任何人都可以粘贴到分享框中获得完全相同的设计。</li>
|
||||
<li><strong>复制链接</strong>创建一个包含代码的 URL。打开链接即可直接加载设计。</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="docs-section">
|
||||
<h2>渐变套装</h2>
|
||||
<p><strong>套装</strong>按钮生成当前设计的 4 至 12 个变体,它们共享风格、调色板和所有设置,但使用不同的种子。它们看起来像一个系列,非常适合网站使用:一个用于主视觉,其他用于卡片或分区背景。</p>
|
||||
<p>点击缩略图将该种子应用到编辑器,或一键下载整套 PNG 的 ZIP 文件。</p>
|
||||
</div>
|
||||
|
||||
<div class="docs-section">
|
||||
<h2>导出</h2>
|
||||
<p>每个导出按钮打开一个带有实时预览和设置的对话框,确认后下载。</p>
|
||||
<table class="docs-table">
|
||||
<tr><td>PNG</td><td>最高 3840×2160 静态图像,离屏全质量渲染。</td></tr>
|
||||
<tr><td>WebM 视频</td><td>使用 WebCodecs 离线编码,VP9 或 VP8,由内置 WebM 封装器封装。24、30 或 60 帧/秒,精确时长以循环数或秒数指定。</td></tr>
|
||||
<tr><td>GIF</td><td>帧精确无缝循环,使用无依赖 GIF89a 编码器在页面内编码。可选抖动,永久循环或播放一次。</td></tr>
|
||||
</table>
|
||||
<p>循环在构造上是无缝的:噪声场沿闭合圆采样,因此最后一帧精确过渡到第一帧。</p>
|
||||
</div>
|
||||
|
||||
<div class="docs-section">
|
||||
<h2>快捷键</h2>
|
||||
<table class="docs-table">
|
||||
<tr><td><span class="kbd">R</span></td><td>随机化全部</td></tr>
|
||||
<tr><td><span class="kbd">Space</span></td><td>播放或暂停</td></tr>
|
||||
<tr><td><span class="kbd">S</span></td><td>以当前设置保存 PNG</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="docs-section">
|
||||
<h2>技术说明</h2>
|
||||
<p>LUMEN 是一个零依赖的静态页面:一个 WebGL2 超级着色器、一个手写 GIF 编码器、一个 WebM 封装器和一个仅存储的 ZIP 写入器。源代码在 <a href="https://github.com/Leonxlnx/lumenshaders" style="color: var(--text);">GitHub</a> 上,采用 MIT 许可证。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.va = window.va || function () { (window.vaq = window.vaq || []).push(arguments); };
|
||||
</script>
|
||||
<script defer src="/_vercel/insights/script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+64
-26
@@ -1,15 +1,33 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>LUMEN — Generative Shader Studio</title>
|
||||
<title>LUMEN 生成式着色器工作室</title>
|
||||
<meta name="description" content="生成循环抽象着色器艺术:液态铬、丝绸、半调、光环等。随机化、微调、分享设计代码并导出 PNG、视频或 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 生成式着色器工作室" />
|
||||
<meta property="og:description" content="在浏览器中生成循环抽象着色器艺术。随机化、微调、分享设计代码并导出 PNG、视频或 GIF。零依赖。" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://lumenshaders.vercel.app/" />
|
||||
<meta property="og:image" content="https://lumenshaders.vercel.app/assets/og.jpg" />
|
||||
<meta property="og:image:secure_url" content="https://lumenshaders.vercel.app/assets/og.jpg" />
|
||||
<meta property="og:image:type" content="image/jpeg" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:image:alt" content="LUMEN 生成式着色器工作室,展示罗纹玻璃余晖艺术" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="LUMEN 生成式着色器工作室" />
|
||||
<meta name="twitter:description" content="在浏览器中生成循环抽象着色器艺术。随机化、微调、分享设计代码并导出 PNG、视频或 GIF。" />
|
||||
<meta name="twitter:image" content="https://lumenshaders.vercel.app/assets/og.jpg" />
|
||||
<link rel="canonical" href="https://lumenshaders.vercel.app/" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Space+Grotesk:wght@500;600&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<body class="booting">
|
||||
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
@@ -18,7 +36,7 @@
|
||||
<path d="M10 5.5 L14.5 10 L10 14.5 L5.5 10 Z" fill="currentColor"/>
|
||||
</svg>
|
||||
<span class="brand-name">LUMEN</span>
|
||||
<span class="brand-sub">generative shader studio</span>
|
||||
<span class="brand-sub">生成式着色器工作室</span>
|
||||
</div>
|
||||
|
||||
<div class="topbar-center">
|
||||
@@ -26,47 +44,60 @@
|
||||
</div>
|
||||
|
||||
<div class="topbar-actions">
|
||||
<button class="btn btn-primary" id="btn-random" title="Randomize everything (R)">
|
||||
<button class="btn btn-primary" id="btn-random" title="随机化全部 (R)">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><rect x="1.5" y="1.5" width="13" height="13" rx="3" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="5.4" cy="5.4" r="1.25" fill="currentColor"/><circle cx="10.6" cy="10.6" r="1.25" fill="currentColor"/><circle cx="10.6" cy="5.4" r="1.25" fill="currentColor"/><circle cx="5.4" cy="10.6" r="1.25" fill="currentColor"/></svg>
|
||||
Randomize
|
||||
随机化
|
||||
</button>
|
||||
<div class="topbar-divider"></div>
|
||||
<button class="btn" id="btn-export-png" title="Save still image (S)">
|
||||
<button class="btn" id="btn-export-png" title="保存静态图像 (S)">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><rect x="1.5" y="1.5" width="13" height="13" rx="2" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="5.5" cy="5.5" r="1.5" fill="currentColor"/><path d="M2 12 L6 8 L9 11 L11.5 8.5 L14 11" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>
|
||||
Image
|
||||
图像
|
||||
</button>
|
||||
<button class="btn" id="btn-export-video" title="Record looping video">
|
||||
<button class="btn" id="btn-export-video" title="录制循环视频">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><rect x="1.5" y="3.5" width="9" height="9" rx="2" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M10.5 7 L14.5 4.5 V11.5 L10.5 9" fill="currentColor"/></svg>
|
||||
Video
|
||||
视频
|
||||
</button>
|
||||
<button class="btn" id="btn-export-gif" title="Render seamless looping GIF">
|
||||
<button class="btn" id="btn-export-gif" title="渲染无缝循环 GIF">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M13.5 8 a5.5 5.5 0 1 1 -1.6 -3.9" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M13.8 1.6 V4.4 H11" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>
|
||||
GIF
|
||||
</button>
|
||||
<button class="btn" id="btn-set" title="生成一组一致的变体">
|
||||
<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>
|
||||
套装
|
||||
</button>
|
||||
<div class="topbar-divider"></div>
|
||||
<a class="btn btn-ghost" href="docs.html" title="文档">
|
||||
<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>
|
||||
文档
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="app">
|
||||
<section class="stage" id="stage">
|
||||
<div class="canvas-frame" id="canvas-frame">
|
||||
<canvas id="view"></canvas>
|
||||
<div class="canvas-shell" id="canvas-shell">
|
||||
<div class="canvas-boot" id="canvas-boot" aria-hidden="true"></div>
|
||||
<canvas id="view"></canvas>
|
||||
<div class="stage-error" id="stage-error" hidden></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stage-meta">
|
||||
<div class="meta-left">
|
||||
<button class="icon-btn" id="btn-play" title="Play / pause (Space)">
|
||||
<button class="icon-btn" id="btn-play" title="播放/暂停 (空格)">
|
||||
<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>
|
||||
<span class="meta-item mono dim" id="meta-seed">种子 0000</span>
|
||||
</div>
|
||||
<div class="meta-right">
|
||||
<span class="meta-item mono dim" id="meta-loop">4.0s loop</span>
|
||||
<span class="meta-item mono dim" id="meta-loop">4.0s 循环</span>
|
||||
<span class="meta-sep"></span>
|
||||
<span class="meta-item mono dim" id="meta-res">0×0</span>
|
||||
<span class="meta-sep"></span>
|
||||
<span class="meta-item mono dim" id="meta-fps">60 fps</span>
|
||||
<span class="meta-item mono dim" id="meta-fps">60 帧/秒</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -76,22 +107,29 @@
|
||||
|
||||
<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-title" id="overlay-title">渲染中</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>
|
||||
<button class="btn overlay-cancel" id="overlay-cancel">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast mono" id="toast" hidden></div>
|
||||
|
||||
<script src="js/palettes.js"></script>
|
||||
<script src="js/shaders.js"></script>
|
||||
<script src="js/engine.js"></script>
|
||||
<script src="js/palettes.js?v=zh1"></script>
|
||||
<script src="js/shaders.js?v=fix4"></script>
|
||||
<script src="js/engine.js?v=fix4"></script>
|
||||
<script src="js/gifenc.js"></script>
|
||||
<script src="js/webmmux.js"></script>
|
||||
<script src="js/exporter.js"></script>
|
||||
<script src="js/ui.js"></script>
|
||||
<script src="js/main.js"></script>
|
||||
<script src="js/zip.js"></script>
|
||||
<script src="js/fx.js"></script>
|
||||
<script src="js/exporter.js?v=zh1"></script>
|
||||
<script src="js/modals.js?v=zh1"></script>
|
||||
<script src="js/ui.js?v=zh1"></script>
|
||||
<script src="js/main.js?v=zh1"></script>
|
||||
<script>
|
||||
window.va = window.va || function () { (window.vaq = window.vaq || []).push(arguments); };
|
||||
</script>
|
||||
<script defer src="/_vercel/insights/script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+135
-20
@@ -5,9 +5,12 @@ var Engine = (function () {
|
||||
var canvas, gl, program, uniforms = {};
|
||||
var playing = true;
|
||||
var suspended = false;
|
||||
var started = false;
|
||||
var ready = false;
|
||||
var loopT = 0; // seconds into current loop
|
||||
var lastTick = 0;
|
||||
var fps = 60, fpsAcc = 0, fpsN = 0, fpsCb = null;
|
||||
var maxFps = 60, minFrameMs = 1000 / 60, lastDraw = 0;
|
||||
var getParams = null; // injected: () => P
|
||||
|
||||
var UNIFORM_NAMES = [
|
||||
@@ -17,7 +20,9 @@ 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",
|
||||
"u_genome", "u_g1", "u_g2", "u_g3"
|
||||
];
|
||||
|
||||
function compile(type, src) {
|
||||
@@ -30,24 +35,72 @@ var Engine = (function () {
|
||||
return sh;
|
||||
}
|
||||
|
||||
function init(canvasEl, paramsGetter) {
|
||||
/* Two-stage boot: the slim base shader (no genome) links in a second
|
||||
or two and gets pixels on screen; the full shader compiles in the
|
||||
background via KHR_parallel_shader_compile and is swapped in when
|
||||
the driver finishes. The main thread never blocks on a long link. */
|
||||
|
||||
var fullReady = false;
|
||||
|
||||
function buildProgram(fragSrc, parallel, cb) {
|
||||
var prog = gl.createProgram();
|
||||
try {
|
||||
gl.attachShader(prog, compile(gl.VERTEX_SHADER, VERT_SRC));
|
||||
gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, fragSrc));
|
||||
} catch (e) {
|
||||
cb(null, String(e.message || e));
|
||||
return;
|
||||
}
|
||||
gl.linkProgram(prog);
|
||||
|
||||
function check() {
|
||||
if (gl.isContextLost()) { cb(null, "context lost"); return; }
|
||||
if (gl.getProgramParameter(prog, gl.LINK_STATUS)) cb(prog, null);
|
||||
else cb(null, gl.getProgramInfoLog(prog) || "unknown link error");
|
||||
}
|
||||
|
||||
if (parallel) {
|
||||
(function poll() {
|
||||
if (gl.isContextLost()) { cb(null, "context lost"); return; }
|
||||
if (gl.getProgramParameter(prog, parallel.COMPLETION_STATUS_KHR)) check();
|
||||
else setTimeout(poll, 80);
|
||||
})();
|
||||
} else {
|
||||
setTimeout(check, 30);
|
||||
}
|
||||
}
|
||||
|
||||
function adoptProgram(prog) {
|
||||
program = prog;
|
||||
gl.useProgram(program);
|
||||
UNIFORM_NAMES.forEach(function (n) {
|
||||
uniforms[n] = gl.getUniformLocation(program, n);
|
||||
});
|
||||
}
|
||||
|
||||
function init(canvasEl, paramsGetter, opts) {
|
||||
opts = opts || {};
|
||||
canvas = canvasEl;
|
||||
getParams = paramsGetter;
|
||||
gl = canvas.getContext("webgl2", {
|
||||
antialias: false,
|
||||
preserveDrawingBuffer: true,
|
||||
powerPreference: "high-performance"
|
||||
powerPreference: "default"
|
||||
});
|
||||
if (!gl) throw new Error("WebGL2 not available");
|
||||
|
||||
program = gl.createProgram();
|
||||
gl.attachShader(program, compile(gl.VERTEX_SHADER, VERT_SRC));
|
||||
gl.attachShader(program, compile(gl.FRAGMENT_SHADER, FRAG_SRC));
|
||||
gl.linkProgram(program);
|
||||
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
||||
throw new Error("Program link error:\n" + gl.getProgramInfoLog(program));
|
||||
if (!gl) {
|
||||
if (opts.onError) { opts.onError("WebGL2 not available"); return; }
|
||||
throw new Error("WebGL2 not available");
|
||||
}
|
||||
gl.useProgram(program);
|
||||
|
||||
canvas.addEventListener("webglcontextlost", function (e) {
|
||||
e.preventDefault();
|
||||
suspended = true;
|
||||
started = false;
|
||||
ready = false;
|
||||
if (opts.onContextLost) opts.onContextLost();
|
||||
}, false);
|
||||
|
||||
var parallel = gl.getExtension("KHR_parallel_shader_compile");
|
||||
|
||||
var buf = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
|
||||
@@ -55,21 +108,55 @@ var Engine = (function () {
|
||||
gl.enableVertexAttribArray(0);
|
||||
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
UNIFORM_NAMES.forEach(function (n) {
|
||||
uniforms[n] = gl.getUniformLocation(program, n);
|
||||
});
|
||||
buildProgram(FRAG_SRC_BASE, parallel, function (baseProg, err) {
|
||||
if (!baseProg) {
|
||||
if (opts.onError) opts.onError("Program link error: " + err);
|
||||
return;
|
||||
}
|
||||
adoptProgram(baseProg);
|
||||
ready = true;
|
||||
lastTick = performance.now();
|
||||
started = false;
|
||||
|
||||
if (opts.onReady) opts.onReady();
|
||||
if (opts.autostart !== false) start();
|
||||
|
||||
/* upgrade to the full shader (genome styles) in the background */
|
||||
buildProgram(FRAG_SRC_FULL, parallel, function (fullProg, ferr) {
|
||||
if (!fullProg) return; /* keep base; genome styles unavailable */
|
||||
adoptProgram(fullProg);
|
||||
gl.deleteProgram(baseProg);
|
||||
fullReady = true;
|
||||
if (opts.onFullReady) opts.onFullReady();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function isLost() {
|
||||
return !gl || gl.isContextLost();
|
||||
}
|
||||
|
||||
function canRender() {
|
||||
return ready && gl && !gl.isContextLost();
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (!canRender() || started) return;
|
||||
started = true;
|
||||
suspended = false;
|
||||
lastTick = performance.now();
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
function setSize(w, h) {
|
||||
if (!canvas || !canRender()) return;
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
gl.viewport(0, 0, w, h);
|
||||
}
|
||||
|
||||
function pushUniforms(P, phase) {
|
||||
if (!canRender()) return;
|
||||
gl.uniform2f(uniforms.u_res, canvas.width, canvas.height);
|
||||
gl.uniform1f(uniforms.u_phase, phase);
|
||||
gl.uniform1f(uniforms.u_seed, P.seed);
|
||||
@@ -106,9 +193,21 @@ 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);
|
||||
|
||||
var g = P.genes || [0,0,0,0, 0,0,0,0, 0,0,0,0];
|
||||
gl.uniform1i(uniforms.u_genome, P.genomeOn ? 1 : 0);
|
||||
gl.uniform4f(uniforms.u_g1, g[0], g[1], g[2], g[3]);
|
||||
gl.uniform4f(uniforms.u_g2, g[4], g[5], g[6], g[7]);
|
||||
gl.uniform4f(uniforms.u_g3, g[8], g[9], g[10], g[11]);
|
||||
}
|
||||
|
||||
function renderAt(phase) {
|
||||
if (!canRender()) return;
|
||||
var P = getParams();
|
||||
pushUniforms(P, phase);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
||||
@@ -120,7 +219,12 @@ var Engine = (function () {
|
||||
}
|
||||
|
||||
function tick(now) {
|
||||
if (suspended) return;
|
||||
if (suspended || !canRender()) return;
|
||||
if (now - lastDraw < minFrameMs) {
|
||||
requestAnimationFrame(tick);
|
||||
return;
|
||||
}
|
||||
lastDraw = now;
|
||||
|
||||
var dt = Math.min((now - lastTick) / 1000, 0.1);
|
||||
lastTick = now;
|
||||
@@ -140,6 +244,11 @@ var Engine = (function () {
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
function setMaxFps(n) {
|
||||
maxFps = Math.max(15, Math.min(n, 60));
|
||||
minFrameMs = 1000 / maxFps;
|
||||
}
|
||||
|
||||
function readPixels() {
|
||||
var w = canvas.width, h = canvas.height;
|
||||
var buf = new Uint8Array(w * h * 4);
|
||||
@@ -155,22 +264,28 @@ var Engine = (function () {
|
||||
|
||||
return {
|
||||
init: init,
|
||||
start: start,
|
||||
isReady: function () { return ready; },
|
||||
setSize: setSize,
|
||||
renderAt: renderAt,
|
||||
readPixels: readPixels,
|
||||
currentPhase: currentPhase,
|
||||
resetTime: function () { loopT = 0; },
|
||||
setLoopTime: function (t) { loopT = t; },
|
||||
suspend: function () { suspended = true; },
|
||||
suspend: function () { suspended = true; started = false; },
|
||||
resume: function () {
|
||||
if (!canRender()) return;
|
||||
suspended = false;
|
||||
lastTick = performance.now();
|
||||
requestAnimationFrame(tick);
|
||||
started = false;
|
||||
start();
|
||||
},
|
||||
isLost: isLost,
|
||||
setMaxFps: setMaxFps,
|
||||
hasGenome: function () { return fullReady; },
|
||||
setPlaying: function (v) { playing = v; lastTick = performance.now(); },
|
||||
isPlaying: function () { return playing; },
|
||||
onFps: function (cb) { fpsCb = cb; },
|
||||
canvas: function () { return canvas; },
|
||||
size: function () { return [canvas.width, canvas.height]; }
|
||||
size: function () { return canvas ? [canvas.width, canvas.height] : [0, 0]; }
|
||||
};
|
||||
})();
|
||||
|
||||
+14
-14
@@ -9,7 +9,7 @@ var Exporter = (function () {
|
||||
function showOverlay(title) {
|
||||
cancelled = false;
|
||||
$("overlay-title").textContent = title;
|
||||
$("overlay-detail").textContent = "preparing";
|
||||
$("overlay-detail").textContent = "准备中";
|
||||
$("overlay-bar").style.width = "0%";
|
||||
$("overlay").hidden = false;
|
||||
}
|
||||
@@ -49,7 +49,7 @@ var Exporter = (function () {
|
||||
busy = false;
|
||||
if (blob) {
|
||||
download(blob, stamp(P, "png"));
|
||||
UI.toast("Saved " + w + "\u00d7" + h + " PNG");
|
||||
FX.celebrate("已保存 " + w + "\u00d7" + h + " PNG");
|
||||
}
|
||||
}, "image/png");
|
||||
}
|
||||
@@ -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("此浏览器不支持 WebCodecs,请使用最新版 Chrome、Edge 或 Firefox");
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
@@ -104,7 +104,7 @@ var Exporter = (function () {
|
||||
var totalSec = videoDurationSec(P);
|
||||
var nFrames = Math.max(2, Math.round(totalSec * fps));
|
||||
|
||||
showOverlay("Rendering video");
|
||||
showOverlay("渲染视频中");
|
||||
Engine.suspend();
|
||||
Engine.setPlaying(false);
|
||||
Engine.setSize(w, h);
|
||||
@@ -112,7 +112,7 @@ var Exporter = (function () {
|
||||
var picked = await pickEncoderConfig(w, h, fps);
|
||||
if (!picked) {
|
||||
restore();
|
||||
UI.toast("No supported video codec (VP9/VP8) found");
|
||||
UI.toast("未找到支持的视频编码器 (VP9/VP8)");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ var Exporter = (function () {
|
||||
vf.close();
|
||||
|
||||
setProgress(0.9 * (f + 1) / nFrames,
|
||||
"frame " + (f + 1) + "/" + nFrames + " \u00b7 " + w + "\u00d7" + h + " @ " + fps + "fps");
|
||||
"帧 " + (f + 1) + "/" + nFrames + " \u00b7 " + w + "\u00d7" + h + " @ " + fps + "帧/秒");
|
||||
|
||||
/* backpressure: never let the encoder queue grow unbounded */
|
||||
while (encoder.encodeQueueSize > 2) await wait(2);
|
||||
@@ -160,7 +160,7 @@ var Exporter = (function () {
|
||||
}
|
||||
|
||||
if (!cancelled && !encError) {
|
||||
setProgress(0.93, "finalizing encode");
|
||||
setProgress(0.93, "完成编码");
|
||||
await encoder.flush();
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -177,11 +177,11 @@ var Exporter = (function () {
|
||||
if (cancelled) { hideOverlay(); busy = false; return; }
|
||||
if (encError || !encFrames.length) {
|
||||
hideOverlay(); busy = false;
|
||||
UI.toast("Video encode failed" + (encError && encError.message ? ": " + encError.message : ""));
|
||||
UI.toast("视频编码失败" + (encError && encError.message ? ":" + encError.message : ""));
|
||||
return;
|
||||
}
|
||||
|
||||
setProgress(0.97, "writing webm container");
|
||||
setProgress(0.97, "写入 WebM 容器");
|
||||
await wait(0);
|
||||
var webm = WebMMux.mux({
|
||||
codecId: picked.codecId,
|
||||
@@ -193,7 +193,7 @@ var Exporter = (function () {
|
||||
hideOverlay();
|
||||
busy = false;
|
||||
download(new Blob([webm], { type: "video/webm" }), stamp(P, "webm"));
|
||||
UI.toast("Saved " + totalSec.toFixed(1) + "s WEBM \u00b7 " + nFrames + " frames \u00b7 " + w + "\u00d7" + h + " @ " + fps + "fps");
|
||||
FX.celebrate("已保存 " + totalSec.toFixed(1) + "s 视频 \u00b7 " + w + "\u00d7" + h + " @ " + fps + "帧/秒");
|
||||
|
||||
function restore() {
|
||||
Engine.setSize(prev[0], prev[1]);
|
||||
@@ -218,7 +218,7 @@ var Exporter = (function () {
|
||||
var fps = parseInt(P.gifFps, 10);
|
||||
var nFrames = Math.max(2, Math.round(P.loop * fps));
|
||||
|
||||
showOverlay("Rendering GIF");
|
||||
showOverlay("渲染 GIF 中");
|
||||
Engine.setSize(w, h);
|
||||
|
||||
var frames = [];
|
||||
@@ -226,7 +226,7 @@ var Exporter = (function () {
|
||||
if (cancelled) break;
|
||||
Engine.renderAt(f / nFrames);
|
||||
frames.push(Engine.readPixels());
|
||||
setProgress(0.4 * (f + 1) / nFrames, "capturing " + (f + 1) + "/" + nFrames);
|
||||
setProgress(0.4 * (f + 1) / nFrames, "捕获 " + (f + 1) + "/" + nFrames);
|
||||
if (f % 4 === 3) await wait(0);
|
||||
}
|
||||
|
||||
@@ -238,7 +238,7 @@ var Exporter = (function () {
|
||||
var data = await GIFEnc.encode({
|
||||
frames: frames, width: w, height: h, fps: fps,
|
||||
dither: P.gifDither, loop: P.gifLoop,
|
||||
onProgress: function (frac, detail) { setProgress(0.4 + 0.6 * frac, "encoding \u00b7 " + detail); },
|
||||
onProgress: function (frac, detail) { setProgress(0.4 + 0.6 * frac, "编码中 \u00b7 " + detail); },
|
||||
isCancelled: function () { return cancelled; }
|
||||
});
|
||||
|
||||
@@ -246,7 +246,7 @@ var Exporter = (function () {
|
||||
busy = false;
|
||||
if (data && !cancelled) {
|
||||
download(new Blob([data], { type: "image/gif" }), stamp(P, "gif"));
|
||||
UI.toast("Saved " + nFrames + "-frame looping GIF (" + w + "\u00d7" + h + ")");
|
||||
FX.celebrate("已保存 " + nFrames + " 帧循环 GIF (" + w + "\u00d7" + h + ")");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/* Celebration effect for finished exports: a palette-colored particle
|
||||
burst with an expanding ring and a success toast with a drawn check.
|
||||
Pure transform/opacity + one lightweight 2d canvas, auto-cleans. */
|
||||
|
||||
var FX = (function () {
|
||||
|
||||
function celebrate(message) {
|
||||
burst();
|
||||
successToast(message);
|
||||
}
|
||||
|
||||
function burst() {
|
||||
var c = document.createElement("canvas");
|
||||
c.className = "fx-layer";
|
||||
var dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
c.width = innerWidth * dpr;
|
||||
c.height = innerHeight * dpr;
|
||||
document.body.appendChild(c);
|
||||
var ctx = c.getContext("2d");
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
var cx = innerWidth / 2;
|
||||
var cy = innerHeight * 0.42;
|
||||
|
||||
var ring = document.createElement("div");
|
||||
ring.className = "fx-ring";
|
||||
ring.style.left = cx + "px";
|
||||
ring.style.top = cy + "px";
|
||||
document.body.appendChild(ring);
|
||||
setTimeout(function () { ring.remove(); }, 750);
|
||||
|
||||
var colors = [P.c1, P.c2, P.c3, P.c4, "#ffffff"];
|
||||
var parts = [];
|
||||
var N = 110;
|
||||
for (var i = 0; i < N; i++) {
|
||||
var a = Math.random() * Math.PI * 2;
|
||||
var sp = 4 + Math.random() * 13;
|
||||
parts.push({
|
||||
x: cx, y: cy,
|
||||
vx: Math.cos(a) * sp,
|
||||
vy: Math.sin(a) * sp - 3,
|
||||
r: 1.5 + Math.random() * 3.2,
|
||||
rot: Math.random() * Math.PI,
|
||||
vr: (Math.random() - 0.5) * 0.3,
|
||||
col: colors[(Math.random() * colors.length) | 0],
|
||||
shape: Math.random() < 0.4 ? 1 : 0,
|
||||
life: 1
|
||||
});
|
||||
}
|
||||
|
||||
var t0 = performance.now();
|
||||
function frame(now) {
|
||||
var dt = Math.min((now - t0) / 1000, 2);
|
||||
ctx.clearRect(0, 0, innerWidth, innerHeight);
|
||||
var alive = false;
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
var p = parts[i];
|
||||
p.x += p.vx; p.y += p.vy;
|
||||
p.vx *= 0.965; p.vy = p.vy * 0.965 + 0.32;
|
||||
p.rot += p.vr;
|
||||
p.life -= 0.012 + Math.random() * 0.006;
|
||||
if (p.life <= 0) continue;
|
||||
alive = true;
|
||||
ctx.globalAlpha = Math.max(p.life, 0);
|
||||
ctx.fillStyle = p.col;
|
||||
if (p.shape === 1) {
|
||||
ctx.save();
|
||||
ctx.translate(p.x, p.y);
|
||||
ctx.rotate(p.rot);
|
||||
ctx.fillRect(-p.r, -p.r * 0.55, p.r * 2, p.r * 1.1);
|
||||
ctx.restore();
|
||||
} else {
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.r * p.life, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
if (alive && dt < 2) requestAnimationFrame(frame);
|
||||
else c.remove();
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
var toastTimer = null;
|
||||
function successToast(message) {
|
||||
var t = document.getElementById("toast");
|
||||
t.innerHTML = '<svg viewBox="0 0 16 16"><circle cx="8" cy="8" r="6.6" fill="none" stroke="#7de8a4" stroke-width="1.4" opacity="0.6"/><path d="M5 8.2 L7.2 10.4 L11.2 5.8" fill="none" stroke="#7de8a4" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg><span>' + message + "</span>";
|
||||
t.classList.add("success");
|
||||
t.hidden = false;
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(function () {
|
||||
t.hidden = true;
|
||||
t.classList.remove("success");
|
||||
t.textContent = "";
|
||||
}, 3400);
|
||||
}
|
||||
|
||||
return { celebrate: celebrate };
|
||||
})();
|
||||
+424
-138
@@ -3,23 +3,23 @@
|
||||
/* ---------------- modes ---------------- */
|
||||
|
||||
var MODES = [
|
||||
{ id: 0, key: "chrome", name: "Chrome", full: "Liquid Chrome",
|
||||
{ id: 0, key: "chrome", name: "\u94ec", full: "\u6db2\u6001\u94ec",
|
||||
icon: '<svg viewBox="0 0 26 18"><path d="M1 12 C5 4 9 15 13 9 C17 3 21 13 25 7" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M1 15 C5 9 10 17 14 12 C18 8 22 15 25 11" fill="none" stroke="currentColor" stroke-width="1.5" opacity="0.45"/></svg>' },
|
||||
{ id: 1, key: "silk", name: "Silk", full: "Silk Ribbons",
|
||||
{ id: 1, key: "silk", name: "\u4e1d\u7ef8", full: "\u4e1d\u5e26",
|
||||
icon: '<svg viewBox="0 0 26 18"><path d="M1 13 C8 11 12 3 25 4" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M1 15.5 C8 13.5 12 5.5 25 6.5" fill="none" stroke="currentColor" stroke-width="1.5" opacity="0.65"/><path d="M1 18 C8 16 12 8 25 9" fill="none" stroke="currentColor" stroke-width="1.5" opacity="0.35"/></svg>' },
|
||||
{ id: 2, key: "bloom", name: "Bloom", full: "Soft Bloom",
|
||||
{ id: 2, key: "bloom", name: "\u7efd\u653e", full: "\u67d4\u5149\u7efd\u653e",
|
||||
icon: '<svg viewBox="0 0 26 18"><circle cx="9" cy="8" r="5.5" fill="currentColor" opacity="0.35"/><circle cx="17" cy="11" r="4" fill="currentColor" opacity="0.6"/></svg>' },
|
||||
{ id: 3, key: "aura", name: "Aura", full: "Aura Rings",
|
||||
{ id: 3, key: "aura", name: "\u5149\u73af", full: "\u5149\u73af",
|
||||
icon: '<svg viewBox="0 0 26 18"><circle cx="13" cy="9" r="7" fill="none" stroke="currentColor" stroke-width="1.4" opacity="0.35"/><circle cx="13" cy="9" r="4.2" fill="none" stroke="currentColor" stroke-width="1.4" opacity="0.7"/><circle cx="13" cy="9" r="1.6" fill="currentColor"/></svg>' },
|
||||
{ id: 4, key: "rays", name: "Rays", full: "Light Rays",
|
||||
{ id: 4, key: "rays", name: "\u5149\u7ebf", full: "\u5149\u7ebf",
|
||||
icon: '<svg viewBox="0 0 26 18"><path d="M13 1 L7 17 M13 1 L13 17 M13 1 L19 17 M13 1 L2 13 M13 1 L24 13" fill="none" stroke="currentColor" stroke-width="1.3" opacity="0.8"/></svg>' },
|
||||
{ id: 5, key: "halftone", name: "Halftone", full: "Halftone",
|
||||
{ id: 5, key: "halftone", name: "\u534a\u8c03", full: "\u534a\u8c03",
|
||||
icon: '<svg viewBox="0 0 26 18"><circle cx="4" cy="5" r="2.4" fill="currentColor"/><circle cx="11" cy="5" r="1.8" fill="currentColor"/><circle cx="18" cy="5" r="1.2" fill="currentColor"/><circle cx="24" cy="5" r="0.7" fill="currentColor"/><circle cx="4" cy="12" r="1.6" fill="currentColor"/><circle cx="11" cy="12" r="2.2" fill="currentColor"/><circle cx="18" cy="12" r="1.5" fill="currentColor"/><circle cx="24" cy="12" r="0.9" fill="currentColor"/></svg>' },
|
||||
{ id: 6, key: "glyphs", name: "Glyphs", full: "Data Glyphs",
|
||||
{ id: 6, key: "glyphs", name: "\u5b57\u5f62", full: "\u6570\u636e\u5b57\u5f62",
|
||||
icon: '<svg viewBox="0 0 26 18"><g fill="currentColor"><rect x="2" y="2" width="2" height="3"/><rect x="7" y="2" width="2" height="3" opacity="0.5"/><rect x="12" y="2" width="2" height="3"/><rect x="17" y="2" width="2" height="3" opacity="0.3"/><rect x="22" y="2" width="2" height="3" opacity="0.7"/><rect x="2" y="8" width="2" height="3" opacity="0.4"/><rect x="7" y="8" width="2" height="3"/><rect x="12" y="8" width="2" height="3" opacity="0.6"/><rect x="17" y="8" width="2" height="3"/><rect x="22" y="8" width="2" height="3" opacity="0.4"/><rect x="2" y="14" width="2" height="3" opacity="0.7"/><rect x="7" y="14" width="2" height="3" opacity="0.3"/><rect x="12" y="14" width="2" height="3" opacity="0.8"/><rect x="17" y="14" width="2" height="3" opacity="0.5"/><rect x="22" y="14" width="2" height="3"/></g></svg>' },
|
||||
{ id: 7, key: "reeded", name: "Reeded", full: "Reeded Glass",
|
||||
{ id: 7, key: "reeded", name: "\u7f57\u7eb9", full: "\u7f57\u7eb9\u73bb\u7483",
|
||||
icon: '<svg viewBox="0 0 26 18"><g stroke="currentColor" stroke-width="1.8" fill="none"><path d="M3 1 V17" opacity="0.9"/><path d="M8 1 V17" opacity="0.5"/><path d="M13 1 V17" opacity="0.9"/><path d="M18 1 V17" opacity="0.5"/><path d="M23 1 V17" opacity="0.9"/></g></svg>' },
|
||||
{ id: 8, key: "mosaic", name: "Mosaic", full: "Pixel Bloom",
|
||||
{ id: 8, key: "mosaic", name: "\u9a6c\u8d5b\u514b", full: "\u50cf\u7d20\u7efd\u653e",
|
||||
icon: '<svg viewBox="0 0 26 18"><g fill="currentColor"><rect x="2" y="2" width="6" height="6" opacity="0.9"/><rect x="9" y="2" width="6" height="6" opacity="0.4"/><rect x="16" y="2" width="6" height="6" opacity="0.7"/><rect x="2" y="9" width="6" height="6" opacity="0.3"/><rect x="9" y="9" width="6" height="6" opacity="0.8"/><rect x="16" y="9" width="6" height="6" opacity="0.5"/></g></svg>' }
|
||||
];
|
||||
|
||||
@@ -27,15 +27,17 @@ var ASPECTS = { "16:9": 16 / 9, "3:2": 1.5, "1:1": 1, "4:5": 0.8, "21:9": 21 / 9
|
||||
|
||||
/* ---------------- state ---------------- */
|
||||
|
||||
/* startup: a calm, clean aura on the Halo palette with a fixed seed */
|
||||
/* startup: curated reeded-glass ember design (code LMN1.WzEsNyw5MDE1...) */
|
||||
var P = {
|
||||
mode: 3, seed: 1207,
|
||||
c1: "#4a30e0", c2: "#7a8cff", c3: "#e89ab8", c4: "#c2d4ff", bg: "#f4f6ff",
|
||||
hue: 0, sat: 1.0, exposure: 1.0, contrast: 1.0,
|
||||
scale: 1.2, complex: 3.8, warp: 0.5, flow: 0.5, stretch: 0,
|
||||
light: 1.0, gloss: 64, lightAngle: 130, irid: 0.3, glow: 0.15,
|
||||
grain: 0.03, cell: 90, lines: 56, ca: 0, vig: 0, soft: 1.35,
|
||||
travel: 0.6, loop: 6,
|
||||
mode: 7, seed: 9015,
|
||||
c1: "#ff6a00", c2: "#ffb347", c3: "#a81c00", c4: "#3d0c02", bg: "#070403",
|
||||
hue: 23, sat: 0.55, exposure: 1.016, contrast: 0.957,
|
||||
scale: 0.803, complex: 5.603, warp: 1.073, flow: 0.233, stretch: -0.089,
|
||||
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,
|
||||
genomeOn: false, genes: [0,0,0.5,3, 0,0,0,0.5, 0.5,0.5,0.5,0],
|
||||
lockStyle: false,
|
||||
imgRes: "2160", vidRes: "1080", vidFps: "30", vidLen: "l2",
|
||||
gifW: "640", gifFps: 25, gifDither: true, gifLoop: true,
|
||||
@@ -79,9 +81,20 @@ var RECIPES = {
|
||||
travel: [0.5, 1.1], loop: [5, 10] },
|
||||
mosaic: { tone: ["light", "dark"], cell: [40, 120], warp: [0.3, 1.2], scale: [0.8, 1.5],
|
||||
grain: [0, 0.04], contrast: [0.95, 1.15], sat: [0.95, 1.3], vig: [0, 0.15],
|
||||
travel: [0.5, 1.2], loop: [5, 10], soft: [0.8, 1.4] }
|
||||
travel: [0.5, 1.2], loop: [5, 10], soft: [0.8, 1.4] },
|
||||
genome: { tone: ["dark", "light", "dark"], scale: [0.9, 1.7], complex: [2.5, 5.5], warp: [0.35, 1.3],
|
||||
flow: [0.1, 0.8], stretch: [-0.2, 0.45], lines: [30, 100], cell: [50, 130],
|
||||
grain: [0, 0.08], ca: [0, 0.15], vig: [0.05, 0.25], soft: [0.75, 1.35],
|
||||
travel: [0.35, 0.95], loop: [4, 9] }
|
||||
};
|
||||
|
||||
var FIELD_NAMES = ["FLUX", "RIDGE", "WAVE", "RING", "CELL", "FLOW", "BAND", "GRID", "HEX", "ARC", "HATCH", "ORB"];
|
||||
var FIELD_TYPES = FIELD_NAMES.length;
|
||||
var DOMAIN_TYPES = 7;
|
||||
var COLOR_MAPS = 6;
|
||||
var SHADE_TYPES = 5;
|
||||
var OVERLAY_TYPES = 5;
|
||||
|
||||
var FORM_KEYS = ["scale", "complex", "warp", "flow", "stretch"];
|
||||
var LIGHT_KEYS = ["light", "gloss", "lightAngle", "irid", "glow", "contrast"];
|
||||
var TEXTURE_KEYS = ["grain", "cell", "lines", "ca", "vig", "soft"];
|
||||
@@ -92,12 +105,22 @@ var GRADE_KEYS = ["sat", "exposure"];
|
||||
|
||||
function rnd() { return Math.random(); }
|
||||
function randIn(range) { return range[0] + rnd() * (range[1] - range[0]); }
|
||||
function recipeKey() {
|
||||
if (P.genomeOn) return "genome";
|
||||
return MODES[P.mode].key;
|
||||
}
|
||||
|
||||
function rangeFor(key) {
|
||||
var rec = RECIPES[MODES[P.mode].key] || {};
|
||||
var rec = RECIPES[recipeKey()] || {};
|
||||
return rec[key] || DEF_RANGE[key];
|
||||
}
|
||||
|
||||
var activePreset = 11; /* Halo — matches the startup design */
|
||||
function pickTone() {
|
||||
var rec = RECIPES[recipeKey()] || { tone: ["dark", "light"] };
|
||||
return rec.tone[Math.floor(rnd() * rec.tone.length)];
|
||||
}
|
||||
|
||||
var activePreset = 3; /* Ember — matches the startup design */
|
||||
var setPresetActive = function () {};
|
||||
|
||||
function applyPalette(pal, presetIdx) {
|
||||
@@ -108,19 +131,58 @@ function applyPalette(pal, presetIdx) {
|
||||
setPresetActive(presetIdx);
|
||||
}
|
||||
|
||||
function randomizePalette() {
|
||||
var rec = RECIPES[MODES[P.mode].key] || { tone: ["dark", "light"] };
|
||||
var tone = rec.tone[Math.floor(rnd() * rec.tone.length)];
|
||||
if (rnd() < 0.25) {
|
||||
applyPalette(generateRandomPalette(rnd, tone), -1);
|
||||
} else {
|
||||
function randomizePalette(tone) {
|
||||
tone = tone || pickTone();
|
||||
if (rnd() < 0.78) {
|
||||
var pool = [];
|
||||
PALETTES.forEach(function (p, i) { if (p.tone === tone) pool.push(i); });
|
||||
if (!pool.length) pool = PALETTES.map(function (_, i) { return i; });
|
||||
var idx = pool[Math.floor(rnd() * pool.length)];
|
||||
applyPalette(PALETTES[idx], idx);
|
||||
} else {
|
||||
applyPalette(generateRandomPalette(rnd, tone), -1);
|
||||
}
|
||||
P.hue = 0;
|
||||
GRADE_KEYS.forEach(function (k) { P[k] = randIn(rangeFor(k)); });
|
||||
}
|
||||
|
||||
/* tuned grading + lighting so random hits stay pleasant, not harsh */
|
||||
function smartTune(tone) {
|
||||
var key = recipeKey();
|
||||
var isLight = tone === "light";
|
||||
var isGlass = key === "chrome" || key === "reeded" || key === "silk" ||
|
||||
(P.genomeOn && Math.round(P.genes[5]) === 4);
|
||||
var isSoft = key === "bloom" || key === "aura" || key === "mosaic" ||
|
||||
key === "rays" || key === "halftone";
|
||||
|
||||
P.exposure = isLight ? randIn([0.94, 1.06]) : randIn([0.90, 1.04]);
|
||||
P.contrast = isLight ? randIn([0.94, 1.10]) : randIn([0.92, 1.12]);
|
||||
P.sat = isLight ? randIn([0.82, 1.06]) : randIn([0.86, 1.16]);
|
||||
P.hue = randIn([-14, 14]);
|
||||
|
||||
if (isGlass) {
|
||||
P.light = randIn([0.62, 1.32]);
|
||||
P.gloss = Math.round(randIn([26, 92]));
|
||||
P.lightAngle = Math.round(randIn([0, 360]));
|
||||
P.irid = randIn([0, 0.38]);
|
||||
P.glow = randIn([0, 0.2]);
|
||||
P.soft = randIn([0.82, 1.28]);
|
||||
} else if (isSoft) {
|
||||
P.light = randIn([0.12, 0.72]);
|
||||
P.gloss = Math.round(randIn([10, 44]));
|
||||
P.lightAngle = Math.round(randIn([0, 360]));
|
||||
P.irid = randIn([0, 0.22]);
|
||||
P.glow = randIn([0, 0.18]);
|
||||
} else {
|
||||
P.light = randIn([0.48, 1.28]);
|
||||
P.gloss = Math.round(randIn([14, 76]));
|
||||
P.lightAngle = Math.round(randIn([0, 360]));
|
||||
P.irid = randIn([0, 0.32]);
|
||||
P.glow = randIn([0, 0.28]);
|
||||
}
|
||||
|
||||
P.ca = randIn([0, 0.16]);
|
||||
P.vig = randIn([0.05, 0.26]);
|
||||
P.grain = randIn([0, isLight ? 0.055 : 0.085]);
|
||||
}
|
||||
|
||||
function randomizeKeys(keys) {
|
||||
@@ -134,14 +196,89 @@ function randomizeKeys(keys) {
|
||||
|
||||
function newSeed() { P.seed = Math.floor(rnd() * 10000); }
|
||||
|
||||
/* genome: 12 genes define a complete standalone style that does not
|
||||
exist in the base set. fields x domains x colors x shading x overlays
|
||||
gives thousands of distinct archetypes. */
|
||||
function pickFieldType() {
|
||||
/* mix organic noise fields with clean geometric patterns */
|
||||
if (rnd() < 0.48) return 6 + Math.floor(rnd() * (FIELD_TYPES - 6));
|
||||
return Math.floor(rnd() * 6);
|
||||
}
|
||||
|
||||
function generateGenomeStyle() {
|
||||
P.genes = [
|
||||
pickFieldType(), // 0 field type
|
||||
Math.floor(rnd() * DOMAIN_TYPES), // 1 domain op
|
||||
0.15 + rnd() * 1.0, // 2 extra warp
|
||||
2 + Math.floor(rnd() * 6), // 3 fold count
|
||||
Math.floor(rnd() * COLOR_MAPS), // 4 color map
|
||||
Math.floor(rnd() * SHADE_TYPES),// 5 shading
|
||||
Math.floor(rnd() * OVERLAY_TYPES), // 6 overlay
|
||||
0.25 + rnd() * 1.0, // 7 overlay scale
|
||||
rnd(), // 8 ridge sharpness
|
||||
rnd(), // 9 poster steps
|
||||
0.25 + rnd() * 0.85, // 10 field scale
|
||||
rnd() // 11 rotation
|
||||
];
|
||||
P.genomeOn = true;
|
||||
P.synthOn = false;
|
||||
}
|
||||
|
||||
function genomeName() {
|
||||
var g = P.genes;
|
||||
var n = (g[0] * 7 + g[1] * 13 + g[4] * 29 + g[5] * 47 + g[6] * 71 +
|
||||
Math.round(g[11] * 99)) % 1000;
|
||||
var ft = Math.min(Math.max(Math.round(g[0]) || 0, 0), FIELD_TYPES - 1);
|
||||
return FIELD_NAMES[ft] + " " + String(Math.round(n)).padStart(3, "0");
|
||||
}
|
||||
|
||||
function styleName() {
|
||||
if (P.genomeOn) return genomeName();
|
||||
if (P.synthOn) return "SYN " + MODES[P.mode].name.slice(0, 3).toUpperCase() + "+" +
|
||||
MODES[P.modeB].name.slice(0, 3).toUpperCase();
|
||||
return MODES[P.mode].full;
|
||||
}
|
||||
|
||||
function randomizeAll() {
|
||||
if (!P.lockStyle) P.mode = Math.floor(rnd() * MODES.length);
|
||||
randomizePalette();
|
||||
randomizeKeys(FORM_KEYS.concat(LIGHT_KEYS, TEXTURE_KEYS, MOTION_KEYS));
|
||||
if (!P.lockStyle) {
|
||||
if (rnd() < 0.42 && Engine.hasGenome()) {
|
||||
generateGenomeStyle();
|
||||
} else {
|
||||
P.synthOn = false;
|
||||
P.genomeOn = false;
|
||||
P.mode = Math.floor(rnd() * MODES.length);
|
||||
}
|
||||
}
|
||||
var tone = pickTone();
|
||||
randomizePalette(tone);
|
||||
randomizeKeys(FORM_KEYS.concat(TEXTURE_KEYS, MOTION_KEYS));
|
||||
smartTune(tone);
|
||||
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 name = styleName() + " " + 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("\u98ce\u683c\u5df2\u4fdd\u5b58\uff1a" + name);
|
||||
}
|
||||
var renderSavedStyles = function () {};
|
||||
|
||||
/* ---------------- design codes (share) ---------------- */
|
||||
|
||||
var SHARE_NUMS = [
|
||||
@@ -153,10 +290,13 @@ var SHARE_NUMS = [
|
||||
];
|
||||
|
||||
function encodeDesign() {
|
||||
var arr = [1, P.mode, Math.round(P.seed),
|
||||
var arr = [3, 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);
|
||||
arr.push(P.genomeOn ? 1 : 0);
|
||||
P.genes.forEach(function (g) { arr.push(Math.round(g * 1000) / 1000); });
|
||||
var b64 = btoa(JSON.stringify(arr))
|
||||
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
return "LMN1." + b64;
|
||||
@@ -169,7 +309,11 @@ 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;
|
||||
var isV3 = Array.isArray(arr) && arr[0] === 3 && arr.length === baseLen + 17;
|
||||
if (!isV1 && !isV2 && !isV3) 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 +327,25 @@ function decodeDesign(code) {
|
||||
var v = Number(arr[9 + i]);
|
||||
if (isFinite(v)) P[k] = v;
|
||||
});
|
||||
if (isV2 || isV3) {
|
||||
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;
|
||||
}
|
||||
if (isV3) {
|
||||
P.genomeOn = !!arr[baseLen + 4];
|
||||
P.genes = [];
|
||||
for (var gi = 0; gi < 12; gi++) {
|
||||
var gv = Number(arr[baseLen + 5 + gi]);
|
||||
P.genes.push(isFinite(gv) ? gv : 0);
|
||||
}
|
||||
} else {
|
||||
P.genomeOn = false;
|
||||
}
|
||||
activePreset = -1;
|
||||
setPresetActive(-1);
|
||||
refreshAll();
|
||||
@@ -197,9 +360,9 @@ function copyText(text, okMsg) {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).then(
|
||||
function () { UI.toast(okMsg); },
|
||||
function () { UI.toast("Could not access clipboard"); });
|
||||
function () { UI.toast("\u65e0\u6cd5\u8bbf\u95ee\u526a\u8d34\u677f"); });
|
||||
} else {
|
||||
UI.toast("Clipboard not available in this browser");
|
||||
UI.toast("\u6b64\u6d4f\u89c8\u5668\u4e0d\u652f\u6301\u526a\u8d34\u677f");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,15 +388,65 @@ function buildRail() {
|
||||
var rail = document.getElementById("rail");
|
||||
|
||||
/* STYLE */
|
||||
var sStyle = UI.section(rail, "Style", function () {
|
||||
var sStyle = UI.section(rail, "\u98ce\u683c", 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.lockRow(sStyle, { label: "Keep style when randomizing", get: get("lockStyle"), set: set("lockStyle") }));
|
||||
reg(UI.modeGrid(sStyle, MODES, function () { return (P.synthOn || P.genomeOn) ? -1 : P.mode; },
|
||||
function (v) { P.mode = v; P.synthOn = false; P.genomeOn = false; refreshAll(); }));
|
||||
|
||||
/* genome: brand-new 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="M8 1.5 L9.8 6.2 L14.5 8 L9.8 9.8 L8 14.5 L6.2 9.8 L1.5 8 L6.2 6.2 Z" fill="none" stroke="currentColor" stroke-width="1.3"/></svg>\u65b0\u98ce\u683c';
|
||||
btnSynth.addEventListener("click", function () {
|
||||
if (!Engine.hasGenome()) {
|
||||
UI.toast("\u98ce\u683c\u5408\u6210\u5668\u6b63\u5728\u9884\u70ed\uff0c\u8bf7\u7a0d\u540e\u518d\u8bd5");
|
||||
return;
|
||||
}
|
||||
generateGenomeStyle();
|
||||
newSeed();
|
||||
refreshAll();
|
||||
UI.toast("\u53d1\u73b0\u65b0\u98ce\u683c\uff1a" + genomeName());
|
||||
});
|
||||
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>\u4fdd\u5b58\u98ce\u683c';
|
||||
btnSave.addEventListener("click", saveCurrentStyle);
|
||||
|
||||
var synthCtl = UI.el("div", "synth-ctl", sStyle);
|
||||
reg(UI.slider(synthCtl, { label: "\u5408\u6210\u6df7\u5408", 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("\u5df2\u52a0\u8f7d " + st.name);
|
||||
});
|
||||
});
|
||||
};
|
||||
renderSavedStyles();
|
||||
|
||||
reg(UI.lockRow(sStyle, { label: "\u968f\u673a\u5316\u65f6\u4fdd\u6301\u98ce\u683c", get: get("lockStyle"), set: set("lockStyle") }));
|
||||
|
||||
/* COLOR */
|
||||
var sColor = UI.section(rail, "Color", function () { randomizePalette(); refreshAll(); });
|
||||
var sColor = UI.section(rail, "\u8272\u5f69", function () { randomizePalette(); refreshAll(); });
|
||||
setPresetActive = UI.presetChips(sColor, PALETTES, function (i) {
|
||||
applyPalette(PALETTES[i], i);
|
||||
refreshAll();
|
||||
@@ -244,69 +457,69 @@ function buildRail() {
|
||||
{ gap: true }, { key: "bg", label: "BG" }],
|
||||
function (k) { return P[k]; },
|
||||
function (k, v) { P[k] = v; activePreset = -1; setPresetActive(-1); }));
|
||||
reg(UI.slider(sColor, { label: "Hue shift", min: -180, max: 180, step: 1, fmt: fmtDeg, get: get("hue"), set: set("hue") }));
|
||||
reg(UI.slider(sColor, { label: "Saturation", min: 0, max: 2, step: 0.01, fmt: fmt2, get: get("sat"), set: set("sat") }));
|
||||
reg(UI.slider(sColor, { label: "Exposure", min: 0.5, max: 1.6, step: 0.01, fmt: fmt2, get: get("exposure"), set: set("exposure") }));
|
||||
reg(UI.slider(sColor, { label: "\u8272\u76f8\u504f\u79fb", min: -180, max: 180, step: 1, fmt: fmtDeg, get: get("hue"), set: set("hue") }));
|
||||
reg(UI.slider(sColor, { label: "\u9971\u548c\u5ea6", min: 0, max: 2, step: 0.01, fmt: fmt2, get: get("sat"), set: set("sat") }));
|
||||
reg(UI.slider(sColor, { label: "\u66dd\u5149", min: 0.5, max: 1.6, step: 0.01, fmt: fmt2, get: get("exposure"), set: set("exposure") }));
|
||||
|
||||
/* FORM */
|
||||
var sForm = UI.section(rail, "Form", function () { randomizeKeys(FORM_KEYS); newSeed(); refreshAll(); });
|
||||
var sForm = UI.section(rail, "\u5f62\u6001", function () { randomizeKeys(FORM_KEYS); newSeed(); refreshAll(); });
|
||||
reg(UI.seedRow(sForm, { get: get("seed"), set: function (v) { P.seed = v; updateMeta(); refreshAll(); }, onDice: function () { newSeed(); refreshAll(); } }));
|
||||
reg(UI.slider(sForm, { label: "Zoom", min: 0.5, max: 3, step: 0.01, fmt: fmt2, get: get("scale"), set: set("scale") }));
|
||||
reg(UI.slider(sForm, { label: "Detail", min: 1, max: 8, step: 0.1, fmt: fmt1, get: get("complex"), set: set("complex") }));
|
||||
reg(UI.slider(sForm, { label: "Warp", min: 0, max: 2.5, step: 0.01, fmt: fmt2, get: get("warp"), set: set("warp") }));
|
||||
reg(UI.slider(sForm, { label: "Turbulence", min: 0, max: 2, step: 0.01, fmt: fmt2, get: get("flow"), set: set("flow") }));
|
||||
reg(UI.slider(sForm, { label: "Stretch", min: -1, max: 1, step: 0.01, fmt: fmt2, get: get("stretch"), set: set("stretch") }));
|
||||
reg(UI.slider(sForm, { label: "\u7f29\u653e", min: 0.5, max: 3, step: 0.01, fmt: fmt2, get: get("scale"), set: set("scale") }));
|
||||
reg(UI.slider(sForm, { label: "\u7ec6\u8282", min: 1, max: 8, step: 0.1, fmt: fmt1, get: get("complex"), set: set("complex") }));
|
||||
reg(UI.slider(sForm, { label: "\u626d\u66f2", min: 0, max: 2.5, step: 0.01, fmt: fmt2, get: get("warp"), set: set("warp") }));
|
||||
reg(UI.slider(sForm, { label: "\u6e4d\u6d41", min: 0, max: 2, step: 0.01, fmt: fmt2, get: get("flow"), set: set("flow") }));
|
||||
reg(UI.slider(sForm, { label: "\u62c9\u4f38", min: -1, max: 1, step: 0.01, fmt: fmt2, get: get("stretch"), set: set("stretch") }));
|
||||
|
||||
/* LIGHTING */
|
||||
var sLight = UI.section(rail, "Lighting", function () { randomizeKeys(LIGHT_KEYS); refreshAll(); });
|
||||
reg(UI.slider(sLight, { label: "Intensity", min: 0, max: 2.2, step: 0.01, fmt: fmt2, get: get("light"), set: set("light") }));
|
||||
reg(UI.slider(sLight, { label: "Gloss", min: 4, max: 120, step: 1, fmt: fmtInt, get: get("gloss"), set: set("gloss") }));
|
||||
reg(UI.slider(sLight, { label: "Angle", min: 0, max: 360, step: 1, fmt: fmtDeg, get: get("lightAngle"), set: set("lightAngle") }));
|
||||
reg(UI.slider(sLight, { label: "Iridescence", min: 0, max: 1, step: 0.01, fmt: fmt2, get: get("irid"), set: set("irid") }));
|
||||
reg(UI.slider(sLight, { label: "Glow", min: 0, max: 1, step: 0.01, fmt: fmt2, get: get("glow"), set: set("glow") }));
|
||||
reg(UI.slider(sLight, { label: "Contrast", min: 0.6, max: 1.6, step: 0.01, fmt: fmt2, get: get("contrast"), set: set("contrast") }));
|
||||
var sLight = UI.section(rail, "\u5149\u7167", function () { randomizeKeys(LIGHT_KEYS); refreshAll(); });
|
||||
reg(UI.slider(sLight, { label: "\u5f3a\u5ea6", min: 0, max: 2.2, step: 0.01, fmt: fmt2, get: get("light"), set: set("light") }));
|
||||
reg(UI.slider(sLight, { label: "\u5149\u6cfd", min: 4, max: 120, step: 1, fmt: fmtInt, get: get("gloss"), set: set("gloss") }));
|
||||
reg(UI.slider(sLight, { label: "\u89d2\u5ea6", min: 0, max: 360, step: 1, fmt: fmtDeg, get: get("lightAngle"), set: set("lightAngle") }));
|
||||
reg(UI.slider(sLight, { label: "\u8679\u5f69", min: 0, max: 1, step: 0.01, fmt: fmt2, get: get("irid"), set: set("irid") }));
|
||||
reg(UI.slider(sLight, { label: "\u8f89\u5149", min: 0, max: 1, step: 0.01, fmt: fmt2, get: get("glow"), set: set("glow") }));
|
||||
reg(UI.slider(sLight, { label: "\u5bf9\u6bd4\u5ea6", min: 0.6, max: 1.6, step: 0.01, fmt: fmt2, get: get("contrast"), set: set("contrast") }));
|
||||
|
||||
/* TEXTURE */
|
||||
var sTex = UI.section(rail, "Texture", function () { randomizeKeys(TEXTURE_KEYS); refreshAll(); });
|
||||
reg(UI.slider(sTex, { label: "Grain", min: 0, max: 0.4, step: 0.005, fmt: fmt2, get: get("grain"), set: set("grain") }));
|
||||
reg(UI.slider(sTex, { label: "Density", min: 14, max: 180, step: 1, fmt: fmtInt, get: get("cell"), set: set("cell") }));
|
||||
reg(UI.slider(sTex, { label: "Ridges", min: 8, max: 160, step: 1, fmt: fmtInt, get: get("lines"), set: set("lines") }));
|
||||
reg(UI.slider(sTex, { label: "Aberration", min: 0, max: 1, step: 0.01, fmt: fmt2, get: get("ca"), set: set("ca") }));
|
||||
reg(UI.slider(sTex, { label: "Vignette", min: 0, max: 1, step: 0.01, fmt: fmt2, get: get("vig"), set: set("vig") }));
|
||||
reg(UI.slider(sTex, { label: "Softness", min: 0.3, max: 1.6, step: 0.01, fmt: fmt2, get: get("soft"), set: set("soft") }));
|
||||
var sTex = UI.section(rail, "\u7eb9\u7406", function () { randomizeKeys(TEXTURE_KEYS); refreshAll(); });
|
||||
reg(UI.slider(sTex, { label: "\u9897\u7c92", min: 0, max: 0.4, step: 0.005, fmt: fmt2, get: get("grain"), set: set("grain") }));
|
||||
reg(UI.slider(sTex, { label: "\u5bc6\u5ea6", min: 14, max: 180, step: 1, fmt: fmtInt, get: get("cell"), set: set("cell") }));
|
||||
reg(UI.slider(sTex, { label: "\u810a\u7ebf", min: 8, max: 160, step: 1, fmt: fmtInt, get: get("lines"), set: set("lines") }));
|
||||
reg(UI.slider(sTex, { label: "\u8272\u5dee", min: 0, max: 1, step: 0.01, fmt: fmt2, get: get("ca"), set: set("ca") }));
|
||||
reg(UI.slider(sTex, { label: "\u6697\u89d2", min: 0, max: 1, step: 0.01, fmt: fmt2, get: get("vig"), set: set("vig") }));
|
||||
reg(UI.slider(sTex, { label: "\u67d4\u548c\u5ea6", min: 0.3, max: 1.6, step: 0.01, fmt: fmt2, get: get("soft"), set: set("soft") }));
|
||||
|
||||
/* MOTION */
|
||||
var sMotion = UI.section(rail, "Motion", function () { randomizeKeys(MOTION_KEYS); refreshAll(); });
|
||||
reg(UI.slider(sMotion, { label: "Loop length", min: 2, max: 12, step: 0.5, fmt: fmtSec, get: get("loop"), set: set("loop") }));
|
||||
reg(UI.slider(sMotion, { label: "Travel", min: 0, max: 1.5, step: 0.01, fmt: fmt2, get: get("travel"), set: set("travel") }));
|
||||
var sMotion = UI.section(rail, "\u8fd0\u52a8", function () { randomizeKeys(MOTION_KEYS); refreshAll(); });
|
||||
reg(UI.slider(sMotion, { label: "\u5faa\u73af\u957f\u5ea6", min: 2, max: 12, step: 0.5, fmt: fmtSec, get: get("loop"), set: set("loop") }));
|
||||
reg(UI.slider(sMotion, { label: "\u884c\u7a0b", min: 0, max: 1.5, step: 0.01, fmt: fmt2, get: get("travel"), set: set("travel") }));
|
||||
|
||||
/* SHARE */
|
||||
var sShare = UI.section(rail, "Share", null);
|
||||
var sShare = UI.section(rail, "\u5206\u4eab", null);
|
||||
var shareRow = UI.el("div", "share-row", sShare);
|
||||
var btnCopyCode = UI.el("button", "mini-btn", shareRow);
|
||||
btnCopyCode.textContent = "Copy design code";
|
||||
btnCopyCode.textContent = "\u590d\u5236\u8bbe\u8ba1\u4ee3\u7801";
|
||||
btnCopyCode.addEventListener("click", function () {
|
||||
copyText(encodeDesign(), "Design code copied \u2014 paste it anywhere");
|
||||
copyText(encodeDesign(), "\u8bbe\u8ba1\u4ee3\u7801\u5df2\u590d\u5236\uff0c\u53ef\u7c98\u8d34\u5230\u4efb\u4f55\u5730\u65b9");
|
||||
});
|
||||
var btnCopyLink = UI.el("button", "mini-btn", shareRow);
|
||||
btnCopyLink.textContent = "Copy link";
|
||||
btnCopyLink.textContent = "\u590d\u5236\u94fe\u63a5";
|
||||
btnCopyLink.addEventListener("click", function () {
|
||||
var url = location.origin + location.pathname + "#" + encodeDesign();
|
||||
copyText(url, "Share link copied");
|
||||
copyText(url, "\u5206\u4eab\u94fe\u63a5\u5df2\u590d\u5236");
|
||||
});
|
||||
var pasteInput = UI.el("input", "num-input mono share-input", sShare);
|
||||
pasteInput.type = "text";
|
||||
pasteInput.placeholder = "Paste a design code\u2026";
|
||||
pasteInput.placeholder = "\u7c98\u8d34\u8bbe\u8ba1\u4ee3\u7801\u2026";
|
||||
pasteInput.spellcheck = false;
|
||||
function tryApplyPaste() {
|
||||
var v = pasteInput.value.trim();
|
||||
if (!v) return;
|
||||
if (decodeDesign(v)) {
|
||||
UI.toast("Design loaded");
|
||||
UI.toast("\u8bbe\u8ba1\u5df2\u52a0\u8f7d");
|
||||
pasteInput.value = "";
|
||||
pasteInput.blur();
|
||||
} else {
|
||||
UI.toast("Invalid design code");
|
||||
UI.toast("\u65e0\u6548\u7684\u8bbe\u8ba1\u4ee3\u7801");
|
||||
}
|
||||
}
|
||||
pasteInput.addEventListener("paste", function () { setTimeout(tryApplyPaste, 0); });
|
||||
@@ -315,58 +528,89 @@ function buildRail() {
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
/* EXPORT */
|
||||
var sExp = UI.section(rail, "Export", null);
|
||||
reg(UI.selectRow(sExp, { label: "Image size", options: [["1080", "1920 \u00d7 1080"], ["1440", "2560 \u00d7 1440"], ["2160", "3840 \u00d7 2160"]], get: get("imgRes"), set: set("imgRes") }));
|
||||
reg(UI.selectRow(sExp, { label: "Video size", options: [["720", "720p"], ["1080", "1080p"], ["1440", "1440p"]], get: get("vidRes"), set: set("vidRes") }));
|
||||
reg(UI.selectRow(sExp, { label: "Video 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") }));
|
||||
|
||||
/* EXPORT: buttons open a dialog with preview and settings */
|
||||
var sExp = UI.section(rail, "\u5bfc\u51fa", null);
|
||||
var grid = UI.el("div", "export-grid", sExp);
|
||||
UI.exportButton(grid, "Save image", "PNG",
|
||||
UI.exportButton(grid, "\u56fe\u50cf", "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, "\u89c6\u9891", "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, "\u5faa\u73af 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, "\u6e10\u53d8\u5957\u88c5", "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-seed").textContent = "seed " + String(Math.round(P.seed)).padStart(4, "0");
|
||||
document.getElementById("meta-loop").textContent = P.loop.toFixed(1) + "s loop";
|
||||
var s = Engine.size();
|
||||
document.getElementById("meta-res").textContent = s[0] + "\u00d7" + s[1];
|
||||
document.getElementById("meta-mode").textContent = styleName();
|
||||
document.getElementById("meta-seed").textContent = "\u79cd\u5b50 " + String(Math.round(P.seed)).padStart(4, "0");
|
||||
document.getElementById("meta-loop").textContent = P.loop.toFixed(1) + "s \u5faa\u73af";
|
||||
if (Engine.isReady()) {
|
||||
var s = Engine.size();
|
||||
document.getElementById("meta-res").textContent = s[0] + "\u00d7" + s[1];
|
||||
}
|
||||
}
|
||||
|
||||
function fitCanvas() {
|
||||
var stage = { failed: false, resizeObs: null, resizeTimer: null, bufW: 0, bufH: 0 };
|
||||
|
||||
function fitCanvas(preview) {
|
||||
if (stage.failed) return;
|
||||
var frame = document.getElementById("canvas-frame");
|
||||
if (!frame) return;
|
||||
var availW = frame.clientWidth - 80;
|
||||
var availH = frame.clientHeight - 52;
|
||||
if (availW <= 0 || availH <= 0) return;
|
||||
var ar = ASPECTS[P.aspect];
|
||||
var w = availW, h = w / ar;
|
||||
if (h > availH) { h = availH; w = h * ar; }
|
||||
var canvas = Engine.canvas();
|
||||
canvas.style.width = Math.round(w) + "px";
|
||||
canvas.style.height = Math.round(h) + "px";
|
||||
var dpr = Math.min(window.devicePixelRatio || 1, 1.35);
|
||||
Engine.setSize(2 * Math.round(w * dpr / 2), 2 * Math.round(h * dpr / 2));
|
||||
w = Math.round(w);
|
||||
h = Math.round(h);
|
||||
var canvasEl = document.getElementById("view");
|
||||
if (!canvasEl || !canvasEl.isConnected) return;
|
||||
canvasEl.style.width = w + "px";
|
||||
canvasEl.style.height = h + "px";
|
||||
var boot = document.getElementById("canvas-boot");
|
||||
if (boot) {
|
||||
boot.style.width = w + "px";
|
||||
boot.style.height = h + "px";
|
||||
}
|
||||
if (!Engine.isReady() || Engine.isLost()) return;
|
||||
var lowMem = (navigator.deviceMemory || 8) <= 4;
|
||||
var dprCap = preview ? 0.65 : (lowMem ? 0.75 : 0.85);
|
||||
var dpr = Math.min(window.devicePixelRatio || 1, dprCap);
|
||||
var bufW = 2 * Math.round(w * dpr / 2);
|
||||
var bufH = 2 * Math.round(h * dpr / 2);
|
||||
if (stage.bufW === bufW && stage.bufH === bufH) {
|
||||
updateMeta();
|
||||
return;
|
||||
}
|
||||
stage.bufW = bufW;
|
||||
stage.bufH = bufH;
|
||||
Engine.setSize(bufW, bufH);
|
||||
updateMeta();
|
||||
}
|
||||
|
||||
function onStageResize() {
|
||||
clearTimeout(stage.resizeTimer);
|
||||
stage.resizeTimer = setTimeout(function () {
|
||||
fitCanvas(false);
|
||||
}, 250);
|
||||
}
|
||||
|
||||
function detachStageResize() {
|
||||
if (stage.resizeObs) {
|
||||
stage.resizeObs.disconnect();
|
||||
stage.resizeObs = null;
|
||||
}
|
||||
clearTimeout(stage.resizeTimer);
|
||||
}
|
||||
|
||||
function setPlayingUI(v) {
|
||||
Engine.setPlaying(v);
|
||||
document.getElementById("icon-pause").style.display = v ? "" : "none";
|
||||
@@ -375,21 +619,7 @@ function setPlayingUI(v) {
|
||||
|
||||
/* ---------------- boot ---------------- */
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
var canvas = document.getElementById("view");
|
||||
try {
|
||||
Engine.init(canvas, function () { return P; });
|
||||
} catch (e) {
|
||||
document.querySelector(".canvas-frame").innerHTML =
|
||||
'<div style="color:#9b9ba4;font-size:13px;max-width:380px;text-align:center;line-height:1.6">' +
|
||||
"WebGL2 is required. Please use a recent Chrome, Edge or Firefox.<br><span style=\"color:#5e5e68;font-size:11px\">" +
|
||||
String(e.message).split("\n")[0] + "</span></div>";
|
||||
return;
|
||||
}
|
||||
|
||||
buildRail();
|
||||
|
||||
/* aspect segmented control */
|
||||
function wireControls() {
|
||||
var segRefresh = UI.segmented(
|
||||
document.getElementById("aspect-seg"),
|
||||
Object.keys(ASPECTS).map(function (k) { return [k, k]; }),
|
||||
@@ -398,22 +628,6 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
);
|
||||
reg(segRefresh);
|
||||
|
||||
/* load a shared design from the URL hash, e.g. .../#LMN1.xxxx */
|
||||
var hash = decodeURIComponent(location.hash.slice(1) || "");
|
||||
if (hash.indexOf("LMN1.") === 0 && decodeDesign(hash)) {
|
||||
UI.toast("Shared design loaded");
|
||||
}
|
||||
|
||||
fitCanvas();
|
||||
new ResizeObserver(fitCanvas).observe(document.getElementById("canvas-frame"));
|
||||
|
||||
/* load a shared design from the URL hash, e.g. site/#LMN1.xxxx */
|
||||
if (location.hash.length > 6) {
|
||||
if (decodeDesign(decodeURIComponent(location.hash.slice(1)))) {
|
||||
UI.toast("Shared design loaded");
|
||||
}
|
||||
}
|
||||
|
||||
Engine.onFps(function (fps) {
|
||||
document.getElementById("meta-fps").textContent = fps + " fps";
|
||||
});
|
||||
@@ -422,9 +636,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();
|
||||
@@ -433,6 +648,77 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
else if (e.key === "r" || e.key === "R") { randomizeAll(); }
|
||||
else if (e.key === "s" || e.key === "S") { Exporter.exportPNG(P, ASPECTS[P.aspect]); }
|
||||
});
|
||||
}
|
||||
|
||||
updateMeta();
|
||||
function showStageError(msg) {
|
||||
var el = document.getElementById("stage-error");
|
||||
if (!el) return;
|
||||
el.innerHTML =
|
||||
"\u9700\u8981 WebGL2\u3002\u8bf7\u4f7f\u7528\u6700\u65b0\u7248 Chrome\u3001Edge \u6216 Firefox\u3002<br>" +
|
||||
'<span style="color:#5e5e68;font-size:11px">' + String(msg).split("\n")[0] + "</span>";
|
||||
el.hidden = false;
|
||||
}
|
||||
|
||||
function showBootError(msg) {
|
||||
stage.failed = true;
|
||||
detachStageResize();
|
||||
Engine.suspend();
|
||||
document.body.classList.remove("booting");
|
||||
document.body.classList.remove("ready");
|
||||
showStageError(msg);
|
||||
}
|
||||
|
||||
function onContextLost() {
|
||||
stage.failed = true;
|
||||
detachStageResize();
|
||||
Engine.suspend();
|
||||
document.body.classList.remove("ready");
|
||||
showStageError("WebGL \u4e0a\u4e0b\u6587\u4e22\u5931\u3002\u8bf7\u5237\u65b0\u9875\u9762\u3002");
|
||||
UI.toast("WebGL \u4e0a\u4e0b\u6587\u4e22\u5931\u3002\u8bf7\u5237\u65b0\u9875\u9762\u3002");
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
var canvas = document.getElementById("view");
|
||||
if (!canvas) return;
|
||||
|
||||
var lowPower = (navigator.hardwareConcurrency || 8) <= 4 ||
|
||||
((navigator.deviceMemory || 8) <= 4);
|
||||
|
||||
document.addEventListener("visibilitychange", function () {
|
||||
if (stage.failed) return;
|
||||
if (document.hidden) Engine.suspend();
|
||||
else if (Engine.isReady() && !Engine.isLost()) Engine.resume();
|
||||
});
|
||||
|
||||
/* show UI shell immediately; the shader compiles on a driver
|
||||
thread (KHR_parallel_shader_compile) and onReady fires when done,
|
||||
so the page never freezes during boot. */
|
||||
fitCanvas(true);
|
||||
buildRail();
|
||||
wireControls();
|
||||
|
||||
requestAnimationFrame(function () {
|
||||
Engine.init(canvas, function () { return P; }, {
|
||||
autostart: false,
|
||||
onContextLost: onContextLost,
|
||||
onError: showBootError,
|
||||
onReady: function () {
|
||||
Engine.setMaxFps(lowPower ? 30 : 45);
|
||||
|
||||
var hash = decodeURIComponent(location.hash.slice(1) || "");
|
||||
if (hash.indexOf("LMN1.") === 0 && decodeDesign(hash)) {
|
||||
UI.toast("\u5df2\u52a0\u8f7d\u5206\u4eab\u7684\u8bbe\u8ba1");
|
||||
}
|
||||
|
||||
fitCanvas(false);
|
||||
stage.resizeObs = new ResizeObserver(onStageResize);
|
||||
stage.resizeObs.observe(document.getElementById("canvas-frame"));
|
||||
|
||||
document.body.classList.remove("booting");
|
||||
document.body.classList.add("ready");
|
||||
Engine.start();
|
||||
updateMeta();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+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: "导出图像", video: "导出视频", gif: "导出 GIF" };
|
||||
var body = open(titles[kind], MODES[P.mode].full + " \u00b7 种子 " + 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 + " 帧/秒 \u00b7 " +
|
||||
sec.toFixed(1) + "s \u00b7 " + Math.round(sec * fps) + " 帧";
|
||||
}
|
||||
var gw = parseInt(P.gifW, 10);
|
||||
return gw + " \u00d7 " + Math.round(gw / ar) + " \u00b7 " + P.gifFps + " 帧/秒 \u00b7 " +
|
||||
Math.round(P.loop * P.gifFps) + " 帧 \u00b7 " + P.loop.toFixed(1) + "s 循环";
|
||||
}
|
||||
function refreshMeta() { pvMeta.textContent = metaText(); }
|
||||
refreshMeta();
|
||||
|
||||
if (kind === "png") {
|
||||
selectField(form, "分辨率", [["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, "分辨率", [["720", "720p"], ["1080", "1080p"], ["1440", "1440p"]],
|
||||
P.vidRes, function (v) { P.vidRes = v; refreshMeta(); });
|
||||
selectField(form, "帧率", [["24", "24 帧/秒"], ["30", "30 帧/秒"], ["60", "60 帧/秒"]],
|
||||
P.vidFps, function (v) { P.vidFps = v; refreshMeta(); });
|
||||
selectField(form, "时长", [
|
||||
["l1", "1 个循环"], ["l2", "2 个循环"], ["l3", "3 个循环"], ["l4", "4 个循环"], ["l6", "6 个循环"], ["l8", "8 个循环"],
|
||||
["s5", "5 秒"], ["s10", "10 秒"], ["s15", "15 秒"], ["s30", "30 秒"], ["s60", "60 秒"]
|
||||
], P.vidLen, function (v) { P.vidLen = v; refreshMeta(); });
|
||||
} else {
|
||||
selectField(form, "宽度", [["360", "360 px"], ["480", "480 px"], ["640", "640 px"], ["800", "800 px"]],
|
||||
P.gifW, function (v) { P.gifW = v; refreshMeta(); });
|
||||
selectField(form, "帧率", [["15", "15 帧/秒"], ["20", "20 帧/秒"], ["25", "25 帧/秒"], ["30", "30 帧/秒"]],
|
||||
String(P.gifFps), function (v) { P.gifFps = parseInt(v, 10); refreshMeta(); });
|
||||
toggleField(form, "抖动", P.gifDither, function (v) { P.gifDither = v; });
|
||||
toggleField(form, "永久循环", 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>下载 ' +
|
||||
(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("渐变套装", "当前设计的一致变体");
|
||||
var info = el("div", "modal-note", body);
|
||||
info.textContent = "相同风格、调色板和设置,不同种子。适用于主视觉、卡片和分区背景等需要视觉一致性的场景。";
|
||||
|
||||
var form = el("div", "modal-form", body);
|
||||
var state = { count: 6, res: "1080" };
|
||||
selectField(form, "变体数量", [["4", "4"], ["6", "6"], ["8", "8"], ["12", "12"]],
|
||||
"6", function (v) { state.count = parseInt(v, 10); build(); });
|
||||
selectField(form, "PNG 尺寸", [["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>下载套装 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("已应用种子 " + 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 = "渲染中\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 = "渲染中 " + (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);
|
||||
close();
|
||||
FX.celebrate("套装已保存:" + entries.length + " 张 PNG (" + w + "\u00d7" + h + ")");
|
||||
});
|
||||
|
||||
await build();
|
||||
}
|
||||
|
||||
return { openExport: openExport, openSetGenerator: openSetGenerator, close: close };
|
||||
})();
|
||||
+20
-20
@@ -2,37 +2,37 @@
|
||||
tone: 'dark' | 'light' decides which modes prefer them. */
|
||||
|
||||
var PALETTES = [
|
||||
{ name: "Inferno Chrome", tone: "dark",
|
||||
{ name: "炼狱铬", tone: "dark",
|
||||
bg: "#050507", colors: ["#e0220a", "#ff5a1f", "#1f8cff", "#bfe7ff"] },
|
||||
{ name: "Neon Silk", tone: "dark",
|
||||
{ name: "霓虹丝绸", tone: "dark",
|
||||
bg: "#040406", colors: ["#19e3e3", "#ff2d78", "#ff7a1a", "#7a2dff"] },
|
||||
{ name: "Ultraviolet", tone: "dark",
|
||||
{ name: "紫外线", tone: "dark",
|
||||
bg: "#06040c", colors: ["#2440ff", "#8a2bff", "#e22bd0", "#ff5470"] },
|
||||
{ name: "Ember", tone: "dark",
|
||||
{ name: "余晖", tone: "dark",
|
||||
bg: "#070403", colors: ["#ff6a00", "#ffb347", "#a81c00", "#3d0c02"] },
|
||||
{ name: "Deep Signal", tone: "dark",
|
||||
{ name: "深信号", tone: "dark",
|
||||
bg: "#030608", colors: ["#0e3a5c", "#2e7fb8", "#9fd4e8", "#16222e"] },
|
||||
{ name: "Red Telemetry", tone: "dark",
|
||||
{ name: "红色遥测", tone: "dark",
|
||||
bg: "#0a0202", colors: ["#ff2414", "#c81204", "#ff7a5c", "#5c0a02"] },
|
||||
{ name: "Ghost Mono", tone: "dark",
|
||||
{ name: "幽灵单色", tone: "dark",
|
||||
bg: "#030304", colors: ["#f2f2f4", "#9a9aa6", "#3c3c46", "#c8c8d2"] },
|
||||
{ name: "Acid Garden", tone: "dark",
|
||||
{ name: "酸性花园", tone: "dark",
|
||||
bg: "#04070a", colors: ["#b8ff2e", "#1fd9a4", "#0a7a5c", "#eaffd0"] },
|
||||
{ name: "Blush", tone: "light",
|
||||
{ name: "绯红", tone: "light",
|
||||
bg: "#fbf6f2", colors: ["#d4607a", "#f0b890", "#fde8d8", "#b8434f"] },
|
||||
{ name: "Prism Pastel", tone: "light",
|
||||
{ name: "棱镜粉彩", tone: "light",
|
||||
bg: "#f4f1fa", colors: ["#ffb340", "#2b3bd4", "#ff4f9a", "#9a8cff"] },
|
||||
{ name: "Sky Aura", tone: "light",
|
||||
{ name: "天空光环", tone: "light",
|
||||
bg: "#e8edfb", colors: ["#2451e8", "#6fa3f5", "#f0a8c8", "#fdfdff"] },
|
||||
{ name: "Halo", tone: "light",
|
||||
{ name: "光晕", tone: "light",
|
||||
bg: "#f4f6ff", colors: ["#4a30e0", "#7a8cff", "#e89ab8", "#c2d4ff"] },
|
||||
{ name: "Solar Flare", tone: "light",
|
||||
{ name: "太阳耀斑", tone: "light",
|
||||
bg: "#fefcf8", colors: ["#e8401c", "#ff8a2a", "#ffc04a", "#a82408"] },
|
||||
{ name: "Glacier", tone: "light",
|
||||
{ name: "冰川", tone: "light",
|
||||
bg: "#f8fafc", colors: ["#1a56d6", "#4a9af0", "#a8d4ff", "#0a2a6e"] },
|
||||
{ name: "Tangerine Glass", tone: "dark",
|
||||
{ name: "橘色玻璃", tone: "dark",
|
||||
bg: "#1c1e22", colors: ["#ff7a14", "#e8e4dc", "#3a3e46", "#ffb066"] },
|
||||
{ name: "Velvet Dusk", tone: "dark",
|
||||
{ name: "丝绒黄昏", tone: "dark",
|
||||
bg: "#08050c", colors: ["#ff3d2e", "#ff8c5a", "#5a1eb8", "#1a0a3c"] }
|
||||
];
|
||||
|
||||
@@ -50,16 +50,16 @@ function generateRandomPalette(rand, tone) {
|
||||
var dark = tone === "dark";
|
||||
var colors = sch.map(function (off, i) {
|
||||
var hue = ((h + off) % 360 + 360) % 360;
|
||||
var s = dark ? 0.75 + rand() * 0.25 : 0.55 + rand() * 0.35;
|
||||
var s = dark ? 0.48 + rand() * 0.32 : 0.38 + rand() * 0.28;
|
||||
var l = dark
|
||||
? (i === 0 ? 0.52 : 0.28 + rand() * 0.45)
|
||||
: (i === 0 ? 0.5 : 0.45 + rand() * 0.4);
|
||||
? (i === 0 ? 0.48 : 0.26 + rand() * 0.38)
|
||||
: (i === 0 ? 0.46 : 0.42 + rand() * 0.34);
|
||||
return hslToHex(hue, s, l);
|
||||
});
|
||||
var bg = dark
|
||||
? hslToHex(h + (rand() - 0.5) * 60, 0.3 + rand() * 0.4, 0.015 + rand() * 0.035)
|
||||
: hslToHex(h + (rand() - 0.5) * 60, 0.25 + rand() * 0.35, 0.93 + rand() * 0.05);
|
||||
return { name: "Generated", tone: tone, bg: bg, colors: colors };
|
||||
return { name: "随机生成", tone: tone, bg: bg, colors: colors };
|
||||
}
|
||||
|
||||
function hslToHex(h, s, l) {
|
||||
|
||||
+254
-25
@@ -6,7 +6,10 @@ var VERT_SRC = "#version 300 es\n" +
|
||||
"layout(location=0) in vec2 a_pos;\n" +
|
||||
"void main(){ gl_Position = vec4(a_pos, 0.0, 1.0); }\n";
|
||||
|
||||
var FRAG_SRC = `#version 300 es
|
||||
/* The fragment source is built twice: a slim "base" variant without the
|
||||
genome synthesizer (links fast, used at boot) and the full variant
|
||||
compiled lazily in the background. */
|
||||
var FRAG_BODY = `
|
||||
precision highp float;
|
||||
precision highp int;
|
||||
|
||||
@@ -29,6 +32,18 @@ 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 (legacy share codes) */
|
||||
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;
|
||||
|
||||
/* genome: parametric style synthesizer, every gene set is its own style */
|
||||
uniform int u_genome; // 1 = render the genome style
|
||||
uniform vec4 u_g1; // field type, domain op, warp amount, fold count
|
||||
uniform vec4 u_g2; // color map, shading, overlay, overlay scale
|
||||
uniform vec4 u_g3; // ridge sharpness, poster steps, field scale, rotation
|
||||
|
||||
out vec4 fragColor;
|
||||
|
||||
#define TAU 6.28318530718
|
||||
@@ -372,18 +387,21 @@ vec3 boldField(vec2 p){
|
||||
}
|
||||
|
||||
vec3 sceneReeded(vec2 uv){
|
||||
float nx = uv.x * u_lines*0.55;
|
||||
float ridgeFreq = max(u_lines*0.55, 6.0);
|
||||
float nx = uv.x * ridgeFreq;
|
||||
float ci = floor(nx);
|
||||
float lx = fract(nx)-0.5;
|
||||
float lx = fract(nx) - 0.5;
|
||||
float lens = sin(lx*PI);
|
||||
float refr = lx*0.22*u_warp + lens*0.08*u_warp;
|
||||
float srcX = (ci + 0.5 + refr) / ridgeFreq;
|
||||
vec3 col = boldField(toP(vec2(srcX, uv.y))*0.8);
|
||||
|
||||
float srcX = (ci + 0.5 + lx*0.20 + sin(lx*PI)*0.34*u_warp) / (u_lines*0.55);
|
||||
vec2 suv = vec2(srcX, uv.y);
|
||||
vec3 col = boldField(toP(suv)*0.8);
|
||||
|
||||
float shade = 0.86 + 0.26*cos(lx*PI);
|
||||
float edge = smoothstep(0.5, 0.465, abs(lx));
|
||||
col *= mix(0.62, shade, edge);
|
||||
col += vec3(1.0)*pow(max(cos(lx*PI), 0.0), 30.0)*0.12*u_light;
|
||||
float ridge = cos(lx*PI);
|
||||
float shade = 0.78 + 0.28*ridge;
|
||||
float groove = smoothstep(0.48, 0.40, abs(lx));
|
||||
col *= mix(0.54, shade, groove);
|
||||
float spec = pow(max(ridge, 0.0), mix(12.0, 36.0, clamp(u_gloss/120.0, 0.0, 1.0)));
|
||||
col += vec3(1.0)*spec*u_light*0.14;
|
||||
return col;
|
||||
}
|
||||
|
||||
@@ -402,29 +420,236 @@ vec3 sceneMosaic(vec2 uv){
|
||||
return col;
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
GENOME, the parametric style synthesizer.
|
||||
12 genes select field, domain geometry, color mapping,
|
||||
shading and overlay. Each combination is a distinct style.
|
||||
========================================================= */
|
||||
#if HAS_GENOME
|
||||
|
||||
float gnVoro(vec2 p){
|
||||
vec2 i = floor(p), f = fract(p);
|
||||
float d = 8.0;
|
||||
for (int y = -1; y <= 1; y++)
|
||||
for (int x = -1; x <= 1; x++){
|
||||
vec2 g = vec2(float(x), float(y));
|
||||
vec2 o = hash22(i + g + floor(u_seed));
|
||||
d = min(d, length(g + o - f));
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
float gnField(int ft, vec2 p){
|
||||
vec2 so = SO(), lt = LT();
|
||||
if (ft == 0) return fbm(p + so + lt);
|
||||
if (ft == 1) { /* ridged */
|
||||
float v = 1.0 - abs(2.0*fbm(p + so + lt) - 1.0);
|
||||
return pow(v, 1.0 + u_g3.x*4.0);
|
||||
}
|
||||
if (ft == 2) { /* wave interference */
|
||||
float a = sin(p.x*2.1 + fbm(p*0.7 + so + lt)*6.0);
|
||||
float b = sin(p.y*1.7 + fbm(p.yx*0.8 - so - lt)*6.0);
|
||||
return a*b*0.25 + 0.5;
|
||||
}
|
||||
if (ft == 3) { /* warped rings */
|
||||
float d = length(p) + (fbm(p*0.9 + so + lt) - 0.5)*1.2;
|
||||
return fract(d*(1.0 + u_g3.x*2.0));
|
||||
}
|
||||
if (ft == 4) { /* cellular */
|
||||
float v = gnVoro(p*1.4 + lt*0.8);
|
||||
return pow(clamp(v, 0.0, 1.0), 0.8 + u_g3.x*2.0);
|
||||
}
|
||||
if (ft == 5) { /* flow */
|
||||
float f1 = fbm(p + so + lt);
|
||||
return fbm(p + 2.4*vec2(f1, 1.0 - f1) + so);
|
||||
}
|
||||
if (ft == 6) { /* cross bands */
|
||||
float bx = sin(p.x*(2.2 + u_g3.x*4.5) + lt.x*2.0);
|
||||
float by = cos(p.y*(1.6 + u_g3.x*3.2) - lt.y*2.0);
|
||||
return bx*by*0.28 + 0.5 + fbm(p*0.25 + so)*0.08;
|
||||
}
|
||||
if (ft == 7) { /* plaid grid */
|
||||
vec2 q = p*(1.8 + u_g3.z*1.2);
|
||||
float j = fbm(p*0.35 + so)*0.15;
|
||||
float gx = step(0.5, fract(q.x + j));
|
||||
float gy = step(0.5, fract(q.y - j));
|
||||
return mix(gx*gy, 1.0 - gx*gy, 0.5 + 0.5*sin(lt.x*3.0));
|
||||
}
|
||||
if (ft == 8) { /* hex lattice edges */
|
||||
float v = gnVoro(p*1.65 + lt*0.55);
|
||||
return smoothstep(0.02, 0.20, v);
|
||||
}
|
||||
if (ft == 9) { /* concentric arcs */
|
||||
float a = atan(p.y, p.x) + lt.x*0.6;
|
||||
float d = length(p);
|
||||
return fract(sin(a*3.0 + u_seed*0.01)*0.5 + d*(0.75 + u_g3.x) + fbm(p*0.28 + so)*0.18);
|
||||
}
|
||||
if (ft == 10) { /* hatch lines */
|
||||
float h1 = sin((p.x + p.y)*(6.0 + u_g3.x*6.0) + lt.x);
|
||||
float h2 = sin((p.x - p.y)*(6.0 + u_g3.x*6.0) - lt.y);
|
||||
float hatch = smoothstep(-0.15, 0.55, h1*h2);
|
||||
return mix(fbm(p*0.45 + so)*0.35, hatch, 0.72);
|
||||
}
|
||||
/* soft orb grid */
|
||||
vec2 cell = fract(p*(0.85 + u_g3.z*0.8)) - 0.5;
|
||||
float d = length(cell);
|
||||
float pulse = sin(TAU*u_phase + hash21(floor(p*(0.85 + u_g3.z*0.8)))*6.28)*0.08;
|
||||
return smoothstep(0.34 + pulse, 0.04, d);
|
||||
}
|
||||
|
||||
vec2 gnDomain(int dop, vec2 p){
|
||||
p = rot(u_g3.w*TAU) * p;
|
||||
if (dop == 1) { /* polar */
|
||||
return vec2(atan(p.y, p.x)*(1.0 + floor(u_g1.w*0.5)), length(p)*1.6);
|
||||
}
|
||||
if (dop == 2) { /* kaleidoscope */
|
||||
float n = 2.0 + floor(u_g1.w);
|
||||
float a = atan(p.y, p.x);
|
||||
float seg = TAU/n;
|
||||
a = abs(mod(a, seg) - seg*0.5);
|
||||
return vec2(cos(a), sin(a))*length(p);
|
||||
}
|
||||
if (dop == 3) return abs(p); /* mirror */
|
||||
if (dop == 4) { /* soft grid repeat */
|
||||
return (fract(p*0.5) - 0.5)*2.6;
|
||||
}
|
||||
if (dop == 5) { /* hex mirror */
|
||||
float seg = TAU/6.0;
|
||||
float a = atan(p.y, p.x);
|
||||
a = abs(mod(a, seg) - seg*0.5);
|
||||
return vec2(cos(a), sin(a))*length(p);
|
||||
}
|
||||
if (dop == 6) { /* brick weave */
|
||||
vec2 q = p*0.62;
|
||||
vec2 cell = floor(q);
|
||||
vec2 f = fract(q) - 0.5;
|
||||
if (mod(cell.y, 2.0) > 0.5) f.x += 0.5;
|
||||
return f*2.4;
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
vec3 gnColor(int cm, float t, vec2 p){
|
||||
t = clamp(t, 0.0, 1.0);
|
||||
if (cm == 1) return paletteCyc(t*1.4);
|
||||
if (cm == 2) { /* posterized */
|
||||
float steps = 3.0 + floor(u_g3.y*5.0);
|
||||
return palette(floor(t*steps)/(steps - 1.0));
|
||||
}
|
||||
if (cm == 3) { /* duotone + highlight pop */
|
||||
vec3 c = mix(u_bg, u_c1, smoothstep(0.15, 0.75, t));
|
||||
return mix(c, u_c3, smoothstep(0.82, 0.98, t));
|
||||
}
|
||||
if (cm == 4) { /* clean bands */
|
||||
float bands = 4.0 + floor(u_g3.y*5.0);
|
||||
return palette(floor(t*bands)/max(bands - 1.0, 1.0));
|
||||
}
|
||||
if (cm == 5) { /* angular sweep */
|
||||
float ang = atan(p.y, p.x)/PI*0.5 + 0.5;
|
||||
return paletteCyc(mix(t, ang, 0.52));
|
||||
}
|
||||
return palette(t);
|
||||
}
|
||||
|
||||
vec3 sceneGenome(vec2 uv){
|
||||
vec2 p0 = toP(uv);
|
||||
int ft = int(u_g1.x);
|
||||
int dop = int(u_g1.y);
|
||||
int cm = int(u_g2.x);
|
||||
int sh = int(u_g2.y);
|
||||
int ov = int(u_g2.z);
|
||||
|
||||
vec2 p = gnDomain(dop, p0*(0.6 + u_g3.z*1.4));
|
||||
vec2 so = SO();
|
||||
p += u_g1.z*u_warp*0.8*vec2(fbm(p*0.6 + so) - 0.5, fbm(p*0.6 - so) - 0.5)*2.0;
|
||||
|
||||
/* the field and its gradient are sampled exactly once and shared by
|
||||
every shading branch: keeps the D3D static-inline budget low */
|
||||
float e = 0.05;
|
||||
float f = gnField(ft, p);
|
||||
float fx = gnField(ft, p + vec2(e, 0.0));
|
||||
float fy = gnField(ft, p + vec2(0.0, e));
|
||||
vec2 grad = vec2((fx - f)/e, (fy - f)/e);
|
||||
|
||||
vec3 col = gnColor(cm, f*1.15 - 0.05, p);
|
||||
|
||||
if (sh == 1) { /* embossed light */
|
||||
vec3 n = normalize(vec3(-grad.x*2.0, -grad.y*2.0, 1.0));
|
||||
float la = u_lightAngle*PI/180.0;
|
||||
vec3 L = normalize(vec3(cos(la), sin(la), 0.6));
|
||||
float diff = max(dot(n, L), 0.0);
|
||||
float spec = pow(max(dot(n, normalize(L + vec3(0,0,1))), 0.0), u_gloss);
|
||||
col *= 0.35 + 0.8*diff;
|
||||
col += vec3(1.0)*spec*u_light*0.7;
|
||||
} else if (sh == 2) { /* glowing edges */
|
||||
float g = (abs(fx - f) + abs(fy - f))*1.25;
|
||||
col = mix(u_bg, col, 0.35);
|
||||
col += palette(clamp(f + 0.2, 0.0, 1.0)) * smoothstep(0.01, 0.14, g) * u_light * 1.4;
|
||||
} else if (sh == 3) { /* deep contrast carve */
|
||||
col *= smoothstep(0.0, 0.55, f)*1.15;
|
||||
col = mix(u_bg, col, smoothstep(0.08, 0.35, f));
|
||||
} else if (sh == 4) { /* glass sheen */
|
||||
float fres = pow(1.0 - clamp(length(grad)*1.4, 0.0, 1.0), 2.2);
|
||||
col *= 0.86 + 0.14*abs(sin(p0.x*u_lines*0.28));
|
||||
col += vec3(1.0)*fres*u_light*0.16;
|
||||
}
|
||||
|
||||
if (ov == 1) { /* stripes */
|
||||
float s = sin(p0.x*u_g2.w*40.0 + f*6.0);
|
||||
col *= 0.82 + 0.18*smoothstep(-0.2, 0.4, s);
|
||||
} else if (ov == 2) { /* dot lattice */
|
||||
vec2 g = fract(p0*u_g2.w*16.0) - 0.5;
|
||||
col *= 0.78 + 0.22*smoothstep(0.42, 0.30, length(g));
|
||||
} else if (ov == 3) { /* scanlines */
|
||||
col *= 0.86 + 0.14*sin(uv.y*u_res.y*0.7 + f*3.0);
|
||||
} else if (ov == 4) { /* fine weave */
|
||||
float wx = sin(p0.x*28.0 + f*4.0);
|
||||
float wy = sin(p0.y*28.0 - f*4.0);
|
||||
col *= 0.90 + 0.10*smoothstep(-0.2, 0.5, wx*wy);
|
||||
}
|
||||
|
||||
return col;
|
||||
}
|
||||
#endif
|
||||
|
||||
/* ---------------- 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);
|
||||
}
|
||||
|
||||
/* single scene call per fragment: keeps the D3D/ANGLE static
|
||||
inlining budget low so the program links fast everywhere.
|
||||
legacy synth share codes fall back to mode A. */
|
||||
vec3 scene(vec2 uv){
|
||||
#if HAS_GENOME
|
||||
if (u_genome == 1) return sceneGenome(uv);
|
||||
#endif
|
||||
return sceneFor(u_mode, uv);
|
||||
}
|
||||
|
||||
void main(){
|
||||
vec2 uv = gl_FragCoord.xy/u_res;
|
||||
vec3 col;
|
||||
vec3 col = scene(uv);
|
||||
|
||||
/* chromatic fringe: cheap radial channel split, no scene re-render */
|
||||
if (u_ca > 0.004){
|
||||
vec2 off = (uv-0.5)*u_ca*0.016;
|
||||
col = vec3(scene(uv-off).r, scene(uv).g, scene(uv+off).b);
|
||||
} else {
|
||||
col = scene(uv);
|
||||
float asp0 = u_res.x/u_res.y;
|
||||
float r2 = length((uv - 0.5)*vec2(asp0, 1.0));
|
||||
float w = clamp(u_ca, 0.0, 1.0)*smoothstep(0.18, 0.85, r2)*0.45;
|
||||
vec3 shifted = vec3(
|
||||
hueRotate(col, 10.0).r,
|
||||
col.g,
|
||||
hueRotate(col, -10.0).b);
|
||||
col = mix(col, shifted, w);
|
||||
}
|
||||
|
||||
/* glow: soft luminance knee */
|
||||
@@ -451,3 +676,7 @@ void main(){
|
||||
fragColor = vec4(clamp(col, 0.0, 1.0), 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
var FRAG_SRC_BASE = "#version 300 es\n#define HAS_GENOME 0\n" + FRAG_BODY;
|
||||
var FRAG_SRC_FULL = "#version 300 es\n#define HAS_GENOME 1\n" + FRAG_BODY;
|
||||
var FRAG_SRC = FRAG_SRC_FULL; /* legacy alias */
|
||||
|
||||
@@ -19,7 +19,7 @@ var UI = (function () {
|
||||
if (onDice) {
|
||||
var d = el("button", "section-dice", head);
|
||||
d.innerHTML = DICE_SVG;
|
||||
d.title = "Randomize " + title.toLowerCase();
|
||||
d.title = "随机化" + title;
|
||||
d.addEventListener("click", onDice);
|
||||
}
|
||||
return sec;
|
||||
@@ -168,7 +168,7 @@ var UI = (function () {
|
||||
opts.set(Math.max(0, Math.min(9999, parseInt(input.value, 10) || 0)));
|
||||
});
|
||||
var btn = el("button", "mini-btn", row);
|
||||
btn.innerHTML = DICE_SVG + "New seed";
|
||||
btn.innerHTML = DICE_SVG + "新种子";
|
||||
btn.addEventListener("click", opts.onDice);
|
||||
function refresh() { input.value = Math.round(opts.get()); }
|
||||
refresh();
|
||||
|
||||
@@ -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 };
|
||||
})();
|
||||
+382
-39
@@ -6,26 +6,30 @@
|
||||
: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; }
|
||||
|
||||
:root { color-scheme: dark; }
|
||||
|
||||
html, body { height: 100%; }
|
||||
|
||||
body {
|
||||
@@ -51,7 +55,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 +88,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;
|
||||
@@ -162,17 +189,70 @@ button { font: inherit; color: inherit; cursor: pointer; background: none; borde
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.canvas-shell {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.canvas-boot {
|
||||
position: absolute;
|
||||
border-radius: var(--radius-l);
|
||||
background: linear-gradient(135deg, #120804 0%, #3d1204 38%, #ff6a00 72%, #ffb347 100%);
|
||||
background-size: 220% 220%;
|
||||
animation: boot-shimmer 2.2s ease-in-out infinite;
|
||||
box-shadow:
|
||||
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);
|
||||
transition: opacity 0.45s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes boot-shimmer {
|
||||
0%, 100% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
}
|
||||
|
||||
body.ready .canvas-boot { opacity: 0; }
|
||||
|
||||
#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;
|
||||
opacity: 0;
|
||||
transition: opacity 0.45s ease;
|
||||
}
|
||||
|
||||
body.ready #view { opacity: 1; }
|
||||
|
||||
.stage-error {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: #9b9ba4;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
background: rgba(9, 9, 11, 0.92);
|
||||
border-radius: var(--radius-l);
|
||||
z-index: 2;
|
||||
}
|
||||
.stage-error[hidden] { display: none !important; }
|
||||
|
||||
.stage-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -190,14 +270,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 +331,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 +588,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 +700,248 @@ 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;
|
||||
}
|
||||
|
||||
/* ---------- boot: shell visible while WebGL compiles ---------- */
|
||||
|
||||
body.booting .topbar,
|
||||
body.booting .rail {
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
body.booting .topbar { background: var(--bg); }
|
||||
body.booting .rail {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
body.ready .rail {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transition: opacity 0.35s ease;
|
||||
}
|
||||
|
||||
/* ---------- intro stagger animation ---------- */
|
||||
|
||||
@keyframes intro-pop {
|
||||
0% { opacity: 0; transform: scale(1.1) translateY(6px); }
|
||||
100% { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
.intro .stagger-item {
|
||||
animation: intro-pop 420ms cubic-bezier(.22,1,.36,1) both;
|
||||
animation-delay: var(--intro-d, 0ms);
|
||||
}
|
||||
.intro .canvas-shell.stagger-item { animation-duration: 520ms; }
|
||||
|
||||
/* ---------- download success effect ---------- */
|
||||
|
||||
.fx-layer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes fx-ring {
|
||||
0% { transform: translate(-50%,-50%) scale(0.3); opacity: 0.9; }
|
||||
100% { transform: translate(-50%,-50%) scale(2.4); opacity: 0; }
|
||||
}
|
||||
.fx-ring {
|
||||
position: fixed;
|
||||
width: 180px; height: 180px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(255,255,255,0.8);
|
||||
z-index: 80;
|
||||
pointer-events: none;
|
||||
animation: fx-ring 700ms cubic-bezier(.2,.8,.3,1) forwards;
|
||||
}
|
||||
@keyframes toast-success-pop {
|
||||
0% { opacity: 0; transform: translateX(-50%) scale(1.4); }
|
||||
55% { opacity: 1; transform: translateX(-50%) scale(0.97); }
|
||||
100% { opacity: 1; transform: translateX(-50%) scale(1); }
|
||||
}
|
||||
.toast.success {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
border-color: rgba(140, 240, 170, 0.35);
|
||||
box-shadow: 0 10px 38px -8px rgba(40, 200, 110, 0.25);
|
||||
animation: toast-success-pop 420ms cubic-bezier(.22,1.4,.36,1);
|
||||
}
|
||||
.toast.success svg { width: 14px; height: 14px; flex: none; }
|
||||
.toast.success svg path {
|
||||
stroke-dasharray: 20;
|
||||
stroke-dashoffset: 20;
|
||||
animation: check-draw 360ms 150ms ease-out forwards;
|
||||
}
|
||||
@keyframes check-draw { to { stroke-dashoffset: 0; } }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* { transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user