waveshaker commited on
Commit
d1afd4e
·
verified ·
1 Parent(s): f638f37

Update model_config.py

Browse files
Files changed (1) hide show
  1. model_config.py +1530 -271
model_config.py CHANGED
@@ -1,299 +1,1558 @@
1
- import os
2
-
3
- # ==========================================================================
4
- # 🛠️ Environment Variables & Targets
5
- # ==========================================================================
6
- DEV_MODE = False
7
- DEV_TARGET_MODEL = ["dev_moss","dev_krea2","pony"]
8
- PROD_TARGET_MODEL = ["moss", "krea2", "anima", "flux1_dev", "pony"]
9
-
10
- # 🌟 核心新增:控制是否从独立的 GUI Space 动态同步前端文件
11
- # True = 每次启动时从 GUI Space 拉取最新代码(适合线上开发阶段)
12
- # False = 完全使用当前仓库内的静态文件(适合生产环境锁定或断网兜底)
13
- SYNC_FROM_GUI_SPACE = True
14
-
15
- # 🌟 新增:全局 TE 精度开关
16
- # 可选值:"w4a8" 或 "int8"
17
- MOSS_TE_PRECISION = "int8"
18
- KREA2_TE_PRECISION = "int8"
19
- FLUX1_DEV_T5_TE_PRECISION = "int8"
20
-
21
- # 根据精度开关,动态定义各模型的 TE 路径和文件名
22
- TE_MAPPING = {
23
- "moss": {
24
- "w4a8": ("moss/text_encoders/qwen3.5_9b_w4a8_gptq.safetensors", "qwen3.5_9b_w4a8_gptq.safetensors"),
25
- "int8": ("moss/text_encoders/qwen3.5_9b_int8_convrot.safetensors", "qwen3.5_9b_int8_convrot.safetensors")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  },
27
- "krea2": {
28
- "w4a8": ("kr2/text_encoders/qwen3vl_4b_w4a8.safetensors", "qwen3vl_4b_w4a8.safetensors"),
29
- "int8": ("kr2/text_encoders/qwen3vl_4b_int8_convrot.safetensors", "qwen3vl_4b_int8_convrot.safetensors")
 
 
 
 
 
 
30
  },
31
- "flux1_dev_t5": {
32
- "w4a8": ("f1d/text_encoders/t5xxl-w4a8_convrot.safetensors", "t5xxl-w4a8_convrot.safetensors"),
33
- "int8": ("f1d/text_encoders/t5xxl_fp16-ctq_int8_convrot.safetensors", "t5xxl_fp16-ctq_int8_convrot.safetensors")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  }
35
  }
36
 
