duqing2026 commited on
Commit
18a44b2
·
1 Parent(s): 4f7a43e

feat: add color presets and text overlay features

Browse files
Files changed (2) hide show
  1. README.md +4 -2
  2. templates/index.html +318 -148
README.md CHANGED
@@ -10,7 +10,7 @@ short_description: 4K 高清渐变壁纸生成器
10
  app_port: 7860
11
  ---
12
 
13
- # 🎨 渐变壁纸实验室 (Gradient Wallpaper Lab)
14
 
15
  一个专注于生成 **4K 高清、噪点质感、弥散风格** 渐变壁纸的在线工具。
16
 
@@ -19,8 +19,9 @@ app_port: 7860
19
  ## ✨ 核心特性
20
 
21
  * **🌌 弥散渐变**: 基于算法生成自然融合的有机流体渐变。
 
22
  * **🌫 质感噪点**: 可调节的颗粒感(Grain),打造胶片/磨砂质感。
23
- * **🎨 智能配色**: 一键随机生成和谐的配色方案,支持手动微调
24
  * **📱 多端适配**: 预设 9:19 (手机) 和 16:9 (4K 桌面) 尺寸。
25
  * **🔒 隐私安全**: 所有渲染均在本地浏览器完成,图片不上传服务器。
26
 
@@ -64,6 +65,7 @@ docker run -p 7860:7860 wallpaper-lab
64
  * **小红书/Instagram 引流**: 生成高颜值壁纸发布,评论区引导使用工具。
65
  * **壁纸社群**: 提供“每日壁纸”订阅,使用本工具批量生成素材。
66
  * **设计素材**: 生成的 4K 渐变图可作为 PPT 背景、UI 设计底图出售。
 
67
 
68
  ## 🤝 贡献
69
 
 
10
  app_port: 7860
11
  ---
12
 
13
+ # 🎨 渐变壁纸实验室 (Gradient Wallpaper Lab) Pro
14
 
15
  一个专注于生成 **4K 高清、噪点质感、弥散风格** 渐变壁纸的在线工具。
16
 
 
19
  ## ✨ 核心特性
20
 
21
  * **🌌 弥散渐变**: 基于算法生成自然融合的有机流体渐变。
22
+ * **🎨 色彩主题**: 内置「赛博霓虹」、「日落大道」、「森之呼吸」等多种设计师精选配色。
23
  * **🌫 质感噪点**: 可调节的颗粒感(Grain),打造胶片/磨砂质感。
24
+ * **✒️ 文字叠加**: 支持添加个性化文字/金句,制作专属机锁屏或社交媒体配图
25
  * **📱 多端适配**: 预设 9:19 (手机) 和 16:9 (4K 桌面) 尺寸。
26
  * **🔒 隐私安全**: 所有渲染均在本地浏览器完成,图片不上传服务器。
27
 
 
65
  * **小红书/Instagram 引流**: 生成高颜值壁纸发布,评论区引导使用工具。
66
  * **壁纸社群**: 提供“每日壁纸”订阅,使用本工具批量生成素材。
67
  * **设计素材**: 生成的 4K 渐变图可作为 PPT 背景、UI 设计底图出售。
68
+ * **定制服务**: 为个人或品牌生成专属配色的背景图。
69
 
70
  ## 🤝 贡献
71
 
templates/index.html CHANGED
@@ -5,113 +5,179 @@
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
  <title>渐变壁纸实验室 (Gradient Wallpaper Lab)</title>
7
  <script src="https://cdn.tailwindcss.com"></script>
8
- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&display=swap" rel="stylesheet">
9
  <style>
10
  body { font-family: 'Inter', sans-serif; }
11
  .canvas-container {
12
- box-shadow: 0 20px 50px -12px rgba(0, 0, 0, 0.25);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  }
14
  </style>
15
  </head>
16
- <body class="bg-gray-900 text-white min-h-screen flex flex-col">
17
 
18
  <!-- Header -->
19
- <header class="p-6 border-b border-gray-800 bg-gray-900/50 backdrop-blur fixed w-full z-10 top-0">
20
  <div class="max-w-7xl mx-auto flex justify-between items-center">
21
  <div class="flex items-center gap-3">
22
- <div class="w-8 h-8 rounded-lg bg-gradient-to-br from-pink-500 via-purple-500 to-indigo-500"></div>
23
  <h1 class="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-pink-300 via-purple-300 to-indigo-300">
24
- 渐变壁纸实验室
25
  </h1>
26
  </div>
27
- <a href="https://huggingface.co/spaces/duqing26/gradient-wallpaper-lab" target="_blank" class="text-sm text-gray-400 hover:text-white transition">
28
- By duqing26
29
- </a>
 
 
 
30
  </div>
31
  </header>
32
 
33
  <!-- Main Content -->
34
- <main class="flex-grow flex flex-col md:flex-row pt-24 pb-12 px-6 gap-8 max-w-7xl mx-auto w-full h-full items-start">
35
 
36
  <!-- Controls Sidebar -->
