dx8152 commited on
Commit
4039687
·
verified ·
1 Parent(s): 3c190d8

Upload 19 files

Browse files
.vscode/settings.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ {
2
+ "files.defaultLanguage": "chinese"
3
+ }
__pycache__/main.cpython-313.pyc ADDED
Binary file (31.6 kB). View file
 
global_config.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ {
2
+ "modelscope_token": ""
3
+ }
history.json.bak ADDED
The diff for this file is too large to render. See raw diff
 
main.py ADDED
@@ -0,0 +1,700 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import uuid
3
+ import urllib.request
4
+ import urllib.parse
5
+ import urllib.error
6
+ import os
7
+ import random
8
+ import time
9
+ import shutil
10
+ import asyncio
11
+ import requests
12
+ import httpx
13
+ from typing import List, Dict, Any
14
+ from threading import Lock
15
+ from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect, UploadFile, File
16
+ from fastapi.staticfiles import StaticFiles
17
+ from fastapi.responses import FileResponse, Response
18
+ from pydantic import BaseModel
19
+ from fastapi.middleware.cors import CORSMiddleware
20
+
21
+ app = FastAPI()
22
+
23
+ # 允许跨域
24
+ app.add_middleware(
25
+ CORSMiddleware,
26
+ allow_origins=["*"],
27
+ allow_methods=["*"],
28
+ allow_headers=["*"],
29
+ )
30
+
31
+ # --- WebSocket 状态管理器 ---
32
+ class ConnectionManager:
33
+ def __init__(self):
34
+ self.active_connections: List[WebSocket] = []
35
+
36
+ async def connect(self, websocket: WebSocket):
37
+ await websocket.accept()
38
+ self.active_connections.append(websocket)
39
+ print(f"WS Connected. Total: {len(self.active_connections)}")
40
+ await self.broadcast_count()
41
+
42
+ async def disconnect(self, websocket: WebSocket):
43
+ if websocket in self.active_connections:
44
+ self.active_connections.remove(websocket)
45
+ print(f"WS Disconnected. Total: {len(self.active_connections)}")
46
+ await self.broadcast_count()
47
+
48
+ async def broadcast_count(self):
49
+ count = len(self.active_connections)
50
+ data = json.dumps({"type": "stats", "online_count": count})
51
+ print(f"Broadcasting online count: {count}")
52
+ # 创建副本进行遍历,防止遍历时修改列表
53
+ for connection in self.active_connections[:]:
54
+ try:
55
+ await connection.send_text(data)
56
+ except Exception as e:
57
+ print(f"Broadcast error for client {id(connection)}: {e}")
58
+ self.active_connections.remove(connection)
59
+
60
+ async def broadcast_new_image(self, image_data: dict):
61
+ """广播新生成的图片数据给所有客户端"""
62
+ data = json.dumps({"type": "new_image", "data": image_data})
63
+ print(f"Broadcasting new image to {len(self.active_connections)} clients")
64
+ for connection in self.active_connections[:]:
65
+ try:
66
+ await connection.send_text(data)
67
+ except Exception as e:
68
+ print(f"Broadcast image error for client {id(connection)}: {e}")
69
+ self.active_connections.remove(connection)
70
+
71
+ manager = ConnectionManager()
72
+
73
+ # 全局事件循环引用
74
+ GLOBAL_LOOP = None
75
+
76
+ @app.on_event("startup")
77
+ async def startup_event():
78
+ global GLOBAL_LOOP
79
+ GLOBAL_LOOP = asyncio.get_running_loop()
80
+
81
+ @app.websocket("/ws/stats")
82
+ async def websocket_endpoint(websocket: WebSocket):
83
+ await manager.connect(websocket)
84
+ try:
85
+ while True:
86
+ # 接收客户端心跳包
87
+ data = await websocket.receive_text()
88
+ if data == "ping":
89
+ await websocket.send_text(json.dumps({"type": "pong"}))
90
+ except WebSocketDisconnect:
91
+ print(f"WebSocket disconnected normally: {id(websocket)}")
92
+ await manager.disconnect(websocket)
93
+ except Exception as e:
94
+ print(f"WS Error for {id(websocket)}: {e}")
95
+ await manager.disconnect(websocket)
96
+
97
+ # --- 配置区域 ---
98
+ # 支持多卡负载均衡:配置多个 ComfyUI 地址
99
+ COMFYUI_INSTANCES = [
100
+ "127.0.0.1:8188", # 本机默认端口
101
+ "127.0.0.1:4090", # 显卡分流端口
102
+ ]
103
+ # 保持向后兼容,默认使用第一个
104
+ COMFYUI_ADDRESS = COMFYUI_INSTANCES[0]
105
+
106
+ CLIENT_ID = str(uuid.uuid4())
107
+ BASE_DIR = os.path.dirname(os.path.abspath(__file__))
108
+ WORKFLOW_DIR = os.path.join(BASE_DIR, "workflows")
109
+ WORKFLOW_PATH = os.path.join(WORKFLOW_DIR, "Z-Image.json")
110
+ STATIC_DIR = os.path.join(BASE_DIR, "static")
111
+ OUTPUT_DIR = os.path.join(BASE_DIR, "output")
112
+ HISTORY_FILE = os.path.join(BASE_DIR, "history.json")
113
+ QUEUE = []
114
+ QUEUE_LOCK = Lock()
115
+ HISTORY_LOCK = Lock()
116
+ # 移除全局执行锁,允许并发以便分发到不同显卡
117
+ # EXECUTION_LOCK = Lock()
118
+ NEXT_TASK_ID = 1
119
+
120
+ # 负载均衡:本地任务计数(解决 ComfyUI 队列更新延迟导致的竞态问题)
121
+ BACKEND_LOCAL_LOAD = {addr: 0 for addr in COMFYUI_INSTANCES}
122
+ LOAD_LOCK = Lock()
123
+
124
+ # 确保必要的目录存在
125
+ os.makedirs(OUTPUT_DIR, exist_ok=True)
126
+ os.makedirs(STATIC_DIR, exist_ok=True)
127
+ os.makedirs(WORKFLOW_DIR, exist_ok=True)
128
+
129
+ GLOBAL_CONFIG_FILE = os.path.join(BASE_DIR, "global_config.json")
130
+ GLOBAL_CONFIG_LOCK = Lock()
131
+
132
+ # 挂载静态文件
133
+ app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
134
+ app.mount("/output", StaticFiles(directory=OUTPUT_DIR), name="output")
135
+
136
+ class GenerateRequest(BaseModel):
137
+ prompt: str = ""
138
+ width: int = 1024
139
+ height: int = 1024
140
+ workflow_json: str = "Z-Image.json"
141
+ params: Dict[str, Any] = {}
142
+ type: str = "zimage"
143
+ client_id: str = ""
144
+
145
+ class CloudGenRequest(BaseModel):
146
+ prompt: str
147
+ api_key: str = ""
148
+ resolution: str = "1024x1024"
149
+ client_id: str = "default"
150
+ type: str = "zimage"
151
+
152
+ class DeleteHistoryRequest(BaseModel):
153
+ timestamp: float
154
+
155
+ # --- 负载均衡辅助功能 ---
156
+
157
+ def get_best_backend():
158
+ """选择队列压力最小的后端"""
159
+ best_backend = COMFYUI_INSTANCES[0]
160
+ min_queue_size = float('inf')
161
+
162
+ for addr in COMFYUI_INSTANCES:
163
+ try:
164
+ # 获取 ComfyUI 队列状态
165
+ with urllib.request.urlopen(f"http://{addr}/queue", timeout=1) as response:
166
+ data = json.loads(response.read())
167
+ # 计算总任务数:运行中 + 等待中
168
+ remote_load = len(data.get('queue_running', [])) + len(data.get('queue_pending', []))
169
+
170
+ # 获取本地记录的负载(解决并发请求时的竞态条件)
171
+ with LOAD_LOCK:
172
+ local_load = BACKEND_LOCAL_LOAD.get(addr, 0)
173
+
174
+ # 使用两者的最大值作为有效负载
175
+ # 这样既能感知外部提交的任务(remote),也能感知刚提交但未显示的内部任务(local)
176
+ effective_load = max(remote_load, local_load)
177
+
178
+ print(f"Backend {addr} load: {effective_load} (Remote: {remote_load}, Local: {local_load})")
179
+
180
+ if effective_load < min_queue_size:
181
+ min_queue_size = effective_load
182
+ best_backend = addr
183
+ except Exception as e:
184
+ print(f"Backend {addr} unreachable: {e}")
185
+ continue
186
+
187
+ print(f"Selected backend: {best_backend}")
188
+ return best_backend
189
+
190
+ # --- 辅助功能 ---
191
+
192
+ def download_image(comfy_address, comfy_url_path, prefix="studio_"):
193
+ """将远程 ComfyUI 图片保存到本地并返回相对路径"""
194
+ filename = f"{prefix}{uuid.uuid4().hex[:10]}.png"
195
+ local_path = os.path.join(OUTPUT_DIR, filename)
196
+ full_url = f"http://{comfy_address}{comfy_url_path}"
197
+ try:
198
+ with urllib.request.urlopen(full_url) as response, open(local_path, 'wb') as out_file:
199
+ shutil.copyfileobj(response, out_file)
200
+ return f"/output/{filename}" # 返回前端可用的路径
201
+ except Exception as e:
202
+ print(f"下载图片失败: {e} (URL: {full_url})")
203
+ # 如果下载失败,返回通过本服务代理的地址,而不是直接返回 ComfyUI 地址
204
+ # ComfyUI 地址 (127.0.0.1) 在外部无法访问
205
+ # 将 /view?xxx 替换为 /api/view?xxx
206
+ if comfy_url_path.startswith("/view"):
207
+ return comfy_url_path.replace("/view", "/api/view", 1)
208
+ return full_url
209
+
210
+ def save_to_history(record):
211
+ """保存记录到 JSON 文件"""
212
+ with HISTORY_LOCK:
213
+ history = []
214
+ if os.path.exists(HISTORY_FILE):
215
+ try:
216
+ with open(HISTORY_FILE, 'r', encoding='utf-8') as f:
217
+ history = json.load(f)
218
+ except: pass
219
+
220
+ # 确保时间戳是浮点数,方便排序
221
+ if "timestamp" not in record:
222
+ record["timestamp"] = time.time()
223
+
224
+ history.insert(0, record)
225
+ # 限制总记录数,避免文件过大
226
+ with open(HISTORY_FILE, 'w', encoding='utf-8') as f:
227
+ json.dump(history[:5000], f, ensure_ascii=False, indent=4)
228
+
229
+ def get_comfy_history(comfy_address, prompt_id):
230
+ try:
231
+ with urllib.request.urlopen(f"http://{comfy_address}/history/{prompt_id}") as response:
232
+ return json.loads(response.read())
233
+ except Exception as e:
234
+ # print(f"获取 ComfyUI 历史失败: {e}")
235
+ return {}
236
+
237
+ # --- 接口路由 ---
238
+
239
+ @app.get("/api/view")
240
+ def view_image(filename: str, type: str = "input", subfolder: str = ""):
241
+ try:
242
+ # 默认尝试从第一个实例查看(通常输入图片上传后各处都有,或者我们只看第一个)
243
+ # 如果是 output 图片,实际上 generate 接口已经搬运到本地了,不会走这个接口查看结果
244
+ # 这个接口主要用于查看上传的原图
245
+ url = f"http://{COMFYUI_INSTANCES[0]}/view"
246
+ params = {"filename": filename, "type": type, "subfolder": subfolder}
247
+ r = requests.get(url, params=params)
248
+ return Response(content=r.content, media_type=r.headers.get('Content-Type'))
249
+ except Exception as e:
250
+ raise HTTPException(status_code=404, detail="Image not found")
251
+
252
+ @app.post("/api/upload")
253
+ async def upload_image(files: List[UploadFile] = File(...)):
254
+ uploaded_files = []
255
+
256
+ # 只需要读取一次文件内容
257
+ files_content = []
258
+ for file in files:
259
+ content = await file.read()
260
+ files_content.append((file, content))
261
+
262
+ # 遍历所有后端实例进行上传
263
+ for file, content in files_content:
264
+ success_count = 0
265
+ last_result = None
266
+
267
+ for addr in COMFYUI_INSTANCES:
268
+ try:
269
+ # Prepare multipart upload for ComfyUI
270
+ files_data = {'image': (file.filename, content, file.content_type)}
271
+
272
+ # Upload to specific backend
273
+ response = requests.post(f"http://{addr}/upload/image", files=files_data, timeout=5)
274
+
275
+ if response.status_code == 200:
276
+ last_result = response.json()
277
+ success_count += 1
278
+ else:
279
+ print(f"Upload to {addr} failed: {response.text}")
280
+
281
+ except Exception as e:
282
+ print(f"Upload error for {addr}: {e}")
283
+
284
+ if success_count > 0 and last_result:
285
+ uploaded_files.append({"comfy_name": last_result.get("name", file.filename)})
286
+ else:
287
+ raise HTTPException(status_code=500, detail=f"Failed to upload to any backend")
288
+
289
+ return {"files": uploaded_files}
290
+
291
+ @app.get("/")
292
+ async def index():
293
+ return FileResponse(os.path.join(STATIC_DIR, "index.html"))
294
+
295
+ @app.get("/api/history")
296
+ async def get_history_api(type: str = None):
297
+ if os.path.exists(HISTORY_FILE):
298
+ try:
299
+ with open(HISTORY_FILE, 'r', encoding='utf-8') as f:
300
+ data = json.load(f)
301
+
302
+ # 过滤类型
303
+ if type:
304
+ # 如果请求 zimage,同时返回 cloud 类型的记录
305
+ target_types = [type]
306
+ if type == "zimage":
307
+ target_types.append("cloud")
308
+
309
+ data = [item for item in data if item.get("type", "zimage") in target_types]
310
+
311
+ # 过滤无效数据(无图片)
312
+ data = [item for item in data if item.get("images") and len(item["images"]) > 0]
313
+
314
+ # 后端进行排序,确保顺序正确
315
+ # 处理兼容性:旧数据可能是字符串时间,新数据是浮点时间戳
316
+ def sort_key(item):
317
+ ts = item.get("timestamp", 0)
318
+ if isinstance(ts, (int, float)):
319
+ return float(ts)
320
+ return 0 # 旧数据排在最后
321
+
322
+ data.sort(key=sort_key, reverse=True)
323
+ return data
324
+ except Exception as e:
325
+ print(f"读取历史文件失败: {e}")
326
+ return []
327
+ return []
328
+
329
+ @app.get("/api/queue_status")
330
+ async def get_queue_status(client_id: str):
331
+ with QUEUE_LOCK:
332
+ total = len(QUEUE)
333
+ positions = [i + 1 for i, t in enumerate(QUEUE) if t["client_id"] == client_id]
334
+ position = positions[0] if positions else 0
335
+ return {"total": total, "position": position}
336
+
337
+ @app.post("/api/history/delete")
338
+ async def delete_history(req: DeleteHistoryRequest):
339
+ if not os.path.exists(HISTORY_FILE):
340
+ return {"success": False, "message": "History file not found"}
341
+
342
+ try:
343
+ with HISTORY_LOCK:
344
+ with open(HISTORY_FILE, 'r', encoding='utf-8') as f:
345
+ history = json.load(f)
346
+
347
+ # Find and remove
348
+ target_record = None
349
+ new_history = []
350
+ for item in history:
351
+ is_match = False
352
+ item_ts = item.get("timestamp", 0)
353
+
354
+ # 尝试数字匹配
355
+ if isinstance(req.timestamp, (int, float)) and isinstance(item_ts, (int, float)):
356
+ if abs(float(item_ts) - float(req.timestamp)) < 0.001:
357
+ is_match = True
358
+ # 尝试字符串匹配
359
+ elif str(item_ts) == str(req.timestamp):
360
+ is_match = True
361
+
362
+ if is_match:
363
+ target_record = item
364
+ else:
365
+ new_history.append(item)
366
+
367
+ if target_record:
368
+ # Save history first (atomic-like)
369
+ with open(HISTORY_FILE, 'w', encoding='utf-8') as f:
370
+ json.dump(new_history, f, ensure_ascii=False, indent=4)
371
+
372
+ # Delete files outside lock (IO operation)
373
+ if target_record:
374
+ for img_url in target_record.get("images", []):
375
+ # img_url is like "/output/filename.png"
376
+ if img_url.startswith("/output/"):
377
+ filename = img_url.split("/")[-1]
378
+ file_path = os.path.join(OUTPUT_DIR, filename)
379
+ if os.path.exists(file_path):
380
+ try:
381
+ os.remove(file_path)
382
+ except Exception as e:
383
+ print(f"Failed to delete file {file_path}: {e}")
384
+
385
+ return {"success": True}
386
+ else:
387
+ return {"success": False, "message": "Record not found"}
388
+
389
+ except Exception as e:
390
+ print(f"Delete history error: {e}")
391
+ return {"success": False, "message": str(e)}
392
+
393
+ class TokenRequest(BaseModel):
394
+ token: str
395
+
396
+ @app.get("/api/config/token")
397
+ async def get_global_token():
398
+ if os.path.exists(GLOBAL_CONFIG_FILE):
399
+ try:
400
+ with open(GLOBAL_CONFIG_FILE, 'r', encoding='utf-8') as f:
401
+ config = json.load(f)
402
+ return {"token": config.get("modelscope_token", "")}
403
+ except:
404
+ return {"token": ""}
405
+ return {"token": ""}
406
+
407
+ @app.post("/api/config/token")
408
+ async def set_global_token(req: TokenRequest):
409
+ with GLOBAL_CONFIG_LOCK:
410
+ config = {}
411
+ if os.path.exists(GLOBAL_CONFIG_FILE):
412
+ try:
413
+ with open(GLOBAL_CONFIG_FILE, 'r', encoding='utf-8') as f:
414
+ config = json.load(f)
415
+ except: pass
416
+
417
+ config["modelscope_token"] = req.token.strip()
418
+
419
+ with open(GLOBAL_CONFIG_FILE, 'w', encoding='utf-8') as f:
420
+ json.dump(config, f, indent=4)
421
+ return {"success": True}
422
+
423
+ @app.delete("/api/config/token")
424
+ async def delete_global_token():
425
+ with GLOBAL_CONFIG_LOCK:
426
+ if os.path.exists(GLOBAL_CONFIG_FILE):
427
+ try:
428
+ config = {}
429
+ with open(GLOBAL_CONFIG_FILE, 'r', encoding='utf-8') as f:
430
+ config = json.load(f)
431
+
432
+ if "modelscope_token" in config:
433
+ del config["modelscope_token"]
434
+ with open(GLOBAL_CONFIG_FILE, 'w', encoding='utf-8') as f:
435
+ json.dump(config, f, indent=4)
436
+ except: pass
437
+ return {"success": True}
438
+
439
+ @app.post("/generate")
440
+ async def generate_cloud(req: CloudGenRequest):
441
+ base_url = 'https://api-inference.modelscope.cn/'
442
+ clean_token = req.api_key.strip()
443
+
444
+ headers = {
445
+ "Authorization": f"Bearer {clean_token}",
446
+ "Content-Type": "application/json",
447
+ }
448
+
449
+ # 按照官方 Z-Image 标准版参数
450
+ payload = {
451
+ "model": "Tongyi-MAI/Z-Image-Turbo",
452
+ "prompt": req.prompt.strip(),
453
+ "size": req.resolution,
454
+ "n": 1
455
+ }
456
+
457
+ try:
458
+ async with httpx.AsyncClient(timeout=30) as client:
459
+ # A. 提交异步任务
460
+ print(f"Submitting ModelScope task for prompt: {req.prompt[:20]}...")
461
+ submit_res = await client.post(
462
+ f"{base_url}v1/images/generations",
463
+ headers={**headers, "X-ModelScope-Async-Mode": "true"},
464
+ content=json.dumps(payload, ensure_ascii=False).encode('utf-8')
465
+ )
466
+
467
+ if submit_res.status_code != 200:
468
+ # 尝试解析错误详情
469
+ try:
470
+ detail = submit_res.json()
471
+ except:
472
+ detail = submit_res.text
473
+ print(f"ModelScope Submit Error: {detail}")
474
+ raise HTTPException(status_code=submit_res.status_code, detail=detail)
475
+
476
+ task_id = submit_res.json().get("task_id")
477
+ print(f"Task submitted, ID: {task_id}")
478
+
479
+ # B. 轮询任务状态
480
+ # 增加到 60 次轮询 * 3秒 = 180秒 (3分钟) 超时
481
+ for i in range(60):
482
+ await asyncio.sleep(3)
483
+ try:
484
+ result = await client.get(
485
+ f"{base_url}v1/tasks/{task_id}",
486
+ headers={**headers, "X-ModelScope-Task-Type": "image_generation"},
487
+ )
488
+ data = result.json()
489
+ status = data.get("task_status")
490
+
491
+ if i % 5 == 0:
492
+ print(f"Task {task_id} status check {i}: {status}")
493
+
494
+ if status == "SUCCEED":
495
+ img_url = data["output_images"][0]
496
+ print(f"Task {task_id} SUCCEED: {img_url}")
497
+
498
+ # 下载保存到本地 output
499
+ local_path = ""
500
+ try:
501
+ # 异步下载
502
+ async with httpx.AsyncClient() as dl_client:
503
+ img_res = await dl_client.get(img_url)
504
+ if img_res.status_code == 200:
505
+ filename = f"cloud_{int(time.time())}.png"
506
+ file_path = os.path.join(OUTPUT_DIR, filename)
507
+ with open(file_path, "wb") as f:
508
+ f.write(img_res.content)
509
+ local_path = f"/output/{filename}"
510
+ print(f"Image saved locally: {local_path}")
511
+ else:
512
+ print(f"Failed to download image: {img_res.status_code}")
513
+ local_path = img_url # Fallback to remote URL
514
+ except Exception as dl_e:
515
+ print(f"Download error: {dl_e}")
516
+ local_path = img_url # Fallback
517
+
518
+ # 保存到本地历史 (使用本地路径以便前端更好加载,或者仍然存 remote 但 download 不 work?)
519
+ # 需求是 output 目录保存,前端展示通常优先用本地快
520
+ record = {
521
+ "timestamp": time.time(),
522
+ "prompt": req.prompt,
523
+ "images": [local_path], # 使用本地路径
524
+ "type": "cloud"
525
+ }
526
+ save_to_history(record)
527
+
528
+ # 广播新图片
529
+ try:
530
+ await manager.broadcast_new_image(record)
531
+ except Exception as e:
532
+ print(f"Broadcast error: {e}")
533
+
534
+ return {"url": local_path}
535
+
536
+ elif status == "FAILED":
537
+ raise Exception(f"ModelScope task failed: {data}")
538
+ except Exception as loop_e:
539
+ print(f"Polling error (retrying): {loop_e}")
540
+ continue
541
+
542
+ raise Exception("Cloud generation timeout (180s)")
543
+
544
+ except Exception as e:
545
+ print(f"Cloud generation error: {e}")
546
+ raise HTTPException(status_code=400, detail=str(e))
547
+
548
+ @app.post("/api/generate")
549
+ def generate(req: GenerateRequest):
550
+ global NEXT_TASK_ID
551
+
552
+ # 1. 入队
553
+ current_task = None
554
+ target_backend = None
555
+ with QUEUE_LOCK:
556
+ task_id = NEXT_TASK_ID
557
+ NEXT_TASK_ID += 1
558
+ current_task = {"task_id": task_id, "client_id": req.client_id}
559
+ QUEUE.append(current_task)
560
+
561
+ try:
562
+ # 2. 负载均衡:选择最佳后端(移除 EXECUTION_LOCK 全局锁以支持并发)
563
+ target_backend = get_best_backend()
564
+
565
+ # 增加本地负载计数
566
+ with LOAD_LOCK:
567
+ BACKEND_LOCAL_LOAD[target_backend] += 1
568
+
569
+ # 3. 开始执行任务
570
+ workflow_path = os.path.join(WORKFLOW_DIR, req.workflow_json)
571
+
572
+ # 兼容性处理:如果文件不存在且是默认值,尝试使用 WORKFLOW_PATH
573
+ if not os.path.exists(workflow_path) and req.workflow_json == "Z-Image.json":
574
+ workflow_path = WORKFLOW_PATH
575
+
576
+ if not os.path.exists(workflow_path):
577
+ raise Exception(f"Workflow file not found: {req.workflow_json}")
578
+
579
+ with open(workflow_path, 'r', encoding='utf-8') as f:
580
+ workflow = json.load(f)
581
+
582
+ seed = random.randint(1, 10**15)
583
+
584
+ # 参数注入
585
+
586
+ # 基础参数兼容 (针对 Z-Image.json)
587
+ if "23" in workflow and req.prompt:
588
+ workflow["23"]["inputs"]["text"] = req.prompt
589
+ if "144" in workflow:
590
+ workflow["144"]["inputs"]["width"] = req.width
591
+ workflow["144"]["inputs"]["height"] = req.height
592
+ if "22" in workflow:
593
+ workflow["22"]["inputs"]["seed"] = seed
594
+ # 兼容 Flux2-Klein 工作流
595
+ if "158" in workflow:
596
+ workflow["158"]["inputs"]["noise_seed"] = seed
597
+
598
+ for node_id in ["146", "181"]:
599
+ if node_id in workflow and "inputs" in workflow[node_id] and "seed" in workflow[node_id]["inputs"]:
600
+ workflow[node_id]["inputs"]["seed"] = seed
601
+
602
+ if "184" in workflow and "inputs" in workflow["184"] and "seed" in workflow["184"]["inputs"]:
603
+ workflow["184"]["inputs"]["seed"] = seed
604
+
605
+ if "172" in workflow and "inputs" in workflow["172"] and "seed" in workflow["172"]["inputs"]:
606
+ # SeedVR2VideoUpscaler 限制 seed 最大为 2^32 - 1
607
+ workflow["172"]["inputs"]["seed"] = seed % 4294967295
608
+
609
+ if "14" in workflow and "inputs" in workflow["14"] and "seed" in workflow["14"]["inputs"]:
610
+ workflow["14"]["inputs"]["seed"] = seed
611
+
612
+ # 动态参数注入 (支持所有工作流)
613
+ for node_id, node_inputs in req.params.items():
614
+ if node_id in workflow:
615
+ if "inputs" not in workflow[node_id]:
616
+ workflow[node_id]["inputs"] = {}
617
+ for input_name, value in node_inputs.items():
618
+ workflow[node_id]["inputs"][input_name] = value
619
+
620
+ # 提交任务
621
+ p = {"prompt": workflow, "client_id": CLIENT_ID}
622
+ data = json.dumps(p).encode('utf-8')
623
+ try:
624
+ post_req = urllib.request.Request(f"http://{target_backend}/prompt", data=data)
625
+ prompt_id = json.loads(urllib.request.urlopen(post_req, timeout=10).read())['prompt_id']
626
+ except urllib.error.HTTPError as e:
627
+ error_body = e.read().decode('utf-8')
628
+ print(f"ComfyUI API Error ({e.code}): {error_body}")
629
+ raise Exception(f"HTTP Error {e.code}: {error_body}")
630
+ except Exception as e:
631
+ raise e
632
+
633
+ # 轮询结果
634
+ history_data = None
635
+ for i in range(300): # 最多等待300秒 (5分钟)
636
+ try:
637
+ res = get_comfy_history(target_backend, prompt_id)
638
+ if prompt_id in res:
639
+ history_data = res[prompt_id]
640
+ break
641
+ except Exception as e:
642
+ pass
643
+
644
+ time.sleep(1)
645
+
646
+ if not history_data:
647
+ raise Exception("ComfyUI 渲染超时")
648
+
649
+ # 处理图片:下载到本地
650
+ local_urls = []
651
+ current_timestamp = time.time()
652
+
653
+ if 'outputs' in history_data:
654
+ for node_id in history_data['outputs']:
655
+ node_output = history_data['outputs'][node_id]
656
+ if 'images' in node_output:
657
+ for img in node_output['images']:
658
+ comfy_url_path = f"/view?filename={img['filename']}&subfolder={img['subfolder']}&type={img['type']}"
659
+ # 搬家:从 ComfyUI 下载到我们的 output
660
+ # 增加文件名标识
661
+ prefix = f"{req.type}_{int(current_timestamp)}_"
662
+ local_path = download_image(target_backend, comfy_url_path, prefix=prefix)
663
+ local_urls.append(local_path)
664
+
665
+ # 存储并返回
666
+ result = {
667
+ "prompt": req.prompt if req.prompt else "Detail Enhance", # 默认标题
668
+ "images": local_urls,
669
+ "seed": seed,
670
+ "timestamp": current_timestamp,
671
+ "type": req.type, # 存储类型
672
+ "params": req.params # 存储参数以支持“做同款”
673
+ }
674
+ save_to_history(result)
675
+
676
+ # 广播新图片
677
+ if GLOBAL_LOOP:
678
+ asyncio.run_coroutine_threadsafe(manager.broadcast_new_image(result), GLOBAL_LOOP)
679
+
680
+ return result
681
+
682
+ except Exception as e:
683
+ return {"images": [], "error": str(e)}
684
+ finally:
685
+ # 减少本地负载计数
686
+ if target_backend:
687
+ with LOAD_LOCK:
688
+ if BACKEND_LOCAL_LOAD.get(target_backend, 0) > 0:
689
+ BACKEND_LOCAL_LOAD[target_backend] -= 1
690
+
691
+ # 任务结束(无论成功失败),移除队列
692
+ if current_task:
693
+ with QUEUE_LOCK:
694
+ if current_task in QUEUE:
695
+ QUEUE.remove(current_task)
696
+
697
+ if __name__ == "__main__":
698
+ import uvicorn
699
+ # 强制单进程模式确保 WebSocket 计数准确
700
+ uvicorn.run(app, host="0.0.0.0", port=3000)
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn
3
+ requests
4
+ pydantic
5
+ python-multipart
6
+ httpx
static/angle.html ADDED
@@ -0,0 +1,983 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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-item {
112
+ break-inside: avoid;
113
+ margin-bottom: 1.25rem;
114
+ background: #fff;
115
+ border: 1px solid #f1f5f9;
116
+ border-radius: 24px;
117
+ overflow: hidden;
118
+ transition: all 0.5s var(--easing);
119
+ }
120
+
121
+ .masonry-item:hover {
122
+ transform: translateY(-6px);
123
+ box-shadow: 0 20px 40px rgba(0, 0, 0, 0.08);
124
+ }
125
+
126
+ .nano-input {
127
+ background: #ffffff;
128
+ border-radius: 16px;
129
+ transition: all 0.3s ease;
130
+ border: 1px solid #e5e7eb;
131
+ }
132
+
133
+ .nano-input:focus {
134
+ background: #ffffff;
135
+ box-shadow: 0 0 0 2px #000;
136
+ border-color: transparent;
137
+ }
138
+
139
+ .masonry-grid {
140
+ columns: 2;
141
+ column-gap: 1.25rem;
142
+ }
143
+
144
+ @media (min-width: 768px) {
145
+ .masonry-grid {
146
+ columns: 4;
147
+ }
148
+ }
149
+
150
+ @keyframes b-loading {
151
+ 0% {
152
+ transform: scale(1);
153
+ background: #000;
154
+ }
155
+
156
+ 50% {
157
+ transform: scale(1.15);
158
+ background: #444;
159
+ }
160
+
161
+ 100% {
162
+ transform: scale(1);
163
+ background: #000;
164
+ }
165
+ }
166
+
167
+ .loading-box {
168
+ width: 10px;
169
+ height: 10px;
170
+ animation: b-loading 1s infinite var(--easing);
171
+ }
172
+ </style>
173
+ </head>
174
+
175
+ <body class="selection:bg-black selection:text-white">
176
+
177
+ <div class="container-box">
178
+ <header class="flex flex-col md:flex-row justify-between items-end mb-16 gap-6">
179
+ <div class="space-y-1">
180
+ <h1 class="text-4xl font-extrabold tracking-[-0.05em] flex items-center">
181
+ ANGLE CONTROL<span class="text-base mt-3 ml-1">®</span>
182
+ </h1>
183
+ <p class="text-[10px] font-bold uppercase tracking-[0.5em] text-gray-400">Camera & Perspective Control
184
+ </p>
185
+ </div>
186
+ <nav class="flex gap-8 text-[11px] font-bold uppercase tracking-widest text-gray-500">
187
+ <span class="text-black border-b-2 border-black pb-1">Angle</span>
188
+ </nav>
189
+ </header>
190
+
191
+ <main class="space-y-12">
192
+ <!-- Row 1: Upload and 3D Control -->
193
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-10 items-start">
194
+ <section class="group w-full">
195
+ <h3 class="text-[9px] font-black uppercase tracking-[0.3em] mb-5 text-gray-400">01. Input Source
196
+ </h3>
197
+ <div id="dropzone"
198
+ class="upload-item relative overflow-hidden rounded-2xl aspect-[4/3] flex flex-col items-center justify-center cursor-pointer">
199
+ <input type="file" id="fileInput" class="hidden" accept="image/*">
200
+
201
+ <div id="uploadContent" class="text-center space-y-4">
202
+ <div
203
+ 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">
204
+ <i data-lucide="arrow-up" class="w-5 h-5"></i>
205
+ </div>
206
+ <p class="text-[11px] font-bold uppercase tracking-tight">Drop image here</p>
207
+ </div>
208
+
209
+ <img id="previewImg" class="hidden absolute inset-0 w-full h-full object-cover">
210
+
211
+ <div id="changeOverlay"
212
+ class="hidden absolute inset-0 bg-black/10 backdrop-blur-sm items-center justify-center opacity-0 hover:opacity-100 transition-opacity">
213
+ <span
214
+ class="bg-white px-5 py-2 rounded-full text-[10px] font-bold uppercase tracking-widest shadow-2xl">Change</span>
215
+ </div>
216
+ </div>
217
+ </section>
218
+
219
+ <section id="cameraControl" class="space-y-6 w-full">
220
+ <h3 class="text-[9px] font-black uppercase tracking-[0.3em] text-gray-400">02. Camera Control</h3>
221
+ <div
222
+ class="w-full aspect-[4/3] flex flex-col md:flex-row bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
223
+ <!-- 3D View -->
224
+ <div id="threeContainer" class="relative flex-1 bg-[#222] h-full min-h-0"></div>
225
+
226
+ <!-- Controls -->
227
+ <div
228
+ 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">
229
+ <!-- Horizontal -->
230
+ <div class="space-y-3">
231
+ <div
232
+ class="flex justify-between items-center text-[10px] font-bold uppercase tracking-wider text-gray-500">
233
+ <div class="flex items-center gap-2">
234
+ <i data-lucide="move-horizontal" class="w-3 h-3"></i>
235
+ <span>Rotation</span>
236
+ </div>
237
+ <div class="flex items-center gap-1.5">
238
+ <button onclick="resetControl('h')"
239
+ class="text-gray-300 hover:text-black transition-colors p-1" title="Reset">
240
+ <i data-lucide="rotate-ccw" class="w-3 h-3"></i>
241
+ </button>
242
+ <div class="flex items-center bg-gray-100 rounded-md px-2">
243
+ <input type="number" id="val-horizontal" value="0"
244
+ 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"
245
+ oninput="syncInput('h')">
246
+ <span class="text-gray-400 select-none text-[10px]">°</span>
247
+ </div>
248
+ </div>
249
+ </div>
250
+ <input type="range" id="rotate-h" min="-90" max="90" value="0"
251
+ class="w-full h-1.5 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-black">
252
+ </div>
253
+
254
+ <!-- Vertical -->
255
+ <div class="space-y-3">
256
+ <div
257
+ class="flex justify-between items-center text-[10px] font-bold uppercase tracking-wider text-gray-500">
258
+ <div class="flex items-center gap-2">
259
+ <i data-lucide="move-vertical" class="w-3 h-3"></i>
260
+ <span>Pitch</span>
261
+ </div>
262
+ <div class="flex items-center gap-1.5">
263
+ <button onclick="resetControl('v')"
264
+ class="text-gray-300 hover:text-black transition-colors p-1" title="Reset">
265
+ <i data-lucide="rotate-ccw" class="w-3 h-3"></i>
266
+ </button>
267
+ <div class="flex items-center bg-gray-100 rounded-md px-2">
268
+ <input type="number" id="val-vertical" value="0"
269
+ 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"
270
+ oninput="syncInput('v')">
271
+ <span class="text-gray-400 select-none text-[10px]">°</span>
272
+ </div>
273
+ </div>
274
+ </div>
275
+ <input type="range" id="rotate-v" min="-90" max="90" value="0"
276
+ class="w-full h-1.5 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-black">
277
+ </div>
278
+
279
+ <!-- Distance -->
280
+ <div class="space-y-3">
281
+ <div
282
+ class="flex justify-between items-center text-[10px] font-bold uppercase tracking-wider text-gray-500">
283
+ <div class="flex items-center gap-2">
284
+ <i data-lucide="zoom-in" class="w-3 h-3"></i>
285
+ <span>Distance</span>
286
+ </div>
287
+ <div class="flex items-center gap-1.5">
288
+ <button onclick="resetControl('d')"
289
+ class="text-gray-300 hover:text-black transition-colors p-1" title="Reset">
290
+ <i data-lucide="rotate-ccw" class="w-3 h-3"></i>
291
+ </button>
292
+ <div class="flex items-center bg-gray-100 rounded-md px-2">
293
+ <input type="number" id="val-distance" value="8.0" step="0.1"
294
+ 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"
295
+ oninput="syncInput('d')">
296
+ </div>
297
+ </div>
298
+ </div>
299
+ <input type="range" id="distance" min="2" max="14" value="8" step="0.1"
300
+ class="w-full h-1.5 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-black">
301
+ </div>
302
+ </div>
303
+ </div>
304
+ </section>
305
+ </div>
306
+
307
+ <!-- Row 2: Parameters & Result -->
308
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-10 items-stretch">
309
+ <section class="flex flex-col space-y-6">
310
+ <h3 class="text-[9px] font-black uppercase tracking-[0.3em] text-gray-400">03. Parameters</h3>
311
+
312
+ <div class="space-y-3 flex-1 flex flex-col">
313
+ <div class="flex items-center gap-2 text-gray-800 ml-1">
314
+ <i data-lucide="text-quote" class="w-3 h-3"></i>
315
+ <span class="text-[10px] font-bold uppercase tracking-widest">Prompt</span>
316
+ </div>
317
+ <textarea id="promptInput"
318
+ class="nano-input w-full flex-1 p-5 text-sm outline-none resize-none placeholder-gray-300"
319
+ placeholder="请通过右侧控制器调整,或输入提示词"></textarea>
320
+ </div>
321
+
322
+ <button id="genBtn" onclick="handleGenerate()"
323
+ 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">
324
+ <i data-lucide="zap" id="btnIcon" class="w-4 h-4 text-yellow-400"></i>
325
+ <span id="btnText">Generate New Angle</span>
326
+ </button>
327
+ </section>
328
+
329
+ <section class="space-y-6 flex flex-col">
330
+ <h3 class="text-[9px] font-black uppercase tracking-[0.3em] text-gray-400">04. Result Preview</h3>
331
+ <div id="resultBox"
332
+ class="result-frame relative aspect-[4/3] w-full flex items-center justify-center overflow-hidden group">
333
+ <div id="emptyState" class="text-center space-y-4 opacity-20">
334
+ <i data-lucide="camera" class="w-12 h-12 mx-auto stroke-[1px]"></i>
335
+ <p class="text-[10px] font-black tracking-[0.5em] uppercase">Canvas Ready</p>
336
+ </div>
337
+
338
+ <div id="loadingState" class="hidden flex flex-col items-center gap-5">
339
+ <div class="loading-box"></div>
340
+ <p class="text-[10px] font-bold uppercase tracking-[0.4em] animate-pulse">Processing...</p>
341
+ </div>
342
+
343
+ <div id="textResult"
344
+ class="hidden w-full h-full p-12 flex flex-col items-center justify-center text-center space-y-8">
345
+ <i data-lucide="terminal" class="w-12 h-12 text-gray-300 mx-auto"></i>
346
+ <div class="space-y-4 max-w-md">
347
+ <p class="text-[10px] font-bold uppercase tracking-[0.5em] text-gray-400">Generated
348
+ Command
349
+ </p>
350
+ <h2 id="generatedText" class="text-2xl font-bold leading-relaxed text-gray-900"></h2>
351
+ </div>
352
+ <button onclick="copyText()"
353
+ 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">
354
+ <i data-lucide="copy" class="w-3 h-3"></i> Copy
355
+ </button>
356
+ </div>
357
+
358
+ <img id="outputImg"
359
+ class="hidden w-full h-full object-contain p-8 cursor-zoom-in transition-all duration-700 hover:scale-[1.02]"
360
+ onclick="zoomImage()">
361
+
362
+ <a id="downloadBtn" href="#" download
363
+ 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">
364
+ <i data-lucide="download" class="w-5 h-5"></i>
365
+ </a>
366
+ </div>
367
+ </section>
368
+ </div>
369
+ </main>
370
+
371
+ <section class="mt-32">
372
+ <div class="flex items-center gap-6 mb-10">
373
+ <h2 class="text-[11px] font-black uppercase tracking-[0.5em]">Archive</h2>
374
+ <div class="h-px flex-1 bg-black/5"></div>
375
+ </div>
376
+ <div id="masonry" class="masonry-grid"></div>
377
+ <div id="loadMoreTrigger"
378
+ class="py-16 text-center opacity-20 text-[10px] font-bold uppercase tracking-widest">
379
+ End of Archive
380
+ </div>
381
+ </section>
382
+ </div>
383
+
384
+ <div id="lightbox" onclick="handleOutsideClick(event)"
385
+ class="hidden fixed inset-0 z-50 bg-white/95 backdrop-blur-3xl flex items-center justify-center p-8">
386
+ <button onclick="closeLightbox()"
387
+ class="absolute top-10 right-10 p-2 hover:rotate-90 transition-transform duration-500">
388
+ <i data-lucide="x" class="w-8 h-8"></i>
389
+ </button>
390
+
391
+ <div class="max-w-6xl w-full h-full flex flex-col items-center justify-center">
392
+ <div class="relative">
393
+ <div id="lightboxRes"
394
+ 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">
395
+ </div>
396
+ <img id="lightboxImg" src="" class="hidden max-h-[80vh] rounded-3xl shadow-2xl">
397
+ </div>
398
+ <div class="mt-8">
399
+ <button onclick="downloadLightboxImage()"
400
+ 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">
401
+ <i data-lucide="save" class="w-4 h-4"></i> Save Master
402
+ </button>
403
+ </div>
404
+ </div>
405
+ </div>
406
+
407
+ <script type="module">
408
+ import * as THREE from 'three';
409
+
410
+ // --- 3D Scene Initialization ---
411
+ const container = document.getElementById('threeContainer');
412
+ const scene = new THREE.Scene();
413
+ scene.background = new THREE.Color(0x222222);
414
+
415
+ // Camera setup
416
+ const camera = new THREE.PerspectiveCamera(75, 1, 0.1, 1000);
417
+
418
+ // Renderer setup
419
+ const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
420
+ renderer.setSize(container.clientWidth, container.clientHeight);
421
+ renderer.setPixelRatio(window.devicePixelRatio);
422
+ container.appendChild(renderer.domElement);
423
+
424
+ // Objects
425
+ const geometry = new THREE.PlaneGeometry(3, 3);
426
+ const material = new THREE.MeshStandardMaterial({
427
+ color: 0x444444,
428
+ side: THREE.DoubleSide
429
+ });
430
+ const cube = new THREE.Mesh(geometry, material);
431
+ scene.add(cube);
432
+
433
+ const gridHelper = new THREE.GridHelper(20, 20, 0x444444, 0x333333);
434
+ scene.add(gridHelper);
435
+
436
+ // Lighting
437
+ const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
438
+ scene.add(ambientLight);
439
+ const pointLight = new THREE.DirectionalLight(0xffffff, 1);
440
+ pointLight.position.set(5, 10, 7);
441
+ scene.add(pointLight);
442
+
443
+ // Camera Logic
444
+ const sliderH = document.getElementById('rotate-h');
445
+ const sliderV = document.getElementById('rotate-v');
446
+ const sliderD = document.getElementById('distance');
447
+ const valH = document.getElementById('val-horizontal');
448
+ const valV = document.getElementById('val-vertical');
449
+ const valD = document.getElementById('val-distance');
450
+
451
+ window.updateCamera = function () {
452
+ const lon = parseFloat(sliderH.value);
453
+ const lat = parseFloat(sliderV.value);
454
+ const dist = parseFloat(sliderD.value);
455
+
456
+ // Sync inputs (avoid overwriting if active)
457
+ if (document.activeElement !== valH) valH.value = lon;
458
+ if (document.activeElement !== valV) valV.value = lat;
459
+ if (document.activeElement !== valD) valD.value = dist.toFixed(1);
460
+
461
+ const phi = THREE.MathUtils.degToRad(90 - lat);
462
+ const theta = THREE.MathUtils.degToRad(lon);
463
+
464
+ camera.position.x = dist * Math.sin(phi) * Math.sin(theta);
465
+ camera.position.y = dist * Math.cos(phi);
466
+ camera.position.z = dist * Math.sin(phi) * Math.cos(theta);
467
+ camera.lookAt(0, 0, 0);
468
+
469
+ // Real-time update prompt
470
+ updatePromptWithAngle(lon, lat, dist);
471
+ }
472
+
473
+ window.syncInput = (type) => {
474
+ if (type === 'h') {
475
+ let v = parseFloat(valH.value);
476
+ if (!isNaN(v)) sliderH.value = v;
477
+ } else if (type === 'v') {
478
+ let v = parseFloat(valV.value);
479
+ if (!isNaN(v)) sliderV.value = v;
480
+ } else if (type === 'd') {
481
+ let v = parseFloat(valD.value);
482
+ if (!isNaN(v)) sliderD.value = v;
483
+ }
484
+ window.updateCamera();
485
+ };
486
+
487
+ window.resetControl = (type) => {
488
+ if (type === 'h') {
489
+ sliderH.value = 0;
490
+ valH.value = 0;
491
+ } else if (type === 'v') {
492
+ sliderV.value = 0;
493
+ valV.value = 0;
494
+ } else if (type === 'd') {
495
+ sliderD.value = 8;
496
+ valD.value = 8;
497
+ }
498
+ window.updateCamera();
499
+ };
500
+
501
+ function updatePromptWithAngle(h, v, d) {
502
+ let parts = [];
503
+ if (h !== 0) {
504
+ const dir = h > 0 ? "向右" : "向左";
505
+ parts.push(`${dir}旋转${Math.abs(h)}度`);
506
+ }
507
+ if (v !== 0) {
508
+ const dir = v > 0 ? "俯视" : "仰视";
509
+ parts.push(`${dir}${Math.abs(v)}度`);
510
+ }
511
+
512
+ // Distance logic
513
+ let lensText = "";
514
+ if (d > 10) {
515
+ lensText = "使用广角镜头";
516
+ } else if (d < 6) {
517
+ lensText = "使用特写镜头";
518
+ }
519
+
520
+ // Removed "保持原位" default text to show placeholder
521
+
522
+ let resultText = "";
523
+ if (parts.length > 0) {
524
+ resultText = `将相机${parts.join(",")}`;
525
+ }
526
+
527
+ if (lensText) {
528
+ resultText += (resultText ? "," : "将相机") + lensText;
529
+ }
530
+
531
+ const promptInput = document.getElementById('promptInput');
532
+ let currentText = promptInput.value;
533
+
534
+ // Regex to find existing angle command (including lens info)
535
+ const regex = /将相机.*?(?=(\n|$))/g;
536
+
537
+ if (regex.test(currentText)) {
538
+ // Replace existing
539
+ promptInput.value = currentText.replace(regex, resultText);
540
+ } else {
541
+ // Append if not exists and resultText is not empty
542
+ if (resultText) {
543
+ if (currentText.trim()) {
544
+ promptInput.value = currentText.trim() + '\n' + resultText;
545
+ } else {
546
+ promptInput.value = resultText;
547
+ }
548
+ }
549
+ }
550
+ }
551
+
552
+ sliderH.addEventListener('input', window.updateCamera);
553
+ sliderV.addEventListener('input', window.updateCamera);
554
+ sliderD.addEventListener('input', window.updateCamera);
555
+ window.updateCamera();
556
+
557
+ // Animation Loop
558
+ function animate() {
559
+ requestAnimationFrame(animate);
560
+ renderer.render(scene, camera);
561
+ }
562
+ animate();
563
+
564
+ // Handle Resize
565
+ const resizeObserver = new ResizeObserver(() => {
566
+ const w = container.clientWidth;
567
+ const h = container.clientHeight;
568
+ camera.aspect = w / h;
569
+ camera.updateProjectionMatrix();
570
+ renderer.setSize(w, h);
571
+ });
572
+ resizeObserver.observe(container);
573
+
574
+ // Expose function to update texture
575
+ window.update3DTexture = (url) => {
576
+ new THREE.TextureLoader().load(url, (texture) => {
577
+ texture.colorSpace = THREE.SRGBColorSpace;
578
+
579
+ // Adjust plane aspect ratio to match image
580
+ const imageAspect = texture.image.width / texture.image.height;
581
+ cube.scale.set(1, 1 / imageAspect, 1);
582
+ if (imageAspect > 1) {
583
+ cube.scale.set(1, 1 / imageAspect, 1);
584
+ // Reset base scale to 3
585
+ cube.geometry.dispose();
586
+ cube.geometry = new THREE.PlaneGeometry(3, 3 / imageAspect);
587
+ cube.scale.set(1, 1, 1);
588
+ } else {
589
+ cube.geometry.dispose();
590
+ cube.geometry = new THREE.PlaneGeometry(3 * imageAspect, 3);
591
+ cube.scale.set(1, 1, 1);
592
+ }
593
+
594
+ cube.material = new THREE.MeshBasicMaterial({
595
+ map: texture,
596
+ side: THREE.DoubleSide
597
+ });
598
+ cube.material.needsUpdate = true;
599
+
600
+ // Show control panel (already visible, but keep logic safe)
601
+ document.getElementById('cameraControl').classList.remove('hidden');
602
+
603
+ // Force resize check after texture load
604
+ setTimeout(() => {
605
+ const w = container.clientWidth;
606
+ const h = container.clientHeight;
607
+ camera.aspect = w / h;
608
+ camera.updateProjectionMatrix();
609
+ renderer.setSize(w, h);
610
+ }, 100);
611
+ });
612
+ };
613
+ </script>
614
+
615
+ <script>
616
+ lucide.createIcons();
617
+ function generateUUID() {
618
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
619
+ try { return crypto.randomUUID(); } catch (e) { }
620
+ }
621
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
622
+ var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
623
+ return v.toString(16);
624
+ });
625
+ }
626
+ const CLIENT_ID = localStorage.getItem("client_id") || generateUUID();
627
+ localStorage.setItem("client_id", CLIENT_ID);
628
+
629
+ let uploadedPath = "";
630
+ let currentResult = null;
631
+ let allHistory = [];
632
+ let currentIndex = 0;
633
+ const PAGE_SIZE = 30;
634
+
635
+ const dropzone = document.getElementById('dropzone');
636
+ const fileInput = document.getElementById('fileInput');
637
+ const previewImg = document.getElementById('previewImg');
638
+ const promptInput = document.getElementById('promptInput');
639
+
640
+ dropzone.onclick = () => fileInput.click();
641
+ fileInput.onchange = (e) => handleFile(e.target.files[0]);
642
+
643
+ // Drag and Drop
644
+ dropzone.addEventListener('dragover', (e) => {
645
+ e.preventDefault();
646
+ dropzone.classList.add('border-black', 'bg-gray-50');
647
+ });
648
+ dropzone.addEventListener('dragleave', () => {
649
+ dropzone.classList.remove('border-black', 'bg-gray-50');
650
+ });
651
+ dropzone.addEventListener('drop', (e) => {
652
+ e.preventDefault();
653
+ dropzone.classList.remove('border-black', 'bg-gray-50');
654
+ if (e.dataTransfer.files && e.dataTransfer.files[0]) {
655
+ handleFile(e.dataTransfer.files[0]);
656
+ }
657
+ });
658
+
659
+ // Paste support
660
+ let isHovering = false;
661
+ dropzone.addEventListener('mouseenter', () => isHovering = true);
662
+ dropzone.addEventListener('mouseleave', () => isHovering = false);
663
+ window.addEventListener('paste', (e) => {
664
+ if (!isHovering) return;
665
+ const items = (e.clipboardData || e.originalEvent.clipboardData).items;
666
+ for (let item of items) {
667
+ if (item.kind === 'file' && item.type.startsWith('image/')) {
668
+ const file = item.getAsFile();
669
+ handleFile(file);
670
+ break;
671
+ }
672
+ }
673
+ });
674
+
675
+ async function handleFile(file) {
676
+ if (!file) return;
677
+ const btn = document.getElementById('genBtn');
678
+ const btnText = document.getElementById('btnText');
679
+
680
+ btn.disabled = true;
681
+ btnText.innerText = "Uploading...";
682
+
683
+ const reader = new FileReader();
684
+ reader.onload = (e) => {
685
+ previewImg.src = e.target.result;
686
+ previewImg.classList.remove('hidden');
687
+ document.getElementById('uploadContent').classList.add('opacity-0');
688
+ document.getElementById('changeOverlay').classList.replace('hidden', 'flex');
689
+
690
+ // Automatically apply to 3D scene
691
+ if (window.update3DTexture) {
692
+ window.update3DTexture(e.target.result);
693
+ }
694
+ };
695
+ reader.readAsDataURL(file);
696
+
697
+ const formData = new FormData();
698
+ formData.append('files', file);
699
+ try {
700
+ const res = await fetch('/api/upload', { method: 'POST', body: formData });
701
+ const data = await res.json();
702
+ uploadedPath = data.files[0].comfy_name;
703
+ btn.disabled = false;
704
+ btnText.innerText = "Generate New Angle";
705
+ } catch (err) {
706
+ console.error("Upload error");
707
+ btnText.innerText = "Upload Failed";
708
+ btn.disabled = false;
709
+ }
710
+ }
711
+
712
+ function applyAngleToPrompt() {
713
+ const h = parseInt(document.getElementById('rotate-h').value);
714
+ const v = parseInt(document.getElementById('rotate-v').value);
715
+
716
+ let parts = [];
717
+ if (h !== 0) {
718
+ const dir = h > 0 ? "向右" : "向左";
719
+ parts.push(`${dir}旋转${Math.abs(h)}度`);
720
+ }
721
+ if (v !== 0) {
722
+ const dir = v > 0 ? "俯视" : "仰视";
723
+ parts.push(`${dir}${Math.abs(v)}度`);
724
+ }
725
+
726
+ if (parts.length === 0) {
727
+ parts.push("保持原位");
728
+ }
729
+
730
+ const resultText = `将相机${parts.join(",")}`;
731
+
732
+ const promptInput = document.getElementById('promptInput');
733
+ // Check if there is existing content, if so append new line
734
+ if (promptInput.value.trim()) {
735
+ promptInput.value += '\n' + resultText;
736
+ } else {
737
+ promptInput.value = resultText;
738
+ }
739
+
740
+ // Visual feedback
741
+ promptInput.style.transition = "0.2s";
742
+ promptInput.style.borderColor = "#000";
743
+ promptInput.style.boxShadow = "0 0 0 2px rgba(0,0,0,0.1)";
744
+ setTimeout(() => {
745
+ promptInput.style.borderColor = "";
746
+ promptInput.style.boxShadow = "";
747
+ }, 500);
748
+ }
749
+
750
+ async function handleGenerate() {
751
+ if (!uploadedPath) {
752
+ const dropzone = document.getElementById('dropzone');
753
+ dropzone.style.transition = "0.2s";
754
+ dropzone.style.borderColor = "#ef4444";
755
+ dropzone.style.transform = "scale(0.98)";
756
+ setTimeout(() => {
757
+ dropzone.style.borderColor = "";
758
+ dropzone.style.transform = "scale(1)";
759
+ }, 300);
760
+ return;
761
+ }
762
+
763
+ // Allow manual prompt even if angle not applied, but require prompt input
764
+ if (!promptInput.value.trim()) {
765
+ promptInput.style.transition = "0.2s";
766
+ promptInput.style.borderColor = "#ef4444";
767
+ setTimeout(() => {
768
+ promptInput.style.borderColor = "";
769
+ }, 300);
770
+ return;
771
+ }
772
+
773
+ const btn = document.getElementById('genBtn');
774
+ const btnText = document.getElementById('btnText');
775
+
776
+ btn.disabled = true;
777
+ btn.style.backgroundColor = '#333';
778
+ 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>`;
779
+ lucide.createIcons();
780
+
781
+ document.getElementById('emptyState').classList.add('hidden');
782
+ document.getElementById('outputImg').classList.add('hidden');
783
+ document.getElementById('textResult').classList.add('hidden'); // Ensure text result is hidden
784
+ document.getElementById('loadingState').classList.remove('hidden');
785
+
786
+ try {
787
+ const seed = Math.floor(Math.random() * 1000000000000000);
788
+ const res = await fetch('/api/generate', {
789
+ method: 'POST',
790
+ headers: { 'Content-Type': 'application/json' },
791
+ body: JSON.stringify({
792
+ workflow_json: "2511.json",
793
+ params: {
794
+ "31": { "image": uploadedPath },
795
+ "11": { "prompt": promptInput.value },
796
+ "14": { "seed": seed }
797
+ },
798
+ type: "angle",
799
+ client_id: CLIENT_ID
800
+ })
801
+ });
802
+
803
+ const data = await res.json();
804
+ if (data.error) throw new Error(data.error);
805
+ if (!data.images?.length) throw new Error("No images returned");
806
+
807
+ currentResult = data;
808
+ const outputImg = document.getElementById('outputImg');
809
+ const downloadBtn = document.getElementById('downloadBtn');
810
+
811
+ outputImg.src = data.images[0];
812
+ outputImg.classList.remove('hidden');
813
+ document.getElementById('loadingState').classList.add('hidden');
814
+
815
+ downloadBtn.href = data.images[0];
816
+ downloadBtn.classList.remove('hidden');
817
+ downloadBtn.download = `Angle-${Date.now()}.png`;
818
+
819
+ btn.style.backgroundColor = '';
820
+ btn.innerHTML = `<i data-lucide="zap" class="w-4 h-4 text-yellow-400"></i><span id="btnText">Generate New Angle</span>`;
821
+ btn.disabled = false;
822
+ lucide.createIcons();
823
+
824
+ // Add to history
825
+ renderImageCard({
826
+ images: data.images,
827
+ prompt: promptInput.value,
828
+ timestamp: Date.now()
829
+ }, true);
830
+
831
+ } catch (err) {
832
+ console.error(err);
833
+ btn.style.backgroundColor = '';
834
+ btn.innerHTML = `<i data-lucide="zap" class="w-4 h-4 text-yellow-400"></i><span id="btnText">Generation Failed</span>`;
835
+ lucide.createIcons();
836
+ document.getElementById('loadingState').classList.add('hidden');
837
+ document.getElementById('emptyState').classList.remove('hidden');
838
+ btn.disabled = false;
839
+ alert(err.message);
840
+ }
841
+ }
842
+
843
+ window.copyText = () => {
844
+ const text = document.getElementById('generatedText').innerText;
845
+ navigator.clipboard.writeText(text).then(() => {
846
+ const btn = document.querySelector('#textResult button');
847
+ const originalHTML = btn.innerHTML;
848
+ btn.innerHTML = `<i data-lucide="check" class="w-3 h-3"></i> Copied`;
849
+ setTimeout(() => {
850
+ btn.innerHTML = originalHTML;
851
+ lucide.createIcons();
852
+ }, 2000);
853
+ });
854
+ };
855
+
856
+ // History Management
857
+ function renderImageCard(data, isNew = false) {
858
+ const masonry = document.getElementById('masonry');
859
+ const imgUrl = data.images ? data.images[0] : '';
860
+ if (!imgUrl) return;
861
+
862
+ const card = document.createElement('div');
863
+ card.className = "masonry-item relative group cursor-pointer";
864
+
865
+ // Random height logic: aspect ratio between 1.0 (square) and 0.66 (1.5x height)
866
+ // But user said "height not exceeding 1.5x width", so aspect ratio >= 1/1.5 = 0.666
867
+ // We want random heights. Let's vary the aspect-ratio style of the image.
868
+ // A random value between 0.67 and 1.0.
869
+ const randomRatio = 0.67 + Math.random() * 0.33;
870
+
871
+ card.onclick = () => openLightbox(imgUrl);
872
+ card.innerHTML = `
873
+ <div style="aspect-ratio: ${randomRatio}; overflow: hidden;">
874
+ <img src="${imgUrl}" class="w-full h-full object-cover block transform group-hover:scale-105 transition-transform duration-[1.5s]">
875
+ </div>
876
+ <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">
877
+ <p class="text-white text-[10px] font-bold uppercase tracking-widest line-clamp-2">${data.prompt || "Angle Control"}</p>
878
+ </div>
879
+ `;
880
+
881
+ if (isNew) masonry.prepend(card);
882
+ else masonry.appendChild(card);
883
+ }
884
+
885
+ function loadNextPage() {
886
+ const batch = allHistory.slice(currentIndex, currentIndex + PAGE_SIZE);
887
+ if (batch.length === 0) {
888
+ const el = document.getElementById('loadMoreTrigger');
889
+ if (el) el.innerText = "End of Archive";
890
+ return;
891
+ }
892
+ batch.forEach(item => renderImageCard(item, false));
893
+ currentIndex += PAGE_SIZE;
894
+ }
895
+
896
+ async function loadHistory() {
897
+ try {
898
+ const res = await fetch('/api/history?type=angle');
899
+ const history = await res.json();
900
+ if (history && Array.isArray(history)) {
901
+ allHistory = history;
902
+ document.getElementById('masonry').innerHTML = '';
903
+ currentIndex = 0;
904
+ loadNextPage();
905
+ }
906
+ } catch (e) { console.error(e); }
907
+ }
908
+
909
+ // Lightbox
910
+ function openLightbox(url) {
911
+ const img = document.getElementById('lightboxImg');
912
+ const resPill = document.getElementById('lightboxRes');
913
+
914
+ resPill.style.opacity = '0';
915
+ img.src = url;
916
+
917
+ const lb = document.getElementById('lightbox');
918
+ lb.classList.replace('hidden', 'flex');
919
+ img.classList.remove('hidden');
920
+ document.body.style.overflow = 'hidden';
921
+
922
+ const updateRes = () => {
923
+ if (img.naturalWidth) {
924
+ resPill.innerText = `${img.naturalWidth} x ${img.naturalHeight}`;
925
+ resPill.style.opacity = '1';
926
+ }
927
+ };
928
+
929
+ img.onload = updateRes;
930
+ if (img.complete) updateRes();
931
+ }
932
+
933
+ function closeLightbox() {
934
+ const lb = document.getElementById('lightbox');
935
+ lb.classList.replace('flex', 'hidden');
936
+ document.body.style.overflow = 'auto';
937
+ }
938
+
939
+ function handleOutsideClick(e) {
940
+ if (e.target.id === 'lightbox') closeLightbox();
941
+ }
942
+
943
+ function downloadLightboxImage() {
944
+ const imgUrl = document.getElementById('lightboxImg').src;
945
+ const link = document.createElement('a');
946
+ link.href = imgUrl;
947
+ link.download = `Angle-Master-${Date.now()}.png`;
948
+ document.body.appendChild(link);
949
+ link.click();
950
+ document.body.removeChild(link);
951
+ }
952
+
953
+ function zoomImage() {
954
+ if (currentResult && currentResult.images && currentResult.images[0]) {
955
+ openLightbox(currentResult.images[0]);
956
+ }
957
+ }
958
+
959
+ // Init
960
+ const observer = new IntersectionObserver((entries) => {
961
+ if (entries[0].isIntersecting && allHistory.length > 0) {
962
+ loadNextPage();
963
+ }
964
+ }, { threshold: 0.1 });
965
+
966
+ window.onload = () => {
967
+ loadHistory();
968
+ observer.observe(document.getElementById('loadMoreTrigger'));
969
+ };
970
+
971
+ // WebSocket for real-time updates (optional but good for multi-tab sync)
972
+ const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
973
+ const wsUrl = `${wsProtocol}//${window.location.host}/ws/stats?client_id=${CLIENT_ID}`;
974
+ const socket = new WebSocket(wsUrl);
975
+ socket.onopen = () => {
976
+ setInterval(() => {
977
+ if (socket.readyState === WebSocket.OPEN) socket.send("ping");
978
+ }, 30000);
979
+ };
980
+ </script>
981
+ </body>
982
+
983
+ </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,515 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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;
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
+ </style>
347
+ </head>
348
+
349
+ <body>
350
+
351
+ <div class="app-shell">
352
+ <aside class="sidebar">
353
+ <div class="logo-ring mb-12">
354
+ <div class="w-1.5 h-1.5 bg-black rounded-full transition-colors" id="logo-dot"></div>
355
+ </div>
356
+
357
+ <nav>
358
+ <div class="nav-item active" onclick="switchUI(this, 'zimage')">
359
+ <svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
360
+ <path
361
+ 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"
362
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
363
+ </svg>
364
+ <span class="nav-text">文生图</span>
365
+ </div>
366
+ <div class="nav-item" onclick="switchUI(this, 'enhance')">
367
+ <svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
368
+ <path d="M13 10V3L4 14h7v7l9-11h-7z" stroke-width="2" stroke-linecap="round"
369
+ stroke-linejoin="round"></path>
370
+ </svg>
371
+ <span class="nav-text">细节增强</span>
372
+ </div>
373
+ <div class="nav-item" onclick="switchUI(this, 'klein')">
374
+ <svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
375
+ <path
376
+ 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"
377
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
378
+ </svg>
379
+ <span class="nav-text">图片编辑</span>
380
+ </div>
381
+ <div class="nav-item" onclick="switchUI(this, 'angle')">
382
+ <svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"
383
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
384
+ <path
385
+ 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">
386
+ </path>
387
+ <polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline>
388
+ <line x1="12" y1="22.08" x2="12" y2="12"></line>
389
+ </svg>
390
+ <span class="nav-text">角度控制</span>
391
+ </div>
392
+ </nav>
393
+
394
+ <div class="author-box">
395
+ <span class="dx-letter letter-d">D</span>
396
+ <span class="dx-letter letter-x">X</span>
397
+
398
+ <div class="author-content-wrap">
399
+ <div class="author-name-lite">wuli大雄</div>
400
+ <div class="social-row-lite">
401
+ <a href="https://space.bilibili.com/78652351" target="_blank" class="social-icon-lite">
402
+ <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
403
+ <path
404
+ 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" />
405
+ </svg>
406
+ </a>
407
+ <a href="https://www.xiaohongshu.com/user/profile/6433c34c000000001a023538" target="_blank"
408
+ class="social-icon-lite">
409
+ <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
410
+ <path
411
+ 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" />
412
+ </svg>
413
+ </a>
414
+ <a href="https://www.youtube.com/@%E5%A4%A7%E9%9B%84dx" target="_blank"
415
+ class="social-icon-lite">
416
+ <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
417
+ <path
418
+ 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" />
419
+ </svg>
420
+ </a>
421
+ <a href="https://x.com/dx8152?s=21" target="_blank" class="social-icon-lite">
422
+ <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
423
+ <path
424
+ 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" />
425
+ </svg>
426
+ </a>
427
+ </div>
428
+ </div>
429
+ </div>
430
+ </aside>
431
+
432
+ <main class="stage">
433
+ <iframe id="frame-zimage" src="/static/zimage.html?v=30" class="active"></iframe>
434
+ <iframe id="frame-enhance" data-src="/static/enhance.html?v=30"></iframe>
435
+ <iframe id="frame-klein" data-src="/static/klein.html?v=30"></iframe>
436
+ <iframe id="frame-angle" data-src="/static/angle.html?v=30"></iframe>
437
+
438
+ <div class="nano-monitor" id="nano-monitor">
439
+ <div class="stat-group">
440
+ <div class="pulse-dot animate-pulse"></div>
441
+ <div class="spinner-nano"></div>
442
+ <span class="label-nano">ONLINE</span>
443
+ <span id="online-val">1</span>
444
+ </div>
445
+ <div class="divider"></div>
446
+ <div class="stat-group">
447
+ <span class="label-nano">QUEUE</span>
448
+ <span id="queue-val">0</span>
449
+ </div>
450
+ </div>
451
+ </main>
452
+ </div>
453
+
454
+ <script>
455
+ function generateUUID() {
456
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
457
+ try { return crypto.randomUUID(); } catch (e) { }
458
+ }
459
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
460
+ var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
461
+ return v.toString(16);
462
+ });
463
+ }
464
+ const CID = localStorage.getItem("client_id") || generateUUID();
465
+ localStorage.setItem("client_id", CID);
466
+
467
+ function switchUI(el, id) {
468
+ document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
469
+ el.classList.add('active');
470
+ document.querySelectorAll('iframe').forEach(f => f.classList.remove('active'));
471
+ const target = document.getElementById('frame-' + id);
472
+ target.classList.add('active');
473
+ if (!target.src) target.src = target.dataset.src;
474
+ }
475
+
476
+ async function syncStatus() {
477
+ try {
478
+ const res = await fetch(`/api/queue_status?client_id=${CID}`);
479
+ const data = await res.json();
480
+
481
+ const monitor = document.getElementById('nano-monitor');
482
+ const queueVal = document.getElementById('queue-val');
483
+ const logoDot = document.getElementById('logo-dot');
484
+
485
+ const total = data.total || 0;
486
+ const pos = data.position || 0;
487
+
488
+ if (pos > 0) {
489
+ monitor.classList.add('is-busy');
490
+ queueVal.innerText = `${pos}/${total}`;
491
+ logoDot.style.backgroundColor = '#3b82f6';
492
+ } else {
493
+ monitor.classList.remove('is-busy');
494
+ queueVal.innerText = total > 0 ? total : '0';
495
+ logoDot.style.backgroundColor = '#000';
496
+ }
497
+ } catch (e) { }
498
+ }
499
+
500
+ const host = window.location.host;
501
+ if (host) {
502
+ const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
503
+ const ws = new WebSocket(`${protocol}://${host}/ws/stats`);
504
+ ws.onmessage = (e) => {
505
+ const d = JSON.parse(e.data);
506
+ if (d.online_count) {
507
+ document.getElementById('online-val').innerText = d.online_count;
508
+ }
509
+ };
510
+ setInterval(syncStatus, 2000);
511
+ }
512
+ </script>
513
+ </body>
514
+
515
+ </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,668 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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="hard-drive" 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
+ <div id="tokenSeparator" class="h-8 w-px bg-gray-100 hidden"></div>
219
+
220
+ <div id="tokenContainer" class="hidden flex-col">
221
+ <span class="text-[9px] font-bold text-gray-400 uppercase mb-1">ModelScope Token</span>
222
+ <div class="flex items-center gap-2">
223
+ <input id="msToken" type="password"
224
+ class="text-xs font-bold bg-transparent border-b border-gray-100 focus:border-black outline-none w-36 py-1 transition-colors"
225
+ placeholder="Stored in LocalStorage">
226
+ <button onclick="saveGlobalToken()"
227
+ class="text-[8px] bg-gray-100 hover:bg-black hover:text-white px-2 py-0.5 rounded transition-colors"
228
+ title="Save as default for all users">SAVE GLOBAL</button>
229
+ <button onclick="deleteGlobalToken()"
230
+ class="text-[8px] bg-gray-100 hover:bg-red-500 hover:text-white px-1.5 py-0.5 rounded transition-colors"
231
+ title="Delete global default">
232
+ <i data-lucide="trash-2" class="w-3 h-3"></i>
233
+ </button>
234
+ </div>
235
+ </div>
236
+ </div>
237
+
238
+ <button id="mainGenBtn" onclick="handleRender()"
239
+ class="w-full md:w-56 h-12 btn-render rounded-xl font-bold text-[11px] uppercase flex items-center justify-center gap-3">
240
+ <i data-lucide="zap" id="btnIcon" class="w-4 h-4 text-yellow-400"></i>
241
+ <span id="btnText">Render Art</span>
242
+ </button>
243
+ </div>
244
+ </div>
245
+ </div>
246
+ </header>
247
+
248
+ <main class="pb-24">
249
+ <div class="layout-container">
250
+ <div id="masonry" class="masonry-grid"></div>
251
+ <div id="loadMoreTrigger"
252
+ class="py-12 text-center text-gray-300 text-[10px] font-bold uppercase tracking-widest cursor-pointer hidden">
253
+ Load More Archive
254
+ </div>
255
+ </div>
256
+ </main>
257
+
258
+ <div id="lightbox" onclick="handleOutsideClick(event)"
259
+ class="hidden fixed inset-0 z-50 gallery-lightbox flex flex-col items-center justify-center p-8">
260
+ <button onclick="closeLightbox()" class="absolute top-10 right-10 text-gray-400 hover:text-black"><i
261
+ data-lucide="x" class="w-8 h-8"></i></button>
262
+ <div class="max-w-5xl w-full flex flex-col items-center pointer-events-none">
263
+ <div class="relative pointer-events-auto">
264
+ <img id="lightboxImg" src="" class="max-h-[60vh] rounded-lg shadow-xl">
265
+ <div
266
+ 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">
267
+ <span id="lightboxRes">0x0</span>
268
+ </div>
269
+ <button onclick="downloadImage()"
270
+ 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">
271
+ <i data-lucide="download" class="w-5 h-5"></i>
272
+ </button>
273
+ </div>
274
+ <div id="lightboxCard"
275
+ 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">
276
+ <div class="flex-1">
277
+ <span class="text-[9px] font-black text-gray-300 uppercase tracking-widest block mb-2">Prompt
278
+ Execution</span>
279
+ <p id="lightboxPrompt" class="text-gray-700 text-sm leading-relaxed max-h-32 overflow-y-auto pr-2">
280
+ </p>
281
+ </div>
282
+ <button onclick="applySameStyle()"
283
+ class="bg-black text-white px-8 py-3.5 rounded-2xl text-[10px] font-black uppercase tracking-widest flex items-center gap-2">
284
+ <i data-lucide="copy" class="w-3 h-3"></i>
285
+ <span>Replicate</span>
286
+ </button>
287
+ </div>
288
+ </div>
289
+ </div>
290
+
291
+ <script>
292
+ lucide.createIcons();
293
+
294
+ function generateUUID() {
295
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
296
+ try { return crypto.randomUUID(); } catch (e) { }
297
+ }
298
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
299
+ var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
300
+ return v.toString(16);
301
+ });
302
+ }
303
+ const CLIENT_ID = localStorage.getItem("client_id") || generateUUID();
304
+ localStorage.setItem("client_id", CLIENT_ID);
305
+
306
+ let allHistory = [];
307
+ let currentIndex = 0;
308
+ const PAGE_SIZE = 15;
309
+ let isLoading = false;
310
+
311
+ // WebSocket
312
+ const socket = new WebSocket(`${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws/stats`);
313
+ socket.onmessage = (e) => {
314
+ try {
315
+ const msg = JSON.parse(e.data);
316
+ if (msg.type === 'new_image' && msg.data?.type === 'zimage') {
317
+ if (!document.getElementById(`history-${msg.data.timestamp}`)) {
318
+ allHistory.unshift(msg.data);
319
+ renderImageCard(msg.data, true);
320
+ currentIndex++;
321
+ }
322
+ }
323
+ } catch (err) { }
324
+ };
325
+
326
+ let currentEngine = 'local';
327
+ const MS_TOKEN_KEY = 'modelscope_api_token';
328
+ const ENGINE_MODE_KEY = 'zimage_engine_mode';
329
+
330
+ // 1. 保存/加载 Token 逻辑
331
+ const tokenInput = document.getElementById('msToken');
332
+
333
+ async function initToken() {
334
+ // First check local storage
335
+ let token = localStorage.getItem(MS_TOKEN_KEY);
336
+ if (!token) {
337
+ // If no local token, check global config
338
+ try {
339
+ const res = await fetch('/api/config/token');
340
+ const data = await res.json();
341
+ if (data.token) {
342
+ token = data.token;
343
+ // Don't save to local storage immediately to distinguish source
344
+ // But fill input
345
+ }
346
+ } catch (e) { }
347
+ }
348
+ if (token) tokenInput.value = token;
349
+ }
350
+ initToken();
351
+
352
+ tokenInput.addEventListener('input', (e) => {
353
+ localStorage.setItem(MS_TOKEN_KEY, e.target.value.trim());
354
+ });
355
+
356
+ async function saveGlobalToken() {
357
+ const token = tokenInput.value.trim();
358
+ if (!token) return alert("Please enter a token first");
359
+
360
+ try {
361
+ await fetch('/api/config/token', {
362
+ method: 'POST',
363
+ headers: { 'Content-Type': 'application/json' },
364
+ body: JSON.stringify({ token })
365
+ });
366
+ alert("Global token saved successfully!");
367
+ } catch (e) {
368
+ alert("Failed to save global token: " + e.message);
369
+ }
370
+ }
371
+
372
+ async function deleteGlobalToken() {
373
+ if (!confirm("Are you sure you want to delete the GLOBAL default token? This will affect all new users.")) return;
374
+ try {
375
+ await fetch('/api/config/token', { method: 'DELETE' });
376
+ alert("Global token deleted.");
377
+ tokenInput.value = '';
378
+ localStorage.removeItem(MS_TOKEN_KEY);
379
+ } catch (e) {
380
+ alert("Failed to delete: " + e.message);
381
+ }
382
+ }
383
+
384
+ // 2. 切换引擎逻辑
385
+ function switchEngine(mode) {
386
+ currentEngine = mode;
387
+ localStorage.setItem(ENGINE_MODE_KEY, mode); // Save state
388
+
389
+ const glider = document.getElementById('glider');
390
+ const localBtn = document.getElementById('modeLocal');
391
+ const cloudBtn = document.getElementById('modeCloud');
392
+ const btnText = document.getElementById('btnText');
393
+ const msLogo = document.getElementById('msLogo');
394
+ const tokenContainer = document.getElementById('tokenContainer');
395
+ const tokenSeparator = document.getElementById('tokenSeparator');
396
+
397
+ if (mode === 'local') {
398
+ glider.style.transform = 'translateX(0)';
399
+ localBtn.classList.add('active');
400
+ cloudBtn.classList.remove('active');
401
+ btnText.innerText = 'Render Art (Local)';
402
+
403
+ tokenContainer.classList.add('hidden');
404
+ tokenContainer.classList.remove('flex');
405
+
406
+ tokenSeparator.classList.add('hidden');
407
+ if (msLogo) {
408
+ msLogo.classList.add('opacity-50');
409
+ msLogo.style.filter = 'grayscale(100%)';
410
+ }
411
+ } else {
412
+ glider.style.transform = 'translateX(100%)';
413
+ cloudBtn.classList.add('active');
414
+ localBtn.classList.remove('active');
415
+ btnText.innerText = 'Render Art (Cloud)';
416
+
417
+ tokenContainer.classList.remove('hidden');
418
+ tokenContainer.classList.add('flex');
419
+
420
+ tokenSeparator.classList.remove('hidden');
421
+ if (msLogo) {
422
+ msLogo.classList.remove('opacity-50');
423
+ msLogo.style.filter = 'none';
424
+ }
425
+ }
426
+ }
427
+
428
+ // Initialize Engine Mode
429
+ const savedEngine = localStorage.getItem(ENGINE_MODE_KEY);
430
+ if (savedEngine && savedEngine !== 'local') {
431
+ switchEngine(savedEngine);
432
+ }
433
+
434
+ // 3. 统一渲染入口
435
+ async function handleRender() {
436
+ const prompt = document.getElementById('prompt').value.trim();
437
+ if (!prompt) return alert("Please enter a prompt");
438
+
439
+ if (currentEngine === 'local') {
440
+ runLocalTask(prompt);
441
+ } else {
442
+ runCloudTask(prompt);
443
+ }
444
+ }
445
+
446
+ // 4. ModelScope 云端逻辑
447
+ async function runCloudTask(prompt) {
448
+ const apiKey = tokenInput.value;
449
+ if (!apiKey) return alert("ModelScope Token Required");
450
+
451
+ const btn = document.getElementById('mainGenBtn');
452
+ setLoading(true);
453
+
454
+ const placeholder = createPlaceholder("ModelScope Rendering");
455
+ document.getElementById('masonry').prepend(placeholder);
456
+
457
+ try {
458
+ const res = await fetch('/generate', {
459
+ method: 'POST',
460
+ headers: { 'Content-Type': 'application/json' },
461
+ body: JSON.stringify({
462
+ prompt: prompt,
463
+ api_key: apiKey,
464
+ resolution: `${document.getElementById('width').value}x${document.getElementById('height').value}`
465
+ })
466
+ });
467
+ const data = await res.json();
468
+ placeholder.remove();
469
+ if (res.ok && data.url) {
470
+ renderImageCard({ timestamp: Date.now(), prompt, images: [data.url], type: 'cloud' }, true);
471
+ } else {
472
+ throw new Error(data.detail?.errors?.message || data.detail || "Cloud Error");
473
+ }
474
+ } catch (e) {
475
+ placeholder.remove();
476
+ alert(e.message);
477
+ } finally {
478
+ setLoading(false);
479
+ }
480
+ }
481
+
482
+ // 5. 本地任务逻辑
483
+ async function runLocalTask(prompt) {
484
+ setLoading(true);
485
+ const placeholder = createPlaceholder("Local Rendering");
486
+ document.getElementById('masonry').prepend(placeholder);
487
+
488
+ try {
489
+ const res = await fetch('/api/generate', {
490
+ method: 'POST',
491
+ headers: { 'Content-Type': 'application/json' },
492
+ body: JSON.stringify({
493
+ prompt,
494
+ width: parseInt(document.getElementById('width').value),
495
+ height: parseInt(document.getElementById('height').value),
496
+ type: "zimage",
497
+ client_id: CLIENT_ID
498
+ })
499
+ });
500
+ const data = await res.json();
501
+ placeholder.remove();
502
+ if (data.images?.length > 0) renderImageCard(data, true);
503
+ } catch (e) {
504
+ placeholder.remove();
505
+ alert("Local render failed");
506
+ } finally {
507
+ setLoading(false);
508
+ }
509
+ }
510
+
511
+ // 工具函数
512
+ function setLoading(isLoading) {
513
+ const btn = document.getElementById('mainGenBtn');
514
+ btn.disabled = isLoading;
515
+
516
+ if (isLoading) {
517
+ // Active state: Dark gray bg, filled yellow pulsing icon
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>Processing...</span>`;
520
+ } else {
521
+ // Reset state: Original bg (via CSS), outline yellow icon
522
+ btn.style.backgroundColor = '';
523
+ btn.innerHTML = `<i data-lucide="zap" class="w-4 h-4 text-yellow-400"></i><span id="btnText">Render Art (${currentEngine.toUpperCase()})</span>`;
524
+ }
525
+ lucide.createIcons();
526
+ }
527
+
528
+ function createPlaceholder(text) {
529
+ const div = document.createElement('div');
530
+ div.className = 'masonry-item bg-gray-50 flex flex-col items-center justify-center border-dashed border-2 border-gray-200';
531
+ 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>`;
532
+ return div;
533
+ }
534
+
535
+ function renderImageCard(data, isNew = false) {
536
+ if (document.getElementById(`history-${data.timestamp}`)) return;
537
+ const masonry = document.getElementById('masonry');
538
+ const card = document.createElement('div');
539
+ card.id = `history-${data.timestamp}`;
540
+ card.className = 'masonry-item group cursor-zoom-in animate-in fade-in duration-700';
541
+ card.onclick = () => openLightbox(data.images[0], data.prompt);
542
+ card.innerHTML = `
543
+ <img src="${data.images[0]}" class="w-full h-full object-cover" loading="lazy">
544
+ ${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>' : ''}
545
+ <div class="absolute top-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity z-10">
546
+ <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">
547
+ <i data-lucide="trash-2" class="w-4 h-4"></i>
548
+ </button>
549
+ </div>
550
+ `;
551
+ isNew ? masonry.prepend(card) : masonry.appendChild(card);
552
+ lucide.createIcons();
553
+ }
554
+
555
+ async function loadHistory(page = 0) {
556
+ if (isLoading) return;
557
+ const trigger = document.getElementById('loadMoreTrigger');
558
+
559
+ try {
560
+ isLoading = true;
561
+ if (page === 0) {
562
+ allHistory = [];
563
+ document.getElementById('masonry').innerHTML = '';
564
+ currentIndex = 0;
565
+
566
+ trigger.classList.remove('hidden');
567
+ trigger.innerText = "Loading Archives...";
568
+
569
+ const res = await fetch('/api/history?type=zimage');
570
+ allHistory = await res.json();
571
+ } else {
572
+ trigger.innerText = "Loading...";
573
+ await new Promise(r => setTimeout(r, 400));
574
+ }
575
+
576
+ const nextData = allHistory.slice(currentIndex, currentIndex + PAGE_SIZE);
577
+ nextData.forEach(item => renderImageCard(item));
578
+ currentIndex += nextData.length;
579
+
580
+ if (currentIndex >= allHistory.length) {
581
+ trigger.classList.add('hidden');
582
+ } else {
583
+ trigger.classList.remove('hidden');
584
+ trigger.innerText = "Load More Archive";
585
+ }
586
+
587
+ } catch (e) {
588
+ console.error(e);
589
+ trigger.innerText = "Error Loading History";
590
+ } finally {
591
+ isLoading = false;
592
+ }
593
+ }
594
+
595
+ async function deleteHistoryItem(timestamp, event) {
596
+ event.stopPropagation();
597
+ const res = await fetch('/api/history/delete', {
598
+ method: 'POST',
599
+ headers: { 'Content-Type': 'application/json' },
600
+ body: JSON.stringify({ timestamp })
601
+ });
602
+ if ((await res.json()).success) document.getElementById(`history-${timestamp}`).remove();
603
+ }
604
+
605
+ const observer = new IntersectionObserver((entries) => {
606
+ if (entries[0].isIntersecting && !isLoading && currentIndex < allHistory.length) {
607
+ loadHistory(1);
608
+ }
609
+ }, { threshold: 0.1 });
610
+
611
+ window.onload = () => {
612
+ loadHistory(0).then(() => {
613
+ const trigger = document.getElementById('loadMoreTrigger');
614
+ if (trigger) {
615
+ observer.observe(trigger);
616
+ trigger.onclick = () => loadHistory(1);
617
+ }
618
+ });
619
+
620
+ setInterval(async () => {
621
+ try {
622
+ const res = await fetch("/api/queue_status?client_id=" + encodeURIComponent(CLIENT_ID));
623
+ const data = await res.json();
624
+ const statusText = document.getElementById("statusText");
625
+ const dotColor = document.getElementById("dotColor");
626
+ if (statusText && dotColor) {
627
+ if (data.total > 0) {
628
+ statusText.innerText = `Queueing ${data.position}/${data.total}`;
629
+ statusText.className = "text-[9px] font-bold text-orange-500 uppercase";
630
+ dotColor.className = "w-1.5 h-1.5 bg-orange-500 rounded-full animate-pulse";
631
+ } else {
632
+ statusText.innerText = "System Ready";
633
+ statusText.className = "text-[9px] font-bold text-gray-500 uppercase";
634
+ dotColor.className = "w-1.5 h-1.5 bg-black rounded-full";
635
+ }
636
+ }
637
+ } catch (e) { }
638
+ }, 3000);
639
+ };
640
+
641
+ // 基础功能
642
+ function openLightbox(url, prompt) {
643
+ const img = document.getElementById('lightboxImg');
644
+ const resDisplay = document.getElementById('lightboxRes');
645
+ resDisplay.innerText = "...";
646
+ img.src = url;
647
+ img.onload = () => {
648
+ resDisplay.innerText = `${img.naturalWidth} x ${img.naturalHeight}`;
649
+ };
650
+ document.getElementById('lightboxPrompt').innerText = prompt;
651
+ document.getElementById('lightbox').classList.remove('hidden');
652
+ document.body.style.overflow = 'hidden';
653
+ }
654
+ function closeLightbox() { document.getElementById('lightbox').classList.add('hidden'); document.body.style.overflow = 'auto'; }
655
+ function handleOutsideClick(e) { if (e.target.id === 'lightbox') closeLightbox(); }
656
+ function downloadImage() {
657
+ const a = document.createElement('a'); a.href = document.getElementById('lightboxImg').src;
658
+ a.download = `Art-${Date.now()}.png`; a.click();
659
+ }
660
+ function applySameStyle() {
661
+ document.getElementById('prompt').value = document.getElementById('lightboxPrompt').innerText;
662
+ closeLightbox();
663
+ window.scrollTo({ top: 0, behavior: 'smooth' });
664
+ }
665
+ </script>
666
+ </body>
667
+
668
+ </html>
workflows/2511.json ADDED
@@ -0,0 +1,264 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "1": {
3
+ "inputs": {
4
+ "strength": 1,
5
+ "model": [
6
+ "2",
7
+ 0
8
+ ]
9
+ },
10
+ "class_type": "CFGNorm",
11
+ "_meta": {
12
+ "title": "CFG归一化"
13
+ }
14
+ },
15
+ "2": {
16
+ "inputs": {
17
+ "shift": 3,
18
+ "model": [
19
+ "20",
20
+ 0
21
+ ]
22
+ },
23
+ "class_type": "ModelSamplingAuraFlow",
24
+ "_meta": {
25
+ "title": "模型采样算法AuraFlow"
26
+ }
27
+ },
28
+ "3": {
29
+ "inputs": {
30
+ "prompt": "",
31
+ "speak_and_recognation": {
32
+ "__value__": [
33
+ false,
34
+ true
35
+ ]
36
+ },
37
+ "clip": [
38
+ "87",
39
+ 0
40
+ ],
41
+ "vae": [
42
+ "22",
43
+ 0
44
+ ],
45
+ "image1": [
46
+ "39",
47
+ 0
48
+ ]
49
+ },
50
+ "class_type": "TextEncodeQwenImageEditPlus",
51
+ "_meta": {
52
+ "title": "文本编码(QwenImageEditPlus)"
53
+ }
54
+ },
55
+ "10": {
56
+ "inputs": {
57
+ "pixels": [
58
+ "39",
59
+ 0
60
+ ],
61
+ "vae": [
62
+ "22",
63
+ 0
64
+ ]
65
+ },
66
+ "class_type": "VAEEncode",
67
+ "_meta": {
68
+ "title": "VAE编码"
69
+ }
70
+ },
71
+ "11": {
72
+ "inputs": {
73
+ "prompt": "将摄像机向右旋转45度",
74
+ "speak_and_recognation": {
75
+ "__value__": [
76
+ false,
77
+ true
78
+ ]
79
+ },
80
+ "clip": [
81
+ "87",
82
+ 0
83
+ ],
84
+ "vae": [
85
+ "22",
86
+ 0
87
+ ],
88
+ "image1": [
89
+ "39",
90
+ 0
91
+ ]
92
+ },
93
+ "class_type": "TextEncodeQwenImageEditPlus",
94
+ "_meta": {
95
+ "title": "文本编码(QwenImageEditPlus)"
96
+ }
97
+ },
98
+ "12": {
99
+ "inputs": {
100
+ "samples": [
101
+ "14",
102
+ 0
103
+ ],
104
+ "vae": [
105
+ "22",
106
+ 0
107
+ ]
108
+ },
109
+ "class_type": "VAEDecode",
110
+ "_meta": {
111
+ "title": "VAE解码"
112
+ }
113
+ },
114
+ "14": {
115
+ "inputs": {
116
+ "seed": 691951626916858,
117
+ "steps": 8,
118
+ "cfg": 1,
119
+ "sampler_name": "euler",
120
+ "scheduler": "simple",
121
+ "denoise": 1,
122
+ "model": [
123
+ "1",
124
+ 0
125
+ ],
126
+ "positive": [
127
+ "84",
128
+ 0
129
+ ],
130
+ "negative": [
131
+ "85",
132
+ 0
133
+ ],
134
+ "latent_image": [
135
+ "10",
136
+ 0
137
+ ]
138
+ },
139
+ "class_type": "KSampler",
140
+ "_meta": {
141
+ "title": "K采样器"
142
+ }
143
+ },
144
+ "20": {
145
+ "inputs": {
146
+ "lora_name": "qwen\\Qwen-Image-Edit-2511-Lightning-4steps-V1.0-bf16.safetensors",
147
+ "strength_model": 1,
148
+ "model": [
149
+ "76",
150
+ 0
151
+ ]
152
+ },
153
+ "class_type": "LoraLoaderModelOnly",
154
+ "_meta": {
155
+ "title": "LoRA加载器(仅模型)"
156
+ }
157
+ },
158
+ "22": {
159
+ "inputs": {
160
+ "vae_name": "qwen_image_vae.safetensors"
161
+ },
162
+ "class_type": "VAELoader",
163
+ "_meta": {
164
+ "title": "VAE加载器"
165
+ }
166
+ },
167
+ "31": {
168
+ "inputs": {
169
+ "image": "10_start_1.jpg"
170
+ },
171
+ "class_type": "LoadImage",
172
+ "_meta": {
173
+ "title": "加载图像"
174
+ }
175
+ },
176
+ "39": {
177
+ "inputs": {
178
+ "upscale_method": "lanczos",
179
+ "megapixels": 1,
180
+ "resolution_steps": 1,
181
+ "image": [
182
+ "31",
183
+ 0
184
+ ]
185
+ },
186
+ "class_type": "ImageScaleToTotalPixels",
187
+ "_meta": {
188
+ "title": "图像按像素缩放"
189
+ }
190
+ },
191
+ "45": {
192
+ "inputs": {
193
+ "images": [
194
+ "12",
195
+ 0
196
+ ]
197
+ },
198
+ "class_type": "PreviewImage",
199
+ "_meta": {
200
+ "title": "预览图像"
201
+ }
202
+ },
203
+ "76": {
204
+ "inputs": {
205
+ "lora_name": "qwen\\qwen-image-edit-2511-multiple-angles-lora.safetensors",
206
+ "strength_model": 1,
207
+ "model": [
208
+ "86",
209
+ 0
210
+ ]
211
+ },
212
+ "class_type": "LoraLoaderModelOnly",
213
+ "_meta": {
214
+ "title": "LoRA加载器(仅模型)"
215
+ }
216
+ },
217
+ "84": {
218
+ "inputs": {
219
+ "reference_latents_method": "index_timestep_zero",
220
+ "conditioning": [
221
+ "11",
222
+ 0
223
+ ]
224
+ },
225
+ "class_type": "FluxKontextMultiReferenceLatentMethod",
226
+ "_meta": {
227
+ "title": "FluxKontext多参考潜在方法"
228
+ }
229
+ },
230
+ "85": {
231
+ "inputs": {
232
+ "reference_latents_method": "index_timestep_zero",
233
+ "conditioning": [
234
+ "3",
235
+ 0
236
+ ]
237
+ },
238
+ "class_type": "FluxKontextMultiReferenceLatentMethod",
239
+ "_meta": {
240
+ "title": "FluxKontext多参考潜在方法"
241
+ }
242
+ },
243
+ "86": {
244
+ "inputs": {
245
+ "unet_name": "qwen_image_edit_2511_fp8_e4m3fn.safetensors",
246
+ "weight_dtype": "default"
247
+ },
248
+ "class_type": "UNETLoader",
249
+ "_meta": {
250
+ "title": "UNET加载器"
251
+ }
252
+ },
253
+ "87": {
254
+ "inputs": {
255
+ "clip_name": "qwen_2.5_vl_7b_fp8_scaled.safetensors",
256
+ "type": "qwen_image",
257
+ "device": "default"
258
+ },
259
+ "class_type": "CLIPLoader",
260
+ "_meta": {
261
+ "title": "CLIP加载器"
262
+ }
263
+ }
264
+ }
workflows/Flux2-Klein.json ADDED
@@ -0,0 +1,525 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "151": {
3
+ "inputs": {
4
+ "sampler_name": "euler"
5
+ },
6
+ "class_type": "KSamplerSelect",
7
+ "_meta": {
8
+ "title": "K采样器选择"
9
+ }
10
+ },
11
+ "152": {
12
+ "inputs": {
13
+ "steps": 4,
14
+ "width": [
15
+ "157",
16
+ 1
17
+ ],
18
+ "height": [
19
+ "157",
20
+ 1
21
+ ]
22
+ },
23
+ "class_type": "Flux2Scheduler",
24
+ "_meta": {
25
+ "title": "Flux2Scheduler"
26
+ }
27
+ },
28
+ "153": {
29
+ "inputs": {
30
+ "cfg": 1,
31
+ "model": [
32
+ "296",
33
+ 0
34
+ ],
35
+ "positive": [
36
+ "307",
37
+ 0
38
+ ],
39
+ "negative": [
40
+ "308",
41
+ 0
42
+ ]
43
+ },
44
+ "class_type": "CFGGuider",
45
+ "_meta": {
46
+ "title": "CFG引导"
47
+ }
48
+ },
49
+ "154": {
50
+ "inputs": {
51
+ "noise": [
52
+ "158",
53
+ 0
54
+ ],
55
+ "guider": [
56
+ "153",
57
+ 0
58
+ ],
59
+ "sampler": [
60
+ "151",
61
+ 0
62
+ ],
63
+ "sigmas": [
64
+ "152",
65
+ 0
66
+ ],
67
+ "latent_image": [
68
+ "156",
69
+ 0
70
+ ]
71
+ },
72
+ "class_type": "SamplerCustomAdvanced",
73
+ "_meta": {
74
+ "title": "自定义采样器(高级)"
75
+ }
76
+ },
77
+ "155": {
78
+ "inputs": {
79
+ "samples": [
80
+ "154",
81
+ 0
82
+ ],
83
+ "vae": [
84
+ "174",
85
+ 0
86
+ ]
87
+ },
88
+ "class_type": "VAEDecode",
89
+ "_meta": {
90
+ "title": "VAE解码"
91
+ }
92
+ },
93
+ "156": {
94
+ "inputs": {
95
+ "width": [
96
+ "157",
97
+ 0
98
+ ],
99
+ "height": [
100
+ "157",
101
+ 1
102
+ ],
103
+ "batch_size": 1
104
+ },
105
+ "class_type": "EmptyFlux2LatentImage",
106
+ "_meta": {
107
+ "title": "Empty Flux 2 Latent"
108
+ }
109
+ },
110
+ "157": {
111
+ "inputs": {
112
+ "image": [
113
+ "291",
114
+ 0
115
+ ]
116
+ },
117
+ "class_type": "GetImageSize",
118
+ "_meta": {
119
+ "title": "获取图像尺寸"
120
+ }
121
+ },
122
+ "158": {
123
+ "inputs": {
124
+ "noise_seed": 1046852288816614
125
+ },
126
+ "class_type": "RandomNoise",
127
+ "_meta": {
128
+ "title": "随机噪波"
129
+ }
130
+ },
131
+ "159": {
132
+ "inputs": {
133
+ "conditioning": [
134
+ "168",
135
+ 0
136
+ ],
137
+ "latent": [
138
+ "162",
139
+ 0
140
+ ]
141
+ },
142
+ "class_type": "ReferenceLatent",
143
+ "_meta": {
144
+ "title": "参考Latent"
145
+ }
146
+ },
147
+ "160": {
148
+ "inputs": {
149
+ "conditioning": [
150
+ "167",
151
+ 0
152
+ ],
153
+ "latent": [
154
+ "162",
155
+ 0
156
+ ]
157
+ },
158
+ "class_type": "ReferenceLatent",
159
+ "_meta": {
160
+ "title": "参考Latent"
161
+ }
162
+ },
163
+ "162": {
164
+ "inputs": {
165
+ "pixels": [
166
+ "291",
167
+ 0
168
+ ],
169
+ "vae": [
170
+ "174",
171
+ 0
172
+ ]
173
+ },
174
+ "class_type": "VAEEncode",
175
+ "_meta": {
176
+ "title": "VAE编码"
177
+ }
178
+ },
179
+ "164": {
180
+ "inputs": {
181
+ "pixels": [
182
+ "271",
183
+ 0
184
+ ],
185
+ "vae": [
186
+ "174",
187
+ 0
188
+ ]
189
+ },
190
+ "class_type": "VAEEncode",
191
+ "_meta": {
192
+ "title": "VAE编码"
193
+ }
194
+ },
195
+ "165": {
196
+ "inputs": {
197
+ "conditioning": [
198
+ "159",
199
+ 0
200
+ ],
201
+ "latent": [
202
+ "164",
203
+ 0
204
+ ]
205
+ },
206
+ "class_type": "ReferenceLatent",
207
+ "_meta": {
208
+ "title": "参考Latent"
209
+ }
210
+ },
211
+ "166": {
212
+ "inputs": {
213
+ "conditioning": [
214
+ "160",
215
+ 0
216
+ ],
217
+ "latent": [
218
+ "164",
219
+ 0
220
+ ]
221
+ },
222
+ "class_type": "ReferenceLatent",
223
+ "_meta": {
224
+ "title": "参考Latent"
225
+ }
226
+ },
227
+ "167": {
228
+ "inputs": {
229
+ "conditioning": [
230
+ "168",
231
+ 0
232
+ ]
233
+ },
234
+ "class_type": "ConditioningZeroOut",
235
+ "_meta": {
236
+ "title": "条件零化"
237
+ }
238
+ },
239
+ "168": {
240
+ "inputs": {
241
+ "text": "改为夜晚",
242
+ "speak_and_recognation": {
243
+ "__value__": [
244
+ false,
245
+ true
246
+ ]
247
+ },
248
+ "clip": [
249
+ "295",
250
+ 0
251
+ ]
252
+ },
253
+ "class_type": "CLIPTextEncode",
254
+ "_meta": {
255
+ "title": "CLIP文本编码器"
256
+ }
257
+ },
258
+ "174": {
259
+ "inputs": {
260
+ "vae_name": "flux2-vae.safetensors"
261
+ },
262
+ "class_type": "VAELoader",
263
+ "_meta": {
264
+ "title": "VAE加载器"
265
+ }
266
+ },
267
+ "178": {
268
+ "inputs": {
269
+ "pixels": [
270
+ "294",
271
+ 0
272
+ ],
273
+ "vae": [
274
+ "174",
275
+ 0
276
+ ]
277
+ },
278
+ "class_type": "VAEEncode",
279
+ "_meta": {
280
+ "title": "VAE编码"
281
+ }
282
+ },
283
+ "179": {
284
+ "inputs": {
285
+ "conditioning": [
286
+ "165",
287
+ 0
288
+ ],
289
+ "latent": [
290
+ "178",
291
+ 0
292
+ ]
293
+ },
294
+ "class_type": "ReferenceLatent",
295
+ "_meta": {
296
+ "title": "参考Latent"
297
+ }
298
+ },
299
+ "180": {
300
+ "inputs": {
301
+ "conditioning": [
302
+ "166",
303
+ 0
304
+ ],
305
+ "latent": [
306
+ "178",
307
+ 0
308
+ ]
309
+ },
310
+ "class_type": "ReferenceLatent",
311
+ "_meta": {
312
+ "title": "参考Latent"
313
+ }
314
+ },
315
+ "270": {
316
+ "inputs": {
317
+ "image": "05-1.jpg"
318
+ },
319
+ "class_type": "LoadImage",
320
+ "_meta": {
321
+ "title": "加载图像"
322
+ }
323
+ },
324
+ "271": {
325
+ "inputs": {
326
+ "upscale_method": "lanczos",
327
+ "megapixels": 1,
328
+ "resolution_steps": 1,
329
+ "image": [
330
+ "270",
331
+ 0
332
+ ]
333
+ },
334
+ "class_type": "ImageScaleToTotalPixels",
335
+ "_meta": {
336
+ "title": "图像按像素缩放"
337
+ }
338
+ },
339
+ "278": {
340
+ "inputs": {
341
+ "image": "1 (4).jpg"
342
+ },
343
+ "class_type": "LoadImage",
344
+ "_meta": {
345
+ "title": "加载图像"
346
+ }
347
+ },
348
+ "291": {
349
+ "inputs": {
350
+ "upscale_method": "lanczos",
351
+ "megapixels": 1,
352
+ "resolution_steps": 1,
353
+ "image": [
354
+ "278",
355
+ 0
356
+ ]
357
+ },
358
+ "class_type": "ImageScaleToTotalPixels",
359
+ "_meta": {
360
+ "title": "图像按像素缩放"
361
+ }
362
+ },
363
+ "292": {
364
+ "inputs": {
365
+ "image": "05-1.jpg"
366
+ },
367
+ "class_type": "LoadImage",
368
+ "_meta": {
369
+ "title": "加载图像"
370
+ }
371
+ },
372
+ "294": {
373
+ "inputs": {
374
+ "upscale_method": "lanczos",
375
+ "megapixels": 1,
376
+ "resolution_steps": 1,
377
+ "image": [
378
+ "292",
379
+ 0
380
+ ]
381
+ },
382
+ "class_type": "ImageScaleToTotalPixels",
383
+ "_meta": {
384
+ "title": "图像按像素缩放"
385
+ }
386
+ },
387
+ "295": {
388
+ "inputs": {
389
+ "model_name1": "qwen_3_8b_fp8mixed.safetensors",
390
+ "model_name2": "None",
391
+ "model_name3": "None",
392
+ "type": "stable_diffusion",
393
+ "key_opt": "",
394
+ "mode": "Auto",
395
+ "device": "default"
396
+ },
397
+ "class_type": "LoadTextEncoderShared //Inspire",
398
+ "_meta": {
399
+ "title": "Shared Text Encoder Loader (Inspire)"
400
+ }
401
+ },
402
+ "296": {
403
+ "inputs": {
404
+ "model_name": "flux-2-klein-9b-fp8.safetensors",
405
+ "weight_dtype": "default",
406
+ "key_opt": "",
407
+ "mode": "Auto"
408
+ },
409
+ "class_type": "LoadDiffusionModelShared //Inspire",
410
+ "_meta": {
411
+ "title": "Shared Diffusion Model Loader (Inspire)"
412
+ }
413
+ },
414
+ "305": {
415
+ "inputs": {
416
+ "switch": [
417
+ "313",
418
+ 0
419
+ ],
420
+ "on_false": [
421
+ "160",
422
+ 0
423
+ ],
424
+ "on_true": [
425
+ "166",
426
+ 0
427
+ ]
428
+ },
429
+ "class_type": "ComfySwitchNode",
430
+ "_meta": {
431
+ "title": "Switch"
432
+ }
433
+ },
434
+ "306": {
435
+ "inputs": {
436
+ "switch": [
437
+ "313",
438
+ 0
439
+ ],
440
+ "on_false": [
441
+ "159",
442
+ 0
443
+ ],
444
+ "on_true": [
445
+ "165",
446
+ 0
447
+ ]
448
+ },
449
+ "class_type": "ComfySwitchNode",
450
+ "_meta": {
451
+ "title": "Switch"
452
+ }
453
+ },
454
+ "307": {
455
+ "inputs": {
456
+ "switch": [
457
+ "314",
458
+ 0
459
+ ],
460
+ "on_false": [
461
+ "306",
462
+ 0
463
+ ],
464
+ "on_true": [
465
+ "179",
466
+ 0
467
+ ]
468
+ },
469
+ "class_type": "ComfySwitchNode",
470
+ "_meta": {
471
+ "title": "Switch"
472
+ }
473
+ },
474
+ "308": {
475
+ "inputs": {
476
+ "switch": [
477
+ "314",
478
+ 0
479
+ ],
480
+ "on_false": [
481
+ "305",
482
+ 0
483
+ ],
484
+ "on_true": [
485
+ "180",
486
+ 0
487
+ ]
488
+ },
489
+ "class_type": "ComfySwitchNode",
490
+ "_meta": {
491
+ "title": "Switch"
492
+ }
493
+ },
494
+ "313": {
495
+ "inputs": {
496
+ "value": false
497
+ },
498
+ "class_type": "PrimitiveBoolean",
499
+ "_meta": {
500
+ "title": "布尔值2"
501
+ }
502
+ },
503
+ "314": {
504
+ "inputs": {
505
+ "value": false
506
+ },
507
+ "class_type": "PrimitiveBoolean",
508
+ "_meta": {
509
+ "title": "布尔值3"
510
+ }
511
+ },
512
+ "315": {
513
+ "inputs": {
514
+ "filename_prefix": "ComfyUI",
515
+ "images": [
516
+ "155",
517
+ 0
518
+ ]
519
+ },
520
+ "class_type": "SaveImage",
521
+ "_meta": {
522
+ "title": "保存图像"
523
+ }
524
+ }
525
+ }
workflows/Z-Image-Enhance.json ADDED
@@ -0,0 +1,383 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "15": {
3
+ "inputs": {
4
+ "image": "pasted/image (794).png"
5
+ },
6
+ "class_type": "LoadImage",
7
+ "_meta": {
8
+ "title": "加载图像"
9
+ }
10
+ },
11
+ "23": {
12
+ "inputs": {
13
+ "text": "丰富的细节",
14
+ "speak_and_recognation": {
15
+ "__value__": [
16
+ false,
17
+ true
18
+ ]
19
+ },
20
+ "clip": [
21
+ "34",
22
+ 0
23
+ ]
24
+ },
25
+ "class_type": "CLIPTextEncode",
26
+ "_meta": {
27
+ "title": "CLIP文本编码器"
28
+ }
29
+ },
30
+ "24": {
31
+ "inputs": {
32
+ "conditioning": [
33
+ "23",
34
+ 0
35
+ ]
36
+ },
37
+ "class_type": "ConditioningZeroOut",
38
+ "_meta": {
39
+ "title": "条件零化"
40
+ }
41
+ },
42
+ "27": {
43
+ "inputs": {
44
+ "vae_name": "ae.safetensors"
45
+ },
46
+ "class_type": "VAELoader",
47
+ "_meta": {
48
+ "title": "VAE加载器"
49
+ }
50
+ },
51
+ "33": {
52
+ "inputs": {
53
+ "model_name": "z_image_turbo_bf16.safetensors",
54
+ "weight_dtype": "default",
55
+ "key_opt": "",
56
+ "mode": "Auto"
57
+ },
58
+ "class_type": "LoadDiffusionModelShared //Inspire",
59
+ "_meta": {
60
+ "title": "Shared Diffusion Model Loader (Inspire)"
61
+ }
62
+ },
63
+ "34": {
64
+ "inputs": {
65
+ "model_name1": "qwen_3_4b.safetensors",
66
+ "model_name2": "None",
67
+ "model_name3": "None",
68
+ "type": "stable_diffusion",
69
+ "key_opt": "",
70
+ "mode": "Auto",
71
+ "device": "default"
72
+ },
73
+ "class_type": "LoadTextEncoderShared //Inspire",
74
+ "_meta": {
75
+ "title": "Shared Text Encoder Loader (Inspire)"
76
+ }
77
+ },
78
+ "142": {
79
+ "inputs": {
80
+ "pixels": [
81
+ "15",
82
+ 0
83
+ ],
84
+ "vae": [
85
+ "27",
86
+ 0
87
+ ]
88
+ },
89
+ "class_type": "VAEEncode",
90
+ "_meta": {
91
+ "title": "VAE编码"
92
+ }
93
+ },
94
+ "146": {
95
+ "inputs": {
96
+ "seed": 279629946795727,
97
+ "steps": 10,
98
+ "cfg": 0.7,
99
+ "sampler_name": "euler",
100
+ "scheduler": "sgm_uniform",
101
+ "denoise": [
102
+ "202",
103
+ 0
104
+ ],
105
+ "model": [
106
+ "166",
107
+ 0
108
+ ],
109
+ "positive": [
110
+ "23",
111
+ 0
112
+ ],
113
+ "negative": [
114
+ "24",
115
+ 0
116
+ ],
117
+ "latent_image": [
118
+ "142",
119
+ 0
120
+ ]
121
+ },
122
+ "class_type": "KSampler",
123
+ "_meta": {
124
+ "title": "K采样器"
125
+ }
126
+ },
127
+ "147": {
128
+ "inputs": {
129
+ "samples": [
130
+ "146",
131
+ 0
132
+ ],
133
+ "vae": [
134
+ "27",
135
+ 0
136
+ ]
137
+ },
138
+ "class_type": "VAEDecode",
139
+ "_meta": {
140
+ "title": "VAE解码"
141
+ }
142
+ },
143
+ "164": {
144
+ "inputs": {
145
+ "preprocessor": "DepthAnythingV2Preprocessor",
146
+ "resolution": 1024,
147
+ "image": [
148
+ "15",
149
+ 0
150
+ ]
151
+ },
152
+ "class_type": "AIO_Preprocessor",
153
+ "_meta": {
154
+ "title": "Aux集成预处理器"
155
+ }
156
+ },
157
+ "165": {
158
+ "inputs": {
159
+ "name": "Z-Image-Turbo-Fun-Controlnet-Union.safetensors"
160
+ },
161
+ "class_type": "ModelPatchLoader",
162
+ "_meta": {
163
+ "title": "加载模型补丁"
164
+ }
165
+ },
166
+ "166": {
167
+ "inputs": {
168
+ "strength": 0.8,
169
+ "model": [
170
+ "33",
171
+ 0
172
+ ],
173
+ "model_patch": [
174
+ "165",
175
+ 0
176
+ ],
177
+ "vae": [
178
+ "27",
179
+ 0
180
+ ],
181
+ "image": [
182
+ "164",
183
+ 0
184
+ ]
185
+ },
186
+ "class_type": "QwenImageDiffsynthControlnet",
187
+ "_meta": {
188
+ "title": "QwenImageDiffsynthControlnet"
189
+ }
190
+ },
191
+ "174": {
192
+ "inputs": {
193
+ "filename_prefix": "ComfyUI",
194
+ "images": [
195
+ "180",
196
+ 0
197
+ ]
198
+ },
199
+ "class_type": "SaveImage",
200
+ "_meta": {
201
+ "title": "保存图像"
202
+ }
203
+ },
204
+ "180": {
205
+ "inputs": {
206
+ "samples": [
207
+ "181",
208
+ 0
209
+ ],
210
+ "vae": [
211
+ "27",
212
+ 0
213
+ ]
214
+ },
215
+ "class_type": "VAEDecode",
216
+ "_meta": {
217
+ "title": "VAE解码"
218
+ }
219
+ },
220
+ "181": {
221
+ "inputs": {
222
+ "seed": 820960346993579,
223
+ "steps": 10,
224
+ "cfg": 1,
225
+ "sampler_name": "euler_cfg_pp",
226
+ "scheduler": "simple",
227
+ "denoise": [
228
+ "202",
229
+ 0
230
+ ],
231
+ "model": [
232
+ "33",
233
+ 0
234
+ ],
235
+ "positive": [
236
+ "23",
237
+ 0
238
+ ],
239
+ "negative": [
240
+ "24",
241
+ 0
242
+ ],
243
+ "latent_image": [
244
+ "186",
245
+ 0
246
+ ]
247
+ },
248
+ "class_type": "KSampler",
249
+ "_meta": {
250
+ "title": "K采样器"
251
+ }
252
+ },
253
+ "184": {
254
+ "inputs": {
255
+ "seed": 882274830236035,
256
+ "strength": [
257
+ "202",
258
+ 0
259
+ ],
260
+ "image": [
261
+ "15",
262
+ 0
263
+ ]
264
+ },
265
+ "class_type": "ImageAddNoise",
266
+ "_meta": {
267
+ "title": "图像添加噪声"
268
+ }
269
+ },
270
+ "186": {
271
+ "inputs": {
272
+ "pixels": [
273
+ "189",
274
+ 0
275
+ ],
276
+ "vae": [
277
+ "27",
278
+ 0
279
+ ]
280
+ },
281
+ "class_type": "VAEEncode",
282
+ "_meta": {
283
+ "title": "VAE编码"
284
+ }
285
+ },
286
+ "189": {
287
+ "inputs": {
288
+ "blend_factor": [
289
+ "202",
290
+ 0
291
+ ],
292
+ "blend_mode": "multiply",
293
+ "image1": [
294
+ "191",
295
+ 0
296
+ ],
297
+ "image2": [
298
+ "184",
299
+ 0
300
+ ]
301
+ },
302
+ "class_type": "ImageBlend",
303
+ "_meta": {
304
+ "title": "图像混合"
305
+ }
306
+ },
307
+ "191": {
308
+ "inputs": {
309
+ "sharpen_radius": 1,
310
+ "sigma": 0.5,
311
+ "alpha": 0.5,
312
+ "image": [
313
+ "147",
314
+ 0
315
+ ]
316
+ },
317
+ "class_type": "ImageSharpen",
318
+ "_meta": {
319
+ "title": "图像锐化"
320
+ }
321
+ },
322
+ "193": {
323
+ "inputs": {
324
+ "image": [
325
+ "15",
326
+ 0
327
+ ]
328
+ },
329
+ "class_type": "GetImageSize+",
330
+ "_meta": {
331
+ "title": "获取图像尺寸"
332
+ }
333
+ },
334
+ "201": {
335
+ "inputs": {
336
+ "expression": "(a+b)*c/10000",
337
+ "speak_and_recognation": {
338
+ "__value__": [
339
+ false,
340
+ true
341
+ ]
342
+ },
343
+ "a": [
344
+ "193",
345
+ 0
346
+ ],
347
+ "b": [
348
+ "193",
349
+ 1
350
+ ],
351
+ "c": [
352
+ "204",
353
+ 0
354
+ ]
355
+ },
356
+ "class_type": "MathExpression|pysssss",
357
+ "_meta": {
358
+ "title": "数学表达式"
359
+ }
360
+ },
361
+ "202": {
362
+ "inputs": {
363
+ "output_type": "float",
364
+ "*": [
365
+ "201",
366
+ 1
367
+ ]
368
+ },
369
+ "class_type": "easy convertAnything",
370
+ "_meta": {
371
+ "title": "转换任何"
372
+ }
373
+ },
374
+ "204": {
375
+ "inputs": {
376
+ "value": 0.5
377
+ },
378
+ "class_type": "FloatConstant",
379
+ "_meta": {
380
+ "title": "浮点常量"
381
+ }
382
+ }
383
+ }
workflows/Z-Image.json ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "20": {
3
+ "inputs": {
4
+ "samples": [
5
+ "22",
6
+ 0
7
+ ],
8
+ "vae": [
9
+ "27",
10
+ 0
11
+ ]
12
+ },
13
+ "class_type": "VAEDecode",
14
+ "_meta": {
15
+ "title": "VAE解码"
16
+ }
17
+ },
18
+ "22": {
19
+ "inputs": {
20
+ "seed": 1104391495230198,
21
+ "steps": 10,
22
+ "cfg": 1,
23
+ "sampler_name": "euler",
24
+ "scheduler": "simple",
25
+ "denoise": 1,
26
+ "model": [
27
+ "33",
28
+ 0
29
+ ],
30
+ "positive": [
31
+ "23",
32
+ 0
33
+ ],
34
+ "negative": [
35
+ "24",
36
+ 0
37
+ ],
38
+ "latent_image": [
39
+ "144",
40
+ 0
41
+ ]
42
+ },
43
+ "class_type": "KSampler",
44
+ "_meta": {
45
+ "title": "K采样器"
46
+ }
47
+ },
48
+ "23": {
49
+ "inputs": {
50
+ "text": "",
51
+ "speak_and_recognation": {
52
+ "__value__": [
53
+ false,
54
+ true
55
+ ]
56
+ },
57
+ "clip": [
58
+ "34",
59
+ 0
60
+ ]
61
+ },
62
+ "class_type": "CLIPTextEncode",
63
+ "_meta": {
64
+ "title": "CLIP文本编码器"
65
+ }
66
+ },
67
+ "24": {
68
+ "inputs": {
69
+ "conditioning": [
70
+ "23",
71
+ 0
72
+ ]
73
+ },
74
+ "class_type": "ConditioningZeroOut",
75
+ "_meta": {
76
+ "title": "条件零化"
77
+ }
78
+ },
79
+ "27": {
80
+ "inputs": {
81
+ "vae_name": "flux ultra vae.safetensors"
82
+ },
83
+ "class_type": "VAELoader",
84
+ "_meta": {
85
+ "title": "VAE加载器"
86
+ }
87
+ },
88
+ "33": {
89
+ "inputs": {
90
+ "model_name": "z_image_turbo_bf16.safetensors",
91
+ "weight_dtype": "default",
92
+ "key_opt": "",
93
+ "mode": "Auto"
94
+ },
95
+ "class_type": "LoadDiffusionModelShared //Inspire",
96
+ "_meta": {
97
+ "title": "Shared Diffusion Model Loader (Inspire)"
98
+ }
99
+ },
100
+ "34": {
101
+ "inputs": {
102
+ "model_name1": "qwen_3_4b.safetensors",
103
+ "model_name2": "None",
104
+ "model_name3": "None",
105
+ "type": "stable_diffusion",
106
+ "key_opt": "",
107
+ "mode": "Auto",
108
+ "device": "default"
109
+ },
110
+ "class_type": "LoadTextEncoderShared //Inspire",
111
+ "_meta": {
112
+ "title": "Shared Text Encoder Loader (Inspire)"
113
+ }
114
+ },
115
+ "89": {
116
+ "inputs": {
117
+ "rgthree_comparer": {
118
+ "images": [
119
+ {
120
+ "name": "A",
121
+ "selected": true,
122
+ "url": "/api/view?filename=rgthree.compare._temp_gmjxy_00001_.png&type=temp&subfolder=&rand=0.7230996201608886"
123
+ },
124
+ {
125
+ "name": "B",
126
+ "selected": true,
127
+ "url": "/api/view?filename=rgthree.compare._temp_gmjxy_00002_.png&type=temp&subfolder=&rand=0.6218111120009978"
128
+ }
129
+ ]
130
+ },
131
+ "image_a": [
132
+ "20",
133
+ 0
134
+ ]
135
+ },
136
+ "class_type": "Image Comparer (rgthree)",
137
+ "_meta": {
138
+ "title": "图像对比"
139
+ }
140
+ },
141
+ "137": {
142
+ "inputs": {
143
+ "images": [
144
+ "20",
145
+ 0
146
+ ]
147
+ },
148
+ "class_type": "PreviewImage",
149
+ "_meta": {
150
+ "title": "预览图像"
151
+ }
152
+ },
153
+ "142": {
154
+ "inputs": {
155
+ "vae": [
156
+ "27",
157
+ 0
158
+ ]
159
+ },
160
+ "class_type": "VAEEncode",
161
+ "_meta": {
162
+ "title": "VAE编码"
163
+ }
164
+ },
165
+ "144": {
166
+ "inputs": {
167
+ "width": 512,
168
+ "height": 512,
169
+ "batch_size": 1
170
+ },
171
+ "class_type": "EmptyLatentImage",
172
+ "_meta": {
173
+ "title": "空Latent"
174
+ }
175
+ }
176
+ }
workflows/upscale.json ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "15": {
3
+ "inputs": {
4
+ "image": "pasted/image (794).png"
5
+ },
6
+ "class_type": "LoadImage",
7
+ "_meta": {
8
+ "title": "加载图像"
9
+ }
10
+ },
11
+ "169": {
12
+ "inputs": {
13
+ "model": "seedvr2_ema_3b_fp16.safetensors",
14
+ "device": "cuda:0",
15
+ "blocks_to_swap": 32,
16
+ "swap_io_components": true,
17
+ "offload_device": "cpu",
18
+ "cache_model": false,
19
+ "attention_mode": "sdpa"
20
+ },
21
+ "class_type": "SeedVR2LoadDiTModel",
22
+ "_meta": {
23
+ "title": "SeedVR2 (Down)Load DiT Model"
24
+ }
25
+ },
26
+ "170": {
27
+ "inputs": {
28
+ "model": "ema_vae_fp16.safetensors",
29
+ "device": "cuda:0",
30
+ "encode_tiled": true,
31
+ "encode_tile_size": 1024,
32
+ "encode_tile_overlap": 128,
33
+ "decode_tiled": true,
34
+ "decode_tile_size": 1024,
35
+ "decode_tile_overlap": 128,
36
+ "tile_debug": "false",
37
+ "offload_device": "cpu",
38
+ "cache_model": false
39
+ },
40
+ "class_type": "SeedVR2LoadVAEModel",
41
+ "_meta": {
42
+ "title": "SeedVR2 (Down)Load VAE Model"
43
+ }
44
+ },
45
+ "172": {
46
+ "inputs": {
47
+ "seed": 21053977,
48
+ "resolution": 2048,
49
+ "max_resolution": 4096,
50
+ "batch_size": 5,
51
+ "uniform_batch_size": false,
52
+ "color_correction": "lab",
53
+ "temporal_overlap": 0,
54
+ "prepend_frames": 0,
55
+ "input_noise_scale": 0,
56
+ "latent_noise_scale": 0,
57
+ "offload_device": "cpu",
58
+ "enable_debug": false,
59
+ "image": [
60
+ "15",
61
+ 0
62
+ ],
63
+ "dit": [
64
+ "169",
65
+ 0
66
+ ],
67
+ "vae": [
68
+ "170",
69
+ 0
70
+ ]
71
+ },
72
+ "class_type": "SeedVR2VideoUpscaler",
73
+ "_meta": {
74
+ "title": "SeedVR2 Video Upscaler (v2.5.10)"
75
+ }
76
+ },
77
+ "174": {
78
+ "inputs": {
79
+ "filename_prefix": "ComfyUI",
80
+ "images": [
81
+ "172",
82
+ 0
83
+ ]
84
+ },
85
+ "class_type": "SaveImage",
86
+ "_meta": {
87
+ "title": "保存图像"
88
+ }
89
+ }
90
+ }