37
- # 获取当前选中的路径
38
- moss_te_path, moss_te_file = TE_MAPPING["moss"][MOSS_TE_PRECISION]
39
- krea2_te_path, krea2_te_file = TE_MAPPING["krea2"][KREA2_TE_PRECISION]
40
- flux_t5_path, flux_t5_file = TE_MAPPING["flux1_dev_t5"][FLUX1_DEV_T5_TE_PRECISION]
41
-
42
- # ==========================================================================
43
- # ⚙️ Model Configuration
44
- # ==========================================================================
45
- MODEL_CONFIG = {
46
- # ==========================================
47
- # 🌐 Frontend & Sync Config
48
- # ==========================================
49
- "ui_space_url": "https://huggingface.co/spaces/waveshaker/universal-lora-studio-gui/resolve/main",
50
- "frontend_cache_dir": "frontend_cache",
51
-
52
- # ==========================================
53
- # ⚙️ Comfy & Model Config
54
- # ==========================================
55
 
56
- "config_url": "https://huggingface.co/waveshaker/comfy_models/resolve/main/studio_config.json",
57
- "char_lora_repo": "waveshaker/loras",
58
 
59
- "name": "Waveshaker-Universal-T2I-Studio-Core",
60
- "exclude_folders": ["Heels"],
 
61
 
62
- "default_prompt": "20 years old woman, 1girl, RAW photo, PORTRAIT, (photorealistic:1.37, realistic), (straight from front:1.2), looking at viewer, ((wearing a simple white t-shirt:1.4)), (casual clothing:1.2), soft lighting, high quality, film grain, Fujifilm XT3, ((dynamic pose:1.2)), ((upper body:1.6)), (professional lighting:1.6), (blurred cafe or minimalist room background:1.2), clean environment, centered composition, dead center, medium shot, ample negative space around.",
 
 
63
 
64
- "node_types": {
65
- "unet_loader": "UNETLoader",
66
- "clip_loader": "CLIPLoader",
67
- "vae_loader": "VAELoader",
68
- "lora_loader": "LoraLoaderModelOnly"
69
- },
 
 
 
 
 
 
 
70
 
71
- # === Base Model Config ===
72
- "models": {
73
- "moss": {
74
- "name": "Moss-Image-SFT",
75
- "companion_downloads": [
76
- ("waveshaker/comfy_models", moss_te_path),
77
- ("waveshaker/comfy_models", "moss/vae/flux2-vae_small.safetensors"),
78
- ],
79
- "local_te_filename": [moss_te_file],
80
- "local_vae_filename": "flux2-vae_small.safetensors",
81
- "clip_type": "moss_image",
82
- "char_lora_prefix": "moss/",
83
- "defaults": { "steps": 8, "cfg": 1.0, "sampler": "euler", "scheduler": "simple", "char_lora_strength": 1.0 },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
 
85
- "fallback_unets": {
86
- "Moss Image SFT Int8 Convrot (8Steps DMD2 Merged)": {
87
- "repo": "waveshaker/comfy_models",
88
- "subfolder": "moss/diffusion_models",
89
- "filename": "moss_image_sft_8steps_dmd2_int8_convrot.safetensors"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  }
91
- },
92
- "fallback_base_loras": {}
93
- },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
 
95
- "dev_moss": {
96
- "name": "Moss-Dev-Test",
97
- "companion_downloads": [
98
- ("waveshaker/comfy_models", moss_te_path),
99
- ("waveshaker/comfy_models", "moss/vae/flux2-vae_small.safetensors"),
100
- ],
101
- "local_te_filename": [moss_te_file],
102
- "local_vae_filename": "flux2-vae_small.safetensors",
103
- "clip_type": "moss_image",
104
- "char_lora_prefix": "moss/",
105
- "defaults": { "steps": 8, "cfg": 1.0, "sampler": "euler", "scheduler": "simple", "char_lora_strength": 1.0 },
106
-
107
- "fallback_unets": {
108
- "Moss Image SFT Int8 Convrot (8Steps DMD2 Merged)": {
109
- "repo": "waveshaker/comfy_models",
110
- "subfolder": "moss/diffusion_models",
111
- "filename": "moss_image_sft_8steps_dmd2_int8_convrot.safetensors"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  }
113
- },
114
- "fallback_base_loras": {}
115
- },
116
-
117
- "krea2": {
118
- "name": "Krea-2-Turbo",
119
- "companion_downloads": [
120
- ("waveshaker/comfy_models", krea2_te_path),
121
- ("waveshaker/comfy_models", "kr2/vae/qwen_image_vae.safetensors"),
122
- ],
123
- "local_te_filename": [krea2_te_file],
124
- "local_vae_filename": "qwen_image_vae.safetensors",
125
- "clip_type": "krea2",
126
- "char_lora_prefix": "krea2/",
127
- "defaults": { "steps": 8, "cfg": 1.0, "sampler": "euler_ancestral", "scheduler": "beta", "char_lora_strength": 1.0 },
128
-
129
- "fallback_unets": {
130
- "Krea 2 Turbo Int8 Convrot": {
131
- "repo": "waveshaker/comfy_models",
132
- "subfolder": "kr2/diffusion_models",
133
- "filename": "krea2_turbo_int8_convrot.safetensors"
134
  }
135
- },
136
- "fallback_base_loras": {},
137
- },
138
-
139
- "dev_krea2": {
140
- "name": "Krea2-Dev-Test",
141
- "companion_downloads": [
142
- ("waveshaker/comfy_models", krea2_te_path),
143
- ("waveshaker/comfy_models", "kr2/vae/qwen_image_vae.safetensors"),
144
- ],
145
- "local_te_filename": [krea2_te_file],
146
- "local_vae_filename": "qwen_image_vae.safetensors",
147
- "clip_type": "krea2",
148
- "char_lora_prefix": "krea2/",
149
- "defaults": { "steps": 8, "cfg": 1.0, "sampler": "euler_ancestral", "scheduler": "beta", "char_lora_strength": 1.0 },
150
-
151
- "fallback_unets": {
152
- "Krea 2 Turbo Int4 Convrot": {
153
- "repo": "waveshaker/comfy_models",
154
- "subfolder": "kr2/diffusion_models",
155
- "filename": "Krea2-Turbo-int4-ConvRot-ComfyUI.safetensors"
156
  }
157
- },
158
- "fallback_base_loras": {},
159
- },
160
- "anima": {
161
- "name": "Anima",
162
- "companion_downloads": [
163
- ("waveshaker/comfy_models", "anima/text_encoders/Qwen3-0.6B_base-int8_convrot.safetensors"),
164
- ("waveshaker/comfy_models", "anima/vae/qwen_image_vae.safetensors"),
165
- ],
166
- "local_te_filename": ["Qwen3-0.6B_base-int8_convrot.safetensors"],
167
- "local_vae_filename": "qwen_image_vae.safetensors",
168
- "clip_type": "stable_diffusion",
169
- "char_lora_prefix": "anima/",
170
- "defaults": { "steps": 8, "cfg": 1.0, "sampler": "euler", "scheduler": "simple", "char_lora_strength": 1.0 },
171
-
172
- "fallback_unets": {
173
- "Diving Anima V4 Int8 Convrot (Anima Turbo LoRA v0.2 merged)": {
174
- "repo": "waveshaker/comfy_models",
175
- "subfolder": "anima/diffusion_models",
176
- "filename": "divingAnima_v40_turbo_int8_convrot.safetensors"
177
  }
178
- },
179
- "fallback_base_loras": {
180
- "Anima Highres/Aesthetic Boost": {
181
- "repo": "waveshaker/comfy_models",
182
- "subfolder": "anima/loras",
183
- "filename": "anima-highres-aesthetic-boost.safetensors",
 
 
 
 
 
 
 
184
  }
185
- },
186
- },
187
-
188
- "dev_anima": {
189
- "name": "Anima-Dev-Test",
190
- "companion_downloads": [
191
- ("waveshaker/comfy_models", "anima/text_encoders/Qwen3-0.6B_base-int8_convrot.safetensors"),
192
- ("waveshaker/comfy_models", "anima/vae/qwen_image_vae.safetensors"),
193
- ],
194
- "local_te_filename": ["Qwen3-0.6B_base-int8_convrot.safetensors"],
195
- "local_vae_filename": "qwen_image_vae.safetensors",
196
- "clip_type": "stable_diffusion",
197
- "char_lora_prefix": "anima/",
198
- "defaults": { "steps": 4, "cfg": 1.0, "sampler": "euler", "scheduler": "simple" },
199
-
200
- "fallback_unets": {
201
- "Anima Int4 Convrot MSE": {
202
- "repo": "waveshaker/comfy_models",
203
- "subfolder": "anima/diffusion_models",
204
- "filename": "animaINT4Quants_int4ConvrotMSE.safetensors"
205
  }
206
- },
207
- "fallback_base_loras": {}
208
- },
209
- "flux1_dev": {
210
- "name": "Flux.1-dev",
211
- "clip_loader_override": "DualCLIPLoader",
212
- "companion_downloads": [
213
- ("waveshaker/comfy_models", "f1d/text_encoders/clip_l.safetensors"),
214
- ("waveshaker/comfy_models", flux_t5_path),
215
- ("waveshaker/comfy_models", "f1d/vae/ae.safetensors"),
216
- ],
217
- "local_te_filename": ["clip_l.safetensors",flux_t5_file],
218
- "local_vae_filename": "ae.safetensors",
219
- "clip_type": "flux",
220
- "char_lora_prefix": "flux1_dev/",
221
- "defaults": { "steps": 8, "cfg": 1.0, "sampler": "euler_ancestral", "scheduler": "beta", "char_lora_strength": 1.0 },
222
-
223
- "fallback_unets": {
224
- "Flux.1-dev Int8 Convrot (Flux.1 Turbo Alpha merged)": {
225
- "repo": "waveshaker/comfy_models",
226
- "subfolder": "f1d/diffusion_models",
227
- "filename": "flux1-dev_turbo_ctq_int8_convrot.safetensors"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
  }
 
 
229
  },
230
- "fallback_base_loras": {}
231
- },
232
-
233
- "pony": {
234
- "name": "Pony-Diffusion-V6",
235
- "clip_loader_override": "DualCLIPLoader",
236
- "default_prompt_override": "score_9,score_8_up,score_7_up,score_6_up,20 years old asian woman, RAW photo, PORTRAIT,PORTRAIT ONLY, (photorealistic:1.37, realistic), 1girl, black hair, looking at viewer, (((straight from front))), soft lighting, high quality, film grain, Fujifilm XT3, ((dynamic pose:1.2)) ((upper body:1.6)) (professional lighting:1.6) , simple and clean environment Background, varying from a blurred cafe, a minimalist room, to a soft outdoor natural setting.Centered composition, positioned exactly in the dead center. Medium shot with ample negative space all around.",
237
- "companion_downloads": [
238
- ("waveshaker/comfy_models", "pony/text_encoders/waiANIHENTAIPONYXL_v60_clipL.safetensors"),
239
- ("waveshaker/comfy_models", "pony/text_encoders/waiANIHENTAIPONYXL_v60_clipG.safetensors"),
240
- ("waveshaker/comfy_models", "pony/vae/sdxl_vae.safetensors"),
241
- ],
242
- "local_te_filename": ["waiANIHENTAIPONYXL_v60_clipL.safetensors","waiANIHENTAIPONYXL_v60_clipG.safetensors"],
243
- "local_vae_filename": "sdxl_vae.safetensors",
244
- "clip_type": "sdxl",
245
- "char_lora_prefix": "pony/",
246
- "defaults": { "steps": 24, "cfg": 4.0, "sampler": "euler_ancestral", "scheduler": "normal", "char_lora_strength": 1.0 },
247
-
248
- "fallback_unets": {
249
- "WAI-ANI-HENTAI-PONYXL V6": {
250
- "repo": "waveshaker/comfy_models",
251
- "subfolder": "pony/diffusion_models",
252
- "filename": "waiANIHENTAIPONYXL_v60_Diffusion.safetensors"
 
 
 
253
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
254
  },
255
- "fallback_base_loras": {
256
- "PAseer-SDXL/PONY-LCM and Turbo Accelerator": {
257
- "repo": "waveshaker/comfy_models",
258
- "subfolder": "pony/loras",
259
- "filename": "LCMV2-PONYplus-PAseer.safetensors"
260
  }
 
 
 
 
 
 
261
  }
262
- }
263
- },
264
-
265
- "json_keys": {
266
- "unets": "unets",
267
- "base_loras": "base_loras",
268
- "root_mapping": {
269
- "moss": "moss",
270
- "krea2": "krea2",
271
- "anima": "anima",
272
- "flux1_dev": "flux1_dev",
273
- "pony": "pony",
274
- "dev_moss": "dev_moss",
275
- "dev_krea2": "dev_krea2",
276
- "dev_anima": "dev_anima"
277
- }
278
  }
279
- }
280
 
281
- # ==========================================================================
282
- # 🌟 Environment Filtering
283
- # ==========================================================================
284
- if DEV_MODE:
285
- print(f"🛠️ [DEV_MODE = True] 开发环境:仅加载 {DEV_TARGET_MODEL}。")
286
- TARGET_MODELS = DEV_TARGET_MODEL
287
- else:
288
- print(f"🚀 [DEV_MODE = False] 生产环境:加载 {PROD_TARGET_MODEL}。")
289
- TARGET_MODELS = PROD_TARGET_MODEL
290
-
291
- MODEL_CONFIG["models"] = {
292
- k: v for k, v in MODEL_CONFIG["models"].items()
293
- if k in TARGET_MODELS
294
- }
295
 
296
- MODEL_CONFIG["json_keys"]["root_mapping"] = {
297
- k: v for k, v in MODEL_CONFIG["json_keys"]["root_mapping"].items()
298
- if k in TARGET_MODELS
299
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ==========================================================================
2
+ // 📝 TODO (Architecture / Enhancements)
3
+ // ==========================================================================
4
+ // 1. [Prompt Presets] 实现基于 ID 的 Prompt 远程动态加载
5
+ // - 方案:将超长 Prompt 抽离为集中的 JSON 文件(如: prompt-assets/presets.json)。
6
+ // - 逻辑:图库 (Gallery) 跳转时仅传递 ?id=xxx,前端 (Studio) 接收到 ID 后,跨域 fetch 该 JSON 并自动填充 Prompt 输入框。
7
+ // - 优点:彻底解决 URL 长度限制,实现前后端数据彻底解耦,方便统一维护。
8
+ // commit@70f742a386b9cbbfd6df774b19d727485ef5446f
9
+
10
+
11
+ // ==========================================================================
12
+ // 🛡️ Dynamic API Base URL
13
+ // ==========================================================================
14
+ const hostname = window.location.hostname;
15
+ let API_BASE_URL = "";
16
+
17
+ // --------------------------------------------------------------------------
18
+ // 🚨 GUI Space 专属:匿名池额度警告 (右上角悬浮 + 自动消失 + 手动关闭)
19
+ // --------------------------------------------------------------------------
20
+ if (hostname.includes("universal-lora-studio-gui")) {
21
+ setTimeout(() => {
22
+ if (document.getElementById('gui-anon-warning')) return;
23
+
24
+ const toast = document.createElement('div');
25
+ toast.id = 'gui-anon-warning';
26
+ toast.style.cssText = `
27
+ position: fixed;
28
+ top: 24px;
29
+ right: 24px;
30
+ background-color: rgba(42, 42, 42, 0.95);
31
+ color: #FF7F50;
32
+ border: 1px solid #444;
33
+ padding: 12px 16px;
34
+ border-radius: 8px;
35
+ font-size: 13px;
36
+ font-weight: 500;
37
+ z-index: 99999;
38
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
39
+ display: flex;
40
+ align-items: flex-start;
41
+ gap: 12px;
42
+ box-shadow: 0 8px 24px rgba(0,0,0,0.6);
43
+ backdrop-filter: blur(8px);
44
+ animation: slideInRight 0.4s ease-out;
45
+ max-width: 320px;
46
+ `;
47
+
48
+ toast.innerHTML = `
49
+ <div style="flex: 1; line-height: 1.5; color: #ccc;">
50
+ ⚠️ [For Testing Only] You are using the <b style="color: #FF7F50;">Anonymous Quota</b>. It depletes very quickly.
51
+ <div style="margin-top: 10px;">
52
+ <a href="https://huggingface.co/spaces/waveshaker/universal-lora-studio" target="_blank" style="display: inline-block; color: #fff; background: #FF7F50; padding: 6px 12px; border-radius: 4px; text-decoration: none; font-weight: bold; font-size: 12px;">
53
+ Switch to Studio ↗
54
+ </a>
55
+ </div>
56
+ </div>
57
+ <button id="close-anon-warning" style="background: transparent; border: none; color: #888; font-size: 18px; cursor: pointer; padding: 0; line-height: 1; transition: color 0.2s;" onmouseover="this.style.color='#fff'" onmouseout="this.style.color='#888'">
58
+ &times;
59
+ </button>
60
+ `;
61
+
62
+ const style = document.createElement('style');
63
+ style.innerHTML = `
64
+ @keyframes slideInRight {
65
+ from { opacity: 0; transform: translateX(40px); }
66
+ to { opacity: 1; transform: translateX(0); }
67
+ }
68
+ `;
69
+ document.head.appendChild(style);
70
+ document.body.appendChild(toast);
71
+
72
+ // 封装关闭逻辑
73
+ const closeToast = () => {
74
+ if (!document.getElementById('gui-anon-warning')) return;
75
+ toast.style.opacity = '0';
76
+ toast.style.transform = 'translateX(40px)';
77
+ toast.style.transition = 'all 0.3s';
78
+ setTimeout(() => toast.remove(), 300);
79
+ };
80
+
81
+ // 1. 绑定手动关闭
82
+ document.getElementById('close-anon-warning').onclick = closeToast;
83
+
84
+ // 2. 绑定 10 秒自动关闭
85
+ setTimeout(closeToast, 10000);
86
+
87
+ }, 100);
88
+ }
89
+
90
+ // 1. 如果在 GUI 预览环境(哪怕带 .static 后缀)
91
+ if (hostname.includes("universal-lora-studio-gui")) {
92
+ API_BASE_URL = "https://waveshaker-universal-lora-studio-test.hf.space";
93
+ }
94
+ // 2. 如果在本地开发环境
95
+ else if (hostname === "localhost" || hostname === "127.0.0.1") {
96
+ API_BASE_URL = "https://waveshaker-universal-lora-studio-test.hf.space";
97
+ }
98
+ // 3. 如果在测试环境(test Space)
99
+ else if (hostname.includes("universal-lora-studio-test")) {
100
+ API_BASE_URL = ""; // 同源,走相对路径
101
+ }
102
+ // 4. 如果在线上环境(prod Space)
103
+ else if (hostname.includes("universal-lora-studio.hf.space") || hostname.includes("universal-lora-studio.static")) {
104
+ API_BASE_URL = ""; // 同源,走相对路径
105
+ }
106
+ // ==========================================================================
107
+ // 🔐 ZeroGPU Token Handshake
108
+ // ==========================================================================
109
+ const ZEROGPU_HEADERS_MSG = "supports-zerogpu-headers";
110
+
111
+ // 监听 HF 父窗口发来的握手信号
112
+ window.addEventListener("message", (event) => {
113
+ if (event.data === ZEROGPU_HEADERS_MSG) {
114
+ window.supports_zerogpu_headers = true;
115
+ }
116
+ });
117
+
118
+ // 向父窗口声明支持,触发后续的 token 索要流程
119
+ (function announceZeroGpuSupport() {
120
+ const hostname = window.location.hostname;
121
+ if (!hostname.endsWith(".hf.space") && !hostname.includes(".dev.")) {
122
+ return; // 非 HF 环境,不执行
123
+ }
124
+ const origin = hostname.includes(".dev.")
125
+ ? `https://moon-${hostname.split(".")[1]}.dev.spaces.huggingface.tech`
126
+ : "https://huggingface.co";
127
+ try {
128
+ window.parent.postMessage(ZEROGPU_HEADERS_MSG, origin);
129
+ } catch (e) {
130
+ console.warn("ZeroGPU handshake failed:", e);
131
+ }
132
+ })();
133
+
134
+ // 向父窗口索要 x-ip-token(带 2 秒超时兜底)
135
+ async function requestZeroGpuToken() {
136
+ if (!window.supports_zerogpu_headers) return null;
137
+
138
+ const hostname = window.location.hostname;
139
+ if (!hostname.endsWith(".hf.space") && !hostname.includes(".dev.")) return null;
140
+
141
+ const origin = hostname.includes(".dev.")
142
+ ? `https://moon-${hostname.split(".")[1]}.dev.spaces.huggingface.tech`
143
+ : "https://huggingface.co";
144
+
145
+ try {
146
+ return await new Promise((resolve) => {
147
+ const channel = new MessageChannel();
148
+ const timeout = setTimeout(() => {
149
+ channel.port1.close();
150
+ resolve(null);
151
+ }, 2000);
152
+ channel.port1.onmessage = ({ data }) => {
153
+ clearTimeout(timeout);
154
+ resolve(data || null);
155
+ };
156
+ window.parent.postMessage("zerogpu-headers", origin, [channel.port2]);
157
+ });
158
+ } catch {
159
+ return null;
160
+ }
161
+ }
162
+
163
+ // ==========================================================================
164
+ // 🛡️ API Layer
165
+ // ==========================================================================
166
+
167
+
168
+ const BackendAPI = {
169
+ // 1. 心跳检测
170
+ checkHealth: async function() {
171
+ try {
172
+ const controller = new AbortController();
173
+ const timeoutId = setTimeout(() => controller.abort(), 3000);
174
+
175
+ const res = await fetch(`${API_BASE_URL}/api/health`, {
176
+ signal: controller.signal,
177
+ cache: 'no-store'
178
+ });
179
+ clearTimeout(timeoutId);
180
+
181
+ if (res.ok) {
182
+ const data = await res.json();
183
+ return data.status === "ok";
184
+ }
185
+ return false;
186
+ } catch (error) {
187
+ return false;
188
+ }
189
+ },
190
+
191
+ // 2. 获取全局配置 (Sampler/Scheduler & Root Mapping)
192
+ getConfig: async function() {
193
+ try {
194
+ const res = await fetch(`${API_BASE_URL}/api/config`, {
195
+ method: 'GET',
196
+ cache: 'no-store'
197
+ });
198
+ if (!res.ok) throw new Error("HTTP error " + res.status);
199
+ return await res.json();
200
+ } catch (error) {
201
+ console.warn("⚠️ Failed to fetch config", error);
202
+ return null;
203
+ }
204
  },
205
+
206
+ // 3. 获取 Base Model 列表
207
+ getModels: async function() {
208
+ const res = await fetch(`${API_BASE_URL}/api/models`, {
209
+ method: 'GET',
210
+ cache: 'no-store'
211
+ });
212
+ if (!res.ok) throw new Error("HTTP error " + res.status);
213
+ return await res.json();
214
  },
215
+
216
+ // 4. 获取角色 LoRA 图库数据
217
+ getLoras: async function() {
218
+ const controller = new AbortController();
219
+ const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时
220
+
221
+ try {
222
+ const res = await fetch(`${API_BASE_URL}/api/loras`, {
223
+ method: 'GET',
224
+ cache: 'no-store',
225
+ signal: controller.signal
226
+ });
227
+ clearTimeout(timeoutId);
228
+ if (!res.ok) throw new Error("HTTP error " + res.status);
229
+ return await res.json();
230
+ } catch (error) {
231
+ clearTimeout(timeoutId);
232
+ throw error;
233
+ }
234
+ },
235
+
236
+ // 5. 提交生成任务
237
+ submitGenerate: async function(payload) {
238
+ const headers = { 'Content-Type': 'application/json' };
239
+
240
+ const tokenInfo = await requestZeroGpuToken();
241
+ if (tokenInfo) {
242
+ if (typeof tokenInfo === 'object') {
243
+ Object.assign(headers, tokenInfo);
244
+ } else if (typeof tokenInfo === 'string') {
245
+ headers['x-ip-token'] = tokenInfo;
246
+ }
247
+ }
248
+
249
+ const res = await fetch(`${API_BASE_URL}/api/generate`, {
250
+ method: 'POST',
251
+ cache: 'no-store',
252
+ headers: headers,
253
+ body: JSON.stringify(payload)
254
+ });
255
+ if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`);
256
+ const data = await res.json();
257
+ if (data.status === "error") throw new Error(data.message);
258
+ return data.task_id;
259
+ },
260
+
261
+ // 6. 轮询任务状态
262
+ pollStatus: async function(taskId, callbacks, retries = 0) {
263
+ const MAX_RETRIES = 150;
264
+
265
+ try {
266
+ const res = await fetch(`${API_BASE_URL}/api/status/${taskId}`, {
267
+ cache: 'no-store'
268
+ });
269
+ const data = await res.json();
270
+
271
+ if (data.status === "processing" || data.status === "queued") {
272
+ if (callbacks.onProcessing) {
273
+ callbacks.onProcessing(data.message || "Processing...");
274
+ }
275
+
276
+ if (retries >= MAX_RETRIES) {
277
+ const timeoutMsg = `Timeout: Backend exceeded ${MAX_RETRIES * 500 / 1000}s. Please try again.`;
278
+ if (callbacks.onError) callbacks.onError(timeoutMsg);
279
+ return;
280
+ }
281
+
282
+ setTimeout(() => this.pollStatus(taskId, callbacks, retries + 1), 100);
283
+ }
284
+ else if (data.status === "success") {
285
+ // 如果后端返回的是绝对路径或 data: 开头,直接使用;否则拼接 API_BASE_URL
286
+ let finalImageUrl = data.image;
287
+ if (finalImageUrl && !finalImageUrl.startsWith('data:') && !finalImageUrl.startsWith('http')) {
288
+ finalImageUrl = `${API_BASE_URL}${finalImageUrl}`;
289
+ }
290
+ if (callbacks.onSuccess) callbacks.onSuccess(finalImageUrl, data.seed, data.metrics, data.filename);
291
+ }
292
+ else if (data.status === "error") {
293
+ if (callbacks.onError) callbacks.onError(data.message);
294
+ }
295
+ else if (data.status === "not_found") {
296
+ if (callbacks.onError) callbacks.onError("Task not found (backend may have restarted).");
297
+ }
298
+ } catch (error) {
299
+ if (callbacks.onError) callbacks.onError(error.message);
300
+ }
301
+ },
302
+ };
303
+
304
+ // ==========================================================================
305
+ // 🧠 Global State & Utilities
306
+ // ==========================================================================
307
+ let generateStartTime = 0;
308
+ let currentStageStartTime = 0;
309
+ let currentStageName = "";
310
+ let isInitializing = false;
311
+ let shouldAnimateNextRender = false;
312
+
313
+ let globalModelData = {};
314
+ let currentGalleryData = [];
315
+ let renderedCount = 0;
316
+ const BATCH_SIZE = 20;
317
+ let selectedLora = "";
318
+ let backendState = { isReady: false, isChecking: false, healthInterval: null };
319
+
320
+ const ANIM_CONFIG = {
321
+ startY: 24,
322
+ stiffness: 280,
323
+ damping: 0.7,
324
+ staggerDelay: 30,
325
+
326
+ };
327
+
328
+ function toUnicodeBold(str) {
329
+ return str.split('').map(char => {
330
+ const code = char.charCodeAt(0);
331
+ if (code >= 65 && code <= 90) return String.fromCodePoint(0x1D5D4 + (code - 65));
332
+ if (code >= 97 && code <= 122) return String.fromCodePoint(0x1D5EE + (code - 97));
333
+ if (code >= 48 && code <= 57) return String.fromCodePoint(0x1D7EC + (code - 48));
334
+ return char;
335
+ }).join('');
336
+ }
337
+
338
+ function bindSlider(sliderId, inputId) {
339
+ const slider = document.getElementById(sliderId);
340
+ const input = document.getElementById(inputId);
341
+
342
+ // 1. 滑动滑块 -> 数字框实时跟随(这步肯定没问题)
343
+ slider.addEventListener('input', (e) => {
344
+ input.value = e.target.value;
345
+ });
346
+
347
+ // 2. 数���框输入中 -> 实时同步给滑块,但不强制打断用户输入
348
+ input.addEventListener('input', (e) => {
349
+ let val = parseFloat(e.target.value);
350
+ // 只有合法且没超范围,才同步给滑块
351
+ if (!isNaN(val) && val >= parseFloat(slider.min) && val <= parseFloat(slider.max)) {
352
+ slider.value = val;
353
+ }
354
+ });
355
+
356
+ // 3. 输入结束(失焦或回车)-> 执行严格的边界校验和回滚
357
+ const enforceLimits = () => {
358
+ let val = parseFloat(input.value);
359
+ const min = parseFloat(slider.min);
360
+ const max = parseFloat(slider.max);
361
+
362
+ if (isNaN(val)) {
363
+ input.value = min; // 如果清空了,默认回到最小值
364
+ slider.value = min;
365
+ return;
366
+ }
367
+
368
+ if (val < min) {
369
+ input.value = min;
370
+ slider.value = min;
371
+ } else if (val > max) {
372
+ input.value = max;
373
+ slider.value = max;
374
+ } else {
375
+ slider.value = val; // 防一手用户输入了 1024,滑块没变
376
+ }
377
+ };
378
+
379
+ input.addEventListener('change', enforceLimits);
380
+ input.addEventListener('blur', enforceLimits);
381
+ }
382
+
383
+ bindSlider('width-slider', 'width-input');
384
+ bindSlider('height-slider', 'height-input');
385
+ bindSlider('steps-slider', 'steps-input');
386
+ bindSlider('cfg-slider', 'cfg-input');
387
+
388
+ const RATIO_PRESETS = {
389
+ "1:1": { w: 1024, h: 1024 },
390
+ "3:4": { w: 888, h: 1184 },
391
+ "9:16": { w: 768, h: 1368 },
392
+ "4:3": { w: 1184, h: 888 },
393
+ "16:9": { w: 1368, h: 768 }
394
+ };
395
+
396
+ document.querySelectorAll('.ratio-btn').forEach(btn => {
397
+ btn.addEventListener('click', () => {
398
+ document.querySelectorAll('.ratio-btn').forEach(b => b.classList.remove('active'));
399
+ btn.classList.add('active');
400
+
401
+ const ratio = btn.dataset.ratio;
402
+ const preset = RATIO_PRESETS[ratio];
403
+ if (preset) {
404
+ document.getElementById('width-slider').value = preset.w;
405
+ document.getElementById('width-input').value = preset.w;
406
+ document.getElementById('height-slider').value = preset.h;
407
+ document.getElementById('height-input').value = preset.h;
408
+ }
409
+ });
410
+ });
411
+
412
+ function clearActiveRatio() {
413
+ document.querySelectorAll('.ratio-btn').forEach(b => b.classList.remove('active'));
414
+ }
415
+
416
+ document.getElementById('width-slider').addEventListener('input', clearActiveRatio);
417
+ document.getElementById('width-input').addEventListener('input', clearActiveRatio);
418
+ document.getElementById('height-slider').addEventListener('input', clearActiveRatio);
419
+ document.getElementById('height-input').addEventListener('input', clearActiveRatio);
420
+
421
+ function setUIState(isGenerating) {
422
+ const btn = document.getElementById('generate-btn');
423
+ if (!backendState.isReady) {
424
+ btn.disabled = true;
425
+ btn.innerText = "⏳ Waiting for Backend...";
426
+ } else if (isGenerating) {
427
+ btn.disabled = true;
428
+ btn.innerText = "⏳ Generating...";
429
+ } else {
430
+ btn.disabled = false;
431
+ btn.innerText = "Generate";
432
  }
433
  }
434
 
435
+ // 获取 Checkbox 元素
436
+ const autoScrollToggle = document.getElementById('auto-scroll-toggle');
437
+
438
+ function smartScroll() {
439
+ // 只有用户勾选了“自动滚动”,才强制贴底
440
+ if (autoScrollToggle && autoScrollToggle.checked) {
441
+ const logContent = document.getElementById('log-content');
442
+ // 用 requestAnimationFrame 确保在 DOM 高度更新后再执行滚动
443
+ requestAnimationFrame(() => {
444
+ logContent.scrollTop = logContent.scrollHeight;
445
+ });
446
+ }
447
+ }
448
+
449
+ function addLog(message, type = "normal") {
450
+ const logContent = document.getElementById('log-content');
 
 
451
 
452
+ const newLine = document.createElement('div');
453
+ newLine.className = `log-line ${type === "normal" ? "" : type}`;
454
 
455
+ const time = new Date().toLocaleTimeString('en-US', { hour12: false });
456
+ newLine.innerText = `[${time}] ${message}`;
457
+ logContent.appendChild(newLine);
458
 
459
+ if (logContent.childElementCount > 100) {
460
+ logContent.firstElementChild.remove();
461
+ }
462
 
463
+ // 插入后调用智能滚动
464
+ smartScroll();
465
+ }
466
+
467
+ // ==========================================================================
468
+ // 💓 Health Check & Initialization
469
+ // ==========================================================================
470
+ async function performHealthCheck() {
471
+ if (backendState.isChecking) return;
472
+ backendState.isChecking = true;
473
+
474
+ const dot = document.getElementById('backend-status-dot');
475
+ const wrapper = document.getElementById('backend-status-wrapper');
476
 
477
+ if (!backendState.isReady) {
478
+ setUIState(false);
479
+ dot.className = "status-dot loading";
480
+ wrapper.title = "Connecting to backend...";
481
+ }
482
+
483
+ const isHealthy = await BackendAPI.checkHealth();
484
+
485
+ if (isHealthy) {
486
+ if (!backendState.isReady) {
487
+ backendState.isReady = true;
488
+ setUIState(false);
489
+ dot.className = "status-dot online";
490
+ wrapper.title = "Backend online, ready to generate!";
491
+ await loadInitialData();
492
+ }
493
+ } else {
494
+ backendState.isReady = false;
495
+ setUIState(false);
496
+ dot.className = "status-dot offline";
497
+ wrapper.title = "Backend offline, trying to reconnect...";
498
+ }
499
+ backendState.isChecking = false;
500
+ }
501
+
502
+ function startHealthPolling() {
503
+ if (backendState.healthInterval) return;
504
+ backendState.healthInterval = setInterval(async () => {
505
+ if (backendState.isReady) {
506
+ clearInterval(backendState.healthInterval);
507
+ backendState.healthInterval = null;
508
+ return;
509
+ }
510
+ await performHealthCheck();
511
+ }, 3000);
512
+ }
513
+
514
+ async function loadInitialData() {
515
+ if (isInitializing) return;
516
+ isInitializing = true;
517
+
518
+ console.log("🟢 [Init] 开始初始化...");
519
+ document.getElementById('unet-select').disabled = true;
520
+
521
+ const config = await BackendAPI.getConfig();
522
+ if (!config) console.error("❌ 获取配置失败,请检查后端 API 是否正常");
523
+
524
+ const finalSamplers = config?.samplers || ["euler", "euler_ancestral", "euler_a", "dpmpp_2m", "dpmpp_2m_sde", "dpmpp_sde", "heun", "lms", "lcm"];
525
+ const finalSchedulers = config?.schedulers || ["simple", "beta", "normal", "karras", "exponential", "sgm_uniform"];
526
+ const rootMapping = config?.root_mapping || {};
527
+
528
+ if (config && config.default_prompt) {
529
+ document.getElementById('prompt').value = config.default_prompt;
530
+ }
531
+
532
+ document.getElementById('sampler').innerHTML = finalSamplers.map(s => `<option value="${s}">${s}</option>`).join('');
533
+ document.getElementById('scheduler').innerHTML = finalSchedulers.map(s => `<option value="${s}">${s}</option>`).join('');
534
+
535
+ const selectEl = document.getElementById('unet-select');
536
+ try {
537
+ const data = await BackendAPI.getModels();
538
+ console.log("🟢 [Init] Models 数据已拉取");
539
+
540
+ if (data.models) {
541
+ globalModelData = data.models;
542
+ selectEl.innerHTML = "";
543
+ Object.keys(data.models).forEach(modelName => {
544
+ const opt = document.createElement("option");
545
+ opt.value = modelName;
546
+
547
+ if (modelName.includes(" | ")) {
548
+ const parts = modelName.split(" | ");
549
+ const enginePart = toUnicodeBold(parts[0]);
550
+ const modelPart = parts[1];
551
+ opt.textContent = `${enginePart} | ${modelPart}`;
552
+ } else {
553
+ opt.textContent = toUnicodeBold(modelName);
554
+ }
555
+ selectEl.appendChild(opt);
556
+ });
557
+
558
+ let modelParam = null;
559
+ let loraParam = null;
560
+
561
+ if (window.location.hash) {
562
+ const hashStr = window.location.hash.substring(1);
563
+ const hashParams = new URLSearchParams(hashStr);
564
+ modelParam = hashParams.get('model');
565
+ loraParam = hashParams.get('lora');
566
+ }
567
+
568
+ if (!modelParam) {
569
+ const urlParams = new URLSearchParams(window.location.search);
570
+ modelParam = urlParams.get('model');
571
+ loraParam = urlParams.get('lora');
572
+ }
573
+
574
+ let targetModel = Object.keys(data.models)[0];
575
+
576
+ if (modelParam) {
577
+ const targetEngine = rootMapping[modelParam] || modelParam;
578
+ console.log(`🔗 URL解析: ${modelParam} -> ${targetEngine}`);
579
+ for (const key of Object.keys(data.models)) {
580
+ const modelEngine = data.models[key].engine;
581
+ if (modelEngine === targetEngine) {
582
+ targetModel = key;
583
+ console.log(`✅ 成功匹配到模型: ${key} (Engine: ${modelEngine})`);
584
+ break;
585
+ }
586
+ }
587
+ }
588
+
589
+ console.log(`🟢 [Init] 准备选中模型: ${targetModel}`);
590
+ selectEl.value = targetModel;
591
+ window._currentSelectedModel = targetModel;
592
+ window._lastUsedArchitecture = globalModelData[targetModel]?.engine || "unknown";
593
+
594
+ const modelInfo = globalModelData[targetModel];
595
+ const infoEl = document.getElementById('model-assets-info');
596
+ if (modelInfo && infoEl) {
597
+ const te = modelInfo.te_name || "Unknown";
598
+ const vae = modelInfo.vae_name || "Unknown";
599
+ infoEl.innerHTML = `
600
+ <div class="te-vae-container">
601
+ <div class="te-wrapper">
602
+ <span class="te-label">TE: </span>
603
+ <span class="te-value">${te}</span>
604
+ </div>
605
+ <div class="vae-wrapper">
606
+ <span class="vae-label">VAE: </span>
607
+ <span class="vae-value">${vae}</span>
608
+ </div>
609
+ </div>
610
+ `;
611
+ }
612
 
613
+ const modelDefaults = globalModelData[targetModel]?.defaults || {};
614
+
615
+ if (globalModelData[targetModel]?.default_prompt !== undefined) {
616
+ document.getElementById('prompt').value = globalModelData[targetModel].default_prompt;
617
+ }
618
+
619
+ if (modelDefaults.cfg !== undefined) {
620
+ document.getElementById('cfg-slider').value = modelDefaults.cfg;
621
+ document.getElementById('cfg-input').value = Number(modelDefaults.cfg).toFixed(1);
622
+ }
623
+ if (modelDefaults.steps !== undefined) {
624
+ document.getElementById('steps-slider').value = modelDefaults.steps;
625
+ document.getElementById('steps-input').value = modelDefaults.steps;
626
+ }
627
+ if (modelDefaults.sampler !== undefined) document.getElementById('sampler').value = modelDefaults.sampler;
628
+ if (modelDefaults.scheduler !== undefined) document.getElementById('scheduler').value = modelDefaults.scheduler;
629
+
630
+ if (modelDefaults.char_lora_strength !== undefined) {
631
+ document.getElementById('char-strength').value = Number(modelDefaults.char_lora_strength).toFixed(1);
632
+ }
633
+
634
+ selectedLora = "";
635
+ document.getElementById('selected-lora-name').innerText = "None";
636
+ document.getElementById('selected-lora-name').style.color = 'var(--text-muted)';
637
+ const bigImg = document.getElementById('selected-lora-big-img');
638
+ const placeholder = document.getElementById('preview-placeholder');
639
+ bigImg.src = "";
640
+ bigImg.style.display = 'none';
641
+ placeholder.style.display = 'flex';
642
+ document.querySelectorAll('.lora-card').forEach(c => c.classList.remove('selected'));
643
+
644
+ renderBaseLoras(targetModel);
645
+
646
+ window._isDataReady = true;
647
+ // 🌟 核心修改:如果 URL 没有指定 lora 参数,说明是普通进入,开启动画
648
+ const isPlainEntry = !loraParam;
649
+ await fetchAndRenderGallery(targetModel, isPlainEntry);
650
+
651
+ if (loraParam) {
652
+ if (!loraParam.endsWith(".safetensors")) {
653
+ console.warn(`⚠️ [Init] 忽略无效的 LoRA 参数(缺少 .safetensors 后缀): ${loraParam}`);
654
+ } else {
655
+ const targetIndex = currentGalleryData.findIndex(item => item.filename === loraParam);
656
+
657
+ if (targetIndex !== -1) {
658
+ const targetItem = currentGalleryData[targetIndex];
659
+ console.log(`🟢 [Init] 数据层成功找到 LoRA: ${loraParam},位于第 ${targetIndex + 1} 个`);
660
+
661
+ selectedLora = targetItem.filename;
662
+ const nameEl = document.getElementById('selected-lora-name');
663
+ nameEl.innerText = targetItem.title;
664
+ nameEl.style.color = 'var(--accent)';
665
+
666
+ const bigImg = document.getElementById('selected-lora-big-img');
667
+ const placeholder = document.getElementById('preview-placeholder');
668
+ const previewWrapper = document.querySelector('.preview-wrapper');
669
+
670
+ if (previewWrapper) previewWrapper.classList.add('shimmer-container');
671
+
672
+ bigImg.src = targetItem.webp;
673
+
674
+ bigImg.onload = () => {
675
+ bigImg.style.display = 'block';
676
+ placeholder.style.display = 'none';
677
+ if (previewWrapper) previewWrapper.classList.remove('shimmer-container');
678
+ bigImg.onload = null;
679
+ };
680
+
681
+ bigImg.onerror = () => {
682
+ if (previewWrapper) previewWrapper.classList.remove('shimmer-container');
683
+ bigImg.onerror = null;
684
+ };
685
+
686
+ if (targetIndex >= renderedCount) {
687
+ const requiredRenderCount = Math.ceil((targetIndex + 1) / BATCH_SIZE) * BATCH_SIZE;
688
+ const tempData = currentGalleryData.slice(renderedCount, requiredRenderCount);
689
+
690
+ const grid = document.getElementById('gallery-grid');
691
+ tempData.forEach(item => {
692
+ const card = document.createElement('div');
693
+ card.className = 'lora-card';
694
+ card.dataset.filename = item.filename;
695
+ card.innerHTML = `
696
+ <img src="${item.webp}" loading="lazy" alt="${item.title}" onerror="this.src='https://via.placeholder.com/300x400?text=No+Image'">
697
+ <div class="card-info">
698
+ <div class="card-title">${item.title}</div>
699
+ </div>
700
+ `;
701
+
702
+ const cardImg = card.querySelector('img');
703
+ applyCardShimmer(card, cardImg);
704
+ bindCardClick(card, item);
705
+ grid.appendChild(card);
706
+ });
707
+ renderedCount = requiredRenderCount;
708
+ }
709
+
710
+ setTimeout(() => {
711
+ const targetCard = document.querySelector(`.lora-card[data-filename="${loraParam}"]`);
712
+ if (targetCard) {
713
+ console.log(`🟢 [Init] DOM 已渲染,执行滚动和高亮`);
714
+ document.querySelectorAll('.lora-card').forEach(c => c.classList.remove('selected'));
715
+ targetCard.classList.add('selected');
716
+ targetCard.scrollIntoView({ behavior: 'smooth', block: 'center' });
717
+ } else {
718
+ console.warn(`⚠️ 极度异常:数据存在,但强制渲染后依然找不到 DOM`);
719
+ }
720
+ }, 150);
721
+
722
+ } else {
723
+ console.warn(`⚠️ [Init] 在返回的数据中不存在该 LoRA: ${loraParam}`);
724
+ }
725
  }
726
+ }
727
+ }
728
+ } catch (e) {
729
+ console.error("❌ 初始化加载失败:", e);
730
+ selectEl.innerHTML = `<option value="">🔴 Backend Offline</option>`;
731
+ } finally {
732
+ document.getElementById('unet-select').disabled = false;
733
+
734
+ // 🌟 智能移除遮罩:如果数据没就绪,就等它就绪
735
+ const waitForDataAndHideMask = setInterval(() => {
736
+ if (window._isDataReady) {
737
+ clearInterval(waitForDataAndHideMask);
738
+
739
+ const mask = document.getElementById('global-loading-mask');
740
+ if (mask) {
741
+ setTimeout(() => {
742
+ mask.style.opacity = '0';
743
+ setTimeout(() => mask.remove(), 300);
744
+ }, 100); // 给 DOM 留 100ms 的渲染时间
745
+ }
746
+ }
747
+ }, 100);
748
+ }
749
+ }
750
+
751
+ // ==========================================================================
752
+ // 🖼️ Gallery & Card Rendering
753
+ // ==========================================================================
754
+ function renderBaseLoras(selectedModel) {
755
+ const container = document.getElementById('base-lora-container');
756
+ container.innerHTML = "";
757
+ const loras = globalModelData[selectedModel]?.base_loras || [];
758
+ if (loras.length === 0) {
759
+ container.innerHTML = `<div style="font-size: 12px; color: var(--text-muted);">No Base LoRA for current model</div>`;
760
+ return;
761
+ }
762
+ loras.forEach((lora) => {
763
+ const div = document.createElement('div');
764
+ div.className = 'lora-row';
765
+ div.innerHTML = `
766
+ <label>
767
+ <input type="checkbox" class="base-lora-cb" ${lora.defaultEnable ? 'checked' : ''}>
768
+ ${lora.name}
769
+ </label>
770
+ <input type="number" class="strength-box base-lora-strength" value="1.0" step="0.05" title="Strength">
771
+ `;
772
+ container.appendChild(div);
773
+ });
774
+ }
775
+
776
+ // 在全局变量区加一个请求锁
777
+ let isGalleryFetching = false;
778
+
779
+ async function fetchAndRenderGallery(modelName, triggerAnim = false) {
780
+ // 🌟 核心修复 2:如果已经在请求中,直接锁死,不准并发
781
+ if (isGalleryFetching) {
782
+ console.log("⚠️ 拦截并发请求,当前图库正在加载中...");
783
+ return;
784
+ }
785
+ isGalleryFetching = true;
786
+
787
+ const grid = document.getElementById('gallery-grid');
788
+ const countEl = document.getElementById('lora-count');
789
+
790
+ // 🌟 注意:这里不再清空 grid.innerHTML,因为我们在 unet-select 里已经清空了
791
+ // 但为了首次加载的安全,如果 grid 里没有 Loading 提示,还是补一个
792
+ if (!grid.innerHTML.includes("Loading") && !grid.innerHTML.includes("Switching")) {
793
+ grid.innerHTML = `<div style="color: var(--text-muted); font-size: 13px; grid-column: span 4;">⏳ Loading character list...</div>`;
794
+ }
795
+
796
+ try {
797
+ const modelInfo = globalModelData[modelName];
798
 
799
+ if (!modelInfo || !modelInfo.engine) {
800
+ grid.innerHTML = `<div style="color: var(--text-muted); font-size: 13px; grid-column: span 4;">⏳ Backend data syncing...</div>`;
801
+ return;
802
+ }
803
+
804
+ const engineKey = modelInfo.engine;
805
+ // 🌟 别忘了你之前给 getLoras 加了 10 秒超时
806
+ const data = await BackendAPI.getLoras();
807
+
808
+ const lorasDict = data.loras || {};
809
+ const lorasForModel = lorasDict[engineKey];
810
+
811
+ if (!lorasForModel || Object.keys(lorasForModel).length === 0) {
812
+ grid.innerHTML = `<div style="color: var(--text-muted); font-size: 13px; grid-column: span 4;">No character LoRA for this model</div>`;
813
+ if (countEl) countEl.innerText = "0 Models";
814
+ return;
815
+ }
816
+
817
+ currentGalleryData = Object.entries(lorasForModel).map(([filename, info]) => ({
818
+ filename: filename,
819
+ title: filename.replace(".safetensors", ""),
820
+ webp: info.webp
821
+ }));
822
+
823
+ renderedCount = 0;
824
+ shouldAnimateNextRender = triggerAnim;
825
+
826
+ if (countEl) countEl.innerText = `${currentGalleryData.length} Models`;
827
+
828
+ // 🌟 核心:把 flex 居中还原成原来的网格布局
829
+ grid.style.display = 'grid';
830
+ grid.style.height = 'auto';
831
+
832
+ grid.innerHTML = "";
833
+ loadMoreCards();
834
+
835
+ const container = document.getElementById('gallery-container');
836
+ container.onscroll = () => {
837
+ if (container.scrollTop + container.clientHeight >= container.scrollHeight - 50) {
838
+ loadMoreCards();
839
+ }
840
+ };
841
+ } catch (error) {
842
+ console.error("Severe error in gallery request:", error);
843
+ grid.innerHTML = `<div style="color: #ff4444; font-size: 13px; grid-column: span 4;">❌ Failed to load gallery, backend may be restarting</div>`;
844
+ if (countEl) countEl.innerText = "Error";
845
+ } finally {
846
+ // 🌟 核心修复 3:无论成功失败,必须释放锁
847
+ isGalleryFetching = false;
848
+ }
849
+ }
850
+
851
+ function applyCardShimmer(card, imgElement) {
852
+ // 🌟 1. 强制卡片裁剪溢出内容 (就地加内联样式,不碰 css 文件)
853
+ card.style.overflow = 'hidden';
854
+ // 确保卡片有相对定位,这样扫光层才能完美贴合
855
+ if (getComputedStyle(card).position === 'static') {
856
+ card.style.position = 'relative';
857
+ }
858
+
859
+ const overlay = document.createElement('div');
860
+ overlay.className = 'shimmer-overlay';
861
+
862
+ // 🌟 2. 给扫光层加绝对定位的内联样式,死死盖在图片上方
863
+ overlay.style.position = 'absolute';
864
+ overlay.style.inset = '0';
865
+ overlay.style.zIndex = '10';
866
+
867
+ // 🌟 3. 加上渐变动画效果
868
+ overlay.style.transition = 'opacity 0.3s ease-out';
869
+ overlay.style.opacity = '1';
870
+
871
+ card.appendChild(overlay);
872
+
873
+ // 🌟 4. 隐藏原图,防止逐行渲染露馅
874
+ imgElement.style.opacity = '0';
875
+ imgElement.style.transition = 'opacity 0.3s ease-out';
876
+
877
+ const revealImage = () => {
878
+ // 等两帧,确保浏览器把布局和尺寸都算准了
879
+ requestAnimationFrame(() => {
880
+ requestAnimationFrame(() => {
881
+ // 让图片显形,同时让扫光层淡出
882
+ imgElement.style.opacity = '1';
883
+ overlay.style.opacity = '0';
884
+
885
+ // 等动画播完后再移除 DOM
886
+ setTimeout(() => {
887
+ if (overlay.parentNode) {
888
+ overlay.remove();
889
+ }
890
+ }, 300);
891
+ });
892
+ });
893
+ };
894
+
895
+ if (imgElement.complete) {
896
+ if (imgElement.decode) {
897
+ imgElement.decode().then(revealImage).catch(revealImage);
898
+ } else {
899
+ revealImage();
900
+ }
901
+ } else {
902
+ imgElement.addEventListener('load', () => {
903
+ if (imgElement.decode) {
904
+ imgElement.decode().then(revealImage).catch(revealImage);
905
+ } else {
906
+ revealImage();
907
+ }
908
+ });
909
+ imgElement.addEventListener('error', () => {
910
+ imgElement.style.opacity = '1'; // 报错也显示占位图
911
+ overlay.remove();
912
+ });
913
+ }
914
+ }
915
+
916
+ function bindCardClick(card, item) {
917
+ card.addEventListener('click', () => {
918
+ document.querySelectorAll('.lora-card').forEach(c => c.classList.remove('selected'));
919
+ card.classList.add('selected');
920
+ selectedLora = item.filename;
921
+
922
+ const nameEl = document.getElementById('selected-lora-name');
923
+ nameEl.innerText = item.title;
924
+ nameEl.style.color = item.title ? 'var(--accent)' : 'var(--text-muted)';
925
+
926
+ const bigImg = document.getElementById('selected-lora-big-img');
927
+ const placeholder = document.getElementById('preview-placeholder');
928
+
929
+ bigImg.style.display = 'none';
930
+ placeholder.style.display = 'flex';
931
+ bigImg.classList.add('shimmer-active');
932
+ bigImg.src = item.webp;
933
+
934
+ bigImg.onload = () => {
935
+ bigImg.style.display = 'block';
936
+ placeholder.style.display = 'none';
937
+ bigImg.onload = null;
938
+ };
939
+
940
+ bigImg.onerror = () => {
941
+ bigImg.style.display = 'none';
942
+ placeholder.style.display = 'flex';
943
+ bigImg.onerror = null;
944
+ };
945
+
946
+ setTimeout(() => {
947
+ bigImg.classList.remove('shimmer-active');
948
+ }, 400);
949
+ });
950
+ }
951
+
952
+ function loadMoreCards() {
953
+ const grid = document.getElementById('gallery-grid');
954
+ const nextBatch = currentGalleryData.slice(renderedCount, renderedCount + BATCH_SIZE);
955
+ if (nextBatch.length === 0) return;
956
+
957
+ // 🌟 注意这里要加 (item, index)
958
+ nextBatch.forEach((item, index) => {
959
+ const card = document.createElement('div');
960
+ card.className = 'lora-card';
961
+ card.dataset.filename = item.filename;
962
+
963
+ // 🌟 动画初始状态
964
+ if (shouldAnimateNextRender) {
965
+ card.style.opacity = '0';
966
+ card.style.transform = 'translateY(20px)';
967
+ card.dataset.animateEntry = "true";
968
+ card.dataset.index = index;
969
+ }
970
+
971
+ card.innerHTML = `
972
+ <img src="${item.webp}" loading="lazy" alt="${item.title}" onerror="this.src='https://via.placeholder.com/300x400?text=No+Image'">
973
+ <div class="card-info">
974
+ <div class="card-title">${item.title}</div>
975
+ </div>
976
+ `;
977
+
978
+ const cardImg = card.querySelector('img');
979
+ applyCardShimmer(card, cardImg);
980
+ bindCardClick(card, item);
981
+
982
+ grid.appendChild(card);
983
+
984
+ // 🌟 动画执行
985
+ if (shouldAnimateNextRender) {
986
+ // 错开延迟触发���利用你配置里的 staggerDelay (30ms)
987
+ setTimeout(() => {
988
+ applySpringAnimation(card);
989
+ }, index * ANIM_CONFIG.staggerDelay);
990
+ }
991
+ });
992
+
993
+ renderedCount += nextBatch.length;
994
+
995
+ // 🌟 重要:动画只管第一屏。渲染完之后立刻关掉,防止滚动加载时还会闪烁。
996
+ shouldAnimateNextRender = false;
997
+
998
+ }
999
+
1000
+ // ==========================================
1001
+ // 🪄 Animation: 物理弹簧动画
1002
+ // ==========================================
1003
+ function applySpringAnimation(element) {
1004
+ if (!element || element.dataset.animating === 'true') return;
1005
+ element.dataset.animating = 'true';
1006
+
1007
+ const startY = ANIM_CONFIG.startY;
1008
+ const omega0 = Math.sqrt(ANIM_CONFIG.stiffness);
1009
+ const zeta = ANIM_CONFIG.damping;
1010
+ const omegaD = omega0 * Math.sqrt(1 - zeta * zeta);
1011
+ const startTime = performance.now();
1012
+
1013
+ element.style.transition = '';
1014
+ element.style.opacity = '0';
1015
+ element.style.transform = `translate3d(0, ${startY}px, 0)`;
1016
+ element.style.willChange = 'transform, opacity';
1017
+ element.style.backfaceVisibility = 'hidden';
1018
+
1019
+ void element.offsetWidth;
1020
+
1021
+ function step(currentTime) {
1022
+ const t = (currentTime - startTime) / 1000;
1023
+ const envelope = Math.exp(-zeta * omega0 * t);
1024
+
1025
+ if (Math.abs(envelope * startY) < 0.1) {
1026
+ // 🌟 核心修复:不要用 translate3d,直接清空 transform,让父级彻底“干干净净”
1027
+ element.style.transform = '';
1028
+ element.style.opacity = 1;
1029
+ element.style.willChange = 'auto';
1030
+ element.style.transition = '';
1031
+ element.dataset.animating = 'false';
1032
+ return;
1033
+ }
1034
+
1035
+ const positionY = startY * envelope * Math.cos(omegaD * t);
1036
+ const opacity = Math.min(t / 0.25, 1);
1037
+
1038
+ element.style.transform = `translate3d(0, ${positionY}px, 0)`;
1039
+ element.style.opacity = opacity;
1040
+
1041
+ requestAnimationFrame(step);
1042
+ }
1043
+
1044
+ requestAnimationFrame(step);
1045
+ }
1046
+
1047
+
1048
+ // ==========================================================================
1049
+ // ⚡ Event Listeners (Generate, Switch, Random Seed)
1050
+ // ==========================================================================
1051
+ document.getElementById('random-seed-btn').addEventListener('click', (e) => {
1052
+ e.preventDefault();
1053
+ document.getElementById('seed').value = Math.floor(Math.random() * 4294967295);
1054
+ });
1055
+
1056
+ // --------------------------------------------------------------------------
1057
+ // 📋 Copy Logs Logic
1058
+ // --------------------------------------------------------------------------
1059
+ document.getElementById('copy-log-btn').addEventListener('click', async () => {
1060
+ const logContent = document.getElementById('log-content');
1061
+ // 获取纯文本,排除多余的 DOM 结构
1062
+ const logText = logContent.innerText;
1063
+
1064
+ try {
1065
+ await navigator.clipboard.writeText(logText);
1066
+ // 复制成功反馈,把按钮文字临时改掉
1067
+ const btn = document.getElementById('copy-log-btn');
1068
+ const originalText = btn.innerHTML;
1069
+ btn.innerHTML = '📋 Copied';
1070
+
1071
+ setTimeout(() => {
1072
+ btn.innerHTML = originalText;
1073
+ btn.style.color = 'var(--text-muted)';
1074
+ }, 2000);
1075
+
1076
+ } catch (err) {
1077
+ console.error('Failed to copy logs:', err);
1078
+ alert('Failed to copy logs.');
1079
+ }
1080
+ });
1081
+
1082
+ document.getElementById('unet-select').addEventListener('change', async (e) => {
1083
+ const selectedModel = e.target.value;
1084
+ const currentEngine = globalModelData[selectedModel]?.engine || "unknown";
1085
+ const lastEngine = window._lastUsedArchitecture || null;
1086
+ const isCrossArchitecture = (lastEngine !== null && lastEngine !== currentEngine);
1087
+
1088
+ // 🌟 新增:更新 TE 和 VAE 的展示
1089
+ const modelInfo = globalModelData[selectedModel];
1090
+ const infoEl = document.getElementById('model-assets-info');
1091
+
1092
+ if (modelInfo && infoEl) {
1093
+ const te = modelInfo.te_name || "Unknown";
1094
+ const vae = modelInfo.vae_name || "Unknown";
1095
+ infoEl.innerHTML = `TE: ${te}<br>VAE: ${vae}`;
1096
+ }
1097
+
1098
+
1099
+ // 🌟 核心修复 1:如果是跨架构切换,第一时间强杀旧 DOM,别等网络请求
1100
+ if (isCrossArchitecture) {
1101
+ const grid = document.getElementById('gallery-grid');
1102
+
1103
+ // 🌟 核心:强行把 grid 改成 flex 居中
1104
+ grid.style.display = 'flex';
1105
+ grid.style.justifyContent = 'center';
1106
+ grid.style.alignItems = 'center';
1107
+ grid.style.height = '100%'; // 撑满父容器高度
1108
+
1109
+ grid.innerHTML = `<div style="color: var(--text-muted); font-size: 13px;">⏳ Loading LoRAs...</div>`;
1110
+
1111
+ document.getElementById('lora-count').innerText = "Loading...";
1112
+ currentGalleryData = [];
1113
+ renderedCount = 0;
1114
+ }
1115
+
1116
+ // 1. 处理 Prompt 覆盖
1117
+ const promptEl = document.getElementById('prompt');
1118
+ const lastModelName = window._currentSelectedModel || null;
1119
+ const previousDefaultPrompt = lastModelName ? (globalModelData[lastModelName]?.default_prompt || "") : "";
1120
+
1121
+ if (!promptEl.value.trim() || promptEl.value === previousDefaultPrompt) {
1122
+ if (globalModelData[selectedModel]?.default_prompt) {
1123
+ promptEl.value = globalModelData[selectedModel].default_prompt;
1124
+ }
1125
+ }
1126
+
1127
+ // 2. 处理参数更新 (不管跨不跨架构,参数肯定要更新)
1128
+ const modelDefaults = globalModelData[selectedModel]?.defaults || {};
1129
+
1130
+ if (modelDefaults.cfg !== undefined) {
1131
+ document.getElementById('cfg-slider').value = modelDefaults.cfg;
1132
+ document.getElementById('cfg-input').value = Number(modelDefaults.cfg).toFixed(1);
1133
+ }
1134
+ if (modelDefaults.steps !== undefined) {
1135
+ document.getElementById('steps-slider').value = modelDefaults.steps;
1136
+ document.getElementById('steps-input').value = modelDefaults.steps;
1137
+ }
1138
+ if (modelDefaults.sampler !== undefined) {
1139
+ document.getElementById('sampler').value = modelDefaults.sampler;
1140
+ }
1141
+ if (modelDefaults.scheduler !== undefined) {
1142
+ document.getElementById('scheduler').value = modelDefaults.scheduler;
1143
+ }
1144
+ if (modelDefaults.char_lora_strength !== undefined && (isCrossArchitecture || !selectedLora)) {
1145
+ document.getElementById('char-strength').value = Number(modelDefaults.char_lora_strength).toFixed(1);
1146
+ }
1147
+
1148
+ // ========================================================
1149
+ // 🌟 核心分支:只有跨架构才执行渲染,同架构直接躺平
1150
+ // ========================================================
1151
+ if (isCrossArchitecture) {
1152
+ // 跨架构:清空已选角色,换 Base LoRA,重新拉数据(并传 true 触发入场动画)
1153
+ selectedLora = "";
1154
+ document.getElementById('selected-lora-name').innerText = "None";
1155
+ document.getElementById('selected-lora-name').style.color = 'var(--text-muted)';
1156
+
1157
+ const bigImg = document.getElementById('selected-lora-big-img');
1158
+ const placeholder = document.getElementById('preview-placeholder');
1159
+ bigImg.src = "";
1160
+ bigImg.style.display = 'none';
1161
+ placeholder.style.display = 'flex';
1162
+ document.querySelectorAll('.lora-card').forEach(c => c.classList.remove('selected'));
1163
+
1164
+ renderBaseLoras(selectedModel);
1165
+ fetchAndRenderGallery(selectedModel, true); // 👈 后台异步加载,不阻塞事件
1166
+
1167
+ // 强制滚动到图库顶部
1168
+ const galleryContainer = document.getElementById('gallery-container');
1169
+ if (galleryContainer) galleryContainer.scrollTop = 0;
1170
+
1171
+ } else {
1172
+ // 🌟 同架构:不请求网络,不动画廊,完美保留 DOM 和滚动位置
1173
+ console.log("🔄 Same architecture, keeping gallery and Base LoRAs intact.");
1174
+ }
1175
+
1176
+ // 更新全局记录
1177
+ window._currentSelectedModel = selectedModel;
1178
+ window._lastUsedArchitecture = currentEngine;
1179
+ });
1180
+
1181
+ document.getElementById('generate-btn').addEventListener('click', async () => {
1182
+ if (!backendState.isReady) {
1183
+ alert("Backend is waking up. Please wait a few seconds and try again!");
1184
+ return;
1185
+ }
1186
+
1187
+ const resultImage = document.getElementById('result-image');
1188
+ const placeholderText = document.getElementById('placeholder-text');
1189
+ const generateBtn = document.getElementById('generate-btn');
1190
+
1191
+ const prompt = document.getElementById('prompt').value.trim();
1192
+ const unet_choice = document.getElementById('unet-select').value;
1193
+ const custom_w = parseInt(document.getElementById('width-input').value);
1194
+ const custom_h = parseInt(document.getElementById('height-input').value);
1195
+ const steps = parseInt(document.getElementById('steps-input').value);
1196
+ const cfg = parseFloat(document.getElementById('cfg-input').value);
1197
+ const sampler = document.getElementById('sampler').value;
1198
+ const scheduler = document.getElementById('scheduler').value;
1199
+
1200
+ const seedVal = document.getElementById('seed').value;
1201
+ const seed = seedVal === "" ? -1 : parseInt(seedVal);
1202
+
1203
+ if (!unet_choice || unet_choice.includes('Loading') || unet_choice.includes('Backend Offline')) {
1204
+ alert("Please select a valid Base Model first."); return;
1205
+ }
1206
+
1207
+ if (!prompt) {
1208
+ alert("Please enter a prompt.");
1209
+ return;
1210
+ }
1211
+
1212
+ const baseLoraStates = [];
1213
+ document.querySelectorAll('#base-lora-container .lora-row').forEach(row => {
1214
+ const isChecked = row.querySelector('.base-lora-cb').checked;
1215
+ const strength = parseFloat(row.querySelector('.base-lora-strength').value) || 1.0;
1216
+ baseLoraStates.push(isChecked, strength);
1217
+ });
1218
+
1219
+ const charEnable = document.getElementById('char-enable').checked;
1220
+ const charStrength = parseFloat(document.getElementById('char-strength').value) || 1.0;
1221
+
1222
+ const requestData = {
1223
+ prompt: prompt,
1224
+ unet_choice: unet_choice,
1225
+ custom_w: custom_w,
1226
+ custom_h: custom_h,
1227
+ steps: steps,
1228
+ cfg: cfg,
1229
+ seed: seed,
1230
+ sampler: sampler,
1231
+ scheduler: scheduler,
1232
+ char_enable: charEnable,
1233
+ char_lora: selectedLora || "",
1234
+ char_strength: charStrength,
1235
+ base_lora_states: baseLoraStates
1236
+ };
1237
+
1238
+ console.log("🚀 Submitting parameters:", requestData);
1239
+
1240
+ if (!requestData.prompt || !requestData.unet_choice || !requestData.custom_w) {
1241
+ alert("Missing parameters. Please check Prompt / Model / Dimensions.");
1242
+ return;
1243
+ }
1244
+
1245
+ generateBtn.disabled = true;
1246
+ generateBtn.innerText = "⏳ Submitting...";
1247
+ resultImage.style.display = 'none';
1248
+ placeholderText.style.display = 'block';
1249
+
1250
+ generateStartTime = Date.now();
1251
+ currentStageStartTime = generateStartTime;
1252
+ currentStageName = "Task Queued";
1253
+
1254
+ document.getElementById('log-content').innerHTML = "";
1255
+
1256
+ if (window._lastGeneratedModel !== unet_choice) {
1257
+ const currentEngine = globalModelData[unet_choice]?.engine || "unknown";
1258
+ const lastModel = window._lastGeneratedModel;
1259
+ const lastEngine = lastModel ? (globalModelData[lastModel]?.engine || "unknown") : null;
1260
+
1261
+ if (lastEngine && lastEngine !== currentEngine) {
1262
+ addLog(`🔄 Arch Switch: Loading UNet/VAE/TE (Full Swap)...`, "highlight");
1263
+ } else if (lastEngine === currentEngine) {
1264
+ addLog(`🔄 Model Update: Swapping UNet (Keep VAE/TE)...`, "highlight");
1265
+ } else {
1266
+ addLog(`🔄 Initializing: Loading UNet/VAE/TE (First Load)...`, "highlight");
1267
+ }
1268
+ window._lastGeneratedModel = unet_choice;
1269
+ }
1270
+
1271
+ addLog("Task submitted to backend.", "highlight");
1272
+
1273
+ try {
1274
+ const taskId = await BackendAPI.submitGenerate(requestData);
1275
+ addLog(`Task ID: ${taskId}`);
1276
+
1277
+ BackendAPI.pollStatus(taskId, {
1278
+ onProcessing: (msg) => {
1279
+ let safeMsg = msg || "🎨 Processing...";
1280
+ let newStage = "Processing";
1281
+
1282
+ if (safeMsg.includes("Rendering") || safeMsg.includes("steps") || safeMsg.includes("Sampling") || safeMsg.includes("KSampler")) {
1283
+ newStage = "Comfy Backend - Sampling";
1284
+ }
1285
+ else if (safeMsg.includes("Downloading")) {
1286
+ newStage = "Downloading";
1287
  }
1288
+ else if (safeMsg.includes("Requesting") || safeMsg.includes("Waiting") || safeMsg.includes("Queue")) {
1289
+ newStage = "Queueing";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1290
  }
1291
+ else if (safeMsg.includes("Allocating") || safeMsg.includes("GPU Acquired") || safeMsg.includes("Loading") || safeMsg.includes("Executing") || safeMsg.includes("Workflow")) {
1292
+ newStage = "Comfy Backend - Model Loading";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1293
  }
1294
+
1295
+ if ((currentStageName === "Comfy Backend - Model Loading" || currentStageName === "Comfy Backend - Sampling")
1296
+ && (safeMsg.includes("Requesting") || safeMsg.includes("Waiting") || safeMsg.includes("Queue"))) {
1297
+ return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1298
  }
1299
+
1300
+ if (newStage !== currentStageName && newStage !== "Processing") {
1301
+ const now = Date.now();
1302
+ const prevDuration = ((now - currentStageStartTime) / 1000).toFixed(1);
1303
+
1304
+ if (currentStageName !== "Task Queued" && currentStageName !== "Processing") {
1305
+ addLog(`⏱️ ${currentStageName} finished in ${prevDuration}s.`);
1306
+ }
1307
+
1308
+ currentStageName = newStage;
1309
+ currentStageStartTime = now;
1310
+
1311
+ addLog(`🚀 Starting ${newStage}...`);
1312
  }
1313
+
1314
+ const logContent = document.getElementById('log-content');
1315
+ const lastLog = logContent.lastElementChild;
1316
+ const lastText = lastLog ? lastLog.innerText : "";
1317
+
1318
+ if (safeMsg.includes("Rendering image (GPU processing)") && !safeMsg.includes("steps")) {
1319
+ const elapsed = ((Date.now() - currentStageStartTime) / 1000).toFixed(0);
1320
+
1321
+ const lastLine = logContent.lastElementChild ? logContent.lastElementChild.innerText : "";
1322
+ const lastElapsedMatch = lastLine.match(/\((\d+)s\)/);
1323
+
1324
+ if (!lastLine.includes("Rendering image") || !lastElapsedMatch || parseInt(lastElapsedMatch[1]) !== parseInt(elapsed)) {
1325
+ addLog(`🎨 Rendering image (GPU processing)... (${elapsed}s)`);
1326
+ }
1327
+ smartScroll();
1328
+ return;
 
 
 
 
1329
  }
1330
+
1331
+ if (safeMsg.includes("steps")) {
1332
+ if (currentStageName !== "Comfy Backend - Sampling") {
1333
+ const now = Date.now();
1334
+ const prevDuration = ((now - currentStageStartTime) / 1000).toFixed(1);
1335
+
1336
+ if (currentStageName === "Comfy Backend - Model Loading") {
1337
+ addLog(`⏱️ Comfy Backend - Model Loading finished in ${prevDuration}s.`, "normal");
1338
+ addLog(`🚀 Starting Comfy Backend - Sampling...`, "normal");
1339
+ }
1340
+
1341
+ currentStageName = "Comfy Backend - Sampling";
1342
+ currentStageStartTime = now;
1343
+ }
1344
+
1345
+ const lastLine = logContent.lastElementChild;
1346
+ if (!lastLine || !lastLine.innerText.includes(safeMsg)) {
1347
+ addLog(`\u2003\u2003${safeMsg}`, "normal");
1348
+ }
1349
+ smartScroll();
1350
+ return;
1351
+ }
1352
+
1353
+ if (currentStageName === "Loading VRAM" && !safeMsg.includes("Executing") && (safeMsg.includes("Waiting for GPU") || safeMsg.includes("Queue for GPU"))) {
1354
+ return;
1355
+ }
1356
+
1357
+ if (safeMsg.includes("Loading") || safeMsg.includes("Swapping") || safeMsg.includes("Waiting") || safeMsg.includes("GPU Acquired") || safeMsg.includes("Queue") || safeMsg.includes("Executing") || safeMsg.includes("Sampling") || safeMsg.includes("KSampler") || currentStageName === "Comfy Backend - Sampling") {
1358
+ const elapsed = ((Date.now() - currentStageStartTime) / 1000).toFixed(0);
1359
+
1360
+ const lastLine = logContent.lastElementChild;
1361
+ let isNewSecond = true;
1362
+ if (lastLine && lastLine.dataset.lastElapsed === elapsed) {
1363
+ isNewSecond = false;
1364
+ }
1365
+
1366
+ if (isNewSecond) {
1367
+ const newLine = document.createElement('div');
1368
+ newLine.className = 'log-line';
1369
+
1370
+ let displayMsg = safeMsg;
1371
+ if (currentStageName === "Comfy Backend - Model Loading") {
1372
+ displayMsg = "📦 Loading tensors to VRAM...";
1373
+ } else if (currentStageName === "Comfy Backend - Sampling") {
1374
+ displayMsg = "🎨 Running KSampler...";
1375
+ }
1376
+
1377
+ newLine.innerText = `[${new Date().toLocaleTimeString('en-US', { hour12: false })}] ${displayMsg} (Elapsed: ${elapsed}s)`;
1378
+ newLine.dataset.lastElapsed = elapsed;
1379
+
1380
+ logContent.appendChild(newLine);
1381
+ if (logContent.childElementCount > 100) {
1382
+ logContent.firstElementChild.remove();
1383
+ }
1384
+ }
1385
+ }
1386
+ else {
1387
+ if (!lastText.includes(safeMsg)) {
1388
+ addLog(safeMsg);
1389
+ }
1390
  }
1391
+
1392
+ smartScroll()
1393
  },
1394
+ onSuccess: async (imageBase64, returnedSeed, metrics, filename) => {
1395
+ placeholderText.style.display = 'none';
1396
+ console.log("🚨 DEBUG METRICS:", JSON.stringify(metrics));
1397
+
1398
+ // 🌟 新增:打印后端传过来的文件名
1399
+ console.log("✅ 后端返回的正确文件名:", filename);
1400
+
1401
+ const now = Date.now();
1402
+ const totalDuration = ((now - generateStartTime) / 1000).toFixed(1);
1403
+
1404
+ const nowForStage = Date.now();
1405
+ const prevStageDuration = ((nowForStage - currentStageStartTime) / 1000).toFixed(1);
1406
+
1407
+ if (currentStageName === "Comfy Backend - Model Loading") {
1408
+ addLog(`⏱️ Comfy Backend - Model Loading finished in ${prevStageDuration}s.`, "normal");
1409
+ addLog(`🚀 Starting Comfy Backend - Sampling...`, "normal");
1410
+
1411
+ const realSteps = metrics?.steps || "?";
1412
+ addLog(`🎨 Rendering: ${realSteps}/${realSteps} steps (100%)`, "normal");
1413
+
1414
+ const pureRender = metrics ? (metrics.workflow - metrics.load_time) : 0;
1415
+ addLog(`⏱️ Comfy Backend - Sampling finished in ${pureRender > 0 ? pureRender.toFixed(2) : "0.0"}s.`, "normal");
1416
+ } else if (currentStageName === "Comfy Backend - Sampling") {
1417
+ addLog(`⏱️ Comfy Backend - Sampling finished in ${prevStageDuration}s.`, "normal");
1418
+ } else if (currentStageName === "Queueing" || currentStageName === "Comfy Workflow") {
1419
+ addLog(`⏱️ Task execution completed. Syncing final stages...`, "normal");
1420
  }
1421
+
1422
+ addLog("✅ Generation successful!", "highlight");
1423
+ addLog(`⏱️ Total Wait Time: ${totalDuration}s`, "highlight");
1424
+
1425
+ if (metrics) {
1426
+ addLog(`----------------------------------------`, "normal");
1427
+ addLog(`📊 [Backend Precise Metrics]`, "highlight");
1428
+
1429
+ const dl = Number(metrics.download || 0);
1430
+ const q = Number(metrics.queue || 0);
1431
+ const wf = Number(metrics.workflow || 0);
1432
+ const load = Number(metrics.load_time || 0);
1433
+
1434
+ const pureRender = wf - load > 0 ? (wf - load) : wf;
1435
+ const serverComputeTotal = (dl + q + wf).toFixed(2);
1436
+ const networkOverhead = Math.max(0, totalDuration - serverComputeTotal).toFixed(1);
1437
+ const secPerStep = pureRender > 0 && metrics.steps > 0 ? (pureRender / metrics.steps).toFixed(2) : 0;
1438
+
1439
+ const metricsLines = [
1440
+ `\u2003\u2003⬇️ Resource Download: ${dl.toFixed(2)}s`,
1441
+ `\u2003\u2003⏳ GPU Queue: ${q.toFixed(2)}s`,
1442
+ load > 0 ? `\u2003\u2003📦 Model Loading: ${load.toFixed(2)}s` : null,
1443
+ `\u2003\u2003🎨 Pure Compute (Sampler & Decode): ${pureRender.toFixed(2)}s (${metrics.steps} steps)`,
1444
+ secPerStep > 0 ? `\u2003\u2003🚀 Average Speed: ${secPerStep}s / step` : null,
1445
+ `\u2003\u2003🌐 Network Overhead: ~${networkOverhead}s`,
1446
+ `\u2003\u2003⚙️ Server Compute Total: ${serverComputeTotal}s`
1447
+ ].filter(Boolean);
1448
+
1449
+ // 启动“打字机”
1450
+ let currentIndex = 0;
1451
+ const typeMetrics = () => {
1452
+ if (currentIndex < metricsLines.length) {
1453
+ requestAnimationFrame(() => {
1454
+ addLog(metricsLines[currentIndex], "normal");
1455
+ smartScroll(); // 🌟 强制触发一次滚动检查
1456
+ currentIndex++;
1457
+ setTimeout(typeMetrics, 120);
1458
+ });
1459
+ } else {
1460
+ setTimeout(() => {
1461
+ addLog(`----------------------------------------`, "normal");
1462
+ smartScroll(); // 🌟 收尾也滚一下
1463
+ if (returnedSeed !== undefined && returnedSeed !== null) {
1464
+ addLog(`🎲 Random Seed resolved to: ${returnedSeed}`, "highlight");
1465
+ smartScroll(); // 🌟 Seed 出来也滚一下
1466
+ }
1467
+ }, 300);
1468
+ }
1469
+ };
1470
+ typeMetrics();
1471
+
1472
+ } else {
1473
+ addLog(`⚠️ [Backend Metrics Missing]`, "error");
1474
+ }
1475
+
1476
+ // ==========================================
1477
+ // 🌟 第三阶段:图像渲染与收尾(极简模式)
1478
+ // ==========================================
1479
+ setTimeout(() => {
1480
+ resultImage.onload = () => {
1481
+ resultImage.style.display = 'block';
1482
+ setUIState(false);
1483
+ resultImage.onload = null;
1484
+ };
1485
+
1486
+ // 🌟 核心:因为是 URL 了,加个时间戳防止浏览器缓存旧图
1487
+ if (imageBase64.startsWith('data:')) {
1488
+ resultImage.src = imageBase64;
1489
+ } else {
1490
+ resultImage.src = `${imageBase64}?t=${Date.now()}`;
1491
+ }
1492
+ }, 100);
1493
  },
1494
+ onError: (msg) => {
1495
+ let shortMsg = msg;
1496
+ if (msg && msg.length > 60) {
1497
+ shortMsg = msg.substring(0, 60) + "...";
 
1498
  }
1499
+
1500
+ placeholderText.style.display = 'block';
1501
+ placeholderText.innerText = `❌ Failed: ${shortMsg}`;
1502
+
1503
+ setUIState(false);
1504
+ addLog(`❌ Error: ${msg}`, "error");
1505
  }
1506
+ });
1507
+ } catch (error) {
1508
+ placeholderText.style.display = 'block';
1509
+ placeholderText.innerText = `❌ Request failed: ${error.message}`;
1510
+ setUIState(false);
1511
+ addLog(`❌ Request Error: ${error.message}`, "error");
 
 
 
 
 
 
 
 
 
 
1512
  }
1513
+ });
1514
 
1515
+ // ==========================================================================
1516
+ // 🚀 Initialization (带双重执行拦截锁)
1517
+ // ==========================================================================
1518
+ let isAppInitialized = false; // 🌟 全局锁
 
 
 
 
 
 
 
 
 
 
1519
 
1520
+ document.addEventListener("DOMContentLoaded", async () => {
1521
+ // 🌟 核心修复:如果已经初始化过了,直接 return,拦截掉第二次的幽灵执行
1522
+
1523
+ if (isAppInitialized) {
1524
+ console.log("⚠️ [Init] 拦截到重复的 DOMContentLoaded,已忽略。");
1525
+ return;
1526
+ }
1527
+ isAppInitialized = true;
1528
+
1529
+
1530
+ setTimeout(() => {
1531
+ window._isDataReady = true; // 10秒后强行标记为就绪
1532
+ }, 10000);
1533
+
1534
+
1535
+ console.log("🔥 触发初始化...");
1536
+ setUIState(false);
1537
+
1538
+ try {
1539
+ const isHealthy = await BackendAPI.checkHealth();
1540
+
1541
+ if (isHealthy) {
1542
+ console.log("✅ 后端在线,开始拉取数据");
1543
+ backendState.isReady = true;
1544
+ document.getElementById('backend-status-dot').className = "status-dot online";
1545
+ setUIState(false);
1546
+ await loadInitialData();
1547
+ } else {
1548
+ console.warn("⚠️ 首次心跳失败,启动后台轮询...");
1549
+ document.getElementById('backend-status-dot').className = "status-dot offline";
1550
+ startHealthPolling();
1551
+ await loadInitialData();
1552
+ }
1553
+ } catch (error) {
1554
+ console.error("❌ 初始化发生严重错误:", error);
1555
+ document.getElementById('backend-status-dot').className = "status-dot offline";
1556
+ startHealthPolling();
1557
+ }
1558
+ });