37
- <aside class="w-full md:w-80 flex-shrink-0 space-y-6 bg-gray-800/50 p-6 rounded-2xl border border-gray-700 backdrop-blur-sm">
38
-
39
- <div>
40
- <h3 class="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-4">调色板</h3>
41
- <div class="flex gap-2 mb-4">
42
- <button onclick="generatePalette()" class="flex-1 bg-white text-gray-900 py-2 rounded-lg font-medium hover:bg-gray-200 transition flex items-center justify-center gap-2">
43
- <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
44
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
45
- </svg>
46
- 随机颜色
47
- </button>
48
- </div>
49
- <div id="color-inputs" class="grid grid-cols-5 gap-2">
50
- <!-- Color inputs will be injected here -->
51
- </div>
52
- </div>
53
-
54
- <div>
55
- <h3 class="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-4">参数调整</h3>
56
 
57
- <div class="space-y-4">
58
- <div>
59
- <label class="flex justify-between text-sm mb-1">
60
- <span>噪点强度 (Grain)</span>
61
- <span id="grain-val" class="text-gray-400">15%</span>
62
- </label>
63
- <input type="range" id="grain" min="0" max="50" value="15" class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-purple-500">
 
 
 
 
 
 
 
 
 
 
64
  </div>
65
 
66
- <div>
67
- <label class="flex justify-between text-sm mb-1">
68
- <span>模糊程度 (Blur)</span>
69
- <span id="blur-val" class="text-gray-400">100px</span>
70
- </label>
71
- <input type="range" id="blur" min="0" max="200" value="100" class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-purple-500">
72
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
 
74
- <div>
75
- <label class="flex justify-between text-sm mb-1">
76
- <span>形状复杂度</span>
77
- <span id="complexity-val" class="text-gray-400">中</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  </label>
79
- <input type="range" id="complexity" min="3" max="8" value="5" class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-purple-500">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  </div>
81
  </div>
82
- </div>
83
 
84
- <div class="pt-4 border-t border-gray-700">
85
- <button onclick="downloadImage('mobile')" class="w-full mb-3 bg-gray-700 hover:bg-gray-600 text-white py-3 rounded-xl font-medium transition flex items-center justify-center gap-2">
86
- <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
87
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
88
- </svg>
89
- 下载手机壁纸 (9:19)
90
- </button>
91
- <button onclick="downloadImage('desktop')" class="w-full bg-gray-700 hover:bg-gray-600 text-white py-3 rounded-xl font-medium transition flex items-center justify-center gap-2">
92
- <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
93
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
94
- </svg>
95
- 下载桌面壁纸 (16:9)
96
- </button>
97
  </div>
98
 
99
- <div class="bg-indigo-900/30 p-4 rounded-xl border border-indigo-500/30 text-xs text-indigo-200">
100
- 💡 提示:所有渲染均在本地浏览器完成,生成 4K 高清原图。
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  </div>
102
  </aside>
103
 
104
  <!-- Preview Area -->
105
- <div class="flex-grow flex items-center justify-center w-full h-full min-h-[500px] bg-gray-800/30 rounded-3xl border border-gray-700/50 p-8 relative overflow-hidden group">
 
106
 
107
  <!-- Hidden Canvas for Rendering -->
108
  <canvas id="renderCanvas" style="display: none;"></canvas>
109
 
110
- <!-- Preview Image -->
111
- <img id="previewImage" class="canvas-container rounded-2xl w-auto h-full max-h-[70vh] object-cover transition-all duration-700 opacity-0" alt="Preview">
112
-
113
- <div id="loading" class="absolute inset-0 flex items-center justify-center bg-gray-900/50 backdrop-blur-sm z-20 hidden">
114
- <div class="animate-spin rounded-full h-12 w-12 border-4 border-purple-500 border-t-transparent"></div>
 
 
 
 
115
  </div>
116
  </div>
117
 
@@ -121,15 +187,29 @@
121
  const canvas = document.getElementById('renderCanvas');
122
  const ctx = canvas.getContext('2d');
123
  const previewImg = document.getElementById('previewImage');
 
124
 
125
  // State
126
  let currentColors = [];
127
  let currentMode = 'mobile'; // 'mobile' or 'desktop'
128
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
  // Config
130
  const SIZES = {
131
- mobile: { width: 1440, height: 3040 }, // High Res Mobile
132
- desktop: { width: 3840, height: 2160 } // 4K Desktop
133
  };
134
 
135
  // Init
@@ -137,32 +217,58 @@
137
  generatePalette();
138
  render();
139
  setupListeners();
 
140
  });
141
 
142
  function setupListeners() {
143
- document.getElementById('grain').addEventListener('input', (e) => {
144
- document.getElementById('grain-val').innerText = e.target.value + '%';
145
- render();
 
 
146
  });
147
- document.getElementById('blur').addEventListener('input', (e) => {
148
- document.getElementById('blur-val').innerText = e.target.value + 'px';
149
- render();
 
 
150
  });
151
- document.getElementById('complexity').addEventListener('input', (e) => {
152
- document.getElementById('complexity-val').innerText = e.target.value > 6 ? '' : (e.target.value > 4 ? '中' : '低');
 
 
 
 
 
 
153
  render();
154
  });
155
  }
