dx8152 commited on
Commit
e51fed7
·
verified ·
1 Parent(s): 4565a01

Upload 8 files

Browse files
static/angle.html ADDED
@@ -0,0 +1,1278 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <link rel="icon" href="/static/logo.png" type="image/png">
8
+ <title>Angle Control | 视角重塑</title>
9
+ <script src="https://cdn.tailwindcss.com"></script>
10
+ <script src="https://unpkg.com/lucide@latest"></script>
11
+ <script type="importmap">
12
+ {
13
+ "imports": {
14
+ "three": "https://unpkg.com/three@0.160.0/build/three.module.js"
15
+ }
16
+ }
17
+ </script>
18
+ <style>
19
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;800&family=JetBrains+Mono:wght@400;700&display=swap');
20
+
21
+ :root {
22
+ --accent: #111827;
23
+ --bg: #f9fafb;
24
+ --card: #ffffff;
25
+ --easing: cubic-bezier(0.4, 0, 0.2, 1);
26
+ }
27
+
28
+ /* --- 极简悬浮浅灰滚动条 (无底色/左移) --- */
29
+ *::-webkit-scrollbar {
30
+ width: 10px !important;
31
+ height: 10px !important;
32
+ background: transparent !important;
33
+ }
34
+
35
+ *::-webkit-scrollbar-track {
36
+ background: transparent !important;
37
+ border: none !important;
38
+ }
39
+
40
+ *::-webkit-scrollbar-thumb {
41
+ background-color: #d8d8d8 !important;
42
+ border: 3px solid transparent !important;
43
+ border-right-width: 5px !important;
44
+ /* 增加右侧间距,使滚动条向左位移 */
45
+ background-clip: padding-box !important;
46
+ border-radius: 10px !important;
47
+ }
48
+
49
+ *::-webkit-scrollbar-thumb:hover {
50
+ background-color: #c0c0c0 !important;
51
+ }
52
+
53
+ *::-webkit-scrollbar-corner {
54
+ background: transparent !important;
55
+ }
56
+
57
+ * {
58
+ scrollbar-width: thin !important;
59
+ scrollbar-color: #d8d8d8 transparent !important;
60
+ }
61
+
62
+ body {
63
+ background-color: var(--bg);
64
+ font-family: 'Inter', -apple-system, sans-serif;
65
+ color: var(--accent);
66
+ -webkit-font-smoothing: antialiased;
67
+ }
68
+
69
+ .container-box {
70
+ max-width: 1280px;
71
+ margin: 0 auto;
72
+ padding: 0 40px;
73
+ margin-top: 50px;
74
+ }
75
+
76
+ /* 统一组件风格 */
77
+ .glass-btn {
78
+ background: #111827;
79
+ transition: all 0.3s var(--easing);
80
+ }
81
+
82
+ .glass-btn:hover {
83
+ background: #000;
84
+ transform: translateY(-1px);
85
+ box-shadow: 0 12px 24px rgba(0, 0, 0, 0.1);
86
+ }
87
+
88
+ .glass-btn:active {
89
+ transform: scale(0.98);
90
+ }
91
+
92
+ .upload-item {
93
+ background: var(--card);
94
+ border: 1px dashed #e2e8f0;
95
+ transition: all 0.4s var(--easing);
96
+ }
97
+
98
+ .upload-item:hover {
99
+ border-color: #000;
100
+ background: #fff;
101
+ transform: translateY(-2px);
102
+ }
103
+
104
+ .result-frame {
105
+ background: #ffffff;
106
+ border-radius: 32px;
107
+ border: 1px solid #f1f5f9;
108
+ box-shadow: 0 2px 15px rgba(0, 0, 0, 0.02);
109
+ }
110
+
111
+ .masonry-grid {
112
+ display: grid;
113
+ grid-template-columns: repeat(2, 1fr);
114
+ gap: 1.25rem;
115
+ }
116
+
117
+ @media (min-width: 768px) {
118
+ .masonry-grid {
119
+ grid-template-columns: repeat(4, 1fr);
120
+ }
121
+ }
122
+
123
+ .masonry-item {
124
+ aspect-ratio: 1 / 1;
125
+ background: #fff;
126
+ border: 1px solid #f1f5f9;
127
+ border-radius: 24px;
128
+ overflow: hidden;
129
+ transition: all 0.5s var(--easing);
130
+ position: relative;
131
+ }
132
+
133
+ .masonry-item:hover {
134
+ transform: translateY(-6px);
135
+ box-shadow: 0 20px 40px rgba(0, 0, 0, 0.08);
136
+ }
137
+
138
+ .nano-input {
139
+ background: #ffffff;
140
+ border-radius: 16px;
141
+ transition: all 0.3s ease;
142
+ border: 1px solid #e5e7eb;
143
+ }
144
+
145
+ .nano-input:focus {
146
+ background: #ffffff;
147
+ box-shadow: 0 0 0 2px #000;
148
+ border-color: transparent;
149
+ }
150
+
151
+ @keyframes b-loading {
152
+ 0% {
153
+ transform: scale(1);
154
+ background: #000;
155
+ }
156
+
157
+ 50% {
158
+ transform: scale(1.15);
159
+ background: #444;
160
+ }
161
+
162
+ 100% {
163
+ transform: scale(1);
164
+ background: #000;
165
+ }
166
+ }
167
+
168
+ .loading-box {
169
+ width: 10px;
170
+ height: 10px;
171
+ animation: b-loading 1s infinite var(--easing);
172
+ }
173
+
174
+ /* 复合切换组件样式 - 来自 zimage */
175
+ .mode-switcher {
176
+ position: relative;
177
+ background: #f1f1f1;
178
+ padding: 4px;
179
+ border-radius: 14px;
180
+ display: flex;
181
+ width: 100%;
182
+ }
183
+
184
+ .mode-btn {
185
+ position: relative;
186
+ z-index: 10;
187
+ flex: 1;
188
+ padding: 8px 0;
189
+ text-align: center;
190
+ font-size: 11px;
191
+ font-weight: 800;
192
+ text-transform: uppercase;
193
+ color: #999;
194
+ transition: color 0.3s ease;
195
+ cursor: pointer;
196
+ }
197
+
198
+ .mode-btn.active {
199
+ color: #000;
200
+ }
201
+
202
+ .mode-glider {
203
+ position: absolute;
204
+ height: calc(100% - 8px);
205
+ width: calc(50% - 4px);
206
+ background: #fff;
207
+ border-radius: 11px;
208
+ box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);
209
+ transition: transform 0.3s var(--easing);
210
+ z-index: 1;
211
+ }
212
+ </style>
213
+ </head>
214
+
215
+ <body class="selection:bg-black selection:text-white">
216
+
217
+ <div class="container-box">
218
+ <header class="flex flex-col md:flex-row justify-between items-end mb-16 gap-6">
219
+ <div class="space-y-1">
220
+ <h1 class="text-4xl font-extrabold tracking-[-0.05em] flex items-center">
221
+ ANGLE CONTROL<span class="text-base mt-3 ml-1">®</span>
222
+ </h1>
223
+ <p class="text-[10px] font-bold uppercase tracking-[0.5em] text-gray-400">Camera & Perspective Control
224
+ </p>
225
+ </div>
226
+ <nav class="flex gap-8 text-[11px] font-bold uppercase tracking-widest text-gray-500">
227
+ <span class="text-black border-b-2 border-black pb-1">Angle</span>
228
+ </nav>
229
+ </header>
230
+
231
+ <main class="space-y-12">
232
+ <!-- Row 1: Upload and 3D Control -->
233
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-10 items-start">
234
+ <section class="group w-full">
235
+ <h3 class="text-[9px] font-black uppercase tracking-[0.3em] mb-5 text-gray-400">01. Input Source
236
+ </h3>
237
+ <div id="dropzone"
238
+ class="upload-item relative overflow-hidden rounded-2xl aspect-[4/3] flex flex-col items-center justify-center cursor-pointer">
239
+ <input type="file" id="fileInput" class="hidden" accept="image/*">
240
+
241
+ <div id="uploadContent" class="text-center space-y-4">
242
+ <div
243
+ class="w-14 h-14 rounded-full border border-gray-200 bg-white flex items-center justify-center mx-auto group-hover:bg-black group-hover:text-white group-hover:border-black transition-all duration-500">
244
+ <i data-lucide="arrow-up" class="w-5 h-5"></i>
245
+ </div>
246
+ <p class="text-[11px] font-bold uppercase tracking-tight">Drop image here</p>
247
+ </div>
248
+
249
+ <img id="previewImg" class="hidden absolute inset-0 w-full h-full object-cover">
250
+
251
+ <div id="changeOverlay"
252
+ class="hidden absolute inset-0 bg-black/10 backdrop-blur-sm items-center justify-center opacity-0 hover:opacity-100 transition-opacity">
253
+ <span
254
+ class="bg-white px-5 py-2 rounded-full text-[10px] font-bold uppercase tracking-widest shadow-2xl">Change</span>
255
+ </div>
256
+ </div>
257
+ </section>
258
+
259
+ <section id="cameraControl" class="space-y-6 w-full">
260
+ <h3 class="text-[9px] font-black uppercase tracking-[0.3em] text-gray-400">02. Camera Control</h3>
261
+ <div
262
+ class="w-full aspect-[4/3] flex flex-col md:flex-row bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
263
+ <!-- 3D View -->
264
+ <div id="threeContainer" class="relative flex-1 bg-[#222] h-full min-h-0"></div>
265
+
266
+ <!-- Controls -->
267
+ <div
268
+ class="w-full md:w-64 flex-shrink-0 p-5 flex flex-col justify-center gap-4 border-l border-gray-100 bg-white overflow-y-auto">
269
+ <!-- Horizontal -->
270
+ <div class="space-y-3">
271
+ <div
272
+ class="flex justify-between items-center text-[10px] font-bold uppercase tracking-wider text-gray-500">
273
+ <div class="flex items-center gap-2">
274
+ <i data-lucide="move-horizontal" class="w-3 h-3"></i>
275
+ <span>Rotation</span>
276
+ </div>
277
+ <div class="flex items-center gap-1.5">
278
+ <button onclick="resetControl('h')"
279
+ class="text-gray-300 hover:text-black transition-colors p-1" title="Reset">
280
+ <i data-lucide="rotate-ccw" class="w-3 h-3"></i>
281
+ </button>
282
+ <div class="flex items-center bg-gray-100 rounded-md px-2">
283
+ <input type="number" id="val-horizontal" value="0"
284
+ class="w-10 bg-transparent py-1 text-black text-center outline-none border-none p-0 text-[10px] font-bold [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
285
+ oninput="syncInput('h')">
286
+ <span class="text-gray-400 select-none text-[10px]">°</span>
287
+ </div>
288
+ </div>
289
+ </div>
290
+ <input type="range" id="rotate-h" min="-90" max="90" value="0"
291
+ class="w-full h-1.5 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-black">
292
+ </div>
293
+
294
+ <!-- Vertical -->
295
+ <div class="space-y-3">
296
+ <div
297
+ class="flex justify-between items-center text-[10px] font-bold uppercase tracking-wider text-gray-500">
298
+ <div class="flex items-center gap-2">
299
+ <i data-lucide="move-vertical" class="w-3 h-3"></i>
300
+ <span>Pitch</span>
301
+ </div>
302
+ <div class="flex items-center gap-1.5">
303
+ <button onclick="resetControl('v')"
304
+ class="text-gray-300 hover:text-black transition-colors p-1" title="Reset">
305
+ <i data-lucide="rotate-ccw" class="w-3 h-3"></i>
306
+ </button>
307
+ <div class="flex items-center bg-gray-100 rounded-md px-2">
308
+ <input type="number" id="val-vertical" value="0"
309
+ class="w-10 bg-transparent py-1 text-black text-center outline-none border-none p-0 text-[10px] font-bold [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
310
+ oninput="syncInput('v')">
311
+ <span class="text-gray-400 select-none text-[10px]">°</span>
312
+ </div>
313
+ </div>
314
+ </div>
315
+ <input type="range" id="rotate-v" min="-90" max="90" value="0"
316
+ class="w-full h-1.5 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-black">
317
+ </div>
318
+
319
+ <!-- Distance -->
320
+ <div class="space-y-3">
321
+ <div
322
+ class="flex justify-between items-center text-[10px] font-bold uppercase tracking-wider text-gray-500">
323
+ <div class="flex items-center gap-2">
324
+ <i data-lucide="zoom-in" class="w-3 h-3"></i>
325
+ <span>Distance</span>
326
+ </div>
327
+ <div class="flex items-center gap-1.5">
328
+ <button onclick="resetControl('d')"
329
+ class="text-gray-300 hover:text-black transition-colors p-1" title="Reset">
330
+ <i data-lucide="rotate-ccw" class="w-3 h-3"></i>
331
+ </button>
332
+ <div class="flex items-center bg-gray-100 rounded-md px-2">
333
+ <input type="number" id="val-distance" value="4.0" step="0.1"
334
+ class="w-10 bg-transparent py-1 text-black text-center outline-none border-none p-0 text-[10px] font-bold [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
335
+ oninput="syncInput('d')">
336
+ </div>
337
+ </div>
338
+ </div>
339
+ <input type="range" id="distance" min="0.1" max="8" value="4" step="0.1"
340
+ class="w-full h-1.5 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-black">
341
+ </div>
342
+ </div>
343
+ </div>
344
+ </section>
345
+ </div>
346
+
347
+ <!-- Row 2: Parameters & Result -->
348
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-10 items-stretch">
349
+ <section class="flex flex-col space-y-6">
350
+ <h3 class="text-[9px] font-black uppercase tracking-[0.3em] text-gray-400">03. Parameters</h3>
351
+
352
+ <div class="space-y-3 flex-1 flex flex-col">
353
+ <div class="flex items-center gap-2 text-gray-800 ml-1">
354
+ <i data-lucide="text-quote" class="w-3 h-3"></i>
355
+ <span class="text-[10px] font-bold uppercase tracking-widest">Prompt</span>
356
+ </div>
357
+ <textarea id="promptInput"
358
+ class="nano-input w-full flex-1 p-5 text-sm outline-none resize-none placeholder-gray-300"
359
+ placeholder="请通过右侧控制器调整,或输入提示词"></textarea>
360
+ </div>
361
+
362
+ <!-- Engine Switcher -->
363
+ <div class="mode-switcher">
364
+ <div id="modeLocal" class="mode-btn active flex items-center justify-center gap-1.5"
365
+ onclick="switchEngine('local')">
366
+ <i data-lucide="monitor" class="w-3 h-3"></i>
367
+ <span>Local</span>
368
+ </div>
369
+ <div id="modeCloud" class="mode-btn flex items-center justify-center"
370
+ onclick="switchEngine('cloud')">
371
+ <img src="/static/modelscope.gif"
372
+ class="h-4 object-contain opacity-50 transition-opacity group-hover:opacity-100"
373
+ style="filter: grayscale(100%);" id="msLogo">
374
+ </div>
375
+ <div id="glider" class="mode-glider"></div>
376
+ </div>
377
+
378
+ <button id="genBtn" onclick="handleGenerate()"
379
+ class="glass-btn w-full py-5 text-white rounded-xl font-bold text-[11px] uppercase tracking-[0.4em] flex items-center justify-center gap-3 shadow-xl shadow-black/10 disabled:opacity-50 disabled:cursor-not-allowed">
380
+ <i data-lucide="zap" id="btnIcon" class="w-4 h-4 text-yellow-400"></i>
381
+ <span id="btnText">Generate New Angle</span>
382
+ </button>
383
+ </section>
384
+
385
+ <section class="space-y-6 flex flex-col">
386
+ <h3 class="text-[9px] font-black uppercase tracking-[0.3em] text-gray-400">04. Result Preview</h3>
387
+ <div id="resultBox"
388
+ class="result-frame relative aspect-[4/3] w-full flex items-center justify-center overflow-hidden group">
389
+ <div id="emptyState" class="text-center space-y-4 opacity-20">
390
+ <i data-lucide="camera" class="w-12 h-12 mx-auto stroke-[1px]"></i>
391
+ <p class="text-[10px] font-black tracking-[0.5em] uppercase">Canvas Ready</p>
392
+ </div>
393
+
394
+ <div id="loadingState" class="hidden flex flex-col items-center gap-5 w-full max-w-[80%]">
395
+ <div class="loading-box"></div>
396
+ <p class="text-[10px] font-bold uppercase tracking-[0.4em] animate-pulse">Processing...</p>
397
+ <!-- Progress Bar -->
398
+ <div id="cloud-progress-container" class="hidden w-full mt-4">
399
+ <div class="flex justify-between text-[9px] font-bold text-gray-400 mb-1 uppercase tracking-widest">
400
+ <span id="cloud-status-text">Pending...</span>
401
+ <span id="cloud-percent">0%</span>
402
+ </div>
403
+ <div class="w-full bg-gray-100 rounded-full h-1.5 overflow-hidden">
404
+ <div id="cloud-progress-bar" class="bg-black h-full rounded-full transition-all duration-300" style="width: 0%"></div>
405
+ </div>
406
+ </div>
407
+ </div>
408
+
409
+ <div id="textResult"
410
+ class="hidden w-full h-full p-12 flex flex-col items-center justify-center text-center space-y-8">
411
+ <i data-lucide="terminal" class="w-12 h-12 text-gray-300 mx-auto"></i>
412
+ <div class="space-y-4 max-w-md">
413
+ <p class="text-[10px] font-bold uppercase tracking-[0.5em] text-gray-400">Generated
414
+ Command
415
+ </p>
416
+ <h2 id="generatedText" class="text-2xl font-bold leading-relaxed text-gray-900"></h2>
417
+ </div>
418
+ <button onclick="copyText()"
419
+ class="px-8 py-3 bg-gray-100 hover:bg-black hover:text-white rounded-full text-[10px] font-bold uppercase tracking-widest transition-all flex items-center gap-2">
420
+ <i data-lucide="copy" class="w-3 h-3"></i> Copy
421
+ </button>
422
+ </div>
423
+
424
+ <img id="outputImg"
425
+ class="hidden w-full h-full object-contain p-8 cursor-zoom-in transition-all duration-700 hover:scale-[1.02]"
426
+ onclick="zoomImage()">
427
+
428
+ <a id="downloadBtn" href="#" download
429
+ class="hidden absolute bottom-8 right-8 w-14 h-14 bg-white shadow-2xl rounded-2xl flex items-center justify-center hover:bg-black hover:text-white transition-all duration-500 border border-gray-100">
430
+ <i data-lucide="download" class="w-5 h-5"></i>
431
+ </a>
432
+ </div>
433
+ </section>
434
+ </div>
435
+ </main>
436
+
437
+ <section class="mt-32">
438
+ <div class="flex items-center gap-6 mb-10">
439
+ <h2 class="text-[11px] font-black uppercase tracking-[0.5em]">Archive</h2>
440
+ <div class="h-px flex-1 bg-black/5"></div>
441
+ </div>
442
+ <div id="masonry" class="masonry-grid"></div>
443
+ <div id="loadMoreTrigger"
444
+ class="py-16 text-center opacity-20 text-[10px] font-bold uppercase tracking-widest">
445
+ End of Archive
446
+ </div>
447
+ </section>
448
+ </div>
449
+
450
+ <div id="lightbox" onclick="handleOutsideClick(event)"
451
+ class="hidden fixed inset-0 z-50 bg-white/95 backdrop-blur-3xl flex items-center justify-center p-8">
452
+ <button onclick="closeLightbox()"
453
+ class="absolute top-10 right-10 p-2 hover:rotate-90 transition-transform duration-500">
454
+ <i data-lucide="x" class="w-8 h-8"></i>
455
+ </button>
456
+
457
+ <div class="max-w-6xl w-full h-full flex flex-col items-center justify-center">
458
+ <div class="relative">
459
+ <div id="lightboxRes"
460
+ class="absolute top-4 left-4 bg-black/30 backdrop-blur-md border border-white/20 text-white px-3 py-1.5 rounded-full text-[10px] font-medium tracking-wider opacity-0 transition-opacity duration-300 pointer-events-none">
461
+ </div>
462
+ <img id="lightboxImg" src="" class="hidden max-h-[80vh] rounded-3xl shadow-2xl">
463
+ </div>
464
+ <div class="mt-8">
465
+ <button onclick="downloadLightboxImage()"
466
+ class="px-10 py-4 bg-black text-white rounded-full text-[10px] font-black uppercase tracking-widest flex items-center gap-3 shadow-xl">
467
+ <i data-lucide="save" class="w-4 h-4"></i> Save Master
468
+ </button>
469
+ </div>
470
+ </div>
471
+ </div>
472
+
473
+ <script type="module">
474
+ import * as THREE from 'three';
475
+
476
+ // Initialize WebSocket Listener for Cloud Progress
477
+ window.addEventListener('message', function(event) {
478
+ const data = event.data;
479
+ if (data && data.type === 'cloud_status') {
480
+ updateCloudProgress(data);
481
+ }
482
+ });
483
+
484
+ function updateCloudProgress(data) {
485
+ const container = document.getElementById('cloud-progress-container');
486
+ const statusText = document.getElementById('cloud-status-text');
487
+ const progressBar = document.getElementById('cloud-progress-bar');
488
+ const percentText = document.getElementById('cloud-percent');
489
+
490
+ if (!container || !statusText || !progressBar) return;
491
+
492
+ // Show container if hidden
493
+ if (container.classList.contains('hidden')) {
494
+ container.classList.remove('hidden');
495
+ }
496
+
497
+ // Update UI
498
+ if (data.status) {
499
+ // Simplify status text (remove internal details if needed)
500
+ let displayStatus = data.status;
501
+ if (displayStatus.includes("PENDING")) displayStatus = "Queueing...";
502
+ if (displayStatus.includes("RUNNING")) displayStatus = "Generating...";
503
+ statusText.innerText = displayStatus;
504
+ }
505
+
506
+ if (typeof data.progress !== 'undefined' && typeof data.total !== 'undefined') {
507
+ const percent = Math.min(100, Math.round((data.progress / data.total) * 100));
508
+ progressBar.style.width = `${percent}%`;
509
+ percentText.innerText = `${percent}%`;
510
+ }
511
+ }
512
+
513
+ const container = document.getElementById('threeContainer');
514
+ const scene = new THREE.Scene();
515
+ scene.background = new THREE.Color(0x222222);
516
+
517
+ // Camera setup
518
+ const camera = new THREE.PerspectiveCamera(75, 1, 0.1, 1000);
519
+
520
+ // Renderer setup
521
+ const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
522
+ renderer.setSize(container.clientWidth, container.clientHeight);
523
+ renderer.setPixelRatio(window.devicePixelRatio);
524
+ container.appendChild(renderer.domElement);
525
+
526
+ // Objects
527
+ const geometry = new THREE.PlaneGeometry(3, 3);
528
+ const material = new THREE.MeshStandardMaterial({
529
+ color: 0x444444,
530
+ side: THREE.DoubleSide
531
+ });
532
+ const cube = new THREE.Mesh(geometry, material);
533
+ scene.add(cube);
534
+
535
+ const gridHelper = new THREE.GridHelper(20, 20, 0x444444, 0x333333);
536
+ scene.add(gridHelper);
537
+
538
+ // Lighting
539
+ const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
540
+ scene.add(ambientLight);
541
+ const pointLight = new THREE.DirectionalLight(0xffffff, 1);
542
+ pointLight.position.set(5, 10, 7);
543
+ scene.add(pointLight);
544
+
545
+ // Camera Logic
546
+ const sliderH = document.getElementById('rotate-h');
547
+ const sliderV = document.getElementById('rotate-v');
548
+ const sliderD = document.getElementById('distance');
549
+ const valH = document.getElementById('val-horizontal');
550
+ const valV = document.getElementById('val-vertical');
551
+ const valD = document.getElementById('val-distance');
552
+
553
+ window.updateCamera = function () {
554
+ const lon = parseFloat(sliderH.value);
555
+ const lat = parseFloat(sliderV.value);
556
+ const dist = parseFloat(sliderD.value);
557
+
558
+ // Sync inputs (avoid overwriting if active)
559
+ if (document.activeElement !== valH) valH.value = lon;
560
+ if (document.activeElement !== valV) valV.value = lat;
561
+ if (document.activeElement !== valD) valD.value = dist.toFixed(1);
562
+
563
+ const phi = THREE.MathUtils.degToRad(90 - lat);
564
+ const theta = THREE.MathUtils.degToRad(lon);
565
+
566
+ camera.position.x = dist * Math.sin(phi) * Math.sin(theta);
567
+ camera.position.y = dist * Math.cos(phi);
568
+ camera.position.z = dist * Math.sin(phi) * Math.cos(theta);
569
+ camera.lookAt(0, 0, 0);
570
+
571
+ // Real-time update prompt
572
+ updatePromptWithAngle(lon, lat, dist);
573
+ }
574
+
575
+ window.syncInput = (type) => {
576
+ if (type === 'h') {
577
+ let v = parseFloat(valH.value);
578
+ if (!isNaN(v)) sliderH.value = v;
579
+ } else if (type === 'v') {
580
+ let v = parseFloat(valV.value);
581
+ if (!isNaN(v)) sliderV.value = v;
582
+ } else if (type === 'd') {
583
+ let v = parseFloat(valD.value);
584
+ if (!isNaN(v)) sliderD.value = v;
585
+ }
586
+ window.updateCamera();
587
+ };
588
+
589
+ window.resetControl = (type) => {
590
+ if (type === 'h') {
591
+ sliderH.value = 0;
592
+ valH.value = 0;
593
+ } else if (type === 'v') {
594
+ sliderV.value = 0;
595
+ valV.value = 0;
596
+ } else if (type === 'd') {
597
+ sliderD.value = 4;
598
+ valD.value = 4;
599
+ }
600
+ window.updateCamera();
601
+ };
602
+
603
+ function updatePromptWithAngle(h, v, d) {
604
+ let parts = [];
605
+ if (h !== 0) {
606
+ const dir = h > 0 ? "向右" : "向左";
607
+ parts.push(`${dir}旋转${Math.abs(h)}度`);
608
+ }
609
+ if (v !== 0) {
610
+ const dir = v > 0 ? "俯视" : "仰视";
611
+ parts.push(`${dir}${Math.abs(v)}度`);
612
+ }
613
+
614
+ // Distance logic
615
+ let lensText = "";
616
+ if (d > 4) {
617
+ lensText = "使用广角镜头";
618
+ } else if (d < 4) {
619
+ lensText = "使用特写镜头";
620
+ }
621
+
622
+ // Removed "保持原位" default text to show placeholder
623
+
624
+ let resultText = "";
625
+ if (parts.length > 0) {
626
+ resultText = `将相机${parts.join(",")}`;
627
+ }
628
+
629
+ if (lensText) {
630
+ resultText += (resultText ? "," : "将相机") + lensText;
631
+ }
632
+
633
+ const promptInput = document.getElementById('promptInput');
634
+ let currentText = promptInput.value;
635
+
636
+ // Regex to find existing angle command (including lens info)
637
+ const regex = /将相机.*?(?=(\n|$))/g;
638
+
639
+ if (regex.test(currentText)) {
640
+ // Replace existing
641
+ promptInput.value = currentText.replace(regex, resultText);
642
+ } else {
643
+ // Append if not exists and resultText is not empty
644
+ if (resultText) {
645
+ if (currentText.trim()) {
646
+ promptInput.value = currentText.trim() + '\n' + resultText;
647
+ } else {
648
+ promptInput.value = resultText;
649
+ }
650
+ }
651
+ }
652
+ }
653
+
654
+ sliderH.addEventListener('input', window.updateCamera);
655
+ sliderV.addEventListener('input', window.updateCamera);
656
+ sliderD.addEventListener('input', window.updateCamera);
657
+ window.updateCamera();
658
+
659
+ // Animation Loop
660
+ function animate() {
661
+ requestAnimationFrame(animate);
662
+ renderer.render(scene, camera);
663
+ }
664
+ animate();
665
+
666
+ // Handle Resize
667
+ const resizeObserver = new ResizeObserver(() => {
668
+ const w = container.clientWidth;
669
+ const h = container.clientHeight;
670
+ camera.aspect = w / h;
671
+ camera.updateProjectionMatrix();
672
+ renderer.setSize(w, h);
673
+ });
674
+ resizeObserver.observe(container);
675
+
676
+ // Expose function to update texture
677
+ window.update3DTexture = (url) => {
678
+ new THREE.TextureLoader().load(url, (texture) => {
679
+ texture.colorSpace = THREE.SRGBColorSpace;
680
+
681
+ // Adjust plane aspect ratio to match image
682
+ const imageAspect = texture.image.width / texture.image.height;
683
+ cube.scale.set(1, 1 / imageAspect, 1);
684
+ if (imageAspect > 1) {
685
+ cube.scale.set(1, 1 / imageAspect, 1);
686
+ // Reset base scale to 3
687
+ cube.geometry.dispose();
688
+ cube.geometry = new THREE.PlaneGeometry(3, 3 / imageAspect);
689
+ cube.scale.set(1, 1, 1);
690
+ } else {
691
+ cube.geometry.dispose();
692
+ cube.geometry = new THREE.PlaneGeometry(3 * imageAspect, 3);
693
+ cube.scale.set(1, 1, 1);
694
+ }
695
+
696
+ cube.material = new THREE.MeshBasicMaterial({
697
+ map: texture,
698
+ side: THREE.DoubleSide
699
+ });
700
+ cube.material.needsUpdate = true;
701
+
702
+ // Show control panel (already visible, but keep logic safe)
703
+ document.getElementById('cameraControl').classList.remove('hidden');
704
+
705
+ // Force resize check after texture load
706
+ setTimeout(() => {
707
+ const w = container.clientWidth;
708
+ const h = container.clientHeight;
709
+ camera.aspect = w / h;
710
+ camera.updateProjectionMatrix();
711
+ renderer.setSize(w, h);
712
+ }, 100);
713
+ });
714
+ };
715
+ </script>
716
+
717
+ <script>
718
+ lucide.createIcons();
719
+
720
+ // --- Engine Switcher Logic (UI Only) ---
721
+ let currentEngine = 'local';
722
+ const ENGINE_MODE_KEY = 'angle_engine_mode';
723
+
724
+ window.switchEngine = function(mode) {
725
+ currentEngine = mode;
726
+ localStorage.setItem(ENGINE_MODE_KEY, mode);
727
+
728
+ const glider = document.getElementById('glider');
729
+ const localBtn = document.getElementById('modeLocal');
730
+ const cloudBtn = document.getElementById('modeCloud');
731
+ const msLogo = document.getElementById('msLogo');
732
+
733
+ if (mode === 'local') {
734
+ glider.style.transform = 'translateX(0)';
735
+ localBtn.classList.add('active');
736
+ cloudBtn.classList.remove('active');
737
+ if (msLogo) {
738
+ msLogo.classList.add('opacity-50');
739
+ msLogo.style.filter = 'grayscale(100%)';
740
+ }
741
+ } else {
742
+ glider.style.transform = 'translateX(100%)';
743
+ cloudBtn.classList.add('active');
744
+ localBtn.classList.remove('active');
745
+ if (msLogo) {
746
+ msLogo.classList.remove('opacity-50');
747
+ msLogo.style.filter = 'none';
748
+ }
749
+ }
750
+ };
751
+
752
+ function generateUUID() {
753
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
754
+ try { return crypto.randomUUID(); } catch (e) { }
755
+ }
756
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
757
+ var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
758
+ return v.toString(16);
759
+ });
760
+ }
761
+ const CLIENT_ID = localStorage.getItem("client_id") || generateUUID();
762
+ localStorage.setItem("client_id", CLIENT_ID);
763
+
764
+ let uploadedPath = "";
765
+ let uploadedFile = null; // Store raw file for cloud upload
766
+ let currentResult = null;
767
+ let allHistory = [];
768
+ let currentIndex = 0;
769
+ const PAGE_SIZE = 30;
770
+
771
+ const dropzone = document.getElementById('dropzone');
772
+ const fileInput = document.getElementById('fileInput');
773
+ const previewImg = document.getElementById('previewImg');
774
+ const promptInput = document.getElementById('promptInput');
775
+
776
+ dropzone.onclick = () => fileInput.click();
777
+ fileInput.onchange = (e) => handleFile(e.target.files[0]);
778
+
779
+ // Drag and Drop
780
+ dropzone.addEventListener('dragover', (e) => {
781
+ e.preventDefault();
782
+ dropzone.classList.add('border-black', 'bg-gray-50');
783
+ });
784
+ dropzone.addEventListener('dragleave', () => {
785
+ dropzone.classList.remove('border-black', 'bg-gray-50');
786
+ });
787
+ dropzone.addEventListener('drop', (e) => {
788
+ e.preventDefault();
789
+ dropzone.classList.remove('border-black', 'bg-gray-50');
790
+ if (e.dataTransfer.files && e.dataTransfer.files[0]) {
791
+ handleFile(e.dataTransfer.files[0]);
792
+ }
793
+ });
794
+
795
+ // Paste support
796
+ let isHovering = false;
797
+ dropzone.addEventListener('mouseenter', () => isHovering = true);
798
+ dropzone.addEventListener('mouseleave', () => isHovering = false);
799
+ window.addEventListener('paste', (e) => {
800
+ if (!isHovering) return;
801
+ const items = (e.clipboardData || e.originalEvent.clipboardData).items;
802
+ for (let item of items) {
803
+ if (item.kind === 'file' && item.type.startsWith('image/')) {
804
+ const file = item.getAsFile();
805
+ handleFile(file);
806
+ break;
807
+ }
808
+ }
809
+ });
810
+
811
+ async function handleFile(file) {
812
+ if (!file) return;
813
+ uploadedFile = file; // Store for cloud usage
814
+ const btn = document.getElementById('genBtn');
815
+ const btnText = document.getElementById('btnText');
816
+
817
+ btn.disabled = true;
818
+ btnText.innerText = "Uploading...";
819
+
820
+ const reader = new FileReader();
821
+ reader.onload = (e) => {
822
+ previewImg.src = e.target.result;
823
+ previewImg.classList.remove('hidden');
824
+ document.getElementById('uploadContent').classList.add('opacity-0');
825
+ document.getElementById('changeOverlay').classList.replace('hidden', 'flex');
826
+
827
+ // Automatically apply to 3D scene
828
+ if (window.update3DTexture) {
829
+ window.update3DTexture(e.target.result);
830
+ }
831
+ };
832
+ reader.readAsDataURL(file);
833
+
834
+ const formData = new FormData();
835
+ formData.append('files', file);
836
+ try {
837
+ const res = await fetch('/api/upload', { method: 'POST', body: formData });
838
+ const data = await res.json();
839
+ uploadedPath = data.files[0].comfy_name;
840
+ btn.disabled = false;
841
+ btnText.innerText = "Generate New Angle";
842
+ } catch (err) {
843
+ console.error("Upload error");
844
+ btnText.innerText = "Upload Failed";
845
+ btn.disabled = false;
846
+ }
847
+ }
848
+
849
+ function applyAngleToPrompt() {
850
+ const h = parseInt(document.getElementById('rotate-h').value);
851
+ const v = parseInt(document.getElementById('rotate-v').value);
852
+
853
+ let parts = [];
854
+ if (h !== 0) {
855
+ const dir = h > 0 ? "向右" : "向左";
856
+ parts.push(`${dir}旋转${Math.abs(h)}度`);
857
+ }
858
+ if (v !== 0) {
859
+ const dir = v > 0 ? "俯视" : "仰视";
860
+ parts.push(`${dir}${Math.abs(v)}度`);
861
+ }
862
+
863
+ if (parts.length === 0) {
864
+ parts.push("保持原位");
865
+ }
866
+
867
+ const resultText = `将相机${parts.join(",")}`;
868
+
869
+ const promptInput = document.getElementById('promptInput');
870
+ // Check if there is existing content, if so append new line
871
+ if (promptInput.value.trim()) {
872
+ promptInput.value += '\n' + resultText;
873
+ } else {
874
+ promptInput.value = resultText;
875
+ }
876
+
877
+ // Visual feedback
878
+ promptInput.style.transition = "0.2s";
879
+ promptInput.style.borderColor = "#000";
880
+ promptInput.style.boxShadow = "0 0 0 2px rgba(0,0,0,0.1)";
881
+ setTimeout(() => {
882
+ promptInput.style.borderColor = "";
883
+ promptInput.style.boxShadow = "";
884
+ }, 500);
885
+ }
886
+
887
+ async function runCloudTask() {
888
+ if (!uploadedFile) throw new Error("Please upload an image first");
889
+
890
+ // Get token from centralized management (Personal -> Global)
891
+ let token = localStorage.getItem('modelscope_api_token');
892
+
893
+ if (!token) {
894
+ try {
895
+ const res = await fetch('/api/config/token');
896
+ if (res.ok) {
897
+ const data = await res.json();
898
+ if (data.token) token = data.token;
899
+ }
900
+ } catch (e) {
901
+ console.warn("Failed to fetch global token", e);
902
+ }
903
+ }
904
+
905
+ if (!token) {
906
+ if (window.parent && typeof window.parent.openTokenModal === 'function') {
907
+ window.parent.openTokenModal();
908
+ // Allow the error to propagate so user sees the message
909
+ // const err = new Error("请先点击右��角设置 ModelScope Token");
910
+ // err.silent = true;
911
+ // throw err;
912
+ }
913
+ throw new Error("请先点击右上角设置 ModelScope Token");
914
+ }
915
+
916
+ // Convert image to Base64
917
+ const toBase64 = file => new Promise((resolve, reject) => {
918
+ const reader = new FileReader();
919
+ reader.readAsDataURL(file);
920
+ reader.onload = () => resolve(reader.result);
921
+ reader.onerror = error => reject(error);
922
+ });
923
+
924
+ const dataUri = await toBase64(uploadedFile);
925
+ console.log("DataURI generated, length:", dataUri.length);
926
+
927
+ // Submit task via dedicated Angle endpoint
928
+ // Get Client ID from parent if available
929
+ let clientId = null;
930
+ try {
931
+ if (window.parent && window.parent.CID) {
932
+ clientId = window.parent.CID;
933
+ }
934
+ } catch (e) { console.warn("Cannot access parent CID", e); }
935
+
936
+ const payload = {
937
+ "prompt": promptInput.value,
938
+ "api_key": token,
939
+ "type": "angle",
940
+ "model": "Qwen/Qwen-Image-Edit-2511",
941
+ "image_urls": [dataUri],
942
+ "client_id": clientId
943
+ };
944
+
945
+ // Reset Progress Bar
946
+ document.getElementById('cloud-progress-container').classList.add('hidden');
947
+ document.getElementById('cloud-progress-bar').style.width = '0%';
948
+ document.getElementById('cloud-percent').innerText = '0%';
949
+
950
+ let response;
951
+ try {
952
+ response = await fetch('/api/angle/generate', {
953
+ method: 'POST',
954
+ headers: { 'Content-Type': 'application/json' },
955
+ body: JSON.stringify(payload)
956
+ });
957
+ } catch (netErr) {
958
+ throw new Error(`Network Error: ${netErr.message}`);
959
+ }
960
+
961
+ // Handle Timeout / Continue Loop
962
+ while (response.ok) {
963
+ const data = await response.json();
964
+
965
+ // If success with URL
966
+ if (data.url) {
967
+ return { images: [data.url] };
968
+ }
969
+
970
+ // If timeout status, ask user
971
+ if (data.status === 'timeout') {
972
+ const taskId = data.task_id;
973
+ const userContinue = confirm("Cloud generation is taking longer than expected (300s). The queue might be full.\n\nDo you want to continue waiting?");
974
+
975
+ if (userContinue) {
976
+ // Call poll endpoint
977
+ const pollPayload = {
978
+ "task_id": taskId,
979
+ "api_key": token,
980
+ "client_id": clientId
981
+ };
982
+
983
+ // Update UI to show we are still waiting
984
+ updateCloudProgress({status: "Resuming...", progress: 0, total: 150});
985
+
986
+ response = await fetch('/api/angle/poll_status', {
987
+ method: 'POST',
988
+ headers: { 'Content-Type': 'application/json' },
989
+ body: JSON.stringify(pollPayload)
990
+ });
991
+ continue; // Loop back to check response
992
+ } else {
993
+ throw new Error("User cancelled waiting.");
994
+ }
995
+ }
996
+
997
+ // Unknown success response
998
+ throw new Error("Unknown response format");
999
+ }
1000
+
1001
+ if (!response.ok) {
1002
+ const errText = await response.text();
1003
+ // Try to parse JSON error if possible
1004
+ try {
1005
+ const errJson = JSON.parse(errText);
1006
+ if (errJson.detail) throw new Error(errJson.detail);
1007
+ } catch (e) {}
1008
+ throw new Error(`Generation Failed: ${errText}`);
1009
+ }
1010
+
1011
+ const data = await response.json();
1012
+ if (data.url) {
1013
+ return {
1014
+ images: [data.url]
1015
+ };
1016
+ } else {
1017
+ throw new Error("No image URL in response");
1018
+ }
1019
+ }
1020
+
1021
+ async function handleGenerate() {
1022
+ if (!uploadedPath && currentEngine === 'local') {
1023
+ // ... existing local check ...
1024
+ const dropzone = document.getElementById('dropzone');
1025
+ dropzone.style.transition = "0.2s";
1026
+ dropzone.style.borderColor = "#ef4444";
1027
+ dropzone.style.transform = "scale(0.98)";
1028
+ setTimeout(() => {
1029
+ dropzone.style.borderColor = "";
1030
+ dropzone.style.transform = "scale(1)";
1031
+ }, 300);
1032
+ return;
1033
+ }
1034
+
1035
+ if (!uploadedFile && currentEngine === 'cloud') {
1036
+ alert("Please upload an image first");
1037
+ return;
1038
+ }
1039
+
1040
+ // Allow manual prompt even if angle not applied, but require prompt input
1041
+ if (!promptInput.value.trim()) {
1042
+ promptInput.style.transition = "0.2s";
1043
+ promptInput.style.borderColor = "#ef4444";
1044
+ setTimeout(() => {
1045
+ promptInput.style.borderColor = "";
1046
+ }, 300);
1047
+ return;
1048
+ }
1049
+
1050
+ const btn = document.getElementById('genBtn');
1051
+ const btnText = document.getElementById('btnText');
1052
+
1053
+ btn.disabled = true;
1054
+ btn.style.backgroundColor = '#333';
1055
+ btn.innerHTML = `<i data-lucide="zap" class="w-4 h-4 text-yellow-400 fill-yellow-400 animate-pulse"></i><span class="tracking-[0.4em] text-[11px] uppercase">Processing...</span>`;
1056
+ lucide.createIcons();
1057
+
1058
+ document.getElementById('emptyState').classList.add('hidden');
1059
+ document.getElementById('outputImg').classList.add('hidden');
1060
+ document.getElementById('textResult').classList.add('hidden'); // Ensure text result is hidden
1061
+ document.getElementById('loadingState').classList.remove('hidden');
1062
+
1063
+ try {
1064
+ let data;
1065
+
1066
+ if (currentEngine === 'cloud') {
1067
+ // Cloud Logic
1068
+ data = await runCloudTask();
1069
+ } else {
1070
+ // Local Logic
1071
+ const seed = Math.floor(Math.random() * 1000000000000000);
1072
+ const res = await fetch('/api/generate', {
1073
+ method: 'POST',
1074
+ headers: { 'Content-Type': 'application/json' },
1075
+ body: JSON.stringify({
1076
+ workflow_json: "2511.json",
1077
+ params: {
1078
+ "31": { "image": uploadedPath },
1079
+ "11": { "prompt": promptInput.value },
1080
+ "14": { "seed": seed }
1081
+ },
1082
+ type: "angle",
1083
+ client_id: CLIENT_ID
1084
+ })
1085
+ });
1086
+ data = await res.json();
1087
+ if (data.error) throw new Error(data.error);
1088
+ if (!data.images?.length) throw new Error("No images returned");
1089
+ }
1090
+
1091
+ currentResult = data;
1092
+ const outputImg = document.getElementById('outputImg');
1093
+ const downloadBtn = document.getElementById('downloadBtn');
1094
+
1095
+ outputImg.src = data.images[0];
1096
+ outputImg.classList.remove('hidden');
1097
+ document.getElementById('loadingState').classList.add('hidden');
1098
+
1099
+ downloadBtn.href = data.images[0];
1100
+ downloadBtn.classList.remove('hidden');
1101
+ downloadBtn.download = `Angle-${Date.now()}.png`;
1102
+
1103
+ btn.style.backgroundColor = '';
1104
+ btn.innerHTML = `<i data-lucide="zap" class="w-4 h-4 text-yellow-400"></i><span id="btnText">Generate New Angle</span>`;
1105
+ btn.disabled = false;
1106
+ lucide.createIcons();
1107
+
1108
+ // Add to history
1109
+ renderImageCard({
1110
+ images: data.images,
1111
+ prompt: promptInput.value,
1112
+ timestamp: Date.now(),
1113
+ is_cloud: (currentEngine === 'cloud')
1114
+ }, true);
1115
+
1116
+ } catch (err) {
1117
+ console.error(err);
1118
+ btn.style.backgroundColor = '';
1119
+ btn.innerHTML = `<i data-lucide="zap" class="w-4 h-4 text-yellow-400"></i><span id="btnText">Generation Failed</span>`;
1120
+ lucide.createIcons();
1121
+ document.getElementById('loadingState').classList.add('hidden');
1122
+ document.getElementById('emptyState').classList.remove('hidden');
1123
+ btn.disabled = false;
1124
+ if (!err.silent) {
1125
+ alert(err.message);
1126
+ }
1127
+ }
1128
+ }
1129
+
1130
+ window.copyText = () => {
1131
+ const text = document.getElementById('generatedText').innerText;
1132
+ navigator.clipboard.writeText(text).then(() => {
1133
+ const btn = document.querySelector('#textResult button');
1134
+ const originalHTML = btn.innerHTML;
1135
+ btn.innerHTML = `<i data-lucide="check" class="w-3 h-3"></i> Copied`;
1136
+ setTimeout(() => {
1137
+ btn.innerHTML = originalHTML;
1138
+ lucide.createIcons();
1139
+ }, 2000);
1140
+ });
1141
+ };
1142
+
1143
+ // History Management
1144
+ function renderImageCard(data, isNew = false) {
1145
+ const masonry = document.getElementById('masonry');
1146
+ const imgUrl = data.images ? data.images[0] : '';
1147
+ if (!imgUrl) return;
1148
+
1149
+ const card = document.createElement('div');
1150
+ card.className = "masonry-item relative group cursor-pointer";
1151
+
1152
+ card.onclick = () => openLightbox(imgUrl);
1153
+
1154
+ // ModelScope Badge
1155
+ const isCloud = data.is_cloud || (imgUrl && imgUrl.includes('cloud_angle'));
1156
+ const badgeHtml = isCloud ? `
1157
+ <div class="absolute top-3 left-3 z-10">
1158
+ <img src="/static/modelscope.gif" class="h-4 w-auto object-contain bg-white/90 rounded-full p-0.5 shadow-sm">
1159
+ </div>
1160
+ ` : '';
1161
+
1162
+ card.innerHTML = `
1163
+ <img src="${imgUrl}" class="w-full h-full object-cover block transform group-hover:scale-105 transition-transform duration-[1.5s]">
1164
+ ${badgeHtml}
1165
+ <div class="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-all duration-300 p-6 flex flex-col justify-end pointer-events-none">
1166
+ <p class="text-white text-[10px] font-bold uppercase tracking-widest line-clamp-2">${data.prompt || "Angle Control"}</p>
1167
+ </div>
1168
+ `;
1169
+
1170
+ if (isNew) masonry.prepend(card);
1171
+ else masonry.appendChild(card);
1172
+ }
1173
+
1174
+ function loadNextPage() {
1175
+ const batch = allHistory.slice(currentIndex, currentIndex + PAGE_SIZE);
1176
+ if (batch.length === 0) {
1177
+ const el = document.getElementById('loadMoreTrigger');
1178
+ if (el) el.innerText = "End of Archive";
1179
+ return;
1180
+ }
1181
+ batch.forEach(item => renderImageCard(item, false));
1182
+ currentIndex += PAGE_SIZE;
1183
+ }
1184
+
1185
+ async function loadHistory() {
1186
+ try {
1187
+ const res = await fetch('/api/history?type=angle');
1188
+ const history = await res.json();
1189
+ if (history && Array.isArray(history)) {
1190
+ allHistory = history;
1191
+ document.getElementById('masonry').innerHTML = '';
1192
+ currentIndex = 0;
1193
+ loadNextPage();
1194
+ }
1195
+ } catch (e) { console.error(e); }
1196
+ }
1197
+
1198
+ // Lightbox
1199
+ function openLightbox(url) {
1200
+ const img = document.getElementById('lightboxImg');
1201
+ const resPill = document.getElementById('lightboxRes');
1202
+
1203
+ resPill.style.opacity = '0';
1204
+ img.src = url;
1205
+
1206
+ const lb = document.getElementById('lightbox');
1207
+ lb.classList.replace('hidden', 'flex');
1208
+ img.classList.remove('hidden');
1209
+ document.body.style.overflow = 'hidden';
1210
+
1211
+ const updateRes = () => {
1212
+ if (img.naturalWidth) {
1213
+ resPill.innerText = `${img.naturalWidth} x ${img.naturalHeight}`;
1214
+ resPill.style.opacity = '1';
1215
+ }
1216
+ };
1217
+
1218
+ img.onload = updateRes;
1219
+ if (img.complete) updateRes();
1220
+ }
1221
+
1222
+ function closeLightbox() {
1223
+ const lb = document.getElementById('lightbox');
1224
+ lb.classList.replace('flex', 'hidden');
1225
+ document.body.style.overflow = 'auto';
1226
+ }
1227
+
1228
+ function handleOutsideClick(e) {
1229
+ if (e.target.id === 'lightbox') closeLightbox();
1230
+ }
1231
+
1232
+ function downloadLightboxImage() {
1233
+ const imgUrl = document.getElementById('lightboxImg').src;
1234
+ const link = document.createElement('a');
1235
+ link.href = imgUrl;
1236
+ link.download = `Angle-Master-${Date.now()}.png`;
1237
+ document.body.appendChild(link);
1238
+ link.click();
1239
+ document.body.removeChild(link);
1240
+ }
1241
+
1242
+ function zoomImage() {
1243
+ if (currentResult && currentResult.images && currentResult.images[0]) {
1244
+ openLightbox(currentResult.images[0]);
1245
+ }
1246
+ }
1247
+
1248
+ // Init
1249
+ const observer = new IntersectionObserver((entries) => {
1250
+ if (entries[0].isIntersecting && allHistory.length > 0) {
1251
+ loadNextPage();
1252
+ }
1253
+ }, { threshold: 0.1 });
1254
+
1255
+ window.onload = () => {
1256
+ // Restore engine mode
1257
+ const savedMode = localStorage.getItem(ENGINE_MODE_KEY);
1258
+ if (savedMode && (savedMode === 'local' || savedMode === 'cloud')) {
1259
+ switchEngine(savedMode);
1260
+ }
1261
+
1262
+ loadHistory();
1263
+ observer.observe(document.getElementById('loadMoreTrigger'));
1264
+ };
1265
+
1266
+ // WebSocket for real-time updates (optional but good for multi-tab sync)
1267
+ const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
1268
+ const wsUrl = `${wsProtocol}//${window.location.host}/ws/stats?client_id=${CLIENT_ID}`;
1269
+ const socket = new WebSocket(wsUrl);
1270
+ socket.onopen = () => {
1271
+ setInterval(() => {
1272
+ if (socket.readyState === WebSocket.OPEN) socket.send("ping");
1273
+ }, 30000);
1274
+ };
1275
+ </script>
1276
+ </body>
1277
+
1278
+ </html>
static/enhance.html ADDED
@@ -0,0 +1,752 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <link rel="icon" href="/static/logo.png" type="image/png">
8
+ <title>Z-IMAGE | 极简影像重塑</title>
9
+ <script src="https://cdn.tailwindcss.com"></script>
10
+ <script src="https://unpkg.com/lucide@latest"></script>
11
+ <style>
12
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;800&family=JetBrains+Mono:wght@400;700&display=swap');
13
+
14
+ :root {
15
+ --accent: #111827;
16
+ --bg: #f9fafb;
17
+ --card: #ffffff;
18
+ --easing: cubic-bezier(0.4, 0, 0.2, 1);
19
+ }
20
+
21
+ /* --- 极简悬浮浅灰滚动条 (无底色/左移) --- */
22
+ *::-webkit-scrollbar {
23
+ width: 10px !important;
24
+ height: 10px !important;
25
+ background: transparent !important;
26
+ }
27
+
28
+ *::-webkit-scrollbar-track {
29
+ background: transparent !important;
30
+ border: none !important;
31
+ }
32
+
33
+ *::-webkit-scrollbar-thumb {
34
+ background-color: #d8d8d8 !important;
35
+ border: 3px solid transparent !important;
36
+ border-right-width: 5px !important;
37
+ /* 增加右侧间距,使滚动条向左位移 */
38
+ background-clip: padding-box !important;
39
+ border-radius: 10px !important;
40
+ }
41
+
42
+ *::-webkit-scrollbar-thumb:hover {
43
+ background-color: #c0c0c0 !important;
44
+ }
45
+
46
+ *::-webkit-scrollbar-corner {
47
+ background: transparent !important;
48
+ }
49
+
50
+ * {
51
+ scrollbar-width: thin !important;
52
+ scrollbar-color: #d8d8d8 transparent !important;
53
+ }
54
+
55
+ body {
56
+ background-color: var(--bg);
57
+ font-family: 'Inter', -apple-system, sans-serif;
58
+ color: var(--accent);
59
+ -webkit-font-smoothing: antialiased;
60
+ }
61
+
62
+ .container-box {
63
+ max-width: 1280px;
64
+ margin: 0 auto;
65
+ padding: 0 40px;
66
+ margin-top: 50px;
67
+ }
68
+
69
+ /* 统一组件风格 */
70
+ .glass-btn {
71
+ background: #111827;
72
+ transition: all 0.3s var(--easing);
73
+ }
74
+
75
+ .glass-btn:hover {
76
+ background: #000;
77
+ transform: translateY(-1px);
78
+ box-shadow: 0 12px 24px rgba(0, 0, 0, 0.1);
79
+ }
80
+
81
+ .glass-btn:active {
82
+ transform: scale(0.98);
83
+ }
84
+
85
+ .upload-item {
86
+ background: var(--card);
87
+ border: 1px dashed #e2e8f0;
88
+ transition: all 0.4s var(--easing);
89
+ }
90
+
91
+ .upload-item:hover {
92
+ border-color: #000;
93
+ background: #fff;
94
+ transform: translateY(-2px);
95
+ }
96
+
97
+ .result-frame {
98
+ background: #ffffff;
99
+ border-radius: 32px;
100
+ border: 1px solid #f1f5f9;
101
+ box-shadow: 0 2px 15px rgba(0, 0, 0, 0.02);
102
+ }
103
+
104
+ .masonry-item {
105
+ break-inside: avoid;
106
+ margin-bottom: 1.25rem;
107
+ background: #fff;
108
+ border: 1px solid #f1f5f9;
109
+ border-radius: 24px;
110
+ overflow: hidden;
111
+ transition: all 0.5s var(--easing);
112
+ }
113
+
114
+ .masonry-item:hover {
115
+ transform: translateY(-6px);
116
+ box-shadow: 0 20px 40px rgba(0, 0, 0, 0.08);
117
+ }
118
+
119
+ /* 统一精致 Range 设计 */
120
+ input[type=range] {
121
+ -webkit-appearance: none;
122
+ background: transparent;
123
+ }
124
+
125
+ input[type=range]::-webkit-slider-runnable-track {
126
+ width: 100%;
127
+ height: 2px;
128
+ background: #e5e5e5;
129
+ border-radius: 1px;
130
+ }
131
+
132
+ input[type=range]::-webkit-slider-thumb {
133
+ -webkit-appearance: none;
134
+ height: 14px;
135
+ width: 14px;
136
+ border-radius: 50%;
137
+ background: var(--accent);
138
+ margin-top: -6px;
139
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
140
+ transition: transform 0.2s var(--easing);
141
+ cursor: pointer;
142
+ }
143
+
144
+ .masonry-grid {
145
+ columns: 2;
146
+ column-gap: 1.25rem;
147
+ }
148
+
149
+ @media (min-width: 768px) {
150
+ .masonry-grid {
151
+ columns: 4;
152
+ }
153
+ }
154
+
155
+ @keyframes b-loading {
156
+ 0% {
157
+ transform: scale(1);
158
+ background: #000;
159
+ }
160
+
161
+ 50% {
162
+ transform: scale(1.15);
163
+ background: #444;
164
+ }
165
+
166
+ 100% {
167
+ transform: scale(1);
168
+ background: #000;
169
+ }
170
+ }
171
+
172
+ .loading-box {
173
+ width: 10px;
174
+ height: 10px;
175
+ animation: b-loading 1s infinite var(--easing);
176
+ }
177
+
178
+ .ios-switch input:checked+.ios-slider {
179
+ background: #000;
180
+ }
181
+
182
+ .ios-slider:before {
183
+ content: "";
184
+ position: absolute;
185
+ height: 24px;
186
+ width: 24px;
187
+ left: 2px;
188
+ bottom: 2px;
189
+ background: white;
190
+ border-radius: 50%;
191
+ transition: 0.3s var(--easing);
192
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
193
+ }
194
+
195
+ input:checked+.ios-slider:before {
196
+ transform: translateX(20px);
197
+ }
198
+ </style>
199
+ </head>
200
+
201
+ <body class="selection:bg-black selection:text-white">
202
+
203
+ <div class="container-box">
204
+ <header class="flex flex-col md:flex-row justify-between items-end mb-16 gap-6">
205
+ <div class="space-y-1">
206
+ <h1 class="text-4xl font-extrabold tracking-[-0.05em] flex items-center">
207
+ Z IMAGE<span class="text-base mt-3 ml-1">®</span>
208
+ </h1>
209
+ <p class="text-[10px] font-bold uppercase tracking-[0.5em] text-gray-400">Computational Photography
210
+ Archive</p>
211
+ </div>
212
+ <nav class="flex gap-8 text-[11px] font-bold uppercase tracking-widest text-gray-500">
213
+ <span class="text-black border-b-2 border-black pb-1">Enhance</span>
214
+ </nav>
215
+ </header>
216
+
217
+ <main class="grid grid-cols-1 lg:grid-cols-12 gap-12">
218
+ <div class="lg:col-span-4 space-y-10">
219
+ <section class="group">
220
+ <h3 class="text-[9px] font-black uppercase tracking-[0.3em] mb-5 text-gray-400">01. Input Source
221
+ </h3>
222
+ <div id="dropzone"
223
+ class="upload-item relative overflow-hidden rounded-2xl aspect-[4/3] flex flex-col items-center justify-center cursor-pointer">
224
+ <input type="file" id="fileInput" class="hidden" accept="image/*">
225
+
226
+ <div id="uploadContent" class="text-center space-y-4">
227
+ <div
228
+ class="w-14 h-14 rounded-full border border-gray-200 bg-white flex items-center justify-center mx-auto group-hover:bg-black group-hover:text-white group-hover:border-black transition-all duration-500">
229
+ <i data-lucide="arrow-up" class="w-5 h-5"></i>
230
+ </div>
231
+ <p class="text-[11px] font-bold uppercase tracking-tight">Drop image here</p>
232
+ </div>
233
+
234
+ <img id="previewImg" class="hidden absolute inset-0 w-full h-full object-cover">
235
+
236
+ <div id="changeOverlay"
237
+ class="hidden absolute inset-0 bg-black/10 backdrop-blur-sm items-center justify-center opacity-0 hover:opacity-100 transition-opacity">
238
+ <span
239
+ class="bg-white px-5 py-2 rounded-full text-[10px] font-bold uppercase tracking-widest shadow-2xl">Change</span>
240
+ </div>
241
+ </div>
242
+ </section>
243
+
244
+ <section class="space-y-10">
245
+ <h3 class="text-[9px] font-black uppercase tracking-[0.3em] text-gray-400">02. Parameters</h3>
246
+
247
+ <div class="space-y-5">
248
+ <div class="flex justify-between items-end">
249
+ <label class="text-xs font-bold uppercase tracking-tight text-gray-800">Refinement
250
+ Strength</label>
251
+ <span id="strengthVal" class="font-mono text-xs font-bold">0.50</span>
252
+ </div>
253
+ <input type="range" id="strengthSlider" min="0.1" max="1.0" step="0.01" value="0.5"
254
+ class="w-full">
255
+ </div>
256
+
257
+ <div class="p-5 bg-white border border-gray-200/50 rounded-2xl shadow-sm">
258
+ <div class="flex items-center justify-between">
259
+ <div class="flex items-center gap-4">
260
+ <div class="p-2.5 bg-gray-50 rounded-xl border border-gray-100"><i data-lucide="layers"
261
+ class="w-4 h-4"></i></div>
262
+ <div>
263
+ <p class="text-[11px] font-bold uppercase">Super Resolution</p>
264
+ <p class="text-[9px] text-gray-400">Double pixels (4K)</p>
265
+ </div>
266
+ </div>
267
+ <label class="ios-switch relative inline-block w-12 h-7">
268
+ <input type="checkbox" id="upscaleToggle" class="opacity-0 w-0 h-0"
269
+ onchange="toggleUpscaleOptions()">
270
+ <span
271
+ class="ios-slider absolute inset-0 cursor-pointer bg-gray-200 rounded-full transition-colors duration-300"></span>
272
+ </label>
273
+ </div>
274
+ <div id="upscaleOptions" class="hidden pt-4 mt-4 border-t border-gray-100 grid-cols-2 gap-3">
275
+ <button id="btn2x" onclick="setUpscaleFactor(2048)"
276
+ class="py-2.5 rounded-xl border border-black bg-black text-white text-[10px] font-bold transition-all">
277
+ 2x (2K)
278
+ </button>
279
+ <button id="btn4x" onclick="setUpscaleFactor(4096)"
280
+ class="py-2.5 rounded-xl border border-gray-200 text-gray-400 text-[10px] font-bold hover:border-black hover:text-black transition-all">
281
+ 4x (4K)
282
+ </button>
283
+ </div>
284
+ </div>
285
+
286
+ <button id="genBtn" onclick="handleGenerate()"
287
+ class="glass-btn w-full py-5 text-white rounded-xl font-bold text-[11px] uppercase tracking-[0.4em] flex items-center justify-center gap-3 shadow-xl shadow-black/10 disabled:opacity-50 disabled:cursor-not-allowed">
288
+ <i data-lucide="zap" id="btnIcon" class="w-4 h-4 text-yellow-400"></i>
289
+ <span id="btnText">Begin Remastering</span>
290
+ </button>
291
+ </section>
292
+ </div>
293
+
294
+ <div class="lg:col-span-8">
295
+ <div id="resultBox"
296
+ class="result-frame relative h-[500px] lg:h-[650px] flex items-center justify-center overflow-hidden group">
297
+ <div id="emptyState" class="text-center space-y-4 opacity-20">
298
+ <i data-lucide="layout" class="w-12 h-12 mx-auto stroke-[1px]"></i>
299
+ <p class="text-[10px] font-black tracking-[0.5em] uppercase">Canvas Ready</p>
300
+ </div>
301
+
302
+ <div id="loadingState" class="hidden flex flex-col items-center gap-5">
303
+ <div class="loading-box"></div>
304
+ <p class="text-[10px] font-bold uppercase tracking-[0.4em] animate-pulse">Computing pixels...
305
+ </p>
306
+ </div>
307
+
308
+ <img id="outputImg"
309
+ class="hidden w-full h-full object-contain p-8 cursor-zoom-in transition-all duration-700 hover:scale-[1.02]"
310
+ onclick="zoomImage()">
311
+
312
+ <a id="downloadBtn" href="#" download
313
+ class="hidden absolute bottom-8 right-8 w-14 h-14 bg-white shadow-2xl rounded-2xl flex items-center justify-center hover:bg-black hover:text-white transition-all duration-500 border border-gray-100">
314
+ <i data-lucide="download" class="w-5 h-5"></i>
315
+ </a>
316
+ </div>
317
+ </div>
318
+ </main>
319
+
320
+ <section class="mt-32">
321
+ <div class="flex items-center gap-6 mb-10">
322
+ <h2 class="text-[11px] font-black uppercase tracking-[0.5em]">Archive</h2>
323
+ <div class="h-px flex-1 bg-black/5"></div>
324
+ </div>
325
+ <div id="masonry" class="masonry-grid"></div>
326
+ <div id="loadMoreTrigger"
327
+ class="py-16 text-center opacity-20 text-[10px] font-bold uppercase tracking-widest">
328
+ End of Archive
329
+ </div>
330
+ </section>
331
+ </div>
332
+
333
+ <div id="lightbox" onclick="handleOutsideClick(event)"
334
+ class="hidden fixed inset-0 z-50 bg-white/95 backdrop-blur-3xl flex items-center justify-center p-8">
335
+ <button onclick="closeLightbox()"
336
+ class="absolute top-10 right-10 p-2 hover:rotate-90 transition-transform duration-500">
337
+ <i data-lucide="x" class="w-8 h-8"></i>
338
+ </button>
339
+
340
+ <div class="max-w-6xl w-full h-full flex flex-col items-center justify-center">
341
+ <div class="relative w-full flex justify-center">
342
+ <div id="compareContainer"
343
+ class="hidden relative w-full h-[75vh] rounded-3xl overflow-hidden bg-[#f8f8f9] border border-gray-200/50 shadow-2xl">
344
+ <img id="compareGenerated" class="absolute inset-0 w-full h-full object-contain">
345
+ <div id="compareOriginalWrapper"
346
+ class="absolute inset-0 w-full h-full overflow-hidden border-r-2 border-white/80">
347
+ <img id="compareOriginal" class="absolute inset-0 w-full h-full object-contain">
348
+ </div>
349
+ <div id="compareSlider" class="absolute inset-y-0 left-1/2 w-0.5 bg-white z-20 cursor-ew-resize">
350
+ <div
351
+ class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-10 h-10 bg-white shadow-2xl rounded-full flex items-center justify-center border border-gray-100">
352
+ <i data-lucide="move-horizontal" class="w-4 h-4 text-black"></i>
353
+ </div>
354
+ </div>
355
+ </div>
356
+ <img id="lightboxImg" src="" class="hidden max-h-[80vh] rounded-3xl shadow-2xl">
357
+ <div id="lightboxRes"
358
+ class="absolute top-4 left-4 bg-black/30 backdrop-blur-md border border-white/20 text-white px-3 py-1.5 rounded-full text-[10px] font-medium tracking-wider opacity-0 transition-opacity duration-300 pointer-events-none z-30">
359
+ </div>
360
+ </div>
361
+ <div class="mt-8">
362
+ <button onclick="downloadLightboxImage()"
363
+ class="px-10 py-4 bg-black text-white rounded-full text-[10px] font-black uppercase tracking-widest flex items-center gap-3 shadow-xl">
364
+ <i data-lucide="save" class="w-4 h-4"></i> Save Master
365
+ </button>
366
+ </div>
367
+ </div>
368
+ </div>
369
+
370
+ <script>
371
+ lucide.createIcons();
372
+ function generateUUID() {
373
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
374
+ try { return crypto.randomUUID(); } catch (e) { }
375
+ }
376
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
377
+ var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
378
+ return v.toString(16);
379
+ });
380
+ }
381
+ const CLIENT_ID = localStorage.getItem("client_id") || generateUUID();
382
+ localStorage.setItem("client_id", CLIENT_ID);
383
+
384
+ let uploadedPath = "";
385
+ let currentResult = null;
386
+ let currentUpscaleFactor = 2048; // Default to 2x
387
+
388
+ const dropzone = document.getElementById('dropzone');
389
+ const fileInput = document.getElementById('fileInput');
390
+ const previewImg = document.getElementById('previewImg');
391
+ const slider = document.getElementById('strengthSlider');
392
+ const valDisplay = document.getElementById('strengthVal');
393
+
394
+ function setUpscaleFactor(factor) {
395
+ currentUpscaleFactor = factor;
396
+ const btn2x = document.getElementById('btn2x');
397
+ const btn4x = document.getElementById('btn4x');
398
+
399
+ // Helper to set active/inactive styles
400
+ const setActive = (btn) => {
401
+ btn.className = "py-2.5 rounded-xl border border-black bg-black text-white text-[10px] font-bold transition-all";
402
+ };
403
+ const setInactive = (btn) => {
404
+ btn.className = "py-2.5 rounded-xl border border-gray-200 text-gray-400 text-[10px] font-bold hover:border-black hover:text-black transition-all";
405
+ };
406
+
407
+ if (factor === 2048) {
408
+ setActive(btn2x);
409
+ setInactive(btn4x);
410
+ } else {
411
+ setInactive(btn2x);
412
+ setActive(btn4x);
413
+ }
414
+ }
415
+
416
+ dropzone.onclick = () => fileInput.click();
417
+ fileInput.onchange = (e) => handleFile(e.target.files[0]);
418
+
419
+ // Drag and Drop
420
+ dropzone.addEventListener('dragover', (e) => {
421
+ e.preventDefault();
422
+ dropzone.classList.add('border-black', 'bg-gray-50');
423
+ });
424
+ dropzone.addEventListener('dragleave', () => {
425
+ dropzone.classList.remove('border-black', 'bg-gray-50');
426
+ });
427
+ dropzone.addEventListener('drop', (e) => {
428
+ e.preventDefault();
429
+ dropzone.classList.remove('border-black', 'bg-gray-50');
430
+ if (e.dataTransfer.files && e.dataTransfer.files[0]) {
431
+ handleFile(e.dataTransfer.files[0]);
432
+ }
433
+ });
434
+
435
+ // Paste support
436
+ let isHovering = false;
437
+ dropzone.addEventListener('mouseenter', () => isHovering = true);
438
+ dropzone.addEventListener('mouseleave', () => isHovering = false);
439
+ window.addEventListener('paste', (e) => {
440
+ if (!isHovering) return;
441
+ const items = (e.clipboardData || e.originalEvent.clipboardData).items;
442
+ for (let item of items) {
443
+ if (item.kind === 'file' && item.type.startsWith('image/')) {
444
+ const file = item.getAsFile();
445
+ handleFile(file);
446
+ break;
447
+ }
448
+ }
449
+ });
450
+
451
+ slider.oninput = function () {
452
+ valDisplay.innerText = parseFloat(this.value).toFixed(2);
453
+ };
454
+
455
+ async function handleFile(file) {
456
+ if (!file) return;
457
+ const btn = document.getElementById('genBtn');
458
+ const btnText = document.getElementById('btnText');
459
+
460
+ // Disable button during upload
461
+ btn.disabled = true;
462
+ btnText.innerText = "Uploading...";
463
+
464
+ const reader = new FileReader();
465
+ reader.onload = (e) => {
466
+ previewImg.src = e.target.result;
467
+ previewImg.classList.remove('hidden');
468
+ document.getElementById('uploadContent').classList.add('opacity-0');
469
+ document.getElementById('changeOverlay').classList.replace('hidden', 'flex');
470
+ };
471
+ reader.readAsDataURL(file);
472
+
473
+ const formData = new FormData();
474
+ formData.append('files', file);
475
+ try {
476
+ const res = await fetch('/api/upload', { method: 'POST', body: formData });
477
+ const data = await res.json();
478
+ uploadedPath = data.files[0].comfy_name;
479
+ // Enable button after successful upload
480
+ btn.disabled = false;
481
+ btnText.innerText = "Begin Remastering";
482
+ } catch (err) {
483
+ console.error("Upload error");
484
+ btnText.innerText = "Upload Failed";
485
+ btn.disabled = false;
486
+ }
487
+ }
488
+
489
+ function toggleUpscaleOptions() {
490
+ const toggle = document.getElementById('upscaleToggle');
491
+ const options = document.getElementById('upscaleOptions');
492
+ if (toggle.checked) {
493
+ options.classList.remove('hidden');
494
+ options.classList.add('grid');
495
+ } else {
496
+ options.classList.add('hidden');
497
+ options.classList.remove('grid');
498
+ }
499
+ }
500
+
501
+ async function handleGenerate() {
502
+ if (!uploadedPath) {
503
+ const dropzone = document.getElementById('dropzone');
504
+ dropzone.style.transition = "0.2s";
505
+ dropzone.style.borderColor = "#ef4444";
506
+ dropzone.style.transform = "scale(0.98)";
507
+ setTimeout(() => {
508
+ dropzone.style.borderColor = "";
509
+ dropzone.style.transform = "scale(1)";
510
+ }, 300);
511
+ return;
512
+ }
513
+ const btn = document.getElementById('genBtn');
514
+ const btnText = document.getElementById('btnText');
515
+ const isUpscale = document.getElementById('upscaleToggle').checked;
516
+
517
+ btn.disabled = true;
518
+ btn.style.backgroundColor = '#333';
519
+ btn.innerHTML = `<i data-lucide="zap" class="w-4 h-4 text-yellow-400 fill-yellow-400 animate-pulse"></i><span class="tracking-[0.4em] text-[11px] uppercase">Processing...</span>`;
520
+ lucide.createIcons();
521
+ document.getElementById('emptyState').classList.add('hidden');
522
+ document.getElementById('outputImg').classList.add('hidden');
523
+ document.getElementById('loadingState').classList.remove('hidden');
524
+
525
+ let debugStep = "Start";
526
+ try {
527
+ // Phase 1: Enhance (always run)
528
+ debugStep = "Phase 1 Request";
529
+ btn.innerHTML = `<i data-lucide="zap" class="w-4 h-4 text-yellow-400 fill-yellow-400 animate-pulse"></i><span class="tracking-[0.4em] text-[11px] uppercase">${isUpscale ? "Phase 1/2: Enhancing..." : "Processing..."}</span>`;
530
+ lucide.createIcons();
531
+
532
+ const enhanceRes = await fetch('/api/generate', {
533
+ method: 'POST',
534
+ headers: { 'Content-Type': 'application/json' },
535
+ body: JSON.stringify({
536
+ workflow_json: "Z-Image-Enhance.json",
537
+ params: {
538
+ "15": { "image": uploadedPath },
539
+ "204": { "value": parseFloat(slider.value) }
540
+ },
541
+ type: "enhance",
542
+ client_id: CLIENT_ID
543
+ })
544
+ });
545
+
546
+ const enhanceData = await enhanceRes.json();
547
+ if (enhanceData.error) throw new Error("Enhance API Error: " + enhanceData.error);
548
+ if (!enhanceData.images?.length) throw new Error("Enhance failed: No images returned");
549
+
550
+ let finalData = enhanceData;
551
+
552
+ // Phase 2: Upscale (if enabled)
553
+ if (isUpscale) {
554
+ debugStep = "Phase 2 Preparation";
555
+ btn.innerHTML = `<i data-lucide="zap" class="w-4 h-4 text-yellow-400 fill-yellow-400 animate-pulse"></i><span class="tracking-[0.4em] text-[11px] uppercase">Phase 2/2: Uploading...</span>`;
556
+ lucide.createIcons();
557
+
558
+ // 1. Fetch the result from Phase 1
559
+ const imgUrl = enhanceData.images[0];
560
+ console.log("Phase 1 Output URL:", imgUrl);
561
+
562
+ let imgBlob;
563
+ try {
564
+ const blobRes = await fetch(imgUrl);
565
+ if (!blobRes.ok) throw new Error(`Fetch failed status: ${blobRes.status}`);
566
+ imgBlob = await blobRes.blob();
567
+ } catch (e) {
568
+ throw new Error(`Failed to fetch Phase 1 image (${imgUrl}): ${e.message}`);
569
+ }
570
+
571
+ // 2. Upload it back to ComfyUI as input
572
+ debugStep = "Phase 2 Upload";
573
+ const formData = new FormData();
574
+ formData.append('files', imgBlob, 'temp_upscale_input.png');
575
+
576
+ const uploadRes = await fetch('/api/upload', { method: 'POST', body: formData });
577
+ if (!uploadRes.ok) throw new Error(`Upload failed status: ${uploadRes.status}`);
578
+
579
+ const uploadData = await uploadRes.json();
580
+ if (!uploadData.files || !uploadData.files[0]) throw new Error("Intermediate upload failed: No file data returned");
581
+ const uploadedInput = uploadData.files[0].comfy_name;
582
+
583
+ // 3. Run Upscale Workflow
584
+ debugStep = "Phase 2 Execution";
585
+ btn.innerHTML = `<i data-lucide="zap" class="w-4 h-4 text-yellow-400 fill-yellow-400 animate-pulse"></i><span class="tracking-[0.4em] text-[11px] uppercase">Phase 2/2: Upscaling...</span>`;
586
+ lucide.createIcons();
587
+ // SeedVR2VideoUpscaler limits seed to 32-bit (max 4294967295)
588
+ const seed = Math.floor(Math.random() * 4294967295);
589
+
590
+ const upscaleRes = await fetch('/api/generate', {
591
+ method: 'POST',
592
+ headers: { 'Content-Type': 'application/json' },
593
+ body: JSON.stringify({
594
+ workflow_json: "upscale.json",
595
+ params: {
596
+ "15": { "image": uploadedInput },
597
+ "172": {
598
+ "seed": seed,
599
+ "resolution": currentUpscaleFactor
600
+ }
601
+ },
602
+ type: "enhance",
603
+ client_id: CLIENT_ID
604
+ })
605
+ });
606
+
607
+ finalData = await upscaleRes.json();
608
+ if (finalData.error) throw new Error("Upscale API Error: " + finalData.error);
609
+ if (!finalData.images?.length) throw new Error("Upscale failed: No images returned");
610
+ }
611
+
612
+ currentResult = finalData;
613
+ const out = document.getElementById('outputImg');
614
+ out.src = finalData.images[0];
615
+ out.classList.remove('hidden');
616
+ document.getElementById('downloadBtn').classList.remove('hidden');
617
+ document.getElementById('downloadBtn').href = finalData.images[0];
618
+ document.getElementById('loadingState').classList.add('hidden');
619
+ renderImageCard(finalData, true);
620
+
621
+ } catch (error) {
622
+ console.error("Generation Error at step " + debugStep, error);
623
+ document.getElementById('emptyState').classList.remove('hidden');
624
+ document.getElementById('loadingState').classList.add('hidden');
625
+ alert(`Error at ${debugStep}: ${error.message || JSON.stringify(error)}`);
626
+ } finally {
627
+ btn.disabled = false;
628
+ btn.style.backgroundColor = '';
629
+ btn.innerHTML = `<i data-lucide="zap" class="w-4 h-4 text-yellow-400"></i><span id="btnText">Begin Remastering</span>`;
630
+ lucide.createIcons();
631
+ }
632
+ }
633
+
634
+ function zoomImage() {
635
+ if (currentResult) {
636
+ // Ensure comparison works by injecting params if missing
637
+ if (!currentResult.params && uploadedPath) {
638
+ currentResult.params = { "15": { "image": uploadedPath } };
639
+ }
640
+ openLightbox(currentResult);
641
+ }
642
+ }
643
+
644
+ function renderImageCard(data, isNew = false) {
645
+ const masonry = document.getElementById('masonry');
646
+ if (document.getElementById(`h-${data.timestamp}`)) return;
647
+
648
+ const card = document.createElement('div');
649
+ card.id = `h-${data.timestamp}`;
650
+ card.className = 'masonry-item group relative rounded-2xl overflow-hidden cursor-zoom-in';
651
+ card.onclick = () => openLightbox(data);
652
+
653
+ card.innerHTML = `
654
+ <img src="${data.images[0]}" class="w-full h-auto block transform group-hover:scale-105 transition-transform duration-1000">
655
+ <div class="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity flex flex-col justify-end p-5">
656
+ <p class="text-white text-[9px] font-black uppercase tracking-widest">Remaster Archive</p>
657
+ </div>
658
+ `;
659
+ isNew ? masonry.prepend(card) : masonry.appendChild(card);
660
+ }
661
+
662
+ function initCompareEvents() {
663
+ const container = document.getElementById('compareContainer');
664
+ const wrapper = document.getElementById('compareOriginalWrapper');
665
+ const slider = document.getElementById('compareSlider');
666
+ let isDragging = false;
667
+
668
+ const updateSlider = (clientX) => {
669
+ const rect = container.getBoundingClientRect();
670
+ let x = clientX - rect.left;
671
+ let percent = (x / rect.width) * 100;
672
+ percent = Math.max(0, Math.min(100, percent));
673
+ wrapper.style.clipPath = `inset(0 ${100 - percent}% 0 0)`;
674
+ slider.style.left = `${percent}%`;
675
+ };
676
+
677
+ const start = (e) => { isDragging = true; e.preventDefault(); };
678
+ const end = () => isDragging = false;
679
+ const move = (e) => {
680
+ if (!isDragging) return;
681
+ const clientX = e.touches ? e.touches[0].clientX : e.clientX;
682
+ updateSlider(clientX);
683
+ };
684
+
685
+ container.addEventListener('mousedown', (e) => { if (e.target === slider) return; updateSlider(e.clientX); start(e); });
686
+ slider.addEventListener('mousedown', start);
687
+ window.addEventListener('mouseup', end);
688
+ window.addEventListener('mousemove', move);
689
+ slider.addEventListener('touchstart', start, { passive: false });
690
+ window.addEventListener('touchend', end);
691
+ window.addEventListener('touchmove', move, { passive: false });
692
+ }
693
+
694
+ function openLightbox(data) {
695
+ const compare = document.getElementById('compareContainer');
696
+ const single = document.getElementById('lightboxImg');
697
+ const resPill = document.getElementById('lightboxRes');
698
+
699
+ resPill.style.opacity = '0';
700
+ const updateRes = (target) => {
701
+ if (target.naturalWidth) {
702
+ resPill.innerText = `${target.naturalWidth} x ${target.naturalHeight}`;
703
+ resPill.style.opacity = '1';
704
+ }
705
+ };
706
+
707
+ if (data.params?.["15"]?.image) {
708
+ compare.classList.remove('hidden');
709
+ single.classList.add('hidden');
710
+ const genImg = document.getElementById('compareGenerated');
711
+ document.getElementById('compareOriginal').src = `/api/view?filename=${encodeURIComponent(data.params["15"].image)}&type=input`;
712
+ genImg.src = data.images[0];
713
+ document.getElementById('compareOriginalWrapper').style.clipPath = 'inset(0 50% 0 0)';
714
+ document.getElementById('compareSlider').style.left = '50%';
715
+
716
+ genImg.onload = () => updateRes(genImg);
717
+ if (genImg.complete) updateRes(genImg);
718
+ } else {
719
+ compare.classList.add('hidden');
720
+ single.classList.remove('hidden');
721
+ single.src = data.images[0];
722
+
723
+ single.onload = () => updateRes(single);
724
+ if (single.complete) updateRes(single);
725
+ }
726
+ document.getElementById('lightbox').classList.replace('hidden', 'flex');
727
+ document.body.style.overflow = 'hidden';
728
+ }
729
+
730
+ function closeLightbox() {
731
+ document.getElementById('lightbox').classList.replace('flex', 'hidden');
732
+ document.body.style.overflow = 'auto';
733
+ }
734
+
735
+ function handleOutsideClick(e) { if (e.target.id === 'lightbox') closeLightbox(); }
736
+
737
+ async function loadHistory() {
738
+ try {
739
+ const res = await fetch('/api/history?type=enhance');
740
+ const history = await res.json();
741
+ history.forEach(item => renderImageCard(item));
742
+ } catch (e) { }
743
+ }
744
+
745
+ window.onload = () => {
746
+ loadHistory();
747
+ initCompareEvents();
748
+ };
749
+ </script>
750
+ </body>
751
+
752
+ </html>
static/index.html ADDED
@@ -0,0 +1,804 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <link rel="icon" href="/static/logo.png" type="image/png">
8
+ <title>AI Studio</title>
9
+ <script src="https://cdn.tailwindcss.com"></script>
10
+ <style>
11
+ @import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;500;700&display=swap');
12
+
13
+ :root {
14
+ --accent: #000000;
15
+ --fluid-ease: cubic-bezier(0.3, 0, 0, 1);
16
+ }
17
+
18
+ /* --- 极简悬浮浅灰滚动条 (无底色/左移) --- */
19
+ *::-webkit-scrollbar {
20
+ width: 10px !important;
21
+ height: 10px !important;
22
+ background: transparent !important;
23
+ }
24
+
25
+ *::-webkit-scrollbar-track {
26
+ background: transparent !important;
27
+ border: none !important;
28
+ }
29
+
30
+ *::-webkit-scrollbar-thumb {
31
+ background-color: #d8d8d8 !important;
32
+ border: 3px solid transparent !important;
33
+ border-right-width: 5px !important;
34
+ /* 增加右侧间距,使滚动条向左位移 */
35
+ background-clip: padding-box !important;
36
+ border-radius: 10px !important;
37
+ }
38
+
39
+ *::-webkit-scrollbar-thumb:hover {
40
+ background-color: #c0c0c0 !important;
41
+ }
42
+
43
+ *::-webkit-scrollbar-corner {
44
+ background: transparent !important;
45
+ }
46
+
47
+ * {
48
+ scrollbar-width: thin !important;
49
+ scrollbar-color: #d8d8d8 transparent !important;
50
+ }
51
+
52
+ body {
53
+ background: #ffffff;
54
+ font-family: 'Space Grotesk', sans-serif;
55
+ overflow: hidden;
56
+ height: 100vh;
57
+ color: #121212;
58
+ }
59
+
60
+ .app-shell {
61
+ display: flex;
62
+ width: 100%;
63
+ height: 100vh;
64
+ background: #fff;
65
+ position: relative;
66
+ }
67
+
68
+ /* 侧边栏 */
69
+ .sidebar {
70
+ width: 80px;
71
+ min-width: 80px;
72
+ background: #fff;
73
+ display: flex;
74
+ flex-direction: column;
75
+ align-items: center;
76
+ border-right: 1px solid #f2f2f2;
77
+ padding: 40px 0;
78
+ transition: width 0.5s var(--fluid-ease) 0.5s;
79
+ z-index: 50;
80
+ }
81
+
82
+ .sidebar:hover {
83
+ width: 220px;
84
+ transition-delay: 0s;
85
+ }
86
+
87
+ .logo-ring {
88
+ width: 36px;
89
+ height: 36px;
90
+ border: 2px solid var(--accent);
91
+ border-radius: 12px;
92
+ display: flex;
93
+ align-items: center;
94
+ justify-content: center;
95
+ transition: all 0.6s var(--fluid-ease) 0.5s;
96
+ }
97
+
98
+ .sidebar:hover .logo-ring {
99
+ transform: rotate(90deg);
100
+ border-radius: 50%;
101
+ transition-delay: 0s;
102
+ }
103
+
104
+ /* 导航项 */
105
+ .nav-item {
106
+ position: relative;
107
+ width: 48px;
108
+ height: 48px;
109
+ margin: 10px 0;
110
+ display: flex;
111
+ align-items: center;
112
+ justify-content: flex-start;
113
+ border-radius: 18px;
114
+ cursor: pointer;
115
+ transition: all 0.3s var(--fluid-ease) 0.5s;
116
+ color: #999;
117
+ overflow: hidden;
118
+ padding-left: 14px;
119
+ }
120
+
121
+ .sidebar:hover .nav-item {
122
+ width: 190px;
123
+ transition-delay: 0s;
124
+ }
125
+
126
+ .nav-item:hover {
127
+ background: #fafafa;
128
+ color: #000;
129
+ }
130
+
131
+ .nav-item.active {
132
+ background: var(--accent);
133
+ color: #fff;
134
+ }
135
+
136
+ .nav-text {
137
+ opacity: 0;
138
+ margin-left: 16px;
139
+ font-weight: 600;
140
+ font-size: 14px;
141
+ white-space: nowrap;
142
+ transition: opacity 0.3s 0.5s;
143
+ }
144
+
145
+ .sidebar:hover .nav-text {
146
+ opacity: 1;
147
+ transition-delay: 0.1s;
148
+ }
149
+
150
+ /* 主舞台区 */
151
+ .stage {
152
+ flex: 1;
153
+ background: #fcfcfc;
154
+ margin: 16px;
155
+ border-radius: 32px;
156
+ overflow: hidden;
157
+ border: 1px solid #f0f0f0;
158
+ position: relative;
159
+ }
160
+
161
+ iframe {
162
+ position: absolute;
163
+ inset: 0;
164
+ width: 100%;
165
+ height: 100%;
166
+ border: none;
167
+ opacity: 0;
168
+ transform: scale(1.02);
169
+ filter: blur(4px);
170
+ transition: all 0.5s var(--fluid-ease);
171
+ pointer-events: none;
172
+ }
173
+
174
+ iframe.active {
175
+ opacity: 1;
176
+ transform: scale(1);
177
+ filter: blur(0);
178
+ pointer-events: auto;
179
+ }
180
+
181
+ /* --- 左下角微型监视器 --- */
182
+ .nano-monitor {
183
+ position: absolute;
184
+ bottom: 24px;
185
+ left: 24px;
186
+ z-index: 100;
187
+ display: flex;
188
+ align-items: center;
189
+ gap: 8px;
190
+ background: rgba(255, 255, 255, 0.7);
191
+ backdrop-filter: blur(12px);
192
+ padding: 6px 14px;
193
+ border-radius: 16px;
194
+ border: 1px solid rgba(0, 0, 0, 0.05);
195
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.04);
196
+ font-family: monospace;
197
+ transition: all 0.4s var(--fluid-ease);
198
+ }
199
+
200
+ .nano-monitor.is-busy {
201
+ background: #000;
202
+ color: #fff;
203
+ border-color: rgba(255, 255, 255, 0.1);
204
+ box-shadow: 0 8px 30px rgba(0, 0, 0, 0.2);
205
+ }
206
+
207
+ .stat-group {
208
+ display: flex;
209
+ align-items: center;
210
+ gap: 6px;
211
+ font-size: 11px;
212
+ font-weight: 700;
213
+ }
214
+
215
+ .divider {
216
+ width: 1px;
217
+ height: 12px;
218
+ background: rgba(0, 0, 0, 0.1);
219
+ }
220
+
221
+ .is-busy .divider {
222
+ background: rgba(255, 255, 255, 0.2);
223
+ }
224
+
225
+ .pulse-dot {
226
+ width: 6px;
227
+ height: 6px;
228
+ border-radius: 50%;
229
+ background: #10b981;
230
+ }
231
+
232
+ .spinner-nano {
233
+ width: 10px;
234
+ height: 10px;
235
+ border: 2px solid rgba(255, 255, 255, 0.2);
236
+ border-top-color: #fff;
237
+ border-radius: 50%;
238
+ animation: spin 0.8s linear infinite;
239
+ display: none;
240
+ }
241
+
242
+ .is-busy .spinner-nano {
243
+ display: block;
244
+ }
245
+
246
+ .is-busy .pulse-dot {
247
+ display: none;
248
+ }
249
+
250
+ @keyframes spin {
251
+ to {
252
+ transform: rotate(360deg);
253
+ }
254
+ }
255
+
256
+ .label-nano {
257
+ text-transform: uppercase;
258
+ letter-spacing: 0.5px;
259
+ opacity: 0.5;
260
+ font-size: 9px;
261
+ }
262
+
263
+ /* --- 设计感:Split-Expansion 作者组件 --- */
264
+ .author-box {
265
+ /* margin-top: auto; Moved to Token Button */
266
+ width: 100%;
267
+ height: 60px;
268
+ position: relative;
269
+ display: flex;
270
+ align-items: center;
271
+ justify-content: center;
272
+ overflow: hidden;
273
+ }
274
+
275
+ /* 字母 DX 的基础样式 */
276
+ .dx-letter {
277
+ position: absolute;
278
+ font-size: 14px;
279
+ font-weight: 800;
280
+ color: f5f5f5;
281
+ transition: all 0.5s var(--fluid-ease) 0.4s;
282
+ z-index: 10;
283
+ }
284
+
285
+ .letter-d {
286
+ transform: translateX(-8px);
287
+ }
288
+
289
+ .letter-x {
290
+ transform: translateX(8px);
291
+ }
292
+
293
+ /* 侧边栏展开时,DX 向两边消失 */
294
+ .sidebar:hover .letter-d {
295
+ transform: translateX(-120px);
296
+ opacity: 0;
297
+ transition-delay: 0s;
298
+ }
299
+
300
+ .sidebar:hover .letter-x {
301
+ transform: translateX(120px);
302
+ opacity: 0;
303
+ transition-delay: 0s;
304
+ }
305
+
306
+ /* 中间内容的容器 */
307
+ .author-content-wrap {
308
+ display: flex;
309
+ flex-direction: column;
310
+ align-items: center;
311
+ opacity: 0;
312
+ transform: scale(0.9);
313
+ transition: all 0.4s var(--fluid-ease) 0s;
314
+ pointer-events: none;
315
+ }
316
+
317
+ /* 侧边栏展开时,中间内容显现 */
318
+ .sidebar:hover .author-content-wrap {
319
+ opacity: 1;
320
+ transform: scale(1);
321
+ transition-delay: 0.2s;
322
+ pointer-events: auto;
323
+ }
324
+
325
+ .author-name-lite {
326
+ font-size: 12px;
327
+ font-weight: 700;
328
+ margin-bottom: 8px;
329
+ color: #000;
330
+ }
331
+
332
+ .social-row-lite {
333
+ display: flex;
334
+ gap: 12px;
335
+ }
336
+
337
+ .social-icon-lite {
338
+ color: #ccc;
339
+ transition: color 0.2s, transform 0.2s;
340
+ }
341
+
342
+ .social-icon-lite:hover {
343
+ color: #000;
344
+ transform: translateY(-1px);
345
+ }
346
+
347
+ /* --- Token Button Custom Styles --- */
348
+ .nav-item.token-btn {
349
+ height: 36px !important;
350
+ width: 36px;
351
+ border-radius: 9999px !important;
352
+ background: #ffffff !important; /* White */
353
+ border: 1px solid #e5e5e5 !important; /* Light gray border */
354
+ color: #000000 !important;
355
+ padding-left: 0 !important;
356
+ justify-content: center;
357
+ box-shadow: none;
358
+
359
+ /* Hidden in collapsed state */
360
+ opacity: 0;
361
+ pointer-events: none;
362
+ transform: scale(0.8);
363
+ transition: all 0.3s var(--fluid-ease);
364
+ }
365
+
366
+ .sidebar:hover .nav-item.token-btn {
367
+ width: 140px;
368
+ opacity: 1;
369
+ pointer-events: auto;
370
+ transform: scale(1);
371
+ transition-delay: 0.1s;
372
+ }
373
+
374
+ .nav-item.token-btn:hover {
375
+ background: #f4f4f5 !important; /* Slightly darker on hover */
376
+ transform: scale(1.05) !important;
377
+ box-shadow: 0 4px 12px rgba(0,0,0,0.06);
378
+ }
379
+
380
+ .nav-item.token-btn .nav-text {
381
+ color: #000000 !important;
382
+ font-weight: 400;
383
+ font-size: 13px;
384
+ }
385
+ </style>
386
+ </head>
387
+
388
+ <body>
389
+
390
+ <div class="app-shell">
391
+ <aside class="sidebar">
392
+ <div class="logo-ring mb-12">
393
+ <div class="w-1.5 h-1.5 bg-black rounded-full transition-colors" id="logo-dot"></div>
394
+ </div>
395
+
396
+ <nav>
397
+ <div class="nav-item active" onclick="switchUI(this, 'zimage')">
398
+ <svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
399
+ <path
400
+ d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.587-1.587a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
401
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
402
+ </svg>
403
+ <span class="nav-text">文生图</span>
404
+ </div>
405
+ <div class="nav-item" onclick="switchUI(this, 'enhance')">
406
+ <svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
407
+ <path d="M13 10V3L4 14h7v7l9-11h-7z" stroke-width="2" stroke-linecap="round"
408
+ stroke-linejoin="round"></path>
409
+ </svg>
410
+ <span class="nav-text">细节增强</span>
411
+ </div>
412
+ <div class="nav-item" onclick="switchUI(this, 'klein')">
413
+ <svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
414
+ <path
415
+ d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
416
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
417
+ </svg>
418
+ <span class="nav-text">图片编辑</span>
419
+ </div>
420
+ <div class="nav-item" onclick="switchUI(this, 'angle')">
421
+ <svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"
422
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
423
+ <path
424
+ d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z">
425
+ </path>
426
+ <polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline>
427
+ <line x1="12" y1="22.08" x2="12" y2="12"></line>
428
+ </svg>
429
+ <span class="nav-text">角度控制</span>
430
+ </div>
431
+ </nav>
432
+
433
+ <div class="nav-item token-btn !mt-auto !mb-6" onclick="openTokenModal()" title="设置 API Token">
434
+ <svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
435
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
436
+ d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
437
+ </svg>
438
+ <span class="nav-text">API Token</span>
439
+ </div>
440
+
441
+ <div class="author-box">
442
+ <span class="dx-letter letter-d">D</span>
443
+ <span class="dx-letter letter-x">X</span>
444
+
445
+ <div class="author-content-wrap">
446
+ <div class="author-name-lite">wuli大雄</div>
447
+ <div class="social-row-lite">
448
+ <a href="https://space.bilibili.com/78652351" target="_blank" class="social-icon-lite">
449
+ <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
450
+ <path
451
+ d="M17.813 4.653h-.854L15.66 3.053a1.147 1.147 0 00-1.63 0l-1.3 1.6h-1.46L9.97 3.053a1.147 1.147 0 00-1.63 0L7.043 4.653h-.854a3.946 3.946 0 00-3.93 3.934v8.117a3.946 3.946 0 003.93 3.934h11.624a3.946 3.946 0 003.93-3.934V8.587a3.946 3.946 0 00-3.93-3.934zM7.152 13.9a1.465 1.465 0 111.47-1.462 1.465 1.465 0 01-1.47 1.462zm7.696 0a1.465 1.465 0 111.47-1.462 1.465 1.465 0 01-1.47 1.462z" />
452
+ </svg>
453
+ </a>
454
+ <a href="https://www.xiaohongshu.com/user/profile/6433c34c000000001a023538" target="_blank"
455
+ class="social-icon-lite">
456
+ <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
457
+ <path
458
+ d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 14.5v-9l6 4.5-6 4.5z" />
459
+ </svg>
460
+ </a>
461
+ <a href="https://www.youtube.com/@%E5%A4%A7%E9%9B%84dx" target="_blank"
462
+ class="social-icon-lite">
463
+ <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
464
+ <path
465
+ d="M23.498 6.186a3.016 3.016 0 00-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 00.502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 002.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 002.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
466
+ </svg>
467
+ </a>
468
+ <a href="https://x.com/dx8152?s=21" target="_blank" class="social-icon-lite">
469
+ <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
470
+ <path
471
+ d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
472
+ </svg>
473
+ </a>
474
+ </div>
475
+ </div>
476
+ </div>
477
+ </aside>
478
+
479
+ <main class="stage">
480
+ <iframe id="frame-zimage" src="/static/zimage.html?v=30" class="active"></iframe>
481
+ <iframe id="frame-enhance" data-src="/static/enhance.html?v=30"></iframe>
482
+ <iframe id="frame-klein" data-src="/static/klein.html?v=30"></iframe>
483
+ <iframe id="frame-angle" data-src="/static/angle.html?v=30"></iframe>
484
+
485
+ <div class="nano-monitor" id="nano-monitor">
486
+ <div class="stat-group">
487
+ <div class="pulse-dot animate-pulse"></div>
488
+ <div class="spinner-nano"></div>
489
+ <span class="label-nano">ONLINE</span>
490
+ <span id="online-val">1</span>
491
+ </div>
492
+ <div class="divider"></div>
493
+ <div class="stat-group">
494
+ <span class="label-nano">QUEUE</span>
495
+ <span id="queue-val">0</span>
496
+ </div>
497
+ </div>
498
+ </main>
499
+ </div>
500
+
501
+ <!-- Token Modal -->
502
+ <div id="token-modal" class="fixed inset-0 z-[100] hidden opacity-0 transition-opacity duration-300">
503
+ <div class="absolute inset-0 bg-black/40 backdrop-blur-md" onclick="closeTokenModal()"></div>
504
+
505
+ <div id="token-modal-content" class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white rounded-3xl shadow-[0_32px_64px_-12px_rgba(0,0,0,0.2)] w-[440px] overflow-hidden scale-95 transition-all duration-300 border border-gray-100">
506
+
507
+ <div class="p-8 pb-0">
508
+ <h3 class="text-xl font-bold text-gray-900 mb-2 text-center tracking-tight">Access Token</h3>
509
+ <div class="text-center mb-6">
510
+ <a href="https://www.modelscope.cn/my/access/token" target="_blank" class="text-xs text-blue-500 hover:text-blue-600 hover:underline transition-colors">
511
+ 获取 API Key (Get Token) ->
512
+ </a>
513
+ </div>
514
+
515
+ <div class="flex p-1 bg-gray-100 rounded-2xl mb-8 relative">
516
+ <button onclick="toggleTokenPanel('personal')" id="btn-personal" class="flex-1 py-2 text-sm font-bold rounded-xl transition-all duration-300 z-10 text-black bg-white shadow-sm">个人-Personal</button>
517
+ <button onclick="toggleTokenPanel('global')" id="btn-global" class="flex-1 py-2 text-sm font-bold rounded-xl transition-all duration-300 z-10 text-gray-500">全局-Global</button>
518
+ </div>
519
+ </div>
520
+
521
+ <div class="px-8 pb-8 relative">
522
+ <div id="panel-personal" class="space-y-5 transition-all duration-300">
523
+ <div class="space-y-2">
524
+ <div class="flex justify-between items-end">
525
+ <label class="text-[10px] font-bold text-gray-400 uppercase tracking-[0.2em]">Local Storage Only</label>
526
+ <span class="text-[10px] text-green-500 font-bold bg-green-50 px-2 py-0.5 rounded-full">Secure</span>
527
+ </div>
528
+ <input type="password" id="personal-token-input" class="w-full px-5 py-4 bg-gray-50 border border-gray-200 rounded-2xl focus:outline-none focus:ring-2 focus:ring-black/5 focus:border-black transition-all text-sm font-mono" placeholder="Enter personal token...">
529
+ </div>
530
+ <div class="flex gap-3">
531
+ <button onclick="savePersonalToken()" class="flex-[2] py-4 text-sm font-bold text-white bg-black hover:bg-gray-800 rounded-2xl transition-all shadow-lg shadow-black/10 active:scale-[0.98]">Save Token</button>
532
+ <button onclick="deletePersonalToken()" class="flex-1 py-4 text-sm font-bold text-red-500 bg-red-50 hover:bg-red-100 rounded-2xl transition-all active:scale-[0.98]">Reset</button>
533
+ </div>
534
+ </div>
535
+
536
+ <div id="panel-global" class="hidden space-y-5 transition-all duration-300 opacity-0 translate-y-4">
537
+ <div class="space-y-2">
538
+ <div class="flex justify-between items-end">
539
+ <label class="text-[10px] font-bold text-gray-400 uppercase tracking-[0.2em]">Server Configuration</label>
540
+ <span class="text-[10px] text-orange-500 font-bold bg-orange-50 px-2 py-0.5 rounded-full">Shared</span>
541
+ </div>
542
+ <input type="password" id="global-token-input" class="w-full px-5 py-4 bg-blue-50/20 border border-blue-100 rounded-2xl focus:outline-none focus:ring-2 focus:ring-blue-500/10 focus:border-blue-300 transition-all text-sm font-mono" placeholder="Enter server token...">
543
+ </div>
544
+ <div class="flex gap-3">
545
+ <button onclick="saveGlobalToken()" class="flex-[2] py-4 text-sm font-bold text-white bg-blue-600 hover:bg-blue-700 rounded-2xl transition-all shadow-lg shadow-blue-500/20 active:scale-[0.98]">Deploy Global</button>
546
+ <button onclick="deleteGlobalToken()" class="flex-1 py-4 text-sm font-bold text-red-500 bg-red-50 hover:bg-red-100 rounded-2xl transition-all active:scale-[0.98]">Remove</button>
547
+ </div>
548
+ </div>
549
+ </div>
550
+
551
+ </div>
552
+ </div>
553
+ <script>
554
+ function generateUUID() {
555
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
556
+ try { return crypto.randomUUID(); } catch (e) { }
557
+ }
558
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
559
+ var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
560
+ return v.toString(16);
561
+ });
562
+ }
563
+ const CID = localStorage.getItem("client_id") || generateUUID();
564
+ localStorage.setItem("client_id", CID);
565
+
566
+ function switchUI(el, id) {
567
+ document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
568
+ el.classList.add('active');
569
+ document.querySelectorAll('iframe').forEach(f => f.classList.remove('active'));
570
+ const target = document.getElementById('frame-' + id);
571
+ target.classList.add('active');
572
+ if (!target.src) target.src = target.dataset.src;
573
+ }
574
+
575
+ async function syncStatus() {
576
+ try {
577
+ const res = await fetch(`/api/queue_status?client_id=${CID}`);
578
+ const data = await res.json();
579
+
580
+ const monitor = document.getElementById('nano-monitor');
581
+ const queueVal = document.getElementById('queue-val');
582
+ const logoDot = document.getElementById('logo-dot');
583
+
584
+ const total = data.total || 0;
585
+ const pos = data.position || 0;
586
+
587
+ if (pos > 0) {
588
+ monitor.classList.add('is-busy');
589
+ queueVal.innerText = `${pos}/${total}`;
590
+ logoDot.style.backgroundColor = '#3b82f6';
591
+ } else {
592
+ monitor.classList.remove('is-busy');
593
+ queueVal.innerText = total > 0 ? total : '0';
594
+ logoDot.style.backgroundColor = '#000';
595
+ }
596
+ } catch (e) { }
597
+ }
598
+
599
+ const host = window.location.host;
600
+ if (host) {
601
+ const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
602
+ // WebSocket for queue status
603
+ const ws = new WebSocket(`${protocol}://${host}/ws/stats?client_id=${CID}`);
604
+ ws.onmessage = (event) => {
605
+ const data = JSON.parse(event.data);
606
+ if (data.type === 'stats') {
607
+ document.getElementById('online-val').innerText = data.data.active_users;
608
+ } else if (data.type === 'cloud_status') {
609
+ // Forward cloud status to active iframe
610
+ const iframe = document.querySelector('iframe.active');
611
+ if (iframe && iframe.contentWindow) {
612
+ iframe.contentWindow.postMessage(data, '*');
613
+ }
614
+ }
615
+ };
616
+ setInterval(syncStatus, 2000);
617
+ }
618
+
619
+ // --- Token Modal Logic ---
620
+ const modal = document.getElementById('token-modal');
621
+ const modalContent = document.getElementById('token-modal-content');
622
+ const personalInput = document.getElementById('personal-token-input');
623
+ const globalInput = document.getElementById('global-token-input');
624
+
625
+ // 修改原有的 openTokenModal,确保每次打开默认显示个人面板
626
+ window.openTokenModal = function() {
627
+ modal.classList.remove('hidden');
628
+ setTimeout(() => {
629
+ modal.classList.remove('opacity-0');
630
+ modalContent.classList.remove('scale-95');
631
+ modalContent.classList.add('scale-100');
632
+ }, 10);
633
+ toggleTokenPanel('personal'); // 默认显示个人
634
+ loadCurrentToken();
635
+ }
636
+
637
+ function toggleTokenPanel(type) {
638
+ const pPanel = document.getElementById('panel-personal');
639
+ const gPanel = document.getElementById('panel-global');
640
+ const pBtn = document.getElementById('btn-personal');
641
+ const gBtn = document.getElementById('btn-global');
642
+
643
+ if (type === 'personal') {
644
+ // Switch to Personal
645
+ gPanel.classList.add('hidden', 'opacity-0', 'translate-y-4');
646
+ pPanel.classList.remove('hidden');
647
+ setTimeout(() => pPanel.classList.remove('opacity-0', 'translate-y-4'), 10);
648
+
649
+ pBtn.classList.add('bg-white', 'text-black', 'shadow-sm');
650
+ pBtn.classList.remove('text-gray-500');
651
+ gBtn.classList.remove('bg-white', 'text-black', 'shadow-sm');
652
+ gBtn.classList.add('text-gray-500');
653
+ } else {
654
+ // Switch to Global
655
+ pPanel.classList.add('hidden', 'opacity-0', 'translate-y-4');
656
+ gPanel.classList.remove('hidden');
657
+ setTimeout(() => gPanel.classList.remove('opacity-0', 'translate-y-4'), 10);
658
+
659
+ gBtn.classList.add('bg-white', 'text-black', 'shadow-sm');
660
+ gBtn.classList.remove('text-gray-500');
661
+ pBtn.classList.remove('bg-white', 'text-black', 'shadow-sm');
662
+ pBtn.classList.add('text-gray-500');
663
+ }
664
+ }
665
+
666
+
667
+ function closeTokenModal() {
668
+ modal.classList.add('opacity-0');
669
+ modalContent.classList.remove('scale-100');
670
+ modalContent.classList.add('scale-95');
671
+ setTimeout(() => {
672
+ modal.classList.add('hidden');
673
+ }, 300);
674
+ }
675
+
676
+ async function loadCurrentToken() {
677
+ // 1. Load Personal
678
+ const localToken = localStorage.getItem('modelscope_api_token');
679
+ personalInput.value = localToken || '';
680
+
681
+ // 2. Load Global
682
+ try {
683
+ const res = await fetch('/api/config/token');
684
+ const data = await res.json();
685
+ globalInput.value = data.token || '';
686
+ } catch (e) {
687
+ console.error("Failed to load global token", e);
688
+ globalInput.value = '';
689
+ }
690
+ }
691
+
692
+ function savePersonalToken() {
693
+ const token = personalInput.value.trim();
694
+ if (!token) {
695
+ alert('请输入 Token');
696
+ return;
697
+ }
698
+ localStorage.setItem('modelscope_api_token', token);
699
+ alert('个人 Token 已保存');
700
+ }
701
+
702
+ function deletePersonalToken() {
703
+ if (confirm('确定要删除个人 Token 吗?')) {
704
+ localStorage.removeItem('modelscope_api_token');
705
+ personalInput.value = '';
706
+ }
707
+ }
708
+
709
+ async function saveGlobalToken() {
710
+ const token = globalInput.value.trim();
711
+ if (!token) {
712
+ alert('请输入 Token');
713
+ return;
714
+ }
715
+ if (!confirm('⚠️ 警告:全局 Token 将对所有用户可见。确定要保存吗?')) return;
716
+
717
+ try {
718
+ const res = await fetch('/api/config/token', {
719
+ method: 'POST',
720
+ headers: { 'Content-Type': 'application/json' },
721
+ body: JSON.stringify({ token })
722
+ });
723
+ if (res.ok) {
724
+ alert('全局 Token 已保存');
725
+ } else {
726
+ throw new Error('Save failed');
727
+ }
728
+ } catch (e) {
729
+ alert('保存失败: ' + e.message);
730
+ }
731
+ }
732
+
733
+ async function deleteGlobalToken() {
734
+ if (!confirm('确定要删除全局 Token 吗?此操作将影响所有使用默认配置的用户。')) return;
735
+
736
+ try {
737
+ const res = await fetch('/api/config/token', {
738
+ method: 'DELETE'
739
+ });
740
+ if (res.ok) {
741
+ globalInput.value = '';
742
+ alert('全局 Token 已删除');
743
+ } else {
744
+ throw new Error('Delete failed');
745
+ }
746
+ } catch (e) {
747
+ alert('删除失败: ' + e.message);
748
+ }
749
+ }
750
+
751
+ // Auto-open Token Modal if not set
752
+ window.addEventListener('load', async () => {
753
+ // Check Local
754
+ const localToken = localStorage.getItem('modelscope_api_token');
755
+ if (localToken) return;
756
+
757
+ // Check Global
758
+ try {
759
+ const res = await fetch('/api/config/token');
760
+ const data = await res.json();
761
+ if (data.token) return;
762
+ } catch (e) {}
763
+
764
+ // If neither, open modal
765
+ console.log("No token found, auto-opening modal");
766
+ openTokenModal();
767
+ });
768
+ </script>
769
+ </body>
770
+
771
+ </html> const queueVal = document.getElementById('queue-val');
772
+ const logoDot = document.getElementById('logo-dot');
773
+
774
+ const total = data.total || 0;
775
+ const pos = data.position || 0;
776
+
777
+ if (pos > 0) {
778
+ monitor.classList.add('is-busy');
779
+ queueVal.innerText = `${pos}/${total}`;
780
+ logoDot.style.backgroundColor = '#3b82f6';
781
+ } else {
782
+ monitor.classList.remove('is-busy');
783
+ queueVal.innerText = total > 0 ? total : '0';
784
+ logoDot.style.backgroundColor = '#000';
785
+ }
786
+ } catch (e) { }
787
+ }
788
+
789
+ const host = window.location.host;
790
+ if (host) {
791
+ const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
792
+ const ws = new WebSocket(`${protocol}://${host}/ws/stats`);
793
+ ws.onmessage = (e) => {
794
+ const d = JSON.parse(e.data);
795
+ if (d.online_count) {
796
+ document.getElementById('online-val').innerText = d.online_count;
797
+ }
798
+ };
799
+ setInterval(syncStatus, 2000);
800
+ }
801
+ </script>
802
+ </body>
803
+
804
+ </html>
static/klein.html ADDED
@@ -0,0 +1,685 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <link rel="icon" href="/static/logo.png" type="image/png">
8
+ <title>Flux Klein | 极简一体化终端</title>
9
+ <script src="https://cdn.tailwindcss.com"></script>
10
+ <script src="https://unpkg.com/lucide@latest"></script>
11
+ <style>
12
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;800&display=swap');
13
+
14
+ :root {
15
+ --accent: #111827;
16
+ --bg: #f9fafb;
17
+ --card: #ffffff;
18
+ --easing: cubic-bezier(0.4, 0, 0.2, 1);
19
+ }
20
+
21
+ /* --- 极简悬浮浅灰滚动条 (无底色/左移) --- */
22
+ *::-webkit-scrollbar {
23
+ width: 10px !important;
24
+ height: 10px !important;
25
+ background: transparent !important;
26
+ }
27
+
28
+ *::-webkit-scrollbar-track {
29
+ background: transparent !important;
30
+ border: none !important;
31
+ }
32
+
33
+ *::-webkit-scrollbar-thumb {
34
+ background-color: #d8d8d8 !important;
35
+ border: 3px solid transparent !important;
36
+ border-right-width: 5px !important;
37
+ /* 增加右侧间距,使滚动条向左位移 */
38
+ background-clip: padding-box !important;
39
+ border-radius: 10px !important;
40
+ }
41
+
42
+ *::-webkit-scrollbar-thumb:hover {
43
+ background-color: #c0c0c0 !important;
44
+ }
45
+
46
+ *::-webkit-scrollbar-corner {
47
+ background: transparent !important;
48
+ }
49
+
50
+ * {
51
+ scrollbar-width: thin !important;
52
+ scrollbar-color: #d8d8d8 transparent !important;
53
+ }
54
+
55
+ body {
56
+ background-color: var(--bg);
57
+ font-family: 'Inter', -apple-system, sans-serif;
58
+ color: var(--accent);
59
+ -webkit-font-smoothing: antialiased;
60
+ }
61
+
62
+ .container-box {
63
+ max-width: 1280px;
64
+ margin: 0 auto;
65
+ padding: 0 40px;
66
+ margin-top: 50px;
67
+ }
68
+
69
+ /* 精致输入框 */
70
+ .nano-input {
71
+ background: var(--card);
72
+ border: 1px solid #eef0f2;
73
+ transition: all 0.3s var(--easing);
74
+ }
75
+
76
+ .nano-input:focus {
77
+ border-color: #000;
78
+ box-shadow: 0 0 0 1px #000;
79
+ }
80
+
81
+ /* 上传组件 */
82
+ .upload-item {
83
+ background: var(--card);
84
+ border: 1px dashed #e2e8f0;
85
+ transition: all 0.4s var(--easing);
86
+ position: relative;
87
+ overflow: hidden;
88
+ }
89
+
90
+ .upload-item:hover {
91
+ border-color: #000;
92
+ background: #fff;
93
+ transform: translateY(-2px);
94
+ }
95
+
96
+ .upload-item.drag-over {
97
+ border-style: solid;
98
+ border-color: #000;
99
+ box-shadow: 0 10px 20px rgba(0, 0, 0, 0.05);
100
+ }
101
+
102
+ .preview-img {
103
+ position: absolute;
104
+ inset: 0;
105
+ width: 100%;
106
+ height: 100%;
107
+ object-fit: cover;
108
+ animation: fadeIn 0.5s var(--easing);
109
+ }
110
+
111
+ /* 生成按钮 */
112
+ .glass-btn {
113
+ background: #111827;
114
+ transition: all 0.3s var(--easing);
115
+ }
116
+
117
+ .glass-btn:hover {
118
+ background: #000;
119
+ transform: translateY(-1px);
120
+ box-shadow: 0 12px 24px rgba(0, 0, 0, 0.1);
121
+ }
122
+
123
+ .glass-btn:active {
124
+ transform: scale(0.98);
125
+ }
126
+
127
+ /* 结果预览框 */
128
+ .result-frame {
129
+ background: #ffffff;
130
+ border-radius: 32px;
131
+ border: 1px solid #f1f5f9;
132
+ box-shadow: 0 2px 15px rgba(0, 0, 0, 0.02);
133
+ }
134
+
135
+ /* 瀑布流 */
136
+ .masonry-grid {
137
+ display: grid;
138
+ grid-template-columns: repeat(2, 1fr);
139
+ gap: 20px;
140
+ }
141
+
142
+ @media (min-width: 768px) {
143
+ .masonry-grid {
144
+ grid-template-columns: repeat(4, 1fr);
145
+ }
146
+ }
147
+
148
+ .masonry-item {
149
+ aspect-ratio: 1 / 1;
150
+ border-radius: 24px;
151
+ overflow: hidden;
152
+ background: #fff;
153
+ border: 1px solid #f1f5f9;
154
+ transition: all 0.5s var(--easing);
155
+ }
156
+
157
+ .masonry-item:hover {
158
+ transform: translateY(-6px);
159
+ box-shadow: 0 20px 40px rgba(0, 0, 0, 0.08);
160
+ }
161
+
162
+ @keyframes fadeIn {
163
+ from {
164
+ opacity: 0;
165
+ }
166
+
167
+ to {
168
+ opacity: 1;
169
+ }
170
+ }
171
+ </style>
172
+ </head>
173
+
174
+ <body class="antialiased transition-colors duration-300">
175
+ <!-- 使用 container-box 并调整顶部间距�� pt-10,去除 justify-center -->
176
+ <div class="container-box min-h-screen flex flex-col">
177
+ <header class="flex justify-between items-end mb-16">
178
+ <div class="space-y-1">
179
+ <h1 class="text-4xl font-extrabold tracking-tighter italic">FLUX KLEIN</h1>
180
+ <p class="text-[10px] font-bold uppercase tracking-[0.4em] text-gray-400">Next-Gen Generative Interface
181
+ </p>
182
+ </div>
183
+ <nav class="hidden md:flex gap-8 text-[11px] font-bold uppercase tracking-widest text-gray-400">
184
+ <span class="text-black border-b-2 border-black pb-1">Create</span>
185
+ </nav>
186
+ </header>
187
+
188
+ <main class="grid grid-cols-1 lg:grid-cols-12 gap-12">
189
+ <div class="lg:col-span-5 space-y-10">
190
+ <section class="space-y-4">
191
+ <div class="flex items-center gap-2 text-gray-400">
192
+ <i data-lucide="terminal" class="w-3.5 h-3.5"></i>
193
+ <span class="text-[10px] font-black uppercase tracking-widest">Input Prompt</span>
194
+ </div>
195
+ <textarea id="promptInput" rows="5"
196
+ class="nano-input w-full p-6 rounded-3xl text-sm outline-none resize-none placeholder-gray-300"
197
+ placeholder="Describe your vision here..."></textarea>
198
+ </section>
199
+
200
+ <section class="space-y-4">
201
+ <div class="flex items-center gap-2 text-gray-400">
202
+ <i data-lucide="image" class="w-3.5 h-3.5"></i>
203
+ <span class="text-[10px] font-black uppercase tracking-widest">Reference Layers</span>
204
+ </div>
205
+ <div class="grid grid-cols-3 gap-4">
206
+ <div id="drop-zone-1" onclick="document.getElementById('file1').click()"
207
+ class="upload-item group aspect-square rounded-2xl flex flex-col items-center justify-center cursor-pointer">
208
+ <input type="file" id="file1" class="hidden" onchange="handleFile(this.files[0], 1)">
209
+ <i data-lucide="plus"
210
+ class="w-5 h-5 text-gray-300 group-hover:text-black transition-colors"></i>
211
+ <span class="text-[9px] mt-2 font-bold text-gray-400 uppercase">Main</span>
212
+ <img id="prev1" class="preview-img hidden">
213
+ <button id="del1" onclick="clearSlot(1, event)"
214
+ class="hidden absolute top-2 right-2 w-6 h-6 bg-white/90 rounded-full shadow-sm z-10 flex items-center justify-center text-xs">×</button>
215
+ </div>
216
+ <div id="drop-zone-2" onclick="document.getElementById('file2').click()"
217
+ class="upload-item group aspect-square rounded-2xl flex flex-col items-center justify-center cursor-pointer">
218
+ <input type="file" id="file2" class="hidden" onchange="handleFile(this.files[0], 2)">
219
+ <i data-lucide="plus"
220
+ class="w-5 h-5 text-gray-300 group-hover:text-black transition-colors"></i>
221
+ <span class="text-[9px] mt-2 font-bold text-gray-400 uppercase">Aux A</span>
222
+ <img id="prev2" class="preview-img hidden">
223
+ <button id="del2" onclick="clearSlot(2, event)"
224
+ class="hidden absolute top-2 right-2 w-6 h-6 bg-white/90 rounded-full shadow-sm z-10 flex items-center justify-center text-xs">×</button>
225
+ </div>
226
+ <div id="drop-zone-3" onclick="document.getElementById('file3').click()"
227
+ class="upload-item group aspect-square rounded-2xl flex flex-col items-center justify-center cursor-pointer">
228
+ <input type="file" id="file3" class="hidden" onchange="handleFile(this.files[0], 3)">
229
+ <i data-lucide="plus"
230
+ class="w-5 h-5 text-gray-300 group-hover:text-black transition-colors"></i>
231
+ <span class="text-[9px] mt-2 font-bold text-gray-400 uppercase">Aux B</span>
232
+ <img id="prev3" class="preview-img hidden">
233
+ <button id="del3" onclick="clearSlot(3, event)"
234
+ class="hidden absolute top-2 right-2 w-6 h-6 bg-white/90 rounded-full shadow-sm z-10 flex items-center justify-center text-xs">×</button>
235
+ </div>
236
+ </div>
237
+ </section>
238
+
239
+ <button id="genBtn" onclick="submitWorkflow()"
240
+ class="glass-btn w-full py-5 text-white rounded-xl font-bold flex items-center justify-center gap-3 shadow-lg">
241
+ <i data-lucide="zap" id="btnIcon" class="w-4 h-4 text-yellow-400"></i>
242
+ <span id="btnText" class="tracking-[0.3em] text-[11px] uppercase">Execute Synthesis</span>
243
+ </button>
244
+ </div>
245
+
246
+ <div class="lg:col-span-7">
247
+ <div id="resultBox"
248
+ class="result-frame min-h-[500px] lg:h-full flex items-center justify-center relative overflow-hidden group">
249
+ <div id="placeholder" class="text-center space-y-4 opacity-20">
250
+ <i data-lucide="layout" class="w-12 h-12 mx-auto stroke-[1px]"></i>
251
+ <p class="text-[10px] font-black tracking-[0.5em] uppercase">Canvas Ready</p>
252
+ </div>
253
+
254
+ <div id="loader" class="hidden text-center space-y-4">
255
+ <div
256
+ class="w-8 h-8 border-2 border-black border-t-transparent rounded-full animate-spin mx-auto">
257
+ </div>
258
+ <p class="text-[10px] font-black tracking-[0.5em] uppercase">Synthesizing</p>
259
+ </div>
260
+
261
+ <img id="outputImg"
262
+ class="hidden w-full h-full object-contain p-8 cursor-zoom-in transition-all duration-700"
263
+ onclick="zoomImage()">
264
+
265
+ <a id="downloadBtn" href="#" download
266
+ class="hidden absolute top-8 right-8 w-12 h-12 bg-white/90 backdrop-blur-md shadow-2xl rounded-2xl flex items-center justify-center hover:bg-black hover:text-white active:scale-95 transition-all">
267
+ <i data-lucide="download" class="w-4 h-4"></i>
268
+ </a>
269
+ </div>
270
+ </div>
271
+ </main>
272
+
273
+ <section class="mt-32">
274
+ <div class="flex items-center gap-6 mb-12">
275
+ <h2 class="text-[11px] font-black uppercase tracking-[0.5em]">Archives</h2>
276
+ <div class="h-px flex-1 bg-gray-100"></div>
277
+ </div>
278
+ <div id="masonry" class="masonry-grid"></div>
279
+ <div id="loadMoreTrigger"
280
+ class="py-20 text-center text-gray-300 text-[10px] font-bold uppercase tracking-widest cursor-pointer hover:text-black transition-colors">
281
+ Load More Archive
282
+ </div>
283
+ </section>
284
+ </div>
285
+
286
+ <div id="lightbox" onclick="handleOutsideClick(event)"
287
+ class="hidden fixed inset-0 z-50 flex items-center justify-center p-6 bg-white/95 backdrop-blur-3xl">
288
+ <div class="max-w-6xl w-full flex flex-col items-center relative">
289
+
290
+ <div class="relative w-full flex justify-center mb-8">
291
+ <div id="compareContainer"
292
+ class="hidden relative w-full h-[75vh] rounded-[2.5rem] overflow-hidden shadow-2xl bg-[#fafafa]">
293
+ <img id="compareGenerated" class="absolute inset-0 w-full h-full object-contain">
294
+ <div id="compareOriginalWrapper"
295
+ class="absolute inset-0 w-full h-full overflow-hidden border-r-2 border-white/50">
296
+ <img id="compareOriginal" class="absolute inset-0 w-full h-full object-contain">
297
+ </div>
298
+ <div id="compareSlider" class="absolute inset-y-0 left-1/2 w-0.5 bg-white z-20 cursor-ew-resize">
299
+ <div
300
+ class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-10 h-10 bg-white shadow-2xl rounded-full flex items-center justify-center border border-gray-100">
301
+ <i data-lucide="move-horizontal" class="w-4 h-4 text-black"></i>
302
+ </div>
303
+ </div>
304
+ </div>
305
+
306
+ <img id="lightboxImg" src="" class="hidden max-h-[75vh] rounded-[2.5rem] shadow-2xl object-contain">
307
+
308
+ <div id="lightboxRes"
309
+ class="absolute top-6 left-6 bg-black/30 backdrop-blur-md border border-white/20 text-white px-3 py-1.5 rounded-full text-[10px] font-medium tracking-wider opacity-0 transition-opacity duration-300 pointer-events-none z-30">
310
+ </div>
311
+
312
+ <button onclick="downloadLightboxImage()"
313
+ class="absolute top-6 right-6 bg-black text-white w-12 h-12 rounded-2xl flex items-center justify-center shadow-2xl z-30 hover:scale-105 transition-transform">
314
+ <i data-lucide="download" class="w-5 h-5"></i>
315
+ </button>
316
+ </div>
317
+
318
+ <div
319
+ class="w-full bg-white border border-gray-100 rounded-[2rem] p-8 shadow-sm flex justify-between items-center gap-8">
320
+ <div class="flex-1">
321
+ <span class="text-[9px] font-black text-gray-300 uppercase tracking-widest block mb-2">Prompt
322
+ Execution</span>
323
+ <p id="lightboxPrompt" class="text-gray-700 text-sm leading-relaxed"></p>
324
+ </div>
325
+ <button id="sameStyleBtn" onclick="applySameStyle()"
326
+ class="hidden whitespace-nowrap bg-black text-white px-8 py-3.5 rounded-2xl text-[10px] font-black uppercase tracking-widest hover:bg-gray-800 transition-all active:scale-95 flex items-center gap-2">
327
+ <i data-lucide="copy" class="w-4 h-4"></i> Replicate
328
+ </button>
329
+ </div>
330
+
331
+ <button onclick="closeLightbox()"
332
+ class="absolute -top-12 -right-12 p-4 text-gray-400 hover:text-black transition-colors">
333
+ <i data-lucide="x" class="w-8 h-8"></i>
334
+ </button>
335
+ </div>
336
+ </div>
337
+
338
+ <script>
339
+ lucide.createIcons();
340
+ function generateUUID() {
341
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
342
+ try { return crypto.randomUUID(); } catch (e) { }
343
+ }
344
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
345
+ var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
346
+ return v.toString(16);
347
+ });
348
+ }
349
+ const CLIENT_ID_KEY = "client_id";
350
+ let CLIENT_ID = localStorage.getItem(CLIENT_ID_KEY) || generateUUID();
351
+ localStorage.setItem(CLIENT_ID_KEY, CLIENT_ID);
352
+
353
+ let uploadedNames = { 1: "", 2: "", 3: "" };
354
+ let allHistory = [];
355
+ let currentResult = null;
356
+ let currentLightboxData = null;
357
+ let currentIndex = 0;
358
+ const PAGE_SIZE = 24;
359
+ let isLoading = false;
360
+
361
+ // 拖拽上传
362
+ let hoveredSlot = null;
363
+ [1, 2, 3].forEach(id => {
364
+ const zone = document.getElementById(`drop-zone-${id}`);
365
+ if (!zone) return;
366
+ zone.ondragover = (e) => { e.preventDefault(); zone.classList.add('drag-over'); };
367
+ zone.ondragleave = () => { zone.classList.remove('drag-over'); };
368
+ zone.ondrop = (e) => { e.preventDefault(); zone.classList.remove('drag-over'); handleFile(e.dataTransfer.files[0], id); };
369
+
370
+ // Paste support
371
+ zone.addEventListener('mouseenter', () => hoveredSlot = id);
372
+ zone.addEventListener('mouseleave', () => { if (hoveredSlot === id) hoveredSlot = null; });
373
+ });
374
+
375
+ window.addEventListener('paste', (e) => {
376
+ if (!hoveredSlot) return;
377
+ const items = (e.clipboardData || e.originalEvent.clipboardData).items;
378
+ for (let item of items) {
379
+ if (item.kind === 'file' && item.type.startsWith('image/')) {
380
+ const file = item.getAsFile();
381
+ handleFile(file, hoveredSlot);
382
+ break;
383
+ }
384
+ }
385
+ });
386
+
387
+ async function handleFile(file, index) {
388
+ if (!file) return;
389
+ const reader = new FileReader();
390
+ reader.onload = (e) => {
391
+ const prev = document.getElementById(`prev${index}`);
392
+ prev.src = e.target.result;
393
+ prev.classList.remove('hidden');
394
+ document.getElementById(`del${index}`).classList.remove('hidden');
395
+ };
396
+ reader.readAsDataURL(file);
397
+
398
+ const formData = new FormData();
399
+ formData.append('files', file);
400
+ try {
401
+ const res = await fetch('/api/upload', { method: 'POST', body: formData });
402
+ const data = await res.json();
403
+ uploadedNames[index] = data.files[0].comfy_name;
404
+ } catch (e) { uploadedNames[index] = file.name; }
405
+ }
406
+
407
+ function clearSlot(index, ev) {
408
+ if (ev) ev.stopPropagation();
409
+ const prev = document.getElementById(`prev${index}`);
410
+ prev.src = ""; prev.classList.add("hidden");
411
+ document.getElementById(`del${index}`).classList.add("hidden");
412
+ uploadedNames[index] = "";
413
+ }
414
+
415
+ async function submitWorkflow() {
416
+ if (!uploadedNames[1]) { alert("Please upload Main Image (Slot 1)"); return; }
417
+ const btn = document.getElementById('genBtn');
418
+ const loader = document.getElementById('loader');
419
+ const placeholder = document.getElementById('placeholder');
420
+ const outputImg = document.getElementById('outputImg');
421
+ const downloadBtn = document.getElementById('downloadBtn');
422
+
423
+ btn.disabled = true;
424
+ btn.style.backgroundColor = '#333';
425
+ btn.innerHTML = `<i data-lucide="zap" class="w-4 h-4 text-yellow-400 fill-yellow-400 animate-pulse"></i><span class="tracking-[0.3em] text-[11px] uppercase">Synthesizing...</span>`;
426
+ lucide.createIcons();
427
+
428
+ placeholder.classList.add('hidden');
429
+
430
+ const payload = {
431
+ prompt: document.getElementById('promptInput').value,
432
+ workflow_json: "Flux2-Klein.json",
433
+ type: "klein",
434
+ params: {
435
+ "168": { "text": document.getElementById('promptInput').value },
436
+ "158": { "noise_seed": Math.floor(Math.random() * 1000000) },
437
+ "278": { "image": uploadedNames[1] },
438
+ "270": { "image": uploadedNames[2] || "" },
439
+ "292": { "image": uploadedNames[3] || "" },
440
+ "313": { "value": uploadedNames[2] !== "" },
441
+ "314": { "value": uploadedNames[3] !== "" }
442
+ }
443
+ };
444
+
445
+ try {
446
+ const response = await fetch('/api/generate', {
447
+ method: 'POST',
448
+ headers: { 'Content-Type': 'application/json' },
449
+ body: JSON.stringify({ ...payload, client_id: CLIENT_ID })
450
+ });
451
+ const result = await response.json();
452
+ if (result.images?.[0]) {
453
+ currentResult = result;
454
+ outputImg.src = result.images[0];
455
+ outputImg.classList.remove('hidden');
456
+ downloadBtn.classList.remove('hidden');
457
+ downloadBtn.href = result.images[0];
458
+ renderImageCard(result, true);
459
+ }
460
+ } catch (err) {
461
+ alert("Generation failed");
462
+ placeholder.classList.remove('hidden');
463
+ } finally {
464
+ loader.classList.add('hidden');
465
+ btn.disabled = false;
466
+ btn.style.backgroundColor = '';
467
+ btn.innerHTML = `<i data-lucide="zap" class="w-4 h-4 text-yellow-400"></i><span class="tracking-[0.3em] text-[11px] uppercase">Execute Synthesis</span>`;
468
+ lucide.createIcons();
469
+ }
470
+ }
471
+
472
+ // --- Comparison & Lightbox Logic ---
473
+ function initCompareSlider() {
474
+ const container = document.getElementById('compareContainer');
475
+ const wrapper = document.getElementById('compareOriginalWrapper');
476
+ const slider = document.getElementById('compareSlider');
477
+ let isDragging = false;
478
+
479
+ const updateSlider = (clientX) => {
480
+ const rect = container.getBoundingClientRect();
481
+ let x = clientX - rect.left;
482
+ let percent = (x / rect.width) * 100;
483
+ percent = Math.max(0, Math.min(100, percent));
484
+ wrapper.style.clipPath = `inset(0 ${100 - percent}% 0 0)`;
485
+ slider.style.left = `${percent}%`;
486
+ };
487
+
488
+ const start = (e) => { isDragging = true; e.preventDefault(); };
489
+ const end = () => isDragging = false;
490
+ const move = (e) => {
491
+ if (!isDragging) return;
492
+ const clientX = e.touches ? e.touches[0].clientX : e.clientX;
493
+ updateSlider(clientX);
494
+ };
495
+
496
+ container.addEventListener('mousedown', (e) => { if (e.target === slider) return; updateSlider(e.clientX); start(e); });
497
+ slider.addEventListener('mousedown', start);
498
+ window.addEventListener('mouseup', end);
499
+ window.addEventListener('mousemove', move);
500
+ slider.addEventListener('touchstart', start, { passive: false });
501
+ window.addEventListener('touchend', end);
502
+ window.addEventListener('touchmove', move, { passive: false });
503
+ }
504
+ initCompareSlider();
505
+
506
+ function openLightbox(dataOrUrl) {
507
+ const lb = document.getElementById('lightbox');
508
+ const img = document.getElementById('lightboxImg');
509
+ const comp = document.getElementById('compareContainer');
510
+ const promptEl = document.getElementById('lightboxPrompt');
511
+ const sameStyleBtn = document.getElementById('sameStyleBtn');
512
+ const resPill = document.getElementById('lightboxRes');
513
+
514
+ let data = (typeof dataOrUrl === 'string') ? { images: [dataOrUrl] } : dataOrUrl;
515
+ currentLightboxData = data;
516
+ promptEl.textContent = data.prompt || "No prompt metadata found";
517
+
518
+ resPill.style.opacity = '0';
519
+ const updateRes = (target) => {
520
+ if (target.naturalWidth) {
521
+ resPill.innerText = `${target.naturalWidth} x ${target.naturalHeight}`;
522
+ resPill.style.opacity = '1';
523
+ }
524
+ };
525
+
526
+ if (data.params?.["278"]?.image) {
527
+ img.classList.add('hidden');
528
+ comp.classList.remove('hidden');
529
+ const genImg = document.getElementById('compareGenerated');
530
+ genImg.src = data.images[0];
531
+ document.getElementById('compareOriginal').src = `/api/view?filename=${encodeURIComponent(data.params["278"].image)}&type=input`;
532
+ document.getElementById('compareOriginalWrapper').style.clipPath = 'inset(0 50% 0 0)';
533
+ document.getElementById('compareSlider').style.left = '50%';
534
+
535
+ genImg.onload = () => updateRes(genImg);
536
+ if (genImg.complete) updateRes(genImg);
537
+ } else {
538
+ comp.classList.add('hidden');
539
+ img.classList.remove('hidden');
540
+ img.src = data.images[0];
541
+
542
+ img.onload = () => updateRes(img);
543
+ if (img.complete) updateRes(img);
544
+ }
545
+
546
+ sameStyleBtn.classList.toggle('hidden', !data.params);
547
+ lb.classList.replace('hidden', 'flex');
548
+ document.body.style.overflow = 'hidden';
549
+ }
550
+
551
+ function closeLightbox() {
552
+ document.getElementById('lightbox').classList.replace('flex', 'hidden');
553
+ document.body.style.overflow = 'auto';
554
+ }
555
+
556
+ function handleOutsideClick(e) { if (e.target.id === 'lightbox') closeLightbox(); }
557
+
558
+ function renderImageCard(data, isNew = false) {
559
+ const masonry = document.getElementById('masonry');
560
+ if (document.getElementById(`history-${data.timestamp}`)) return;
561
+
562
+ const card = document.createElement('div');
563
+ card.id = `history-${data.timestamp}`;
564
+ card.className = 'masonry-item group relative cursor-zoom-in';
565
+ card.onclick = () => openLightbox(data);
566
+
567
+ card.innerHTML = `
568
+ <img src="${data.images[0]}" class="w-full h-full object-cover block transform group-hover:scale-105 transition-transform duration-1000" loading="lazy">
569
+ <button onclick="deleteHistoryItem('${data.timestamp}', event)" class="absolute top-4 right-4 text-white hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity z-10">
570
+ <i data-lucide="trash-2" class="w-4 h-4"></i>
571
+ </button>
572
+ <div class="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-all p-6 flex flex-col justify-end">
573
+ <p class="text-white text-[10px] font-medium line-clamp-2 uppercase tracking-wider">${data.prompt || "Klein Archive"}</p>
574
+ </div>
575
+ `;
576
+ isNew ? masonry.prepend(card) : masonry.appendChild(card);
577
+ lucide.createIcons();
578
+ }
579
+
580
+ async function applySameStyle() {
581
+ if (!currentLightboxData?.params) return;
582
+ document.getElementById('promptInput').value = currentLightboxData.prompt || "";
583
+ const params = currentLightboxData.params;
584
+
585
+ const setSlot = (slotId, nodeId) => {
586
+ if (params[nodeId]?.image) {
587
+ const fname = params[nodeId].image;
588
+ uploadedNames[slotId] = fname;
589
+ const prev = document.getElementById(`prev${slotId}`);
590
+ prev.src = `/api/view?filename=${encodeURIComponent(fname)}&type=input`;
591
+ prev.classList.remove('hidden');
592
+ document.getElementById(`del${slotId}`).classList.remove('hidden');
593
+ } else { clearSlot(slotId); }
594
+ };
595
+
596
+ setSlot(1, "278"); setSlot(2, "270"); setSlot(3, "292");
597
+ closeLightbox();
598
+ window.scrollTo({ top: 0, behavior: 'smooth' });
599
+ }
600
+
601
+ async function loadHistory(page = 0) {
602
+ if (isLoading) return;
603
+ const loader = document.getElementById('loadMoreTrigger');
604
+
605
+ try {
606
+ isLoading = true;
607
+ if (page === 0) {
608
+ loader.classList.remove('hidden');
609
+ loader.innerText = "Loading Archives...";
610
+
611
+ const res = await fetch('/api/history?type=klein');
612
+ allHistory = await res.json();
613
+ document.getElementById('masonry').innerHTML = '';
614
+ currentIndex = 0;
615
+ } else {
616
+ loader.innerText = "Loading...";
617
+ await new Promise(r => setTimeout(r, 400));
618
+ }
619
+
620
+ const nextData = allHistory.slice(currentIndex, currentIndex + PAGE_SIZE);
621
+ nextData.forEach(item => renderImageCard(item));
622
+ currentIndex += nextData.length;
623
+
624
+ if (currentIndex >= allHistory.length) {
625
+ loader.classList.add('hidden');
626
+ } else {
627
+ loader.classList.remove('hidden');
628
+ loader.innerText = "Load More Archive";
629
+ }
630
+ } catch (e) {
631
+ console.error(e);
632
+ loader.textContent = "Error loading history";
633
+ } finally {
634
+ isLoading = false;
635
+ }
636
+ }
637
+
638
+ function zoomImage() {
639
+ if (currentResult) openLightbox(currentResult);
640
+ }
641
+
642
+ function downloadLightboxImage() {
643
+ const url = currentLightboxData?.images[0];
644
+ if (!url) return;
645
+ const link = document.createElement('a');
646
+ link.href = url;
647
+ link.download = `Klein-${Date.now()}.png`;
648
+ link.click();
649
+ }
650
+
651
+ async function deleteHistoryItem(ts, ev) {
652
+ ev.stopPropagation();
653
+ if (!confirm("Delete this archive?")) return;
654
+ try {
655
+ const res = await fetch('/api/history/delete', {
656
+ method: 'POST',
657
+ headers: { 'Content-Type': 'application/json' },
658
+ body: JSON.stringify({ timestamp: ts })
659
+ });
660
+ if ((await res.json()).success) {
661
+ document.getElementById(`history-${ts}`).remove();
662
+ }
663
+ } catch (e) { alert("Delete failed"); }
664
+ }
665
+
666
+ // Infinite Scroll Observer
667
+ const observer = new IntersectionObserver((entries) => {
668
+ if (entries[0].isIntersecting && !isLoading && currentIndex < allHistory.length) {
669
+ loadHistory(1);
670
+ }
671
+ }, { threshold: 0.1 });
672
+
673
+ window.onload = () => {
674
+ loadHistory(0).then(() => {
675
+ const trigger = document.getElementById('loadMoreTrigger');
676
+ if (trigger) {
677
+ observer.observe(trigger);
678
+ trigger.onclick = () => loadHistory(1);
679
+ }
680
+ });
681
+ };
682
+ </script>
683
+ </body>
684
+
685
+ </html>
static/login.html ADDED
@@ -0,0 +1,292 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title>Impact & Bound Square - Enhanced</title>
6
+ <style>
7
+ body {
8
+ margin: 0;
9
+ overflow: hidden;
10
+ background-color: #000;
11
+ font-family: 'Inter', -apple-system, sans-serif;
12
+ }
13
+ canvas { display: block; }
14
+
15
+ #loginForm {
16
+ position: absolute;
17
+ top: 50%;
18
+ left: 50%;
19
+ transform: translate(-50%, -40%);
20
+ opacity: 0;
21
+ visibility: hidden;
22
+ transition: all 0.8s cubic-bezier(0.16, 1, 0.3, 1);
23
+ z-index: 10;
24
+ width: 280px;
25
+ padding: 45px;
26
+ background: rgba(255, 255, 255, 0.02);
27
+ backdrop-filter: blur(20px);
28
+ -webkit-backdrop-filter: blur(20px);
29
+ border-radius: 40px;
30
+ border: 1px solid rgba(255, 255, 255, 0.08);
31
+ }
32
+
33
+ #loginForm.visible {
34
+ opacity: 1;
35
+ visibility: visible;
36
+ transform: translate(-50%, -50%);
37
+ }
38
+
39
+ .label {
40
+ color: rgba(255, 255, 255, 0.3);
41
+ font-size: 10px;
42
+ letter-spacing: 4px;
43
+ margin-bottom: 8px;
44
+ text-transform: uppercase;
45
+ }
46
+
47
+ .form-group {
48
+ position: relative;
49
+ margin-bottom: 35px;
50
+ }
51
+
52
+ .form-group input {
53
+ width: 100%;
54
+ padding: 12px 0;
55
+ background: transparent;
56
+ border: none;
57
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
58
+ color: #fff;
59
+ font-size: 14px;
60
+ outline: none;
61
+ }
62
+
63
+ .form-group::after {
64
+ content: '';
65
+ position: absolute;
66
+ bottom: 0; left: 0;
67
+ width: 0; height: 1px;
68
+ background: #fff;
69
+ transition: width 0.6s ease;
70
+ }
71
+
72
+ .form-group input:focus ~ ::after { width: 100%; }
73
+
74
+ #loginButton {
75
+ width: 100%;
76
+ padding: 16px;
77
+ background: #fff;
78
+ border: none;
79
+ border-radius: 50px;
80
+ color: #000;
81
+ font-size: 11px;
82
+ font-weight: 800;
83
+ letter-spacing: 6px;
84
+ cursor: pointer;
85
+ transition: all 0.4s;
86
+ margin-top: 10px;
87
+ }
88
+
89
+ #loginButton:hover {
90
+ transform: scale(1.05);
91
+ box-shadow: 0 0 40px rgba(255, 255, 255, 0.4);
92
+ }
93
+
94
+ .timestamp {
95
+ position: absolute;
96
+ top: 40px;
97
+ right: 40px;
98
+ color: rgba(255, 255, 255, 0.15);
99
+ font-size: 10px;
100
+ font-family: monospace;
101
+ }
102
+ </style>
103
+ </head>
104
+ <body>
105
+ <div class="timestamp" id="timer"></div>
106
+ <canvas id="sandCanvas"></canvas>
107
+
108
+ <div id="loginForm">
109
+ <div class="form-group">
110
+ <div class="label">Identity</div>
111
+ <input type="text" id="username" placeholder=" " autocomplete="off">
112
+ </div>
113
+ <div class="form-group">
114
+ <div class="label">Access Code</div>
115
+ <input type="password" id="password" placeholder=" " autocomplete="off">
116
+ </div>
117
+ <button id="loginButton">CONNECT</button>
118
+ </div>
119
+
120
+ <script>
121
+ const canvas = document.getElementById('sandCanvas');
122
+ const ctx = canvas.getContext('2d');
123
+ const loginForm = document.getElementById('loginForm');
124
+ const timerEl = document.getElementById('timer');
125
+
126
+ let width, height, particles = [];
127
+ const particleCount = 4500;
128
+ const mouseThreshold = 220;
129
+ let isHovering = false;
130
+ let noiseTimer = 0;
131
+
132
+ function updateTimer() {
133
+ const now = new Date();
134
+ timerEl.innerText = now.toLocaleString('en-GB').toUpperCase();
135
+ }
136
+ setInterval(updateTimer, 1000);
137
+
138
+ window.addEventListener('resize', init);
139
+ window.addEventListener('mousemove', (e) => {
140
+ const dx = e.clientX - width / 2;
141
+ const dy = e.clientY - height / 2;
142
+ isHovering = Math.sqrt(dx*dx + dy*dy) < mouseThreshold;
143
+ });
144
+
145
+ function isInsideRoundedSquare(px, py, halfSide, radius) {
146
+ const dx = Math.abs(px - width / 2);
147
+ const dy = Math.abs(py - height / 2);
148
+ if (dx > halfSide || dy > halfSide) return false;
149
+ if (dx > halfSide - radius && dy > halfSide - radius) {
150
+ const cx = dx - (halfSide - radius);
151
+ const cy = dy - (halfSide - radius);
152
+ return (cx * cx + cy * cy <= radius * radius);
153
+ }
154
+ return true;
155
+ }
156
+
157
+ class Particle {
158
+ constructor() {
159
+ this.init();
160
+ }
161
+
162
+ init() {
163
+ const angle = Math.random() * Math.PI * 2;
164
+ // 重置时从中心稍外一点出生,避免堆在原点
165
+ const r = 5 + Math.random() * 15;
166
+ this.x = width / 2 + Math.cos(angle) * r;
167
+ this.y = height / 2 + Math.sin(angle) * r;
168
+ this.vx = Math.cos(angle) * 2;
169
+ this.vy = Math.sin(angle) * 2;
170
+ this.size = Math.random() * 1.6;
171
+ this.alpha = Math.random() * 0.5 + 0.2;
172
+ this.isEscaping = false;
173
+ }
174
+
175
+ update(breath) {
176
+ const dx = this.x - width / 2;
177
+ const dy = this.y - height / 2;
178
+ const dist = Math.sqrt(dx * dx + dy * dy) || 1;
179
+
180
+ if (isHovering) {
181
+ this.vx += (Math.random() - 0.5) * 4;
182
+ this.vy += (Math.random() - 0.5) * 4;
183
+ } else {
184
+ // 1. 中心核心斥力 (防止从中心穿过)
185
+ const repulsionRadius = 45;
186
+ if (dist < repulsionRadius) {
187
+ const force = (repulsionRadius - dist) / repulsionRadius;
188
+ this.vx += (dx / dist) * force * 6;
189
+ this.vy += (dy / dist) * force * 6;
190
+ }
191
+
192
+ // 2. 爆发力 (随呼吸曲线)
193
+ const pushBase = Math.pow(breath, 5) * 14;
194
+ const randomScatter = (Math.random() - 0.5) * pushBase * 0.5;
195
+ this.vx += (dx / dist) * pushBase + randomScatter;
196
+ this.vy += (dy / dist) * pushBase + randomScatter;
197
+
198
+ // 3. 抛飞逃逸逻辑:呼吸最强时极小概率触发
199
+ if (breath > 0.88 && Math.random() > 0.98) {
200
+ this.isEscaping = true;
201
+ }
202
+
203
+ // 4. 边界逻辑
204
+ const side = 180;
205
+ const cornerR = 80;
206
+
207
+ if (!this.isEscaping) {
208
+ if (!isInsideRoundedSquare(this.x + this.vx, this.y + this.vy, side, cornerR)) {
209
+ this.vx *= -0.4; // 碰撞衰减
210
+ this.vy *= -0.4;
211
+ this.vx += (Math.random() - 0.5) * 2;
212
+ this.vy += (Math.random() - 0.5) * 2;
213
+ }
214
+
215
+ // 5. 向心引力:回归到圆环
216
+ const targetR = 140;
217
+ const pull = (targetR - dist) * 0.012;
218
+ this.vx += (dx / dist) * pull;
219
+ this.vy += (dy / dist) * pull;
220
+ }
221
+ }
222
+
223
+ this.x += this.vx;
224
+ this.y += this.vy;
225
+
226
+ // 摩擦力
227
+ const friction = this.isEscaping ? 0.98 : 0.86;
228
+ this.vx *= friction;
229
+ this.vy *= friction;
230
+
231
+ // 6. 重置:飞出界外或速度几乎停滞的逃逸粒子
232
+ if (dist > width * 0.6 || (this.isEscaping && Math.abs(this.vx) < 0.05)) {
233
+ this.init();
234
+ }
235
+ }
236
+
237
+ draw(breath) {
238
+ const b = 180 + breath * 75;
239
+ const finalAlpha = this.isEscaping ? this.alpha * 0.4 : this.alpha;
240
+ ctx.fillStyle = `rgba(${b}, ${b}, ${b}, ${finalAlpha})`;
241
+ ctx.fillRect(this.x, this.y, this.size, this.size);
242
+ }
243
+ }
244
+
245
+ function init() {
246
+ width = canvas.width = window.innerWidth;
247
+ height = canvas.height = window.innerHeight;
248
+ particles = [];
249
+ for (let i = 0; i < particleCount; i++) particles.push(new Particle());
250
+ }
251
+
252
+ function drawCore(breath) {
253
+ if (isHovering) return;
254
+ const size = 10 + breath * 4;
255
+ ctx.save();
256
+ ctx.shadowBlur = 40 * breath;
257
+ ctx.shadowColor = '#fff';
258
+ ctx.fillStyle = '#fff';
259
+ ctx.beginPath();
260
+ ctx.arc(width / 2, height / 2, size, 0, Math.PI * 2);
261
+ ctx.fill();
262
+ ctx.restore();
263
+ }
264
+
265
+ function animate() {
266
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.25)';
267
+ ctx.fillRect(0, 0, width, height);
268
+
269
+ noiseTimer += 0.04;
270
+ const breath = Math.pow((Math.sin(noiseTimer) + 1) / 2, 2);
271
+
272
+ if (isHovering) {
273
+ loginForm.classList.add('visible');
274
+ } else {
275
+ loginForm.classList.remove('visible');
276
+ }
277
+
278
+ particles.forEach(p => {
279
+ p.update(breath);
280
+ p.draw(breath);
281
+ });
282
+
283
+ drawCore(breath);
284
+ requestAnimationFrame(animate);
285
+ }
286
+
287
+ init();
288
+ updateTimer();
289
+ animate();
290
+ </script>
291
+ </body>
292
+ </html>
static/logo.png ADDED
static/modelscope.gif ADDED
static/zimage.html ADDED
@@ -0,0 +1,596 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <link rel="icon" href="/static/logo.png" type="image/png">
8
+ <title>Flux Modern Gallery | Unified Console</title>
9
+ <script src="https://cdn.tailwindcss.com"></script>
10
+ <script src="https://unpkg.com/lucide@latest"></script>
11
+ <style>
12
+ :root {
13
+ --bg-base: #f8f8f8;
14
+ --text-main: #1a1a1a;
15
+ --max-w: 1280px;
16
+ --easing: cubic-bezier(0.4, 0, 0.2, 1);
17
+ --accent: #000000;
18
+ }
19
+
20
+ /* --- 极简悬浮浅灰滚动条 (无底色/左移) --- */
21
+ *::-webkit-scrollbar {
22
+ width: 10px !important;
23
+ height: 10px !important;
24
+ background: transparent !important;
25
+ }
26
+
27
+ *::-webkit-scrollbar-track {
28
+ background: transparent !important;
29
+ border: none !important;
30
+ }
31
+
32
+ *::-webkit-scrollbar-thumb {
33
+ background-color: #d8d8d8 !important;
34
+ border: 3px solid transparent !important;
35
+ border-right-width: 5px !important;
36
+ /* 增加右侧间距,使滚动条向左位移 */
37
+ background-clip: padding-box !important;
38
+ border-radius: 10px !important;
39
+ }
40
+
41
+ *::-webkit-scrollbar-thumb:hover {
42
+ background-color: #c0c0c0 !important;
43
+ }
44
+
45
+ *::-webkit-scrollbar-corner {
46
+ background: transparent !important;
47
+ }
48
+
49
+ * {
50
+ scrollbar-width: thin !important;
51
+ scrollbar-color: #d8d8d8 transparent !important;
52
+ }
53
+
54
+ body {
55
+ background-color: var(--bg-base);
56
+ font-family: "Inter", -apple-system, "PingFang SC", sans-serif;
57
+ color: var(--text-main);
58
+ -webkit-font-smoothing: antialiased;
59
+ }
60
+
61
+ .layout-container {
62
+ max-width: var(--max-w);
63
+ margin: 0 auto;
64
+ padding: 0 40px;
65
+ }
66
+
67
+ .console-card {
68
+ background: #ffffff;
69
+ border: 1px solid rgba(0, 0, 0, 0.08);
70
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.02);
71
+ }
72
+
73
+ /* 复合切换组件样式 */
74
+ .mode-switcher {
75
+ position: relative;
76
+ background: #f1f1f1;
77
+ padding: 4px;
78
+ border-radius: 14px;
79
+ display: flex;
80
+ width: 220px;
81
+ }
82
+
83
+ .mode-btn {
84
+ position: relative;
85
+ z-index: 10;
86
+ flex: 1;
87
+ padding: 8px 0;
88
+ text-align: center;
89
+ font-size: 11px;
90
+ font-weight: 800;
91
+ text-transform: uppercase;
92
+ color: #999;
93
+ transition: color 0.3s ease;
94
+ cursor: pointer;
95
+ }
96
+
97
+ .mode-btn.active {
98
+ color: #000;
99
+ }
100
+
101
+ .mode-glider {
102
+ position: absolute;
103
+ height: calc(100% - 8px);
104
+ width: calc(50% - 4px);
105
+ background: #fff;
106
+ border-radius: 11px;
107
+ box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);
108
+ transition: transform 0.3s var(--easing);
109
+ z-index: 1;
110
+ }
111
+
112
+ .masonry-grid {
113
+ display: grid;
114
+ grid-template-columns: repeat(2, 1fr);
115
+ gap: 20px;
116
+ }
117
+
118
+ @media (min-width: 768px) {
119
+ .masonry-grid {
120
+ grid-template-columns: repeat(4, 1fr);
121
+ }
122
+ }
123
+
124
+ .masonry-item {
125
+ aspect-ratio: 1 / 1;
126
+ border-radius: 24px;
127
+ overflow: hidden;
128
+ background: #eee;
129
+ border: 1px solid #f1f5f9;
130
+ transition: all 0.5s var(--easing);
131
+ position: relative;
132
+ }
133
+
134
+ .masonry-item:hover {
135
+ transform: translateY(-6px);
136
+ box-shadow: 0 20px 40px rgba(0, 0, 0, 0.08);
137
+ }
138
+
139
+ .gallery-lightbox {
140
+ background: rgba(255, 255, 255, 0.99);
141
+ }
142
+
143
+ .btn-render {
144
+ background: #000;
145
+ color: #fff;
146
+ transition: all 0.3s ease;
147
+ }
148
+
149
+ .btn-render:hover {
150
+ transform: translateY(-1px);
151
+ background: #222;
152
+ }
153
+
154
+ .btn-render:disabled {
155
+ background: #ccc;
156
+ cursor: not-allowed;
157
+ }
158
+
159
+ input::-webkit-inner-spin-button {
160
+ display: none;
161
+ }
162
+ </style>
163
+ </head>
164
+
165
+ <body class="antialiased">
166
+
167
+ <header class="pt-20 pb-12">
168
+ <div class="layout-container">
169
+ <div class="console-card rounded-3xl p-1.5">
170
+ <div class="bg-gray-50/50 rounded-[22px] p-8">
171
+ <div class="flex justify-between items-center mb-6">
172
+ <span class="text-[10px] font-bold text-gray-400 uppercase tracking-widest">Unified Art
173
+ Console</span>
174
+ <div id="statusDot" class="flex items-center gap-2">
175
+ <span id="statusText" class="text-[9px] font-bold text-gray-500 uppercase">System
176
+ Ready</span>
177
+ <div id="dotColor" class="w-1.5 h-1.5 bg-black rounded-full"></div>
178
+ </div>
179
+ </div>
180
+ <textarea id="prompt" rows="2"
181
+ class="w-full bg-transparent text-2xl font-medium outline-none placeholder:text-gray-200 text-black resize-none leading-relaxed"
182
+ placeholder="Describe your vision..."></textarea>
183
+ </div>
184
+
185
+ <div class="flex flex-col md:flex-row items-center justify-between p-4 px-6 gap-6">
186
+ <div class="flex items-center gap-8">
187
+ <div class="flex flex-col">
188
+ <span class="text-[9px] font-bold text-gray-400 uppercase mb-1">Engine Source</span>
189
+ <div class="mode-switcher">
190
+ <div id="modeLocal" class="mode-btn active flex items-center justify-center gap-1.5"
191
+ onclick="switchEngine('local')">
192
+ <i data-lucide="monitor" class="w-3 h-3"></i>
193
+ <span>Local</span>
194
+ </div>
195
+ <div id="modeCloud" class="mode-btn flex items-center justify-center"
196
+ onclick="switchEngine('cloud')">
197
+ <img src="/static/modelscope.gif"
198
+ class="h-4 object-contain opacity-50 transition-opacity group-hover:opacity-100"
199
+ style="filter: grayscale(100%);" id="msLogo">
200
+ </div>
201
+ <div id="glider" class="mode-glider"></div>
202
+ </div>
203
+ </div>
204
+
205
+ <div class="h-8 w-px bg-gray-100"></div>
206
+
207
+ <div class="flex flex-col">
208
+ <span class="text-[9px] font-bold text-gray-400 uppercase mb-1">Dimensions</span>
209
+ <div class="flex items-center gap-2 text-xs font-bold">
210
+ <input id="width" type="number" value="1024"
211
+ class="w-10 bg-transparent outline-none border-b border-transparent focus:border-black">
212
+ <span class="text-gray-200">×</span>
213
+ <input id="height" type="number" value="1024"
214
+ class="w-10 bg-transparent outline-none border-b border-transparent focus:border-black">
215
+ </div>
216
+ </div>
217
+
218
+
219
+ </div>
220
+
221
+ <button id="mainGenBtn" onclick="handleRender()"
222
+ class="w-full md:w-56 h-12 btn-render rounded-xl font-bold text-[11px] uppercase flex items-center justify-center gap-3">
223
+ <i data-lucide="zap" id="btnIcon" class="w-4 h-4 text-yellow-400"></i>
224
+ <span id="btnText">Render Art</span>
225
+ </button>
226
+ </div>
227
+ </div>
228
+ </div>
229
+ </header>
230
+
231
+ <main class="pb-24">
232
+ <div class="layout-container">
233
+ <div id="masonry" class="masonry-grid"></div>
234
+ <div id="loadMoreTrigger"
235
+ class="py-12 text-center text-gray-300 text-[10px] font-bold uppercase tracking-widest cursor-pointer hidden">
236
+ Load More Archive
237
+ </div>
238
+ </div>
239
+ </main>
240
+
241
+ <div id="lightbox" onclick="handleOutsideClick(event)"
242
+ class="hidden fixed inset-0 z-50 gallery-lightbox flex flex-col items-center justify-center p-8">
243
+ <button onclick="closeLightbox()" class="absolute top-10 right-10 text-gray-400 hover:text-black"><i
244
+ data-lucide="x" class="w-8 h-8"></i></button>
245
+ <div class="max-w-5xl w-full flex flex-col items-center pointer-events-none">
246
+ <div class="relative pointer-events-auto">
247
+ <img id="lightboxImg" src="" class="max-h-[60vh] rounded-lg shadow-xl">
248
+ <div
249
+ class="absolute top-6 left-6 bg-black/50 backdrop-blur-md text-white px-3 py-1.5 rounded-xl text-[10px] font-black tracking-widest shadow-2xl">
250
+ <span id="lightboxRes">0x0</span>
251
+ </div>
252
+ <button onclick="downloadImage()"
253
+ class="absolute top-6 right-6 bg-black text-white w-12 h-12 rounded-2xl flex items-center justify-center shadow-2xl hover:scale-105 transition-transform">
254
+ <i data-lucide="download" class="w-5 h-5"></i>
255
+ </button>
256
+ </div>
257
+ <div id="lightboxCard"
258
+ class="w-full mt-16 pointer-events-auto bg-white border border-gray-100 rounded-[2rem] p-8 shadow-sm flex justify-between items-center gap-8">
259
+ <div class="flex-1">
260
+ <span class="text-[9px] font-black text-gray-300 uppercase tracking-widest block mb-2">Prompt
261
+ Execution</span>
262
+ <p id="lightboxPrompt" class="text-gray-700 text-sm leading-relaxed max-h-32 overflow-y-auto pr-2">
263
+ </p>
264
+ </div>
265
+ <button onclick="applySameStyle()"
266
+ class="bg-black text-white px-8 py-3.5 rounded-2xl text-[10px] font-black uppercase tracking-widest flex items-center gap-2">
267
+ <i data-lucide="copy" class="w-3 h-3"></i>
268
+ <span>Replicate</span>
269
+ </button>
270
+ </div>
271
+ </div>
272
+ </div>
273
+
274
+ <script>
275
+ lucide.createIcons();
276
+
277
+ function generateUUID() {
278
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
279
+ try { return crypto.randomUUID(); } catch (e) { }
280
+ }
281
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
282
+ var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
283
+ return v.toString(16);
284
+ });
285
+ }
286
+ const CLIENT_ID = localStorage.getItem("client_id") || generateUUID();
287
+ localStorage.setItem("client_id", CLIENT_ID);
288
+
289
+ let allHistory = [];
290
+ let currentIndex = 0;
291
+ const PAGE_SIZE = 15;
292
+ let isLoading = false;
293
+
294
+ // WebSocket
295
+ const socket = new WebSocket(`${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws/stats`);
296
+ socket.onmessage = (e) => {
297
+ try {
298
+ const msg = JSON.parse(e.data);
299
+ if (msg.type === 'new_image' && msg.data?.type === 'zimage') {
300
+ if (!document.getElementById(`history-${msg.data.timestamp}`)) {
301
+ allHistory.unshift(msg.data);
302
+ renderImageCard(msg.data, true);
303
+ currentIndex++;
304
+ }
305
+ }
306
+ } catch (err) { }
307
+ };
308
+
309
+ let currentEngine = 'local';
310
+ const MS_TOKEN_KEY = 'modelscope_api_token';
311
+ const ENGINE_MODE_KEY = 'zimage_engine_mode';
312
+
313
+ // 1. Token handled via index.html sidebar
314
+
315
+ // 2. 切换引擎逻辑
316
+ function switchEngine(mode) {
317
+ currentEngine = mode;
318
+ localStorage.setItem(ENGINE_MODE_KEY, mode); // Save state
319
+
320
+ const glider = document.getElementById('glider');
321
+ const localBtn = document.getElementById('modeLocal');
322
+ const cloudBtn = document.getElementById('modeCloud');
323
+ const btnText = document.getElementById('btnText');
324
+ const msLogo = document.getElementById('msLogo');
325
+
326
+ if (mode === 'local') {
327
+ glider.style.transform = 'translateX(0)';
328
+ localBtn.classList.add('active');
329
+ cloudBtn.classList.remove('active');
330
+ btnText.innerText = 'Render Art (Local)';
331
+
332
+ if (msLogo) {
333
+ msLogo.classList.add('opacity-50');
334
+ msLogo.style.filter = 'grayscale(100%)';
335
+ }
336
+ } else {
337
+ glider.style.transform = 'translateX(100%)';
338
+ cloudBtn.classList.add('active');
339
+ localBtn.classList.remove('active');
340
+ btnText.innerText = 'Render Art (Cloud)';
341
+
342
+ if (msLogo) {
343
+ msLogo.classList.remove('opacity-50');
344
+ msLogo.style.filter = 'none';
345
+ }
346
+ }
347
+ }
348
+
349
+ // Initialize Engine Mode
350
+ const savedEngine = localStorage.getItem(ENGINE_MODE_KEY);
351
+ if (savedEngine && savedEngine !== 'local') {
352
+ switchEngine(savedEngine);
353
+ }
354
+
355
+ // 3. 统一渲染入口
356
+ async function handleRender() {
357
+ const prompt = document.getElementById('prompt').value.trim();
358
+ if (!prompt) return alert("Please enter a prompt");
359
+
360
+ if (currentEngine === 'local') {
361
+ runLocalTask(prompt);
362
+ } else {
363
+ runCloudTask(prompt);
364
+ }
365
+ }
366
+
367
+ // 4. ModelScope 云端逻辑
368
+ async function runCloudTask(prompt) {
369
+ let apiKey = localStorage.getItem(MS_TOKEN_KEY);
370
+ if (!apiKey) {
371
+ try {
372
+ const res = await fetch('/api/config/token');
373
+ const data = await res.json();
374
+ if (data.token) apiKey = data.token;
375
+ } catch (e) { }
376
+ }
377
+ if (!apiKey) return alert("ModelScope Token Required. Please set it in the sidebar (API Token).");
378
+
379
+ const btn = document.getElementById('mainGenBtn');
380
+ setLoading(true);
381
+
382
+ const placeholder = createPlaceholder("ModelScope Rendering");
383
+ document.getElementById('masonry').prepend(placeholder);
384
+
385
+ try {
386
+ const res = await fetch('/generate', {
387
+ method: 'POST',
388
+ headers: { 'Content-Type': 'application/json' },
389
+ body: JSON.stringify({
390
+ prompt: prompt,
391
+ api_key: apiKey,
392
+ resolution: `${document.getElementById('width').value}x${document.getElementById('height').value}`
393
+ })
394
+ });
395
+ const data = await res.json();
396
+ placeholder.remove();
397
+ if (res.ok && data.url) {
398
+ renderImageCard({ timestamp: Date.now(), prompt, images: [data.url], type: 'cloud' }, true);
399
+ } else {
400
+ throw new Error(data.detail?.errors?.message || data.detail || "Cloud Error");
401
+ }
402
+ } catch (e) {
403
+ placeholder.remove();
404
+ alert(e.message);
405
+ } finally {
406
+ setLoading(false);
407
+ }
408
+ }
409
+
410
+ // 5. 本地任务逻辑
411
+ async function runLocalTask(prompt) {
412
+ setLoading(true);
413
+ const placeholder = createPlaceholder("Local Rendering");
414
+ document.getElementById('masonry').prepend(placeholder);
415
+
416
+ try {
417
+ const res = await fetch('/api/generate', {
418
+ method: 'POST',
419
+ headers: { 'Content-Type': 'application/json' },
420
+ body: JSON.stringify({
421
+ prompt,
422
+ width: parseInt(document.getElementById('width').value),
423
+ height: parseInt(document.getElementById('height').value),
424
+ type: "zimage",
425
+ client_id: CLIENT_ID
426
+ })
427
+ });
428
+ const data = await res.json();
429
+ placeholder.remove();
430
+ if (data.images?.length > 0) renderImageCard(data, true);
431
+ } catch (e) {
432
+ placeholder.remove();
433
+ alert("Local render failed");
434
+ } finally {
435
+ setLoading(false);
436
+ }
437
+ }
438
+
439
+ // 工具函数
440
+ function setLoading(isLoading) {
441
+ const btn = document.getElementById('mainGenBtn');
442
+ btn.disabled = isLoading;
443
+
444
+ if (isLoading) {
445
+ // Active state: Dark gray bg, filled yellow pulsing icon
446
+ btn.style.backgroundColor = '#333';
447
+ btn.innerHTML = `<i data-lucide="zap" class="w-4 h-4 text-yellow-400 fill-yellow-400 animate-pulse"></i><span>Processing...</span>`;
448
+ } else {
449
+ // Reset state: Original bg (via CSS), outline yellow icon
450
+ btn.style.backgroundColor = '';
451
+ btn.innerHTML = `<i data-lucide="zap" class="w-4 h-4 text-yellow-400"></i><span id="btnText">Render Art (${currentEngine.toUpperCase()})</span>`;
452
+ }
453
+ lucide.createIcons();
454
+ }
455
+
456
+ function createPlaceholder(text) {
457
+ const div = document.createElement('div');
458
+ div.className = 'masonry-item bg-gray-50 flex flex-col items-center justify-center border-dashed border-2 border-gray-200';
459
+ div.innerHTML = `<div class="w-6 h-6 border-2 border-gray-100 border-t-black rounded-full animate-spin mb-3"></div><span class="text-[8px] font-bold text-gray-400 uppercase tracking-tighter">${text}</span>`;
460
+ return div;
461
+ }
462
+
463
+ function renderImageCard(data, isNew = false) {
464
+ if (document.getElementById(`history-${data.timestamp}`)) return;
465
+ const masonry = document.getElementById('masonry');
466
+ const card = document.createElement('div');
467
+ card.id = `history-${data.timestamp}`;
468
+ card.className = 'masonry-item group cursor-zoom-in animate-in fade-in duration-700';
469
+ card.onclick = () => openLightbox(data.images[0], data.prompt);
470
+ card.innerHTML = `
471
+ <img src="${data.images[0]}" class="w-full h-full object-cover" loading="lazy">
472
+ ${data.type === 'cloud' ? '<div class="absolute top-3 left-3 z-10"><img src="/static/modelscope.gif" class="h-4 w-auto object-contain bg-white/90 rounded-full p-0.5 shadow-sm"></div>' : ''}
473
+ <div class="absolute top-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity z-10">
474
+ <button onclick="deleteHistoryItem('${data.timestamp}', event)" class="w-8 h-8 bg-white/90 backdrop-blur text-black hover:bg-black hover:text-white rounded-lg flex items-center justify-center transition-colors">
475
+ <i data-lucide="trash-2" class="w-4 h-4"></i>
476
+ </button>
477
+ </div>
478
+ `;
479
+ isNew ? masonry.prepend(card) : masonry.appendChild(card);
480
+ lucide.createIcons();
481
+ }
482
+
483
+ async function loadHistory(page = 0) {
484
+ if (isLoading) return;
485
+ const trigger = document.getElementById('loadMoreTrigger');
486
+
487
+ try {
488
+ isLoading = true;
489
+ if (page === 0) {
490
+ allHistory = [];
491
+ document.getElementById('masonry').innerHTML = '';
492
+ currentIndex = 0;
493
+
494
+ trigger.classList.remove('hidden');
495
+ trigger.innerText = "Loading Archives...";
496
+
497
+ const res = await fetch('/api/history?type=zimage');
498
+ allHistory = await res.json();
499
+ } else {
500
+ trigger.innerText = "Loading...";
501
+ await new Promise(r => setTimeout(r, 400));
502
+ }
503
+
504
+ const nextData = allHistory.slice(currentIndex, currentIndex + PAGE_SIZE);
505
+ nextData.forEach(item => renderImageCard(item));
506
+ currentIndex += nextData.length;
507
+
508
+ if (currentIndex >= allHistory.length) {
509
+ trigger.classList.add('hidden');
510
+ } else {
511
+ trigger.classList.remove('hidden');
512
+ trigger.innerText = "Load More Archive";
513
+ }
514
+
515
+ } catch (e) {
516
+ console.error(e);
517
+ trigger.innerText = "Error Loading History";
518
+ } finally {
519
+ isLoading = false;
520
+ }
521
+ }
522
+
523
+ async function deleteHistoryItem(timestamp, event) {
524
+ event.stopPropagation();
525
+ const res = await fetch('/api/history/delete', {
526
+ method: 'POST',
527
+ headers: { 'Content-Type': 'application/json' },
528
+ body: JSON.stringify({ timestamp })
529
+ });
530
+ if ((await res.json()).success) document.getElementById(`history-${timestamp}`).remove();
531
+ }
532
+
533
+ const observer = new IntersectionObserver((entries) => {
534
+ if (entries[0].isIntersecting && !isLoading && currentIndex < allHistory.length) {
535
+ loadHistory(1);
536
+ }
537
+ }, { threshold: 0.1 });
538
+
539
+ window.onload = () => {
540
+ loadHistory(0).then(() => {
541
+ const trigger = document.getElementById('loadMoreTrigger');
542
+ if (trigger) {
543
+ observer.observe(trigger);
544
+ trigger.onclick = () => loadHistory(1);
545
+ }
546
+ });
547
+
548
+ setInterval(async () => {
549
+ try {
550
+ const res = await fetch("/api/queue_status?client_id=" + encodeURIComponent(CLIENT_ID));
551
+ const data = await res.json();
552
+ const statusText = document.getElementById("statusText");
553
+ const dotColor = document.getElementById("dotColor");
554
+ if (statusText && dotColor) {
555
+ if (data.total > 0) {
556
+ statusText.innerText = `Queueing ${data.position}/${data.total}`;
557
+ statusText.className = "text-[9px] font-bold text-orange-500 uppercase";
558
+ dotColor.className = "w-1.5 h-1.5 bg-orange-500 rounded-full animate-pulse";
559
+ } else {
560
+ statusText.innerText = "System Ready";
561
+ statusText.className = "text-[9px] font-bold text-gray-500 uppercase";
562
+ dotColor.className = "w-1.5 h-1.5 bg-black rounded-full";
563
+ }
564
+ }
565
+ } catch (e) { }
566
+ }, 3000);
567
+ };
568
+
569
+ // 基础功能
570
+ function openLightbox(url, prompt) {
571
+ const img = document.getElementById('lightboxImg');
572
+ const resDisplay = document.getElementById('lightboxRes');
573
+ resDisplay.innerText = "...";
574
+ img.src = url;
575
+ img.onload = () => {
576
+ resDisplay.innerText = `${img.naturalWidth} x ${img.naturalHeight}`;
577
+ };
578
+ document.getElementById('lightboxPrompt').innerText = prompt;
579
+ document.getElementById('lightbox').classList.remove('hidden');
580
+ document.body.style.overflow = 'hidden';
581
+ }
582
+ function closeLightbox() { document.getElementById('lightbox').classList.add('hidden'); document.body.style.overflow = 'auto'; }
583
+ function handleOutsideClick(e) { if (e.target.id === 'lightbox') closeLightbox(); }
584
+ function downloadImage() {
585
+ const a = document.createElement('a'); a.href = document.getElementById('lightboxImg').src;
586
+ a.download = `Art-${Date.now()}.png`; a.click();
587
+ }
588
+ function applySameStyle() {
589
+ document.getElementById('prompt').value = document.getElementById('lightboxPrompt').innerText;
590
+ closeLightbox();
591
+ window.scrollTo({ top: 0, behavior: 'smooth' });
592
+ }
593
+ </script>
594
+ </body>
595
+
596
+ </html>