156
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
  function generatePalette() {
158
  currentColors = [];
159
  const hueBase = Math.random() * 360;
160
-
161
- // Generate 5 harmonious colors
162
  for (let i = 0; i < 5; i++) {
163
- const hue = (hueBase + (Math.random() * 60 - 30)) % 360;
164
- const sat = 60 + Math.random() * 40;
165
- const light = 40 + Math.random() * 40;
166
  currentColors.push(`hsl(${hue}, ${sat}%, ${light}%)`);
167
  }
168
  updateColorInputs();
@@ -174,12 +280,21 @@
174
  container.innerHTML = '';
175
  currentColors.forEach((color, index) => {
176
  const div = document.createElement('div');
177
- div.className = 'w-full aspect-square rounded-full border-2 border-white/20 cursor-pointer hover:scale-110 transition relative overflow-hidden';
 
 
 
 
 
 
 
 
 
178
  div.style.backgroundColor = color;
179
 
180
  const input = document.createElement('input');
181
  input.type = 'color';
182
- input.value = hslToHex(color);
183
  input.className = 'absolute inset-0 w-full h-full opacity-0 cursor-pointer';
184
  input.addEventListener('input', (e) => {
185
  currentColors[index] = e.target.value;
@@ -196,115 +311,170 @@
196
  const size = SIZES[currentMode];
197
  canvas.width = size.width;
198
  canvas.height = size.height;
 
 
199
 
200
  // 1. Fill Background
201
  ctx.fillStyle = currentColors[0];
202
- ctx.fillRect(0, 0, canvas.width, canvas.height);
203
 
204
  // 2. Draw Blobs
205
  const complexity = parseInt(document.getElementById('complexity').value);
206
- const blurAmount = parseInt(document.getElementById('blur').value);
207
-
208
- // Use globalCompositeOperation for better blending
209
- ctx.globalCompositeOperation = 'screen'; // or 'overlay', 'soft-light'
210
-
211
- for (let i = 0; i < complexity; i++) {
212
- const color = currentColors[(i + 1) % currentColors.length];
213
- ctx.fillStyle = color;
214
-
215
- const x = Math.random() * canvas.width;
216
- const y = Math.random() * canvas.height;
217
- const r = (Math.random() * 0.4 + 0.3) * canvas.width; // Radius relative to width
218
 
219
- ctx.beginPath();
220
- ctx.arc(x, y, r, 0, Math.PI * 2);
221
- ctx.fill();
 
 
 
 
 
 
 
 
222
  }
223
 
224
- // Reset composite
225
- ctx.globalCompositeOperation = 'source-over';
226
-
227
- // 3. Apply Blur (Simulated via stacking or CSS? CSS won't affect download)
228
- // Canvas API filter is supported in modern browsers
229
- // But for compatibility and speed with noise, we might need a trick.
230
- // Since ctx.filter = 'blur()' is expensive on 4K, let's try drawing scaled up or using simple gradients?
231
- // Actually, for "Gradient Mesh" look, we just need very large blurred circles.
232
- // The circles above are already drawn. Now let's apply a massive blur if supported,
233
- // or just rely on the large soft gradients (radial).
234
-
235
- // Let's re-draw with radial gradients instead of solid circles + blur for performance & quality
236
- ctx.clearRect(0, 0, canvas.width, canvas.height);
237
-
238
- // Base background
239
- ctx.fillStyle = currentColors[0];
240
- ctx.fillRect(0, 0, canvas.width, canvas.height);
241
-
242
- for (let i = 0; i < complexity; i++) {
243
- const color = currentColors[(i + 1) % currentColors.length];
244
- const x = Math.random() * canvas.width;
245
- const y = Math.random() * canvas.height;
246
- const r = (Math.random() * 0.6 + 0.2) * Math.max(canvas.width, canvas.height);
247
 
248
  const g = ctx.createRadialGradient(x, y, 0, x, y, r);
249
- // Convert color to RGBA for transparency
250
  g.addColorStop(0, color);
251
- g.addColorStop(1, 'rgba(0,0,0,0)'); // Fade out
252
 
253
  ctx.fillStyle = g;
254
- ctx.globalCompositeOperation = 'screen'; // Blend mode
255
  ctx.beginPath();
256
  ctx.arc(x, y, r, 0, Math.PI * 2);
257
  ctx.fill();
258
- }
259
-
260
- // 4. Add Grain/Noise
 
 
261
  const grainAmount = parseInt(document.getElementById('grain').value);
262
  if (grainAmount > 0) {
263
- addNoise(ctx, canvas.width, canvas.height, grainAmount / 100);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
264
  }
265
 
266
  // Update Preview
267
  previewImg.src = canvas.toDataURL('image/jpeg', 0.8);
268
- previewImg.onload = () => {
269
- previewImg.classList.remove('opacity-0');
270
- };
 
 
 
 
 
 
 
 
271
  }
272
 
273
  function addNoise(ctx, w, h, opacity) {
 
 
 
 
 
 
 
 
274
  const imageData = ctx.getImageData(0, 0, w, h);
275
  const data = imageData.data;
276
  const len = data.length;
277
 
278
- // Simple random noise
279
  for (let i = 0; i < len; i += 4) {
280
- const noise = (Math.random() - 0.5) * 255 * opacity;
281
- data[i] = data[i] + noise; // R
282
- data[i+1] = data[i+1] + noise; // G
283
- data[i+2] = data[i+2] + noise; // B
 
 
 
 
284
  }
285
  ctx.putImageData(imageData, 0, 0);
286
  }
287
 
288
  async function downloadImage(mode) {
289
- currentMode = mode;
290
  const btn = event.currentTarget;
291
- const originalText = btn.innerHTML;
292
- btn.innerHTML = `<svg class="animate-spin h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg> 生成中...`;
 
 
293
  btn.disabled = true;
 
294
 
295
- // Wait a tick for UI update
296
  setTimeout(() => {
297
- render(); // Re-render with new size
 
 
298
 
 
 
 
 
299
  const link = document.createElement('a');
300
- link.download = `gradient-wallpaper-${Date.now()}.png`;
301
- link.href = canvas.toDataURL('image/png');
302
  link.click();
303
 
304
- btn.innerHTML = originalText;
 
 
 
 
 
 
305
  btn.disabled = false;
306
  }, 100);
307
  }
 
 
 
 
 
 
 
 
 
308
 
309
  // Helper: HSL to Hex
310
  function hslToHex(hsl) {
 
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
  <title>渐变壁纸实验室 (Gradient Wallpaper Lab)</title>
7
  <script src="https://cdn.tailwindcss.com"></script>
8
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&family=Playfair+Display:wght@700&family=JetBrains+Mono:wght@400&display=swap" rel="stylesheet">
9
  <style>
10
  body { font-family: 'Inter', sans-serif; }
11
  .canvas-container {
12
+ box-shadow: 0 20px 50px -12px rgba(0, 0, 0, 0.5);
13
+ }
14
+ /* Custom Scrollbar */
15
+ ::-webkit-scrollbar {
16
+ width: 8px;
17
+ height: 8px;
18
+ }
19
+ ::-webkit-scrollbar-track {
20
+ background: #1f2937;
21
+ }
22
+ ::-webkit-scrollbar-thumb {
23
+ background: #4b5563;
24
+ border-radius: 4px;
25
+ }
26
+ ::-webkit-scrollbar-thumb:hover {
27
+ background: #6b7280;
28
  }
29
  </style>
30
  </head>
31
+ <body class="bg-gray-950 text-white min-h-screen flex flex-col overflow-hidden">
32
 
33
  <!-- Header -->
34
+ <header class="p-4 border-b border-gray-800 bg-gray-900/80 backdrop-blur fixed w-full z-20 top-0">
35
  <div class="max-w-7xl mx-auto flex justify-between items-center">
36
  <div class="flex items-center gap-3">
37
+ <div class="w-8 h-8 rounded-lg bg-gradient-to-br from-pink-500 via-purple-500 to-indigo-500 shadow-lg shadow-purple-500/20"></div>
38
  <h1 class="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-pink-300 via-purple-300 to-indigo-300">
39
+ 渐变壁纸实验室 <span class="text-xs text-gray-500 font-normal ml-2 border border-gray-700 px-1.5 py-0.5 rounded">Pro</span>
40
  </h1>
41
  </div>
42
+ <div class="flex items-center gap-4">
43
+ <a href="https://huggingface.co/spaces/duqing26/gradient-wallpaper-lab" target="_blank" class="text-xs text-gray-400 hover:text-white transition flex items-center gap-1">
44
+ <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/></svg>
45
+ By duqing26
46
+ </a>
47
+ </div>
48
  </div>
49
  </header>
50
 
51
  <!-- Main Content -->
52
+ <main class="flex-grow flex flex-col md:flex-row pt-20 pb-0 h-screen max-w-full mx-auto w-full">
53
 
54
  <!-- Controls Sidebar -->
55
+ <aside class="w-full md:w-96 flex-shrink-0 bg-gray-900/90 border-r border-gray-800 backdrop-blur-sm flex flex-col h-full z-10">
56
+ <div class="p-6 overflow-y-auto flex-grow space-y-8 custom-scrollbar">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
 
58
+ <!-- Palettes -->
59
+ <div>
60
+ <div class="flex justify-between items-center mb-4">
61
+ <h3 class="text-xs font-bold text-gray-400 uppercase tracking-wider">色彩主题</h3>
62
+ <button onclick="generatePalette()" class="text-xs bg-gray-800 hover:bg-gray-700 text-purple-400 px-2 py-1 rounded transition flex items-center gap-1">
63
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>
64
+ 随机生成
65
+ </button>
66
+ </div>
67
+
68
+ <div class="grid grid-cols-2 gap-2 mb-4">
69
+ <button onclick="applyPreset('neon')" class="h-10 rounded-lg bg-gradient-to-r from-[#f72585] via-[#7209b7] to-[#4361ee] hover:opacity-90 transition relative group overflow-hidden"><span class="absolute inset-0 flex items-center justify-center bg-black/20 text-xs font-medium opacity-0 group-hover:opacity-100 transition">赛博霓虹</span></button>
70
+ <button onclick="applyPreset('sunset')" class="h-10 rounded-lg bg-gradient-to-r from-[#ffbe0b] via-[#fb5607] to-[#8338ec] hover:opacity-90 transition relative group overflow-hidden"><span class="absolute inset-0 flex items-center justify-center bg-black/20 text-xs font-medium opacity-0 group-hover:opacity-100 transition">日落大道</span></button>
71
+ <button onclick="applyPreset('nature')" class="h-10 rounded-lg bg-gradient-to-r from-[#2d6a4f] via-[#52b788] to-[#95d5b2] hover:opacity-90 transition relative group overflow-hidden"><span class="absolute inset-0 flex items-center justify-center bg-black/20 text-xs font-medium opacity-0 group-hover:opacity-100 transition">森之呼吸</span></button>
72
+ <button onclick="applyPreset('pastel')" class="h-10 rounded-lg bg-gradient-to-r from-[#cdb4db] via-[#ffafcc] to-[#a2d2ff] hover:opacity-90 transition relative group overflow-hidden"><span class="absolute inset-0 flex items-center justify-center bg-black/20 text-xs font-medium opacity-0 group-hover:opacity-100 transition">棉花糖</span></button>
73
+ <button onclick="applyPreset('ocean')" class="h-10 rounded-lg bg-gradient-to-r from-[#03045e] via-[#0077b6] to-[#00b4d8] hover:opacity-90 transition relative group overflow-hidden"><span class="absolute inset-0 flex items-center justify-center bg-black/20 text-xs font-medium opacity-0 group-hover:opacity-100 transition">深海迷航</span></button>
74
+ <button onclick="applyPreset('dark')" class="h-10 rounded-lg bg-gradient-to-r from-[#000000] via-[#14213d] to-[#e5e5e5] hover:opacity-90 transition relative group overflow-hidden"><span class="absolute inset-0 flex items-center justify-center bg-black/20 text-xs font-medium opacity-0 group-hover:opacity-100 transition">极简黑白</span></button>
75
  </div>
76
 
77
+ <div id="color-inputs" class="flex gap-2 justify-between">
78
+ <!-- Color inputs injected here -->
 
 
 
 
79
  </div>
80
+ </div>
81
+
82
+ <!-- Parameters -->
83
+ <div>
84
+ <h3 class="text-xs font-bold text-gray-400 uppercase tracking-wider mb-4">画面质感</h3>
85
+ <div class="space-y-5">
86
+ <div class="group">
87
+ <label class="flex justify-between text-xs mb-2 text-gray-300">
88
+ <span>噪点强度 (Grain)</span>
89
+ <span id="grain-val" class="text-purple-400 font-mono">15%</span>
90
+ </label>
91
+ <input type="range" id="grain" min="0" max="40" value="15" class="w-full h-1.5 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-purple-500 group-hover:bg-gray-600 transition">
92
+ </div>
93
+
94
+ <div class="group">
95
+ <label class="flex justify-between text-xs mb-2 text-gray-300">
96
+ <span>模糊程度 (Blur)</span>
97
+ <span id="blur-val" class="text-purple-400 font-mono">100px</span>
98
+ </label>
99
+ <input type="range" id="blur" min="0" max="200" value="100" class="w-full h-1.5 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-purple-500 group-hover:bg-gray-600 transition">
100
+ </div>
101
 
102
+ <div class="group">
103
+ <label class="flex justify-between text-xs mb-2 text-gray-300">
104
+ <span>形状复杂度</span>
105
+ <span id="complexity-val" class="text-purple-400 font-mono">中</span>
106
+ </label>
107
+ <input type="range" id="complexity" min="3" max="10" value="6" class="w-full h-1.5 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-purple-500 group-hover:bg-gray-600 transition">
108
+ </div>
109
+ </div>
110
+ </div>
111
+
112
+ <!-- Text Overlay -->
113
+ <div class="pt-4 border-t border-gray-800">
114
+ <div class="flex justify-between items-center mb-4">
115
+ <h3 class="text-xs font-bold text-gray-400 uppercase tracking-wider">文字叠加</h3>
116
+ <label class="relative inline-flex items-center cursor-pointer">
117
+ <input type="checkbox" id="show-text" class="sr-only peer">
118
+ <div class="w-9 h-5 bg-gray-700 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-purple-800 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-purple-600"></div>
119
  </label>
120
+ </div>
121
+
122
+ <div id="text-controls" class="space-y-4 opacity-50 pointer-events-none transition-opacity duration-200">
123
+ <input type="text" id="overlay-text" value="Stay Creative" class="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-purple-500 transition placeholder-gray-500" placeholder="输入文字...">
124
+
125
+ <div class="grid grid-cols-2 gap-2">
126
+ <select id="font-family" class="bg-gray-800 border border-gray-700 rounded-lg px-2 py-2 text-xs text-white focus:outline-none">
127
+ <option value="'Inter', sans-serif">Modern (Sans)</option>
128
+ <option value="'Playfair Display', serif">Elegant (Serif)</option>
129
+ <option value="'JetBrains Mono', monospace">Coding (Mono)</option>
130
+ </select>
131
+ <input type="number" id="font-size" value="120" min="10" max="500" class="bg-gray-800 border border-gray-700 rounded-lg px-2 py-2 text-xs text-white focus:outline-none" title="Font Size">
132
+ </div>
133
+
134
+ <div class="flex items-center gap-2">
135
+ <input type="color" id="text-color" value="#ffffff" class="h-8 w-8 rounded cursor-pointer bg-transparent border-0 p-0">
136
+ <label class="text-xs text-gray-400">文字颜色</label>
137
+ </div>
138
  </div>
139
  </div>
 
140
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
  </div>
142
 
143
+ <!-- Download Buttons -->
144
+ <div class="p-6 border-t border-gray-800 bg-gray-900 z-20">
145
+ <div class="grid grid-cols-2 gap-3">
146
+ <button onclick="downloadImage('mobile')" class="bg-gray-800 hover:bg-gray-700 text-white py-3 rounded-xl font-medium transition flex flex-col items-center justify-center gap-1 border border-gray-700 hover:border-gray-600 group">
147
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-400 group-hover:text-white transition" fill="none" viewBox="0 0 24 24" stroke="currentColor">
148
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
149
+ </svg>
150
+ <span class="text-xs text-gray-300">手机壁纸 (9:19)</span>
151
+ </button>
152
+ <button onclick="downloadImage('desktop')" class="bg-gray-800 hover:bg-gray-700 text-white py-3 rounded-xl font-medium transition flex flex-col items-center justify-center gap-1 border border-gray-700 hover:border-gray-600 group">
153
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-400 group-hover:text-white transition" fill="none" viewBox="0 0 24 24" stroke="currentColor">
154
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
155
+ </svg>
156
+ <span class="text-xs text-gray-300">桌面壁纸 (16:9)</span>
157
+ </button>
158
+ </div>
159
+ <div class="mt-3 text-center">
160
+ <p class="text-[10px] text-gray-600">Generated locally in 4K resolution</p>
161
+ </div>
162
  </div>
163
  </aside>
164
 
165
  <!-- Preview Area -->
166
+ <div class="flex-grow bg-black relative flex items-center justify-center overflow-hidden p-8">
167
+ <div class="absolute inset-0 opacity-20 pointer-events-none" style="background-image: radial-gradient(#333 1px, transparent 1px); background-size: 20px 20px;"></div>
168
 
169
  <!-- Hidden Canvas for Rendering -->
170
  <canvas id="renderCanvas" style="display: none;"></canvas>
171
 
172
+ <!-- Preview Image Wrapper -->
173
+ <div class="relative shadow-2xl rounded-2xl overflow-hidden transition-all duration-500 ease-in-out border border-gray-800" id="preview-container">
174
+ <img id="previewImage" class="block w-full h-full object-cover bg-gray-900" alt="Preview">
175
+
176
+ <!-- Loading Overlay -->
177
+ <div id="loading" class="absolute inset-0 flex flex-col items-center justify-center bg-gray-900/60 backdrop-blur-sm z-20 hidden transition-opacity">
178
+ <div class="animate-spin rounded-full h-10 w-10 border-4 border-purple-500 border-t-transparent mb-3"></div>
179
+ <span class="text-xs font-semibold tracking-wider text-purple-200">RENDERING 4K...</span>
180
+ </div>
181
  </div>
182
  </div>
183
 
 
187
  const canvas = document.getElementById('renderCanvas');
188
  const ctx = canvas.getContext('2d');
189
  const previewImg = document.getElementById('previewImage');
190
+ const previewContainer = document.getElementById('preview-container');
191
 
192
  // State
193
  let currentColors = [];
194
  let currentMode = 'mobile'; // 'mobile' or 'desktop'
195
 
196
+ // Presets
197
+ const PRESETS = {
198
+ neon: ['#f72585', '#7209b7', '#3a0ca3', '#4361ee', '#4cc9f0'],
199
+ sunset: ['#ffbe0b', '#fb5607', '#ff006e', #8338ec', '#3a86ff'], // Corrected hex syntax below
200
+ nature: ['#2d6a4f', '#40916c', '#52b788', '#74c69d', '#95d5b2'],
201
+ pastel: ['#cdb4db', '#ffc8dd', '#ffafcc', '#bde0fe', '#a2d2ff'],
202
+ ocean: ['#03045e', '#0077b6', '#00b4d8', '#90e0ef', '#caf0f8'],
203
+ dark: ['#000000', '#14213d', '#2c3e50', '#4a4e69', '#9a8c98']
204
+ };
205
+
206
+ // Fix preset syntax error in object above if any
207
+ PRESETS.sunset = ['#ffbe0b', '#fb5607', '#ff006e', '#8338ec', '#3a86ff'];
208
+
209
  // Config
210
  const SIZES = {
211
+ mobile: { width: 1440, height: 3040, displayClass: 'h-[80vh] w-auto aspect-[9/19]' },
212
+ desktop: { width: 3840, height: 2160, displayClass: 'w-[80vw] h-auto aspect-[16/9]' }
213
  };
214
 
215
  // Init
 
217
  generatePalette();
218
  render();
219
  setupListeners();
220
+ updatePreviewStyle();
221
  });
222
 
223
  function setupListeners() {
224
+ ['grain', 'blur', 'complexity'].forEach(id => {
225
+ document.getElementById(id).addEventListener('input', (e) => {
226
+ updateLabels();
227
+ render();
228
+ });
229
  });
230
+
231
+ // Text Controls
232
+ const textInputs = ['overlay-text', 'font-family', 'font-size', 'text-color'];
233
+ textInputs.forEach(id => {
234
+ document.getElementById(id).addEventListener('input', render);
235
  });
236
+
237
+ document.getElementById('show-text').addEventListener('change', (e) => {
238
+ const controls = document.getElementById('text-controls');
239
+ if (e.target.checked) {
240
+ controls.classList.remove('opacity-50', 'pointer-events-none');
241
+ } else {
242
+ controls.classList.add('opacity-50', 'pointer-events-none');
243
+ }
244
  render();
245
  });
246
  }
247
 
248
+ function updateLabels() {
249
+ document.getElementById('grain-val').innerText = document.getElementById('grain').value + '%';
250
+ document.getElementById('blur-val').innerText = document.getElementById('blur').value + 'px';
251
+ const c = document.getElementById('complexity').value;
252
+ document.getElementById('complexity-val').innerText = c > 7 ? '高' : (c > 4 ? '中' : '低');
253
+ }
254
+
255
+ function updatePreviewStyle() {
256
+ previewImg.className = SIZES[currentMode].displayClass + ' object-cover rounded-xl shadow-2xl';
257
+ }
258
+
259
+ function applyPreset(name) {
260
+ currentColors = [...PRESETS[name]];
261
+ updateColorInputs();
262
+ render();
263
+ }
264
+
265
  function generatePalette() {
266
  currentColors = [];
267
  const hueBase = Math.random() * 360;
 
 
268
  for (let i = 0; i < 5; i++) {
269
+ const hue = (hueBase + (Math.random() * 90 - 45)) % 360;
270
+ const sat = 50 + Math.random() * 50;
271
+ const light = 30 + Math.random() * 50;
272
  currentColors.push(`hsl(${hue}, ${sat}%, ${light}%)`);
273
  }
274
  updateColorInputs();
 
280
  container.innerHTML = '';
281
  currentColors.forEach((color, index) => {
282
  const div = document.createElement('div');
283
+ div.className = 'w-full h-8 rounded-md border border-white/10 cursor-pointer hover:scale-105 transition relative overflow-hidden';
284
+
285
+ // Handle HSL to Hex for input value if needed, or just set style
286
+ // If it's already hex (from preset), fine. If HSL (from random), fine for style but input needs hex.
287
+ // We'll standardise to Hex for simplicity in input value
288
+ let hexColor = color;
289
+ if (color.startsWith('hsl')) {
290
+ hexColor = hslToHex(color);
291
+ }
292
+
293
  div.style.backgroundColor = color;
294
 
295
  const input = document.createElement('input');
296
  input.type = 'color';
297
+ input.value = hexColor;
298
  input.className = 'absolute inset-0 w-full h-full opacity-0 cursor-pointer';
299
  input.addEventListener('input', (e) => {
300
  currentColors[index] = e.target.value;
 
311
  const size = SIZES[currentMode];
312
  canvas.width = size.width;
313
  canvas.height = size.height;
314
+ const w = canvas.width;
315
+ const h = canvas.height;
316
 
317
  // 1. Fill Background
318
  ctx.fillStyle = currentColors[0];
319
+ ctx.fillRect(0, 0, w, h);
320
 
321
  // 2. Draw Blobs
322
  const complexity = parseInt(document.getElementById('complexity').value);
323
+
324
+ ctx.globalCompositeOperation = 'screen'; // Blend mode
 
 
 
 
 
 
 
 
 
 
325
 
326
+ // Deterministic seeding for consistency during tweaking?
327
+ // For now random is fine, but maybe we want "Seed" later.
328
+ // To prevent flickering when changing grain, we should probably seed random.
329
+ // But let's keep it dynamic for now as users might want to click "Render" to re-roll positions.
330
+ // Actually, we should probably save positions unless "Generate" is clicked?
331
+ // For simplicity in this v2, we'll re-roll positions on render which happens on input change.
332
+ // Wait, that means sliding "Grain" will make blobs dance. That's annoying.
333
+ // Let's implement a simple seeded random or cached positions.
334
+
335
+ if (!window.blobPositions || window.blobPositions.length !== complexity) {
336
+ regenerateBlobPositions(complexity);
337
  }
338
 
339
+ window.blobPositions.forEach((pos, i) => {
340
+ const color = currentColors[(i + 1) % currentColors.length] || currentColors[1];
341
+ const x = pos.x * w;
342
+ const y = pos.y * h;
343
+ const r = pos.r * Math.max(w, h);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
344
 
345
  const g = ctx.createRadialGradient(x, y, 0, x, y, r);
 
346
  g.addColorStop(0, color);
347
+ g.addColorStop(1, 'rgba(0,0,0,0)');
348
 
349
  ctx.fillStyle = g;
 
350
  ctx.beginPath();
351
  ctx.arc(x, y, r, 0, Math.PI * 2);
352
  ctx.fill();
353
+ });
354
+
355
+ ctx.globalCompositeOperation = 'source-over';
356
+
357
+ // 3. Add Grain
358
  const grainAmount = parseInt(document.getElementById('grain').value);
359
  if (grainAmount > 0) {
360
+ addNoise(ctx, w, h, grainAmount / 100);
361
+ }
362
+
363
+ // 4. Add Text Overlay
364
+ if (document.getElementById('show-text').checked) {
365
+ const text = document.getElementById('overlay-text').value;
366
+ if (text) {
367
+ const fontFamily = document.getElementById('font-family').value;
368
+ const fontSizeBase = parseInt(document.getElementById('font-size').value);
369
+ const textColor = document.getElementById('text-color').value;
370
+
371
+ // Scale font size based on resolution
372
+ const scaleFactor = w / 1440; // Reference width
373
+ const fontSize = fontSizeBase * scaleFactor;
374
+
375
+ ctx.font = `${fontSize}px ${fontFamily.split(',')[0]}`; // Hack for canvas font string
376
+ ctx.textAlign = 'center';
377
+ ctx.textBaseline = 'middle';
378
+
379
+ // Shadow
380
+ ctx.shadowColor = 'rgba(0,0,0,0.3)';
381
+ ctx.shadowBlur = 20 * scaleFactor;
382
+ ctx.shadowOffsetX = 0;
383
+ ctx.shadowOffsetY = 10 * scaleFactor;
384
+
385
+ ctx.fillStyle = textColor;
386
+ ctx.fillText(text, w / 2, h / 2);
387
+
388
+ // Reset shadow
389
+ ctx.shadowColor = 'transparent';
390
+ ctx.shadowBlur = 0;
391
+ }
392
  }
393
 
394
  // Update Preview
395
  previewImg.src = canvas.toDataURL('image/jpeg', 0.8);
396
+ }
397
+
398
+ function regenerateBlobPositions(count) {
399
+ window.blobPositions = [];
400
+ for (let i = 0; i < count; i++) {
401
+ window.blobPositions.push({
402
+ x: Math.random(),
403
+ y: Math.random(),
404
+ r: Math.random() * 0.6 + 0.3
405
+ });
406
+ }
407
  }
408
 
409
  function addNoise(ctx, w, h, opacity) {
410
+ // Optimization: Create a smaller noise pattern and tile it?
411
+ // For 4K, pixel-by-pixel noise in JS is slow (blocking main thread).
412
+ // Let's use a faster approach: generate small noise canvas and drawImage tile it, or draw random pixels.
413
+ // Or better: getImageData is slow.
414
+ // Let's try drawing tiny rectangles? No, too many draw calls.
415
+ // Let's stick to pixel manipulation but optimize or accept lag?
416
+ // Current approach is fine for "generating", maybe add loading state.
417
+
418
  const imageData = ctx.getImageData(0, 0, w, h);
419
  const data = imageData.data;
420
  const len = data.length;
421
 
 
422
  for (let i = 0; i < len; i += 4) {
423
+ // Optimization: Math.random() is somewhat slow.
424
+ // Use a simple LCG or just accept it.
425
+ if (Math.random() > 0.5) { // 50% density
426
+ const noise = (Math.random() - 0.5) * 255 * opacity;
427
+ data[i] = data[i] + noise;
428
+ data[i+1] = data[i+1] + noise;
429
+ data[i+2] = data[i+2] + noise;
430
+ }
431
  }
432
  ctx.putImageData(imageData, 0, 0);
433
  }
434
 
435
  async function downloadImage(mode) {
 
436
  const btn = event.currentTarget;
437
+ const originalHTML = btn.innerHTML;
438
+
439
+ // Show loading
440
+ document.getElementById('loading').classList.remove('hidden');
441
  btn.disabled = true;
442
+ btn.innerHTML = `<span class="text-xs">Generating...</span>`;
443
 
444
+ // Wait for UI to update
445
  setTimeout(() => {
446
+ // Switch mode temporarily
447
+ const prevMode = currentMode;
448
+ currentMode = mode;
449
 
450
+ // Re-render at target resolution
451
+ render();
452
+
453
+ // Download
454
  const link = document.createElement('a');
455
+ link.download = `wallpaper-${mode}-${Date.now()}.png`;
456
+ link.href = canvas.toDataURL('image/png'); // High quality
457
  link.click();
458
 
459
+ // Restore
460
+ currentMode = prevMode;
461
+ render(); // Render back to preview size if we want optimization (but here we just use high res scaled down in preview)
462
+
463
+ // Hide loading
464
+ document.getElementById('loading').classList.add('hidden');
465
+ btn.innerHTML = originalHTML;
466
  btn.disabled = false;
467
  }, 100);
468
  }
469
+
470
+ // Listen for Palette Regen specifically to reset positions?
471
+ // Actually generatePalette calls render, which checks blobPositions.
472
+ // We might want new positions on new palette?
473
+ const originalGenPalette = generatePalette;
474
+ generatePalette = function() {
475
+ regenerateBlobPositions(parseInt(document.getElementById('complexity').value));
476
+ originalGenPalette();
477
+ }
478
 
479
  // Helper: HSL to Hex
480
  function hslToHex(hsl) {