amv-diffusion 2.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. amv_diffusion-2.1.0.dist-info/METADATA +180 -0
  2. amv_diffusion-2.1.0.dist-info/RECORD +39 -0
  3. amv_diffusion-2.1.0.dist-info/WHEEL +5 -0
  4. amv_diffusion-2.1.0.dist-info/entry_points.txt +7 -0
  5. amv_diffusion-2.1.0.dist-info/licenses/LICENSE +21 -0
  6. amv_diffusion-2.1.0.dist-info/top_level.txt +1 -0
  7. termux_diffusion/__init__.py +185 -0
  8. termux_diffusion/__main__.py +7 -0
  9. termux_diffusion/_version.py +3 -0
  10. termux_diffusion/adapter.py +41 -0
  11. termux_diffusion/canvas.py +364 -0
  12. termux_diffusion/cli.py +492 -0
  13. termux_diffusion/cluster.py +340 -0
  14. termux_diffusion/control/__init__.py +5 -0
  15. termux_diffusion/control/component.py +309 -0
  16. termux_diffusion/control/status.py +15 -0
  17. termux_diffusion/core.py +2936 -0
  18. termux_diffusion/data/bootstrap-manifest.json +15 -0
  19. termux_diffusion/data/prompt_cache/108a7149c4e70550.json +10 -0
  20. termux_diffusion/data/release-public-keys.json +10 -0
  21. termux_diffusion/data/validated-vulkan-profiles.json +298 -0
  22. termux_diffusion/detailer.py +283 -0
  23. termux_diffusion/doctor.py +128 -0
  24. termux_diffusion/downloader.py +111 -0
  25. termux_diffusion/engine.py +226 -0
  26. termux_diffusion/exceptions.py +158 -0
  27. termux_diffusion/hardware.py +985 -0
  28. termux_diffusion/hub.py +686 -0
  29. termux_diffusion/installer.py +499 -0
  30. termux_diffusion/locking.py +134 -0
  31. termux_diffusion/manifest.py +128 -0
  32. termux_diffusion/npu.py +267 -0
  33. termux_diffusion/platform.py +533 -0
  34. termux_diffusion/purger.py +145 -0
  35. termux_diffusion/py.typed +1 -0
  36. termux_diffusion/safety.py +150 -0
  37. termux_diffusion/selftest.py +178 -0
  38. termux_diffusion/web.py +3370 -0
  39. termux_diffusion/workflow.py +1345 -0
@@ -0,0 +1,3370 @@
1
+ # Copyright (c) 2026 Uno Kim / AMEVA Foundation. All rights reserved.
2
+ # SPDX-License-Identifier: Apache-2.0 OR Commercial-License
3
+ """AmfyUI Mobile Studio & Local HTTP Daemon for termux-diffusion.
4
+
5
+ Tailored specifically for smartphone viewports (e.g. Samsung Galaxy S25 / S21 / S20, 360px-420px width).
6
+ Zero third-party dependencies: Built strictly with Python standard library (http.server, threading).
7
+
8
+ Features:
9
+ 1. Quick Mode: Single-touch generation with model presets, prompt chips, and precision parameter controls.
10
+ 2. AmfyUI Workflow Engine: ComfyUI UI Graph and API Prompt JSON DAG execution and topological inspection.
11
+ 3. Real-Time Telemetry: Live progress bar, step counter, speed, ETA, GPU load, and memory gauge.
12
+ 4. Direct Android Gallery synchronization and high-resolution image preview.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import io
18
+ import json
19
+ import logging
20
+ import mimetypes
21
+ import os
22
+ import sys
23
+ import threading
24
+ import time
25
+ from http import HTTPStatus
26
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
27
+ from pathlib import Path
28
+ from typing import Any, Dict, List, Optional
29
+ from urllib.parse import parse_qs, urlparse
30
+
31
+ from .core import generate, get_galaxy_gallery_dir, ProgressInfo
32
+ from .hub import list_presets, list_cached_models, get_device_model_catalog
33
+ from .hardware import (
34
+ detect_hardware_profile,
35
+ get_kernel_physical_memory_gb,
36
+ get_system_resource_metrics,
37
+ TermuxExecutionGuard,
38
+ )
39
+ from .platform import is_android_termux
40
+ from .workflow import (
41
+ WorkflowExecutor,
42
+ WorkflowParser,
43
+ DAGEngine,
44
+ WorkflowError,
45
+ list_builtin_workflows,
46
+ get_builtin_workflow,
47
+ inspect_workflow_details,
48
+ save_user_workflow,
49
+ update_workflow_node_parameter
50
+ )
51
+
52
+ logger = logging.getLogger("termux_diffusion.web")
53
+ if not logger.handlers:
54
+ _sh = logging.StreamHandler(sys.stderr)
55
+ _sh.setFormatter(logging.Formatter("[%(asctime)s] [%(levelname)s] [%(name)s] %(message)s"))
56
+ logger.addHandler(_sh)
57
+ logger.setLevel(logging.INFO)
58
+
59
+ # Global generation and queue state manager
60
+ _STATE_LOCK = threading.Lock()
61
+ _QUEUE_LOCK = threading.Lock()
62
+ _JOB_QUEUE: List[Dict[str, Any]] = []
63
+
64
+ _CURRENT_JOB: Dict[str, Any] = {
65
+ "status": "idle", # "idle", "running", "complete", "error"
66
+ "job_id": None,
67
+ "job_type": None, # "quick", "workflow"
68
+ "job_name": None,
69
+ "progress": {
70
+ "step": 0,
71
+ "total_steps": 0,
72
+ "percent": 0.0,
73
+ "phase": "idle",
74
+ "speed_s_per_it": None,
75
+ "eta_seconds": None,
76
+ "gpu_busy": None,
77
+ "rss_mb": 0.0,
78
+ "message": "Ready",
79
+ "current_node": None,
80
+ "executed_nodes": []
81
+ },
82
+ "result": None,
83
+ "error": None,
84
+ "start_time": 0.0,
85
+ "elapsed": 0.0
86
+ }
87
+
88
+
89
+ def _get_latest_existing_result() -> Optional[Dict[str, Any]]:
90
+ output_dirs = [
91
+ get_galaxy_gallery_dir(),
92
+ Path("/sdcard/DCIM/TermuxDiffusion"),
93
+ Path.home() / "storage" / "dcim" / "TermuxDiffusion",
94
+ Path("/sdcard/Pictures/TermuxDiffusion"),
95
+ Path.home() / "storage" / "pictures" / "TermuxDiffusion",
96
+ Path.home() / ".cache" / "termux-diffusion" / "outputs",
97
+ ]
98
+ latest_file: Optional[Path] = None
99
+ latest_mtime: float = 0.0
100
+ for d in output_dirs:
101
+ try:
102
+ if d.is_dir():
103
+ for p in d.glob("*.png"):
104
+ try:
105
+ mtime = p.stat().st_mtime
106
+ if mtime > latest_mtime:
107
+ latest_mtime = mtime
108
+ latest_file = p
109
+ except OSError:
110
+ pass
111
+ except Exception:
112
+ pass
113
+ if latest_file:
114
+ return {
115
+ "filename": latest_file.name,
116
+ "url": f"/outputs/{latest_file.name}",
117
+ "elapsed_sec": 0.0,
118
+ "device": "vulkan",
119
+ "steps": 0,
120
+ "seed": -1,
121
+ "width": 512,
122
+ "height": 512
123
+ }
124
+ return None
125
+
126
+
127
+ def _update_progress(info: ProgressInfo) -> None:
128
+ with _STATE_LOCK:
129
+ _CURRENT_JOB["progress"]["step"] = info.step
130
+ _CURRENT_JOB["progress"]["total_steps"] = info.total_steps
131
+ _CURRENT_JOB["progress"]["percent"] = info.percent
132
+ _CURRENT_JOB["progress"]["phase"] = info.phase
133
+ _CURRENT_JOB["progress"]["speed_s_per_it"] = info.speed_s_per_it
134
+ _CURRENT_JOB["progress"]["eta_seconds"] = info.eta_seconds
135
+ _CURRENT_JOB["progress"]["gpu_busy"] = info.gpu_busy
136
+ _CURRENT_JOB["progress"]["rss_mb"] = info.rss_mb
137
+ _CURRENT_JOB["progress"]["message"] = info.raw_message or info.phase
138
+
139
+
140
+ def _get_queue_snapshot() -> Dict[str, Any]:
141
+ with _QUEUE_LOCK:
142
+ items = [
143
+ {"id": j["id"], "name": j.get("name", "Job"), "type": j.get("type", "quick"), "enqueued_at": j.get("enqueued_at", 0)}
144
+ for j in _JOB_QUEUE
145
+ ]
146
+ return {"total": len(items), "items": items}
147
+
148
+
149
+ def _enqueue_job(job_type: str, payload: Dict[str, Any], name: str = "") -> Dict[str, Any]:
150
+ job_id = f"job_{int(time.time() * 1000)}_{len(_JOB_QUEUE) + 1}"
151
+ item = {
152
+ "id": job_id,
153
+ "type": job_type,
154
+ "name": name or f"{job_type.capitalize()} Task",
155
+ "payload": payload,
156
+ "enqueued_at": time.time()
157
+ }
158
+ with _QUEUE_LOCK:
159
+ _JOB_QUEUE.append(item)
160
+ pos = len(_JOB_QUEUE)
161
+ logger.info("[JobQueue] Enqueued %s (ID: %s, position: %d)", item["name"], job_id, pos)
162
+ return {"job_id": job_id, "queue_position": pos, "queue_total": pos}
163
+
164
+
165
+ def _remove_job_from_queue(job_id: str) -> bool:
166
+ with _QUEUE_LOCK:
167
+ for idx, j in enumerate(_JOB_QUEUE):
168
+ if j["id"] == job_id:
169
+ _JOB_QUEUE.pop(idx)
170
+ logger.info("[JobQueue] Cancelled job ID %s", job_id)
171
+ return True
172
+ return False
173
+
174
+
175
+ def _clear_job_queue() -> None:
176
+ with _QUEUE_LOCK:
177
+ count = len(_JOB_QUEUE)
178
+ _JOB_QUEUE.clear()
179
+ logger.info("[JobQueue] Cleared %d jobs from queue", count)
180
+
181
+
182
+ def _process_next_job_in_pipeline() -> None:
183
+ with _STATE_LOCK:
184
+ if _CURRENT_JOB["status"] == "running":
185
+ return
186
+
187
+ next_job = None
188
+ with _QUEUE_LOCK:
189
+ if _JOB_QUEUE:
190
+ next_job = _JOB_QUEUE.pop(0)
191
+
192
+ if not next_job:
193
+ return
194
+
195
+ logger.info("[JobQueue] Dequeuing next pipeline task: %s (ID: %s)", next_job["name"], next_job["id"])
196
+ if next_job["type"] == "quick":
197
+ _start_quick_generation(next_job["payload"], job_id=next_job["id"], job_name=next_job["name"])
198
+ else:
199
+ _start_workflow_execution(next_job["payload"], job_id=next_job["id"], job_name=next_job["name"])
200
+
201
+
202
+ def _start_quick_generation(body: Dict[str, Any], job_id: Optional[str] = None, job_name: Optional[str] = None) -> None:
203
+ with _STATE_LOCK:
204
+ _CURRENT_JOB["status"] = "running"
205
+ _CURRENT_JOB["job_id"] = job_id or f"job_{int(time.time() * 1000)}"
206
+ _CURRENT_JOB["job_type"] = "quick"
207
+ _CURRENT_JOB["job_name"] = job_name or f"Quick: {body.get('model', 'realistic')}"
208
+ _CURRENT_JOB["error"] = None
209
+ _CURRENT_JOB["result"] = None
210
+ _CURRENT_JOB["start_time"] = time.time()
211
+ _CURRENT_JOB["elapsed"] = 0.0
212
+ _CURRENT_JOB["progress"] = {
213
+ "step": 0, "total_steps": 1, "percent": 0.0, "phase": "init",
214
+ "speed_s_per_it": None, "eta_seconds": None, "gpu_busy": None, "rss_mb": 0.0,
215
+ "message": "Initializing pipeline...",
216
+ "current_node": {"id": "1", "title": "Direct Model Inference", "class_type": "Txt2Img", "index": 1, "total": 1},
217
+ "executed_nodes": []
218
+ }
219
+
220
+ def worker():
221
+ try:
222
+ # Web UI default: Auto-protect with Safe Mode (GPU 90% cap, VAE tiling, Zero-LMK) unless explicitly disabled
223
+ raw_safe = body.get("safe_mode", True)
224
+ safe_mode = raw_safe.lower() not in ("false", "0", "off", "no") if isinstance(raw_safe, str) else bool(raw_safe)
225
+ raw_duty = body.get("gpu_duty_cycle", 90 if safe_mode else None)
226
+ gpu_duty_cycle = int(raw_duty) if raw_duty is not None else (90 if safe_mode else None)
227
+
228
+ with TermuxExecutionGuard(enable_wake_lock=True, boost_priority=not safe_mode, safe_mode=safe_mode):
229
+ prompt = body.get("prompt", "")
230
+ model = body.get("model", "realistic")
231
+ device = body.get("device", "auto")
232
+ steps = body.get("steps")
233
+ cfg = body.get("cfg")
234
+ width = int(body.get("width", 512))
235
+ height = int(body.get("height", 512))
236
+ seed = int(body.get("seed", -1))
237
+ detail_faces = bool(body.get("detail_faces", False))
238
+ negative = body.get("negative_prompt")
239
+
240
+ res = generate(
241
+ prompt=prompt,
242
+ negative_prompt=negative,
243
+ model=model,
244
+ device=device,
245
+ steps=steps,
246
+ cfg_scale=cfg,
247
+ width=width,
248
+ height=height,
249
+ seed=seed,
250
+ detail_faces=detail_faces,
251
+ safe_mode=safe_mode,
252
+ gpu_duty_cycle=gpu_duty_cycle,
253
+ progress_callback=_update_progress,
254
+ progress=True
255
+ )
256
+
257
+ with _STATE_LOCK:
258
+ _CURRENT_JOB["status"] = "complete"
259
+ _CURRENT_JOB["elapsed"] = res.elapsed_sec
260
+ _CURRENT_JOB["progress"]["executed_nodes"] = ["1"]
261
+ _CURRENT_JOB["result"] = {
262
+ "filename": res.path.name,
263
+ "url": f"/outputs/{res.path.name}",
264
+ "elapsed_sec": res.elapsed_sec,
265
+ "device": res.device,
266
+ "steps": res.steps,
267
+ "seed": res.seed,
268
+ "width": res.width,
269
+ "height": res.height
270
+ }
271
+ except Exception as e:
272
+ logger.exception("[Worker] Generation failed: %s", e)
273
+ with _STATE_LOCK:
274
+ _CURRENT_JOB["status"] = "error"
275
+ _CURRENT_JOB["error"] = str(e)
276
+ finally:
277
+ _process_next_job_in_pipeline()
278
+
279
+ t = threading.Thread(target=worker, daemon=True, name="DiffusionWorker")
280
+ t.start()
281
+
282
+
283
+ def _start_workflow_execution(body: Dict[str, Any], job_id: Optional[str] = None, job_name: Optional[str] = None) -> None:
284
+ with _STATE_LOCK:
285
+ _CURRENT_JOB["status"] = "running"
286
+ _CURRENT_JOB["job_id"] = job_id or f"job_{int(time.time() * 1000)}"
287
+ _CURRENT_JOB["job_type"] = "workflow"
288
+ wf_name = (body.get("workflow", {}) if isinstance(body.get("workflow"), dict) else {}).get("name", "Custom Workflow")
289
+ _CURRENT_JOB["job_name"] = job_name or f"AmfyUI: {wf_name}"
290
+ _CURRENT_JOB["error"] = None
291
+ _CURRENT_JOB["result"] = None
292
+ _CURRENT_JOB["start_time"] = time.time()
293
+ _CURRENT_JOB["elapsed"] = 0.0
294
+ _CURRENT_JOB["progress"] = {
295
+ "step": 0, "total_steps": 1, "percent": 0.0, "phase": "init",
296
+ "speed_s_per_it": None, "eta_seconds": None, "gpu_busy": None, "rss_mb": 0.0,
297
+ "message": "Initializing DAG graph...",
298
+ "current_node": None,
299
+ "executed_nodes": []
300
+ }
301
+
302
+ def on_node_stage(stage_info: Dict[str, Any]):
303
+ with _STATE_LOCK:
304
+ _CURRENT_JOB["progress"]["current_node"] = {
305
+ "id": stage_info["node_id"],
306
+ "title": stage_info["node_title"],
307
+ "class_type": stage_info["class_type"],
308
+ "index": stage_info["current_node_index"],
309
+ "total": stage_info["total_nodes"],
310
+ "status": stage_info["status"]
311
+ }
312
+ _CURRENT_JOB["progress"]["phase"] = stage_info["phase"]
313
+ _CURRENT_JOB["progress"]["message"] = stage_info["phase"]
314
+ _CURRENT_JOB["progress"]["percent"] = stage_info["percent"]
315
+ _CURRENT_JOB["progress"]["executed_nodes"] = stage_info["executed_nodes"]
316
+
317
+ def wf_worker():
318
+ try:
319
+ raw_safe = body.get("safe_mode", True)
320
+ safe_mode = raw_safe.lower() not in ("false", "0", "off", "no") if isinstance(raw_safe, str) else bool(raw_safe)
321
+ with TermuxExecutionGuard(enable_wake_lock=True, boost_priority=not safe_mode, safe_mode=safe_mode):
322
+ if "workflow_id" in body and "workflow" not in body and "nodes" not in body:
323
+ wf_id = str(body["workflow_id"])
324
+ wf_data = get_builtin_workflow(wf_id)
325
+ if not wf_data:
326
+ raise WorkflowError(f"Workflow preset '{wf_id}' not found")
327
+ else:
328
+ wf_data = body.get("workflow", body)
329
+ device = body.get("device", "auto")
330
+ force_run = bool(body.get("force_run", True))
331
+ out_dir = Path.home() / ".cache" / "termux-diffusion" / "outputs"
332
+ out_dir.mkdir(parents=True, exist_ok=True)
333
+
334
+ executor = WorkflowExecutor()
335
+ res = executor.run(
336
+ workflow=wf_data,
337
+ output_dir=out_dir,
338
+ device=device,
339
+ force_run=force_run,
340
+ progress=True,
341
+ node_progress_callback=on_node_stage
342
+ )
343
+
344
+ with _STATE_LOCK:
345
+ _CURRENT_JOB["status"] = "complete"
346
+ _CURRENT_JOB["elapsed"] = res.elapsed_seconds
347
+ out_urls = [f"/outputs/{p.name}" for p in res.output_images]
348
+ _CURRENT_JOB["result"] = {
349
+ "images": out_urls,
350
+ "url": out_urls[0] if out_urls else None,
351
+ "filename": res.output_images[0].name if res.output_images else None,
352
+ "elapsed_sec": res.elapsed_seconds,
353
+ "executed_nodes": res.executed_nodes
354
+ }
355
+ except Exception as e:
356
+ logger.exception("[WorkflowWorker] Run failed: %s", e)
357
+ with _STATE_LOCK:
358
+ _CURRENT_JOB["status"] = "error"
359
+ _CURRENT_JOB["error"] = str(e)
360
+ finally:
361
+ _process_next_job_in_pipeline()
362
+
363
+ t = threading.Thread(target=wf_worker, daemon=True, name="WorkflowWorker")
364
+ t.start()
365
+
366
+
367
+ class DiffusionWebHandler(BaseHTTPRequestHandler):
368
+ """Zero-dependency HTTP request handler for mobile client and REST APIs."""
369
+
370
+ server_version = "TermuxDiffusionWeb/1.4.0"
371
+
372
+ def log_message(self, format: str, *args: Any) -> None:
373
+ logger.debug("[HTTP] " + format % args)
374
+
375
+ def _send_json(self, data: Any, status: int = 200) -> None:
376
+ payload = json.dumps(data, ensure_ascii=False).encode("utf-8")
377
+ self.send_response(status)
378
+ self.send_header("Content-Type", "application/json; charset=utf-8")
379
+ self.send_header("Content-Length", str(len(payload)))
380
+ self.send_header("Access-Control-Allow-Origin", "*")
381
+ self.send_header("Access-Control-Allow-Headers", "Content-Type")
382
+ self.end_headers()
383
+ self.wfile.write(payload)
384
+
385
+ def do_OPTIONS(self) -> None:
386
+ self.send_response(204)
387
+ self.send_header("Access-Control-Allow-Origin", "*")
388
+ self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
389
+ self.send_header("Access-Control-Allow-Headers", "Content-Type")
390
+ self.end_headers()
391
+
392
+ def do_GET(self) -> None:
393
+ parsed = urlparse(self.path)
394
+ path = parsed.path
395
+
396
+ if path in ("/", "/index.html"):
397
+ self._serve_mobile_ui()
398
+ elif path == "/api/models":
399
+ self._serve_models_api()
400
+ elif path == "/api/status":
401
+ self._serve_status_api()
402
+ elif path == "/api/workflows":
403
+ self._serve_workflows_api()
404
+ elif path.startswith("/api/workflows/") and path.endswith("/export"):
405
+ parts = path.split("/")
406
+ wf_id = parts[3]
407
+ self._serve_workflow_export_api(wf_id)
408
+ elif path.startswith("/api/workflows/"):
409
+ wf_id = path[len("/api/workflows/"):]
410
+ self._serve_workflow_detail_api(wf_id)
411
+ elif path == "/api/queue":
412
+ with _STATE_LOCK:
413
+ curr = dict(_CURRENT_JOB)
414
+ self._send_json({
415
+ "current_job": curr if curr.get("status") == "running" else None,
416
+ "queue": _get_queue_snapshot()
417
+ })
418
+ elif path == "/api/progress":
419
+ with _STATE_LOCK:
420
+ job_data = dict(_CURRENT_JOB)
421
+ job_data["progress"] = dict(_CURRENT_JOB["progress"])
422
+ if job_data["status"] == "running" and job_data["start_time"] > 0:
423
+ job_data["elapsed"] = round(time.time() - job_data["start_time"], 2)
424
+ if not job_data.get("result"):
425
+ job_data["result"] = _get_latest_existing_result()
426
+ try:
427
+ metrics = get_system_resource_metrics()
428
+ except Exception:
429
+ metrics = {
430
+ "ram_total_gb": 0.0, "ram_avail_gb": 0.0, "ram_used_gb": 0.0,
431
+ "ram_percent": 0.0, "cpu_percent": 0.0, "gpu_percent": 0.0,
432
+ "process_rss_mb": 0.0
433
+ }
434
+ job_data["metrics"] = metrics
435
+ job_data["queue"] = _get_queue_snapshot()
436
+ self._send_json(job_data)
437
+ elif path.startswith("/outputs/"):
438
+ self._serve_output_file(path[len("/outputs/"):])
439
+ else:
440
+ self.send_error(HTTPStatus.NOT_FOUND, "Resource not found")
441
+
442
+ def do_POST(self) -> None:
443
+ parsed = urlparse(self.path)
444
+ path = parsed.path
445
+
446
+ content_length = int(self.headers.get("Content-Length", 0))
447
+ body_bytes = self.rfile.read(content_length) if content_length > 0 else b""
448
+
449
+ try:
450
+ body = json.loads(body_bytes.decode("utf-8")) if body_bytes else {}
451
+ except json.JSONDecodeError:
452
+ self._send_json({"error": "Malformed JSON payload"}, 400)
453
+ return
454
+
455
+ if path == "/api/generate":
456
+ self._handle_api_generate(body)
457
+ elif path == "/api/workflow/inspect":
458
+ self._handle_api_workflow_inspect(body)
459
+ elif path == "/api/workflow/save":
460
+ self._handle_api_workflow_save(body)
461
+ elif path == "/api/workflow/update_node":
462
+ self._handle_api_workflow_update_node(body)
463
+ elif path == "/api/workflow/run":
464
+ self._handle_api_workflow_run(body)
465
+ elif path == "/api/queue/cancel":
466
+ job_id = body.get("job_id")
467
+ if not job_id:
468
+ self._send_json({"error": "Missing job_id"}, 400)
469
+ return
470
+ removed = _remove_job_from_queue(str(job_id))
471
+ self._send_json({"success": removed, "job_id": job_id})
472
+ elif path == "/api/queue/clear":
473
+ _clear_job_queue()
474
+ self._send_json({"success": True, "message": "Queue cleared"})
475
+ else:
476
+ self.send_error(HTTPStatus.NOT_FOUND, "API route not found")
477
+
478
+ def _serve_models_api(self) -> None:
479
+ presets = list_presets()
480
+ cached = list_cached_models()
481
+ catalog = get_device_model_catalog()
482
+ hw = detect_hardware_profile()
483
+ ram_info = get_kernel_physical_memory_gb()
484
+ ram_val = ram_info[0] if isinstance(ram_info, (tuple, list)) else ram_info
485
+
486
+ self._send_json({
487
+ "presets": presets,
488
+ "cached_models": cached,
489
+ "model_catalog": catalog,
490
+ "hardware": {
491
+ "soc": hw.soc_name,
492
+ "gpu": hw.gpu_name,
493
+ "ram_gb": round(float(ram_val), 1),
494
+ "is_termux": is_android_termux()
495
+ }
496
+ })
497
+
498
+ def _serve_status_api(self) -> None:
499
+ with _STATE_LOCK:
500
+ job_copy = dict(_CURRENT_JOB)
501
+ ram_info = get_kernel_physical_memory_gb()
502
+ ram_val = ram_info[0] if isinstance(ram_info, (tuple, list)) else ram_info
503
+ self._send_json({
504
+ "job": job_copy,
505
+ "system": {
506
+ "ram_gb": round(float(ram_val), 1),
507
+ "time": time.time()
508
+ }
509
+ })
510
+
511
+ def _serve_workflows_api(self) -> None:
512
+ workflows = list_builtin_workflows()
513
+ self._send_json({"workflows": workflows, "total": len(workflows)})
514
+
515
+ def _serve_workflow_detail_api(self, wf_id: str) -> None:
516
+ data = get_builtin_workflow(wf_id)
517
+ if not data:
518
+ self.send_error(HTTPStatus.NOT_FOUND, f"Workflow not found: {wf_id}")
519
+ return
520
+ self._send_json(data)
521
+
522
+ def _serve_workflow_export_api(self, wf_id: str) -> None:
523
+ data = get_builtin_workflow(wf_id)
524
+ if not data:
525
+ self.send_error(HTTPStatus.NOT_FOUND, f"Workflow not found: {wf_id}")
526
+ return
527
+ content = json.dumps(data, indent=2, ensure_ascii=False).encode("utf-8")
528
+ self.send_response(HTTPStatus.OK)
529
+ self.send_header("Content-Type", "application/json; charset=utf-8")
530
+ self.send_header("Content-Disposition", f'attachment; filename="{wf_id}.json"')
531
+ self.send_header("Content-Length", str(len(content)))
532
+ self.end_headers()
533
+ self.wfile.write(content)
534
+
535
+ def _serve_output_file(self, filename: str) -> None:
536
+ safe_name = os.path.basename(filename)
537
+ output_dirs = [
538
+ get_galaxy_gallery_dir(),
539
+ Path("/sdcard/DCIM/TermuxDiffusion"),
540
+ Path.home() / "storage" / "dcim" / "TermuxDiffusion",
541
+ Path.home() / ".cache" / "termux-diffusion" / "outputs",
542
+ Path("/sdcard/Pictures/TermuxDiffusion"),
543
+ Path.home() / "storage" / "pictures" / "TermuxDiffusion",
544
+ Path.home(),
545
+ Path.cwd()
546
+ ]
547
+ target_file = None
548
+ for d in output_dirs:
549
+ p = d / safe_name
550
+ if p.is_file():
551
+ target_file = p
552
+ break
553
+
554
+ if not target_file:
555
+ self.send_error(HTTPStatus.NOT_FOUND, f"Image not found: {safe_name}")
556
+ return
557
+
558
+ mime_type, _ = mimetypes.guess_type(str(target_file))
559
+ mime_type = mime_type or "application/octet-stream"
560
+
561
+ try:
562
+ with open(target_file, "rb") as f:
563
+ content = f.read()
564
+ self.send_response(200)
565
+ self.send_header("Content-Type", mime_type)
566
+ self.send_header("Content-Length", str(len(content)))
567
+ self.send_header("Cache-Control", "public, max-age=86400")
568
+ self.end_headers()
569
+ self.wfile.write(content)
570
+ except Exception as e:
571
+ self.send_error(HTTPStatus.INTERNAL_SERVER_ERROR, f"Failed reading file: {e}")
572
+
573
+ def _handle_api_generate(self, body: Dict[str, Any]) -> None:
574
+ with _STATE_LOCK:
575
+ is_running = (_CURRENT_JOB["status"] == "running")
576
+
577
+ model_name = body.get("model", "realistic")
578
+ prompt_snippet = (body.get("prompt") or "")[:24]
579
+ job_name = f"Quick: {model_name} ('{prompt_snippet}...')"
580
+
581
+ if is_running:
582
+ enqueue_info = _enqueue_job("quick", body, name=job_name)
583
+ self._send_json({
584
+ "message": "Task added to pipeline queue",
585
+ "status": "queued",
586
+ "job_id": enqueue_info["job_id"],
587
+ "queue_position": enqueue_info["queue_position"],
588
+ "queue_total": enqueue_info["queue_total"]
589
+ }, 202)
590
+ else:
591
+ _start_quick_generation(body, job_name=job_name)
592
+ self._send_json({"message": "Generation dispatched", "status": "running"})
593
+
594
+ def _handle_api_workflow_inspect(self, body: Dict[str, Any]) -> None:
595
+ try:
596
+ if "workflow_id" in body and "workflow" not in body and "nodes" not in body:
597
+ wf_id = str(body["workflow_id"])
598
+ wf_data = get_builtin_workflow(wf_id)
599
+ if not wf_data:
600
+ self._send_json({"error": f"Workflow preset '{wf_id}' not found"}, 404)
601
+ return
602
+ else:
603
+ wf_data = body.get("workflow", body)
604
+ inspection_result = inspect_workflow_details(wf_data)
605
+ self._send_json(inspection_result)
606
+ except WorkflowError as wfe:
607
+ self._send_json({"error": str(wfe)}, 400)
608
+ except Exception as e:
609
+ self._send_json({"error": str(e)}, 500)
610
+
611
+ def _handle_api_workflow_save(self, body: Dict[str, Any]) -> None:
612
+ try:
613
+ wf_data = body.get("workflow")
614
+ if not wf_data:
615
+ self._send_json({"error": "Missing 'workflow' object in payload"}, 400)
616
+ return
617
+ wf_id = body.get("id")
618
+ name = body.get("name")
619
+ desc = body.get("description")
620
+ res = save_user_workflow(wf_data, workflow_id=wf_id, name=name, description=desc)
621
+ self._send_json({"success": True, "saved": res})
622
+ except Exception as e:
623
+ logger.exception("[Web] Failed saving user workflow: %s", e)
624
+ self._send_json({"error": str(e)}, 400)
625
+
626
+ def _handle_api_workflow_update_node(self, body: Dict[str, Any]) -> None:
627
+ try:
628
+ wf_data = body.get("workflow")
629
+ node_id = body.get("node_id")
630
+ params = body.get("parameters", {})
631
+ if not wf_data or node_id is None:
632
+ self._send_json({"error": "Missing 'workflow' or 'node_id' in payload"}, 400)
633
+ return
634
+ updated_wf = update_workflow_node_parameter(wf_data, node_id, params)
635
+ inspection = inspect_workflow_details(updated_wf)
636
+ self._send_json({"success": True, "workflow": updated_wf, "inspection": inspection})
637
+ except Exception as e:
638
+ logger.exception("[Web] Failed updating node parameter: %s", e)
639
+ self._send_json({"error": str(e)}, 400)
640
+
641
+ def _handle_api_workflow_run(self, body: Dict[str, Any]) -> None:
642
+ with _STATE_LOCK:
643
+ is_running = (_CURRENT_JOB["status"] == "running")
644
+
645
+ wf_name = "Workflow"
646
+ if "workflow_id" in body and "workflow" not in body:
647
+ wf_name = f"Preset: {body['workflow_id']}"
648
+ elif isinstance(body.get("workflow"), dict):
649
+ wf_name = body["workflow"].get("name", "Custom Workflow")
650
+ job_name = f"AmfyUI: {wf_name}"
651
+
652
+ if is_running:
653
+ enqueue_info = _enqueue_job("workflow", body, name=job_name)
654
+ self._send_json({
655
+ "message": "Workflow added to pipeline queue",
656
+ "status": "queued",
657
+ "job_id": enqueue_info["job_id"],
658
+ "queue_position": enqueue_info["queue_position"],
659
+ "queue_total": enqueue_info["queue_total"]
660
+ }, 202)
661
+ else:
662
+ _start_workflow_execution(body, job_name=job_name)
663
+ self._send_json({"message": "Workflow dispatched", "status": "running"})
664
+
665
+ def _serve_mobile_ui(self) -> None:
666
+ """Renders the high-performance, responsive Galaxy S25 OLED dark mobile interface."""
667
+ html = """<!DOCTYPE html>
668
+ <html lang="ko">
669
+ <head>
670
+ <meta charset="utf-8">
671
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
672
+ <title>AMEVA AmfyUI Studio</title>
673
+ <style>
674
+ :root {
675
+ --bg: #07090e;
676
+ --card: #111622;
677
+ --card-border: #1e293b;
678
+ --accent: #6366f1;
679
+ --accent-glow: rgba(99, 102, 241, 0.35);
680
+ --success: #10b981;
681
+ --text: #f8fafc;
682
+ --text-muted: #94a3b8;
683
+ --radius: 16px;
684
+ --safe-bottom: env(safe-area-inset-bottom, 16px);
685
+ }
686
+ * { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
687
+ body {
688
+ background-color: var(--bg);
689
+ color: var(--text);
690
+ font-family: -apple-system, BlinkMacSystemFont, "Pretendard", "Segoe UI", Roboto, sans-serif;
691
+ font-size: 15px;
692
+ line-height: 1.4;
693
+ padding-bottom: calc(90px + var(--safe-bottom));
694
+ min-height: 100vh;
695
+ display: flex;
696
+ flex-direction: column;
697
+ }
698
+ header {
699
+ padding: 16px 20px 12px;
700
+ display: flex;
701
+ align-items: center;
702
+ justify-content: space-between;
703
+ border-bottom: 1px solid var(--card-border);
704
+ background: rgba(11, 15, 23, 0.85);
705
+ backdrop-filter: blur(12px);
706
+ position: sticky;
707
+ top: 0;
708
+ z-index: 50;
709
+ }
710
+ .brand { display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 17px; }
711
+ .brand-badge {
712
+ font-size: 10px;
713
+ background: linear-gradient(135deg, #6366f1, #8b5cf6);
714
+ color: #fff;
715
+ padding: 2px 7px;
716
+ border-radius: 20px;
717
+ text-transform: uppercase;
718
+ letter-spacing: 0.5px;
719
+ }
720
+ .status-badge {
721
+ font-size: 12px;
722
+ font-weight: 600;
723
+ padding: 4px 10px;
724
+ border-radius: 12px;
725
+ background: rgba(16, 185, 129, 0.15);
726
+ color: var(--success);
727
+ display: flex;
728
+ align-items: center;
729
+ gap: 6px;
730
+ }
731
+ .status-dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
732
+ .status-badge.running { background: rgba(99, 102, 241, 0.2); color: #818cf8; }
733
+ .status-badge.running .status-dot { animation: pulse 1s infinite alternate; }
734
+ @keyframes pulse { from { opacity: 0.3; } to { opacity: 1; } }
735
+
736
+ /* Resource HUD Bar */
737
+ .resource-hud {
738
+ display: flex;
739
+ gap: 6px;
740
+ padding: 7px 16px;
741
+ background: #090e1a;
742
+ border-bottom: 1px solid var(--card-border);
743
+ overflow-x: auto;
744
+ white-space: nowrap;
745
+ scrollbar-width: none;
746
+ align-items: center;
747
+ position: sticky;
748
+ top: 57px;
749
+ z-index: 45;
750
+ backdrop-filter: blur(8px);
751
+ }
752
+ .resource-hud::-webkit-scrollbar { display: none; }
753
+ .hud-chip {
754
+ background: #111827;
755
+ border: 1px solid var(--card-border);
756
+ border-radius: 8px;
757
+ padding: 4px 8px;
758
+ display: inline-flex;
759
+ align-items: center;
760
+ gap: 5px;
761
+ font-size: 11px;
762
+ font-family: -apple-system, BlinkMacSystemFont, monospace;
763
+ color: var(--text);
764
+ }
765
+ .hud-label {
766
+ font-size: 9px;
767
+ font-weight: 800;
768
+ color: var(--text-muted);
769
+ letter-spacing: 0.5px;
770
+ }
771
+ .hud-val {
772
+ font-weight: 700;
773
+ color: #38bdf8;
774
+ }
775
+ .hud-chip.queue-chip {
776
+ background: rgba(99, 102, 241, 0.15);
777
+ border-color: rgba(99, 102, 241, 0.4);
778
+ cursor: pointer;
779
+ transition: all 0.2s ease;
780
+ }
781
+ .hud-chip.queue-chip:hover {
782
+ background: rgba(99, 102, 241, 0.25);
783
+ }
784
+ .hud-chip.queue-chip .hud-val {
785
+ color: #a5b4fc;
786
+ }
787
+
788
+ /* Live Node Execution States */
789
+ .flow-node-card.flow-node-executing {
790
+ border-color: #6366f1 !important;
791
+ box-shadow: 0 0 16px rgba(99, 102, 241, 0.8), inset 0 0 8px rgba(99, 102, 241, 0.3) !important;
792
+ animation: executingPulse 1.2s infinite alternate ease-in-out;
793
+ position: relative;
794
+ }
795
+ @keyframes executingPulse {
796
+ from { border-color: #6366f1; box-shadow: 0 0 10px rgba(99, 102, 241, 0.5); }
797
+ to { border-color: #a855f7; box-shadow: 0 0 22px rgba(168, 85, 247, 0.9); }
798
+ }
799
+ .flow-node-card.flow-node-executing::after {
800
+ content: "▶ RUNNING";
801
+ position: absolute;
802
+ top: -8px; right: 10px;
803
+ background: linear-gradient(135deg, #6366f1, #8b5cf6);
804
+ color: white;
805
+ font-size: 8px;
806
+ font-weight: 900;
807
+ padding: 1px 6px;
808
+ border-radius: 4px;
809
+ letter-spacing: 0.5px;
810
+ box-shadow: 0 2px 6px rgba(0,0,0,0.4);
811
+ }
812
+ .flow-node-card.flow-node-completed {
813
+ border-color: #10b981 !important;
814
+ position: relative;
815
+ }
816
+ .flow-node-card.flow-node-completed::after {
817
+ content: "✓ DONE";
818
+ position: absolute;
819
+ top: -8px; right: 10px;
820
+ background: #10b981;
821
+ color: white;
822
+ font-size: 8px;
823
+ font-weight: 900;
824
+ padding: 1px 6px;
825
+ border-radius: 4px;
826
+ letter-spacing: 0.5px;
827
+ }
828
+ .generate-btn.enqueue-mode {
829
+ background: linear-gradient(135deg, #4f46e5, #0891b2) !important;
830
+ box-shadow: 0 4px 18px rgba(8, 145, 178, 0.45) !important;
831
+ }
832
+ .queue-item {
833
+ background: #141c2e;
834
+ border: 1px solid var(--card-border);
835
+ border-radius: 8px;
836
+ padding: 8px 12px;
837
+ display: flex;
838
+ justify-content: space-between;
839
+ align-items: center;
840
+ gap: 8px;
841
+ font-size: 12px;
842
+ }
843
+
844
+ main { padding: 16px; flex: 1; max-width: 480px; margin: 0 auto; width: 100%; display: flex; flex-direction: column; gap: 16px; }
845
+
846
+ /* Segmented Tab Switcher */
847
+ .tabs {
848
+ display: flex;
849
+ background: var(--card);
850
+ border: 1px solid var(--card-border);
851
+ border-radius: 14px;
852
+ padding: 4px;
853
+ gap: 4px;
854
+ }
855
+ .tab-btn {
856
+ flex: 1;
857
+ padding: 10px 0;
858
+ font-size: 14px;
859
+ font-weight: 600;
860
+ border: none;
861
+ background: transparent;
862
+ color: var(--text-muted);
863
+ border-radius: 10px;
864
+ cursor: pointer;
865
+ transition: all 0.2s ease;
866
+ }
867
+ .tab-btn.active {
868
+ background: #1e293b;
869
+ color: var(--text);
870
+ box-shadow: 0 2px 8px rgba(0,0,0,0.3);
871
+ }
872
+
873
+ /* Cards */
874
+ .card {
875
+ background: var(--card);
876
+ border: 1px solid var(--card-border);
877
+ border-radius: var(--radius);
878
+ padding: 16px;
879
+ display: flex;
880
+ flex-direction: column;
881
+ gap: 12px;
882
+ }
883
+ .card-title {
884
+ font-size: 13px;
885
+ font-weight: 700;
886
+ color: var(--text-muted);
887
+ text-transform: uppercase;
888
+ letter-spacing: 0.5px;
889
+ display: flex;
890
+ justify-content: space-between;
891
+ }
892
+
893
+ /* Model Chips */
894
+ .chip-scroll {
895
+ display: flex;
896
+ gap: 8px;
897
+ overflow-x: auto;
898
+ padding-bottom: 4px;
899
+ scrollbar-width: none;
900
+ }
901
+ .chip-scroll::-webkit-scrollbar { display: none; }
902
+ .chip {
903
+ flex-shrink: 0;
904
+ padding: 7px 14px;
905
+ font-size: 13px;
906
+ font-weight: 600;
907
+ border-radius: 20px;
908
+ background: #182234;
909
+ border: 1px solid var(--card-border);
910
+ color: var(--text-muted);
911
+ cursor: pointer;
912
+ transition: all 0.15s ease;
913
+ }
914
+ .chip.active {
915
+ background: linear-gradient(135deg, #4f46e5, #6366f1);
916
+ border-color: #6366f1;
917
+ color: #fff;
918
+ box-shadow: 0 2px 10px var(--accent-glow);
919
+ }
920
+
921
+ /* Inputs */
922
+ textarea, input[type="text"] {
923
+ width: 100%;
924
+ background: #0b0f19;
925
+ border: 1px solid var(--card-border);
926
+ border-radius: 12px;
927
+ padding: 12px 14px;
928
+ color: var(--text);
929
+ font-size: 15px;
930
+ font-family: inherit;
931
+ resize: vertical;
932
+ outline: none;
933
+ transition: border-color 0.2s;
934
+ }
935
+ textarea:focus, input[type="text"]:focus {
936
+ border-color: var(--accent);
937
+ }
938
+ .quick-tags { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 4px; }
939
+ .tag {
940
+ font-size: 11px;
941
+ padding: 3px 9px;
942
+ border-radius: 8px;
943
+ background: #182234;
944
+ color: #94a3b8;
945
+ cursor: pointer;
946
+ }
947
+
948
+ /* Controls Grid */
949
+ .controls-grid {
950
+ display: grid;
951
+ grid-template-columns: 1fr 1fr;
952
+ gap: 12px;
953
+ }
954
+ .ctrl-group { display: flex; flex-direction: column; gap: 6px; }
955
+ .ctrl-group label {
956
+ font-size: 12px;
957
+ font-weight: 600;
958
+ color: var(--text-muted);
959
+ display: flex;
960
+ justify-content: space-between;
961
+ }
962
+ input[type="range"] {
963
+ accent-color: var(--accent);
964
+ cursor: pointer;
965
+ }
966
+
967
+ /* Toggle Switch */
968
+ .switch-row {
969
+ display: flex;
970
+ align-items: center;
971
+ justify-content: space-between;
972
+ padding: 4px 0;
973
+ }
974
+ .switch {
975
+ position: relative;
976
+ display: inline-block;
977
+ width: 44px;
978
+ height: 24px;
979
+ }
980
+ .switch input { opacity: 0; width: 0; height: 0; }
981
+ .slider {
982
+ position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0;
983
+ background-color: #1e293b; transition: .2s; border-radius: 24px;
984
+ }
985
+ .slider:before {
986
+ position: absolute; content: ""; height: 18px; width: 18px; left: 3px; bottom: 3px;
987
+ background-color: white; transition: .2s; border-radius: 50%;
988
+ }
989
+ input:checked + .slider { background-color: var(--accent); }
990
+ input:checked + .slider:before { transform: translateX(20px); }
991
+
992
+ /* Progress Display */
993
+ .progress-box {
994
+ display: none;
995
+ background: #111726;
996
+ border: 1px solid var(--accent);
997
+ border-radius: var(--radius);
998
+ padding: 16px;
999
+ flex-direction: column;
1000
+ gap: 10px;
1001
+ }
1002
+ .progress-box.active { display: flex; }
1003
+ .bar-bg {
1004
+ width: 100%;
1005
+ height: 8px;
1006
+ background: #1e293b;
1007
+ border-radius: 4px;
1008
+ overflow: hidden;
1009
+ }
1010
+ .bar-fill {
1011
+ height: 100%;
1012
+ width: 0%;
1013
+ background: linear-gradient(90deg, #6366f1, #38bdf8);
1014
+ transition: width 0.3s ease;
1015
+ }
1016
+ .metrics-row {
1017
+ display: flex;
1018
+ justify-content: space-between;
1019
+ font-size: 12px;
1020
+ color: var(--text-muted);
1021
+ font-weight: 600;
1022
+ }
1023
+
1024
+ /* Image Preview */
1025
+ .preview-box {
1026
+ display: none;
1027
+ background: var(--card);
1028
+ border: 1px solid var(--card-border);
1029
+ border-radius: var(--radius);
1030
+ padding: 12px;
1031
+ flex-direction: column;
1032
+ gap: 12px;
1033
+ align-items: center;
1034
+ }
1035
+ .preview-box.active { display: flex; }
1036
+ .preview-img {
1037
+ width: 100%;
1038
+ aspect-ratio: 1 / 1;
1039
+ object-fit: cover;
1040
+ border-radius: 12px;
1041
+ background: #0b0f19;
1042
+ }
1043
+ .preview-actions { display: flex; gap: 8px; width: 100%; }
1044
+ .action-btn {
1045
+ flex: 1;
1046
+ padding: 10px;
1047
+ font-size: 13px;
1048
+ font-weight: 600;
1049
+ border-radius: 10px;
1050
+ border: 1px solid var(--card-border);
1051
+ background: #1e293b;
1052
+ color: var(--text);
1053
+ cursor: pointer;
1054
+ text-align: center;
1055
+ text-decoration: none;
1056
+ }
1057
+
1058
+ /* Fixed Bottom Action Bar */
1059
+ .bottom-bar {
1060
+ position: fixed;
1061
+ bottom: 0;
1062
+ left: 0;
1063
+ right: 0;
1064
+ background: rgba(11, 15, 23, 0.9);
1065
+ backdrop-filter: blur(16px);
1066
+ padding: 12px 16px calc(12px + var(--safe-bottom));
1067
+ border-top: 1px solid var(--card-border);
1068
+ display: flex;
1069
+ justify-content: center;
1070
+ z-index: 100;
1071
+ }
1072
+ .generate-btn {
1073
+ width: 100%;
1074
+ max-width: 480px;
1075
+ padding: 16px;
1076
+ font-size: 17px;
1077
+ font-weight: 700;
1078
+ border-radius: 16px;
1079
+ border: none;
1080
+ background: linear-gradient(135deg, #4f46e5, #6366f1);
1081
+ color: #fff;
1082
+ cursor: pointer;
1083
+ box-shadow: 0 4px 20px var(--accent-glow);
1084
+ display: flex;
1085
+ align-items: center;
1086
+ justify-content: center;
1087
+ gap: 8px;
1088
+ transition: transform 0.1s, opacity 0.2s;
1089
+ }
1090
+ .generate-btn:active { transform: scale(0.98); }
1091
+ .generate-btn:disabled {
1092
+ opacity: 0.6;
1093
+ cursor: not-allowed;
1094
+ background: #334155;
1095
+ box-shadow: none;
1096
+ }
1097
+
1098
+ /* Workflow Tab Drop Zone */
1099
+ .drop-zone {
1100
+ border: 2px dashed var(--card-border);
1101
+ border-radius: 14px;
1102
+ padding: 24px 16px;
1103
+ text-align: center;
1104
+ color: var(--text-muted);
1105
+ cursor: pointer;
1106
+ display: flex;
1107
+ flex-direction: column;
1108
+ gap: 8px;
1109
+ }
1110
+ .drop-zone:hover { border-color: var(--accent); }
1111
+ .node-sequence { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
1112
+ .node-pill {
1113
+ background: #182234;
1114
+ padding: 8px 12px;
1115
+ border-radius: 10px;
1116
+ font-size: 13px;
1117
+ display: flex;
1118
+ align-items: center;
1119
+ gap: 8px;
1120
+ }
1121
+ .node-num {
1122
+ width: 20px;
1123
+ height: 20px;
1124
+ background: var(--accent);
1125
+ border-radius: 50%;
1126
+ display: flex;
1127
+ align-items: center;
1128
+ justify-content: center;
1129
+ font-size: 11px;
1130
+ font-weight: 700;
1131
+ }
1132
+ .wf-preset-list { display: flex; flex-direction: column; gap: 8px; }
1133
+ .wf-card {
1134
+ background: #0b0f19;
1135
+ border: 1px solid var(--card-border);
1136
+ border-radius: 12px;
1137
+ padding: 12px 14px;
1138
+ cursor: pointer;
1139
+ transition: all 0.2s ease;
1140
+ display: flex;
1141
+ flex-direction: column;
1142
+ gap: 4px;
1143
+ }
1144
+ .wf-card:hover { border-color: rgba(99, 102, 241, 0.6); }
1145
+ .wf-card.active {
1146
+ border-color: var(--accent);
1147
+ background: #151a2d;
1148
+ box-shadow: 0 2px 12px var(--accent-glow);
1149
+ }
1150
+ .wf-card-header { display: flex; justify-content: space-between; align-items: center; }
1151
+ .wf-card-title { font-size: 14px; font-weight: 700; color: var(--text); }
1152
+ .wf-card-badge {
1153
+ font-size: 10px;
1154
+ font-weight: 600;
1155
+ background: #1e293b;
1156
+ color: #94a3b8;
1157
+ padding: 2px 8px;
1158
+ border-radius: 10px;
1159
+ }
1160
+ .wf-card.active .wf-card-badge { background: var(--accent); color: #fff; }
1161
+ .wf-card-desc { font-size: 12px; color: var(--text-muted); line-height: 1.3; }
1162
+
1163
+ /* Visual DAG Flow Diagram */
1164
+ .dag-view-switcher {
1165
+ display: flex;
1166
+ gap: 6px;
1167
+ margin-top: 6px;
1168
+ margin-bottom: 8px;
1169
+ }
1170
+ .dag-view-btn {
1171
+ flex: 1;
1172
+ padding: 6px 10px;
1173
+ font-size: 12px;
1174
+ font-weight: 600;
1175
+ border-radius: 8px;
1176
+ border: 1px solid var(--card-border);
1177
+ background: #111622;
1178
+ color: var(--text-muted);
1179
+ cursor: pointer;
1180
+ text-align: center;
1181
+ transition: all 0.15s ease;
1182
+ }
1183
+ .dag-view-btn.active {
1184
+ background: #1e293b;
1185
+ color: #fff;
1186
+ border-color: var(--accent);
1187
+ }
1188
+
1189
+ .dag-flow-container {
1190
+ display: flex;
1191
+ flex-direction: column;
1192
+ gap: 6px;
1193
+ margin-top: 6px;
1194
+ }
1195
+ .flow-node-card {
1196
+ background: #0f1626;
1197
+ border: 1px solid var(--card-border);
1198
+ border-radius: 12px;
1199
+ padding: 10px 12px;
1200
+ cursor: pointer;
1201
+ transition: all 0.2s ease;
1202
+ display: flex;
1203
+ flex-direction: column;
1204
+ gap: 6px;
1205
+ }
1206
+ .flow-node-card:hover {
1207
+ border-color: rgba(99, 102, 241, 0.7);
1208
+ background: #141d33;
1209
+ }
1210
+ .flow-node-card.selected {
1211
+ border-color: var(--accent);
1212
+ background: #18223c;
1213
+ box-shadow: 0 0 16px var(--accent-glow);
1214
+ }
1215
+ .flow-node-header {
1216
+ display: flex;
1217
+ justify-content: space-between;
1218
+ align-items: center;
1219
+ }
1220
+ .flow-node-title {
1221
+ font-size: 13px;
1222
+ font-weight: 700;
1223
+ color: var(--text);
1224
+ display: flex;
1225
+ align-items: center;
1226
+ gap: 6px;
1227
+ }
1228
+ .flow-node-badge {
1229
+ font-size: 9px;
1230
+ font-weight: 700;
1231
+ padding: 2px 6px;
1232
+ border-radius: 6px;
1233
+ background: #1e293b;
1234
+ color: #94a3b8;
1235
+ text-transform: uppercase;
1236
+ letter-spacing: 0.3px;
1237
+ }
1238
+ .flow-node-badge.loader { background: rgba(139, 92, 246, 0.2); color: #a78bfa; border: 1px solid rgba(139, 92, 246, 0.3); }
1239
+ .flow-node-badge.conditioning { background: rgba(16, 185, 129, 0.2); color: #34d399; border: 1px solid rgba(16, 185, 129, 0.3); }
1240
+ .flow-node-badge.sampler { background: rgba(245, 158, 11, 0.2); color: #fbbf24; border: 1px solid rgba(245, 158, 11, 0.3); }
1241
+ .flow-node-badge.decode { background: rgba(56, 189, 248, 0.2); color: #38bdf8; border: 1px solid rgba(56, 189, 248, 0.3); }
1242
+ .flow-node-badge.detailer { background: rgba(236, 72, 153, 0.2); color: #f472b6; border: 1px solid rgba(236, 72, 153, 0.3); }
1243
+ .flow-node-badge.save { background: rgba(100, 116, 139, 0.2); color: #94a3b8; border: 1px solid rgba(100, 116, 139, 0.3); }
1244
+
1245
+ .flow-node-params {
1246
+ display: flex;
1247
+ flex-wrap: wrap;
1248
+ gap: 4px;
1249
+ }
1250
+ .flow-param-chip {
1251
+ font-size: 11px;
1252
+ padding: 2px 6px;
1253
+ border-radius: 4px;
1254
+ background: #1e293b;
1255
+ color: #cbd5e1;
1256
+ font-family: monospace;
1257
+ }
1258
+ .flow-arrow-down {
1259
+ display: flex;
1260
+ justify-content: center;
1261
+ color: var(--accent);
1262
+ font-size: 13px;
1263
+ opacity: 0.5;
1264
+ }
1265
+
1266
+ /* Node Inspector Panel */
1267
+ .inspector-card {
1268
+ display: none;
1269
+ background: #0a0f1d;
1270
+ border: 1px solid var(--accent);
1271
+ border-radius: var(--radius);
1272
+ padding: 14px;
1273
+ flex-direction: column;
1274
+ gap: 10px;
1275
+ box-shadow: 0 4px 20px var(--accent-glow);
1276
+ animation: fadeIn 0.2s ease;
1277
+ }
1278
+ .inspector-card.active { display: flex; }
1279
+ @keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
1280
+ .inspector-header {
1281
+ display: flex;
1282
+ justify-content: space-between;
1283
+ align-items: center;
1284
+ border-bottom: 1px solid var(--card-border);
1285
+ padding-bottom: 8px;
1286
+ }
1287
+ .inspector-role {
1288
+ font-size: 12px;
1289
+ color: #cbd5e1;
1290
+ line-height: 1.4;
1291
+ background: #141b2e;
1292
+ padding: 8px 10px;
1293
+ border-radius: 8px;
1294
+ border-left: 3px solid var(--accent);
1295
+ }
1296
+ .inspector-grid {
1297
+ display: grid;
1298
+ grid-template-columns: 1fr 1fr;
1299
+ gap: 6px;
1300
+ }
1301
+ .inspector-prop {
1302
+ background: #0f172a;
1303
+ border: 1px solid var(--card-border);
1304
+ border-radius: 8px;
1305
+ padding: 6px 8px;
1306
+ display: flex;
1307
+ flex-direction: column;
1308
+ gap: 2px;
1309
+ }
1310
+ .inspector-prop-key {
1311
+ font-size: 10px;
1312
+ font-weight: 700;
1313
+ color: var(--text-muted);
1314
+ text-transform: uppercase;
1315
+ }
1316
+ .inspector-prop-val {
1317
+ font-size: 12px;
1318
+ font-weight: 600;
1319
+ color: #38bdf8;
1320
+ font-family: monospace;
1321
+ word-break: break-all;
1322
+ }
1323
+ .inspector-prompt-box {
1324
+ background: #070c16;
1325
+ border: 1px solid var(--card-border);
1326
+ border-radius: 8px;
1327
+ padding: 10px;
1328
+ font-size: 12px;
1329
+ color: #f8fafc;
1330
+ line-height: 1.4;
1331
+ max-height: 100px;
1332
+ overflow-y: auto;
1333
+ white-space: pre-wrap;
1334
+ word-break: break-word;
1335
+ }
1336
+ .wiring-box {
1337
+ display: flex;
1338
+ flex-direction: column;
1339
+ gap: 4px;
1340
+ }
1341
+ .wiring-row {
1342
+ font-size: 11px;
1343
+ padding: 4px 8px;
1344
+ border-radius: 6px;
1345
+ background: #141c2e;
1346
+ border: 1px solid var(--card-border);
1347
+ display: flex;
1348
+ justify-content: space-between;
1349
+ align-items: center;
1350
+ font-family: monospace;
1351
+ }
1352
+ .wiring-row .slot-name { color: #818cf8; font-weight: 600; }
1353
+ .wiring-row .target-node { color: #94a3b8; }
1354
+
1355
+ /* Modal Dialog */
1356
+ .modal-overlay {
1357
+ position: fixed;
1358
+ top: 0; left: 0; right: 0; bottom: 0;
1359
+ background: rgba(0, 0, 0, 0.75);
1360
+ backdrop-filter: blur(4px);
1361
+ display: flex;
1362
+ align-items: center;
1363
+ justify-content: center;
1364
+ z-index: 1000;
1365
+ padding: 16px;
1366
+ }
1367
+ .modal-card {
1368
+ background: #0d1527;
1369
+ border: 1px solid var(--accent);
1370
+ border-radius: var(--radius);
1371
+ padding: 20px;
1372
+ width: 100%;
1373
+ max-width: 380px;
1374
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.6);
1375
+ display: flex;
1376
+ flex-direction: column;
1377
+ gap: 12px;
1378
+ animation: fadeIn 0.2s ease;
1379
+ }
1380
+ .modal-title {
1381
+ font-size: 15px;
1382
+ font-weight: 700;
1383
+ color: var(--text);
1384
+ }
1385
+ .custom-input, .custom-select, .custom-textarea {
1386
+ background: #141d33;
1387
+ color: #f8fafc;
1388
+ border: 1px solid var(--card-border);
1389
+ border-radius: 6px;
1390
+ padding: 7px 10px;
1391
+ font-size: 12px;
1392
+ width: 100%;
1393
+ box-sizing: border-box;
1394
+ font-family: inherit;
1395
+ outline: none;
1396
+ transition: border-color 0.2s ease;
1397
+ }
1398
+ .custom-input:focus, .custom-select:focus, .custom-textarea:focus {
1399
+ border-color: var(--accent);
1400
+ }
1401
+ .inspector-prop.full-span { grid-column: span 2; }
1402
+ .param-slider-wrap {
1403
+ display: flex;
1404
+ align-items: center;
1405
+ gap: 8px;
1406
+ margin-top: 2px;
1407
+ }
1408
+ .param-slider-wrap input[type="range"] {
1409
+ flex: 1;
1410
+ height: 6px;
1411
+ accent-color: var(--accent);
1412
+ background: #1e293b;
1413
+ border-radius: 3px;
1414
+ outline: none;
1415
+ }
1416
+ .param-num-compact {
1417
+ width: 58px !important;
1418
+ text-align: center;
1419
+ padding: 4px 6px !important;
1420
+ font-weight: 700;
1421
+ color: #38bdf8;
1422
+ font-family: monospace;
1423
+ }
1424
+ .custom-path-box {
1425
+ display: none;
1426
+ margin-top: 6px;
1427
+ }
1428
+ .model-badge-tag {
1429
+ font-size: 9px;
1430
+ padding: 1px 6px;
1431
+ border-radius: 4px;
1432
+ background: rgba(99, 102, 241, 0.2);
1433
+ color: #a5b4fc;
1434
+ border: 1px solid rgba(99, 102, 241, 0.35);
1435
+ margin-left: 6px;
1436
+ font-weight: 600;
1437
+ }
1438
+
1439
+ /* Floating Draggable Bottom Sheet */
1440
+ .sheet-overlay {
1441
+ display: none;
1442
+ position: fixed;
1443
+ top: 0; left: 0; right: 0; bottom: 0;
1444
+ background: rgba(0, 0, 0, 0.45);
1445
+ z-index: 800;
1446
+ transition: opacity 0.2s ease;
1447
+ }
1448
+ .sheet-overlay.active { display: block; }
1449
+
1450
+ .bottom-sheet {
1451
+ position: fixed;
1452
+ left: 0; right: 0; bottom: 0;
1453
+ z-index: 850;
1454
+ background: #090e1d;
1455
+ border-top: 2px solid var(--accent);
1456
+ border-radius: 20px 20px 0 0;
1457
+ box-shadow: 0 -10px 40px rgba(0, 0, 0, 0.85);
1458
+ display: flex;
1459
+ flex-direction: column;
1460
+ transition: height 0.25s cubic-bezier(0.16, 1, 0.3, 1), transform 0.25s ease;
1461
+ transform: translateY(100%);
1462
+ max-width: 600px;
1463
+ margin: 0 auto;
1464
+ }
1465
+ .bottom-sheet.open {
1466
+ transform: translateY(0);
1467
+ }
1468
+ .bottom-sheet.peek {
1469
+ height: 68px;
1470
+ }
1471
+ .bottom-sheet.half {
1472
+ height: 48vh;
1473
+ }
1474
+ .bottom-sheet.full {
1475
+ height: 82vh;
1476
+ }
1477
+
1478
+ .sheet-handle-area {
1479
+ width: 100%;
1480
+ padding: 8px 0 4px 0;
1481
+ display: flex;
1482
+ justify-content: center;
1483
+ align-items: center;
1484
+ cursor: grab;
1485
+ touch-action: none;
1486
+ }
1487
+ .sheet-drag-pill {
1488
+ width: 44px;
1489
+ height: 5px;
1490
+ border-radius: 3px;
1491
+ background: #475569;
1492
+ transition: background 0.2s;
1493
+ }
1494
+ .sheet-handle-area:hover .sheet-drag-pill, .sheet-handle-area:active .sheet-drag-pill {
1495
+ background: var(--accent);
1496
+ }
1497
+
1498
+ .sheet-header-bar {
1499
+ display: flex;
1500
+ justify-content: space-between;
1501
+ align-items: center;
1502
+ padding: 0 16px 8px 16px;
1503
+ border-bottom: 1px solid var(--card-border);
1504
+ }
1505
+ .sheet-header-left {
1506
+ display: flex;
1507
+ align-items: center;
1508
+ gap: 8px;
1509
+ flex: 1;
1510
+ overflow: hidden;
1511
+ }
1512
+ .sheet-header-controls {
1513
+ display: flex;
1514
+ gap: 6px;
1515
+ align-items: center;
1516
+ }
1517
+ .sheet-btn {
1518
+ background: #1e293b;
1519
+ border: 1px solid var(--card-border);
1520
+ color: #cbd5e1;
1521
+ padding: 4px 8px;
1522
+ border-radius: 6px;
1523
+ font-size: 11px;
1524
+ cursor: pointer;
1525
+ font-weight: 600;
1526
+ }
1527
+ .sheet-btn:hover { background: #334155; }
1528
+ .sheet-btn.close { color: #f87171; }
1529
+
1530
+ .sheet-content {
1531
+ flex: 1;
1532
+ overflow-y: auto;
1533
+ padding: 14px 16px 24px 16px;
1534
+ display: flex;
1535
+ flex-direction: column;
1536
+ gap: 12px;
1537
+ }
1538
+
1539
+ /* Multi-Branch Port Badges */
1540
+ .flow-ports-in, .flow-ports-out {
1541
+ display: flex;
1542
+ flex-wrap: wrap;
1543
+ align-items: center;
1544
+ gap: 4px;
1545
+ margin-top: 4px;
1546
+ font-size: 10px;
1547
+ }
1548
+ .ports-label {
1549
+ font-weight: 700;
1550
+ font-size: 9px;
1551
+ color: var(--text-muted);
1552
+ text-transform: uppercase;
1553
+ }
1554
+ .port-chip {
1555
+ padding: 2px 6px;
1556
+ border-radius: 4px;
1557
+ font-family: monospace;
1558
+ font-size: 10px;
1559
+ font-weight: 600;
1560
+ white-space: nowrap;
1561
+ }
1562
+ .port-chip.in {
1563
+ background: rgba(14, 165, 233, 0.15);
1564
+ border: 1px solid rgba(14, 165, 233, 0.35);
1565
+ color: #38bdf8;
1566
+ }
1567
+ .port-chip.out {
1568
+ background: rgba(168, 85, 247, 0.15);
1569
+ border: 1px solid rgba(168, 85, 247, 0.35);
1570
+ color: #c084fc;
1571
+ }
1572
+
1573
+ /* Multi-Node Branch Lighting & Dimming */
1574
+ .flow-node-card.flow-node-source {
1575
+ border-color: #38bdf8 !important;
1576
+ box-shadow: 0 0 14px rgba(56, 189, 248, 0.45) !important;
1577
+ position: relative;
1578
+ }
1579
+ .flow-node-card.flow-node-source::after {
1580
+ content: "▲ INFLOW SOURCE";
1581
+ position: absolute;
1582
+ top: -8px; right: 10px;
1583
+ background: #0284c7;
1584
+ color: white;
1585
+ font-size: 8px;
1586
+ font-weight: 800;
1587
+ padding: 1px 5px;
1588
+ border-radius: 4px;
1589
+ letter-spacing: 0.5px;
1590
+ }
1591
+ .flow-node-card.flow-node-target {
1592
+ border-color: #f59e0b !important;
1593
+ box-shadow: 0 0 14px rgba(245, 158, 11, 0.45) !important;
1594
+ position: relative;
1595
+ }
1596
+ .flow-node-card.flow-node-target::after {
1597
+ content: "▼ OUTFLOW TARGET";
1598
+ position: absolute;
1599
+ top: -8px; right: 10px;
1600
+ background: #d97706;
1601
+ color: white;
1602
+ font-size: 8px;
1603
+ font-weight: 800;
1604
+ padding: 1px 5px;
1605
+ border-radius: 4px;
1606
+ letter-spacing: 0.5px;
1607
+ }
1608
+ .flow-node-card.flow-node-dimmed {
1609
+ opacity: 0.35;
1610
+ filter: grayscale(40%);
1611
+ }
1612
+
1613
+ /* Topology Matrix View */
1614
+ .dag-matrix-container {
1615
+ display: flex;
1616
+ flex-direction: column;
1617
+ gap: 12px;
1618
+ margin-top: 10px;
1619
+ }
1620
+ .matrix-layer {
1621
+ background: #0d1526;
1622
+ border: 1px dashed var(--card-border);
1623
+ border-radius: 10px;
1624
+ padding: 10px;
1625
+ display: flex;
1626
+ flex-direction: column;
1627
+ gap: 8px;
1628
+ }
1629
+ .matrix-layer-title {
1630
+ font-size: 10px;
1631
+ font-weight: 800;
1632
+ color: var(--text-muted);
1633
+ text-transform: uppercase;
1634
+ letter-spacing: 0.5px;
1635
+ display: flex;
1636
+ justify-content: space-between;
1637
+ }
1638
+ .matrix-row-parallel {
1639
+ display: grid;
1640
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
1641
+ gap: 8px;
1642
+ }
1643
+ .matrix-convergence-chip {
1644
+ display: inline-flex;
1645
+ align-items: center;
1646
+ gap: 4px;
1647
+ font-size: 10px;
1648
+ font-family: monospace;
1649
+ padding: 2px 6px;
1650
+ border-radius: 4px;
1651
+ background: rgba(99, 102, 241, 0.2);
1652
+ border: 1px solid rgba(99, 102, 241, 0.4);
1653
+ color: #a5b4fc;
1654
+ }
1655
+ </style>
1656
+ </head>
1657
+ <body>
1658
+ <header>
1659
+ <div class="brand">
1660
+ <span>AMEVA</span>
1661
+ <span class="brand-badge">AmfyUI</span>
1662
+ </div>
1663
+ <div class="status-badge" id="statusBadge">
1664
+ <span class="status-dot"></span>
1665
+ <span id="statusText">Ready</span>
1666
+ </div>
1667
+ </header>
1668
+
1669
+ <div class="resource-hud" id="resourceHud">
1670
+ <div class="hud-chip" id="hudRamChip">
1671
+ <span class="hud-label">RAM</span>
1672
+ <span class="hud-val" id="hudRamVal">--/-- GB</span>
1673
+ </div>
1674
+ <div class="hud-chip" id="hudCpuChip">
1675
+ <span class="hud-label">CPU</span>
1676
+ <span class="hud-val" id="hudCpuVal">--%</span>
1677
+ </div>
1678
+ <div class="hud-chip" id="hudGpuChip">
1679
+ <span class="hud-label">GPU</span>
1680
+ <span class="hud-val" id="hudGpuVal">--%</span>
1681
+ </div>
1682
+ <div class="hud-chip" id="hudTimeChip">
1683
+ <span class="hud-label">ELAPSED</span>
1684
+ <span class="hud-val" id="hudTimeVal">0.0s</span>
1685
+ </div>
1686
+ <div class="hud-chip queue-chip" id="hudQueueChip" onclick="openQueueModal()" title="작업 대기열 보기">
1687
+ <span class="hud-label">QUEUE</span>
1688
+ <span class="hud-val" id="hudQueueVal">0 pending</span>
1689
+ </div>
1690
+ </div>
1691
+
1692
+ <main>
1693
+ <div class="tabs">
1694
+ <button class="tab-btn active" id="tabQuickBtn" onclick="switchTab('quick')">Quick Mode</button>
1695
+ <button class="tab-btn" id="tabWfBtn" onclick="switchTab('wf')">AmfyUI</button>
1696
+ </div>
1697
+
1698
+ <!-- TAB 1: Quick Generate -->
1699
+ <div id="quickTab" style="display: flex; flex-direction: column; gap: 14px;">
1700
+ <div class="card">
1701
+ <div class="card-title">Model Selection</div>
1702
+ <div class="chip-scroll" id="modelChips">
1703
+ <div class="chip active" data-model="realistic" onclick="selectModel(this)">Realistic</div>
1704
+ <div class="chip" data-model="z-image-turbo" onclick="selectModel(this)">Z-Image Turbo</div>
1705
+ <div class="chip" data-model="sdxs" onclick="selectModel(this)">SDXS Fast</div>
1706
+ <div class="chip" data-model="anime" onclick="selectModel(this)">Anime</div>
1707
+ </div>
1708
+ </div>
1709
+
1710
+ <div class="card">
1711
+ <div class="card-title">Prompt</div>
1712
+ <textarea id="promptInput" rows="3" placeholder="Input prompt text...">A professional portrait of an East Asian woman in her 20s smiling warmly, radiant eyes, natural skin texture, 8k photography</textarea>
1713
+ <div class="quick-tags">
1714
+ <span class="tag" onclick="addTag('Korean woman portrait')">+ Korean Portrait</span>
1715
+ <span class="tag" onclick="addTag('natural sunlight, soft daylight')">+ Natural Light</span>
1716
+ <span class="tag" onclick="addTag('8k masterpiece, raw photo')">+ 8K Masterpiece</span>
1717
+ <span class="tag" onclick="addTag('1girl, cute anime masterpiece')">+ Anime Girl</span>
1718
+ <span class="tag" onclick="addTag('makoto shinkai style, cloudy sky')">+ Shinkai Sky</span>
1719
+ <span class="tag" onclick="addTag('studio ghibli aesthetic, vibrant')">+ Ghibli Style</span>
1720
+ </div>
1721
+ </div>
1722
+
1723
+ <div class="card">
1724
+ <div class="card-title">Parameters</div>
1725
+ <div class="controls-grid">
1726
+ <div class="ctrl-group">
1727
+ <label>Steps: <span id="stepsVal">15</span></label>
1728
+ <input type="range" id="stepsInput" min="1" max="30" value="15" oninput="stepsVal.innerText=this.value">
1729
+ </div>
1730
+ <div class="ctrl-group">
1731
+ <label>CFG: <span id="cfgVal">7.0</span></label>
1732
+ <input type="range" id="cfgInput" min="1.0" max="15.0" step="0.5" value="7.0" oninput="cfgVal.innerText=this.value">
1733
+ </div>
1734
+ </div>
1735
+
1736
+ <div class="switch-row" style="margin-top: 6px;">
1737
+ <span style="font-weight: 600; font-size: 14px;">FaceDetailer (안면 복원)</span>
1738
+ <label class="switch">
1739
+ <input type="checkbox" id="detailerSwitch" checked>
1740
+ <span class="slider"></span>
1741
+ </label>
1742
+ </div>
1743
+
1744
+ <div class="switch-row" style="margin-top: 6px;" title="GPU 90% 상한 제어 및 프로세스 우선순위 조정으로 화면 렌더링(SurfaceFlinger)과 백그라운드 앱 튕김 방지">
1745
+ <div>
1746
+ <span style="font-weight: 600; font-size: 14px;">Safe Mode (앱/화면 생존 가드)</span>
1747
+ <div style="font-size: 11px; color: var(--text-muted);">GPU 90% Cap &amp; Zero-LMK VAE Tiling</div>
1748
+ </div>
1749
+ <label class="switch">
1750
+ <input type="checkbox" id="safeModeSwitch" checked>
1751
+ <span class="slider"></span>
1752
+ </label>
1753
+ </div>
1754
+ </div>
1755
+ </div>
1756
+
1757
+ <!-- TAB 2: AmfyUI Workflow -->
1758
+ <div id="wfTab" style="display: none; flex-direction: column; gap: 14px;">
1759
+ <div class="card">
1760
+ <div class="card-title">Standard Workflows (인기 표준 프리셋)</div>
1761
+ <div class="wf-preset-list" id="wfPresetList">
1762
+ <div class="wf-card active" onclick="selectPresetWorkflow('standard_photoreal', this)">
1763
+ <div class="wf-card-header">
1764
+ <span class="wf-card-title">Standard Photoreal + FaceDetailer</span>
1765
+ <span class="wf-card-badge">8 Nodes</span>
1766
+ </div>
1767
+ <div class="wf-card-desc">전 세계에서 가장 널리 쓰이는 표준 ComfyUI TXT2IMG + FaceDetailer 안면 복원 배전반</div>
1768
+ </div>
1769
+ <div class="wf-card" onclick="selectPresetWorkflow('z_image_turbo', this)">
1770
+ <div class="wf-card-header">
1771
+ <span class="wf-card-title">Z-Image Turbo 6B (Next-Gen DiT)</span>
1772
+ <span class="wf-card-badge">7 Nodes</span>
1773
+ </div>
1774
+ <div class="wf-card-desc">차세대 6B 파라미터 Diffusion Transformer 플로우 매칭 실사 고화질 배전반</div>
1775
+ </div>
1776
+ <div class="wf-card" onclick="selectPresetWorkflow('sdxs_lightning', this)">
1777
+ <div class="wf-card-header">
1778
+ <span class="wf-card-title">SDXS Lightning (High-Speed Mobile)</span>
1779
+ <span class="wf-card-badge">7 Nodes</span>
1780
+ </div>
1781
+ <div class="wf-card-desc">모바일 초고속 2스텝 경량화 디스틸레이션 실시간 추론 배전반</div>
1782
+ </div>
1783
+ <div class="wf-card" onclick="selectPresetWorkflow('anime_masterpiece', this)">
1784
+ <div class="wf-card-header">
1785
+ <span class="wf-card-title">Anime Masterpiece (DreamShaper8 LCM)</span>
1786
+ <span class="wf-card-badge">7 Nodes</span>
1787
+ </div>
1788
+ <div class="wf-card-desc">모바일 특화 6스텝 LCM 애니메 / 일러스트레이션 고화질 배전반</div>
1789
+ </div>
1790
+ </div>
1791
+ </div>
1792
+
1793
+ <div class="card">
1794
+ <div class="card-title">Custom Workflow Upload</div>
1795
+ <div class="drop-zone" onclick="document.getElementById('wfFileInput').click()">
1796
+ <span style="font-weight: 600;">Load Workflow Specification (.json)</span>
1797
+ <span style="font-size: 12px; color: var(--text-muted);">ComfyUI UI Graph &amp; API Prompt Specification</span>
1798
+ <input type="file" id="wfFileInput" accept=".json" style="display: none;" onchange="handleWorkflowFile(this)">
1799
+ </div>
1800
+ </div>
1801
+
1802
+ <!-- Interactive DAG Visualization & Inspector Section -->
1803
+ <div class="card" id="wfInfo" style="display: none;">
1804
+ <div class="card-title">
1805
+ <span>Workflow DAG Pipeline (<span id="wfStepCount">0</span> Nodes)</span>
1806
+ </div>
1807
+
1808
+ <!-- Action Toolbar for Export and Save -->
1809
+ <div style="display: flex; gap: 8px; margin-bottom: 12px;">
1810
+ <button type="button" class="action-btn" style="flex: 1; padding: 10px 8px; font-size: 12px; font-weight: 700; background: #182238; border: 1px solid var(--accent); color: #38bdf8;" onclick="exportWorkflowJson()">
1811
+ <span>📥 Export JSON (추출)</span>
1812
+ </button>
1813
+ <button type="button" class="action-btn" style="flex: 1; padding: 10px 8px; font-size: 12px; font-weight: 700; background: #182238; border: 1px solid var(--accent); color: #a78bfa;" onclick="openSavePresetModal()">
1814
+ <span>💾 Save Preset (단말 저장)</span>
1815
+ </button>
1816
+ </div>
1817
+
1818
+ <div class="dag-view-switcher">
1819
+ <button class="dag-view-btn active" id="btnViewFlow" onclick="switchDagView('flow')">Visual Flow</button>
1820
+ <button class="dag-view-btn" id="btnViewMatrix" onclick="switchDagView('matrix')">Topology Matrix</button>
1821
+ <button class="dag-view-btn" id="btnViewList" onclick="switchDagView('list')">Sequence List</button>
1822
+ </div>
1823
+
1824
+ <!-- View 1: Visual Connected DAG Flow -->
1825
+ <div id="dagFlowView" class="dag-flow-container"></div>
1826
+
1827
+ <!-- View 2: Multi-Lane Parallel Topology Matrix View -->
1828
+ <div id="dagMatrixView" class="dag-matrix-container" style="display: none;"></div>
1829
+
1830
+ <!-- View 3: Compact Ordered List -->
1831
+ <div id="dagListView" class="node-sequence" style="display: none;"></div>
1832
+ </div>
1833
+ </div>
1834
+
1835
+ <!-- Save Preset Modal -->
1836
+ <div id="savePresetModal" class="modal-overlay" style="display: none;">
1837
+ <div class="modal-card">
1838
+ <div class="modal-title">💾 Save Custom Workflow Preset</div>
1839
+ <div style="font-size: 12px; color: var(--text-muted);">현재 튜닝된 배전반 파라미터를 단말기 로컬 프리셋으로 저장합니다.</div>
1840
+ <div style="display: flex; flex-direction: column; gap: 8px;">
1841
+ <label style="font-size: 11px; font-weight: 700; color: var(--text-muted); text-transform: uppercase;">프리셋 명칭 (Preset Name)</label>
1842
+ <input type="text" id="savePresetNameInput" class="custom-input" placeholder="e.g. My Photoreal 8K Tuned" value="">
1843
+ <label style="font-size: 11px; font-weight: 700; color: var(--text-muted); text-transform: uppercase;">고유 식별자 (Preset ID)</label>
1844
+ <input type="text" id="savePresetIdInput" class="custom-input" placeholder="e.g. my_photoreal_8k" value="">
1845
+ </div>
1846
+ <div style="display: flex; gap: 8px; margin-top: 10px;">
1847
+ <button type="button" class="action-btn" style="flex: 1; background: var(--accent); color: white; font-weight: 700;" onclick="confirmSavePreset()">저장 (Save)</button>
1848
+ <button type="button" class="action-btn" style="flex: 1;" onclick="closeSavePresetModal()">취소 (Cancel)</button>
1849
+ </div>
1850
+ </div>
1851
+ </div>
1852
+
1853
+ <!-- Real-time Progress Display -->
1854
+ <div class="progress-box" id="progressBox">
1855
+ <div class="metrics-row">
1856
+ <span id="phaseText">Standby</span>
1857
+ <span id="stepCounter">0 / 0</span>
1858
+ </div>
1859
+ <div class="metrics-row" id="activeNodeRow" style="display: none; margin-top: 4px; font-size: 11px;">
1860
+ <span id="activeNodeBadge" style="background: rgba(99, 102, 241, 0.25); color: #a5b4fc; padding: 2px 6px; border-radius: 4px; font-weight: 700;">Node --</span>
1861
+ <span id="activeNodeTitle" style="color: #cbd5e1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"></span>
1862
+ </div>
1863
+ <div class="bar-bg">
1864
+ <div class="bar-fill" id="barFill"></div>
1865
+ </div>
1866
+ <div class="metrics-row">
1867
+ <span id="speedEtaText">Initializing...</span>
1868
+ <span id="liveElapsedEtaText" style="color: #38bdf8; font-weight: 700;">0.0s</span>
1869
+ </div>
1870
+ </div>
1871
+
1872
+ <!-- Image Preview Result -->
1873
+ <div class="preview-box" id="previewBox">
1874
+ <img id="resultImg" class="preview-img" alt="Generated Image">
1875
+ <div class="metrics-row" style="width: 100%;">
1876
+ <span id="elapsedText">Elapsed: --</span>
1877
+ <span style="color: var(--success); font-weight: 700;">Complete (Exit 0)</span>
1878
+ </div>
1879
+ <div class="preview-actions">
1880
+ <a id="downloadLink" class="action-btn" download="generated_image.png" target="_blank">Download</a>
1881
+ <button class="action-btn" onclick="shareToGallery()">Gallery</button>
1882
+ </div>
1883
+ </div>
1884
+ </main>
1885
+
1886
+ <!-- Pipeline Queue Modal -->
1887
+ <div id="queueModal" class="modal-overlay" style="display: none;">
1888
+ <div class="modal-card">
1889
+ <div style="display: flex; justify-content: space-between; align-items: center;">
1890
+ <div class="modal-title">📋 Task Queue Pipeline (작업 대기열)</div>
1891
+ <button type="button" class="sheet-btn close" onclick="closeQueueModal()">✕</button>
1892
+ </div>
1893
+ <div style="font-size: 12px; color: var(--text-muted);">실행 중인 작업 완료 후 순차적으로 자동 실행됩니다.</div>
1894
+ <div id="queueListContainer" style="display: flex; flex-direction: column; gap: 6px; max-height: 240px; overflow-y: auto; margin-top: 6px;">
1895
+ <div style="font-size: 12px; color: var(--text-muted); text-align: center; padding: 12px;">대기열이 비어 있습니다.</div>
1896
+ </div>
1897
+ <div style="display: flex; gap: 8px; margin-top: 10px;">
1898
+ <button type="button" class="action-btn" style="flex: 1; background: #b91c1c; color: white; font-weight: 700;" onclick="clearQueue()">대기열 비우기</button>
1899
+ <button type="button" class="action-btn" style="flex: 1;" onclick="closeQueueModal()">닫기</button>
1900
+ </div>
1901
+ </div>
1902
+ </div>
1903
+
1904
+ <!-- Floating Draggable Bottom Sheet Overlay -->
1905
+ <div class="sheet-overlay" id="sheetOverlay" onclick="setSheetState('closed')"></div>
1906
+
1907
+ <!-- Floating Draggable Bottom Sheet (화면 따라다니는 모바일 인스펙터) -->
1908
+ <div class="bottom-sheet" id="nodeInspectorSheet">
1909
+ <div class="sheet-handle-area" id="sheetDragHandle" onclick="cycleSheetState()">
1910
+ <div class="sheet-drag-pill"></div>
1911
+ </div>
1912
+ <div class="sheet-header-bar">
1913
+ <div class="sheet-header-left">
1914
+ <span class="flow-node-badge" id="inspClassBadge">CLASS</span>
1915
+ <div style="min-width:0; overflow:hidden;">
1916
+ <div style="font-size: 10px; font-weight: 700; color: var(--accent); text-transform: uppercase;" id="inspStage">Step 1 of 8</div>
1917
+ <div style="font-size: 13px; font-weight: 700; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" id="inspTitle">Node Title</div>
1918
+ </div>
1919
+ </div>
1920
+ <div class="sheet-header-controls">
1921
+ <button type="button" class="sheet-btn" title="Peek (최소화)" onclick="setSheetState('peek')">─</button>
1922
+ <button type="button" class="sheet-btn" title="Half (중간)" onclick="setSheetState('half')">◫</button>
1923
+ <button type="button" class="sheet-btn" title="Full (최대화)" onclick="setSheetState('full')">⤢</button>
1924
+ <button type="button" class="sheet-btn close" title="Close (닫기)" onclick="setSheetState('closed')">✕</button>
1925
+ </div>
1926
+ </div>
1927
+ <div class="sheet-content">
1928
+ <div>
1929
+ <div style="font-size: 11px; font-weight: 700; color: var(--text-muted); text-transform: uppercase; margin-bottom: 4px;">Operation Role</div>
1930
+ <div class="inspector-role" id="inspRole">Role description</div>
1931
+ </div>
1932
+
1933
+ <div>
1934
+ <div style="font-size: 11px; font-weight: 700; color: var(--text-muted); text-transform: uppercase; margin-bottom: 4px;">Configured Parameters (수정 가능)</div>
1935
+ <div id="inspPromptBox" class="inspector-prompt-box" style="display: none; margin-bottom: 8px;"></div>
1936
+ <div class="inspector-grid" id="inspParamsGrid"></div>
1937
+ <div style="display: flex; gap: 8px; margin-top: 10px;">
1938
+ <button type="button" class="action-btn" style="flex: 2; padding: 9px 12px; font-size: 12px; background: var(--accent); color: white; border-color: var(--accent); font-weight: 700;" onclick="applyNodeChanges()">
1939
+ <span>✓ Apply Changes (노드 수정 적용)</span>
1940
+ </button>
1941
+ <button type="button" class="action-btn" style="flex: 1; padding: 9px 12px; font-size: 12px;" onclick="resetNodeChanges()">
1942
+ <span>↺ Reset</span>
1943
+ </button>
1944
+ </div>
1945
+ <div id="nodeFeedbackMsg" style="font-size: 11px; color: var(--success); font-weight: 600; display: none; margin-top: 6px; padding: 6px 10px; background: rgba(16, 185, 129, 0.12); border-radius: 6px; border: 1px solid rgba(16, 185, 129, 0.3);"></div>
1946
+ </div>
1947
+
1948
+ <div>
1949
+ <div style="font-size: 11px; font-weight: 700; color: var(--text-muted); text-transform: uppercase; margin-bottom: 4px;">Data Flow &amp; Wiring</div>
1950
+ <div class="wiring-box" id="inspWiringBox"></div>
1951
+ </div>
1952
+ </div>
1953
+ </div>
1954
+
1955
+ <div class="bottom-bar">
1956
+ <button class="generate-btn" id="generateBtn" onclick="onGenerateClick()">
1957
+ <span>Generate</span>
1958
+ </button>
1959
+ </div>
1960
+
1961
+ <script>
1962
+ let activeTab = 'quick';
1963
+ let currentModel = 'realistic';
1964
+ let loadedWorkflowData = null;
1965
+ let pollInterval = null;
1966
+
1967
+ function switchTab(tab) {
1968
+ activeTab = tab;
1969
+ document.getElementById('tabQuickBtn').classList.toggle('active', tab === 'quick');
1970
+ document.getElementById('tabWfBtn').classList.toggle('active', tab === 'wf');
1971
+ document.getElementById('quickTab').style.display = tab === 'quick' ? 'flex' : 'none';
1972
+ document.getElementById('wfTab').style.display = tab === 'wf' ? 'flex' : 'none';
1973
+
1974
+ const genBtn = document.getElementById('generateBtn');
1975
+ if (tab === 'quick') {
1976
+ genBtn.innerHTML = '<span>Generate</span>';
1977
+ } else {
1978
+ genBtn.innerHTML = '<span>Execute AmfyUI Workflow</span>';
1979
+ }
1980
+ }
1981
+
1982
+ function selectModel(elem) {
1983
+ document.querySelectorAll('#modelChips .chip').forEach(c => c.classList.remove('active'));
1984
+ elem.classList.add('active');
1985
+ currentModel = elem.dataset.model;
1986
+ if (currentModel === 'z-image-turbo') {
1987
+ document.getElementById('stepsInput').value = 8;
1988
+ document.getElementById('stepsVal').innerText = 8;
1989
+ document.getElementById('cfgInput').value = 1.0;
1990
+ document.getElementById('cfgVal').innerText = 1.0;
1991
+ } else if (currentModel === 'sdxs') {
1992
+ document.getElementById('stepsInput').value = 2;
1993
+ document.getElementById('stepsVal').innerText = 2;
1994
+ document.getElementById('cfgInput').value = 1.0;
1995
+ document.getElementById('cfgVal').innerText = 1.0;
1996
+ } else if (currentModel === 'anime') {
1997
+ document.getElementById('stepsInput').value = 4;
1998
+ document.getElementById('stepsVal').innerText = 4;
1999
+ document.getElementById('cfgInput').value = 1.0;
2000
+ document.getElementById('cfgVal').innerText = 1.0;
2001
+ const pInput = document.getElementById('promptInput');
2002
+ if (!pInput.value || pInput.value.includes('professional portrait of an East Asian woman')) {
2003
+ pInput.value = '1girl, cute anime style, masterpiece, best quality, beautiful detailed eyes, vibrant colors';
2004
+ }
2005
+ } else {
2006
+ document.getElementById('stepsInput').value = 15;
2007
+ document.getElementById('stepsVal').innerText = 15;
2008
+ document.getElementById('cfgInput').value = 7.0;
2009
+ document.getElementById('cfgVal').innerText = 7.0;
2010
+ }
2011
+ }
2012
+
2013
+ function addTag(text) {
2014
+ const p = document.getElementById('promptInput');
2015
+ p.value = p.value.trim() + ', ' + text;
2016
+ }
2017
+
2018
+ let currentPresetId = 'standard_photoreal';
2019
+
2020
+ async function loadPresetList() {
2021
+ try {
2022
+ const resp = await fetch('/api/workflows');
2023
+ if (!resp.ok) return;
2024
+ const data = await resp.json();
2025
+ const container = document.getElementById('wfPresetList');
2026
+ if (!container || !data.workflows) return;
2027
+
2028
+ container.innerHTML = '';
2029
+ data.workflows.forEach(w => {
2030
+ const card = document.createElement('div');
2031
+ card.className = 'wf-card' + (w.id === currentPresetId ? ' active' : '');
2032
+ card.id = 'wfCard_' + w.id;
2033
+ card.onclick = () => selectPresetWorkflow(w.id, card);
2034
+
2035
+ const badgeHtml = w.custom
2036
+ ? `<span class="wf-card-badge" style="background: rgba(16, 185, 129, 0.2); color: #34d399; border: 1px solid rgba(16, 185, 129, 0.3);">CUSTOM (${w.node_count}N)</span>`
2037
+ : `<span class="wf-card-badge">${w.node_count} Nodes</span>`;
2038
+
2039
+ card.innerHTML = `
2040
+ <div class="wf-card-header">
2041
+ <span class="wf-card-title">${escapeHtml(w.name)}</span>
2042
+ ${badgeHtml}
2043
+ </div>
2044
+ <div class="wf-card-desc">${escapeHtml(w.description || '')}</div>
2045
+ `;
2046
+ container.appendChild(card);
2047
+ });
2048
+ } catch (err) {
2049
+ console.error('Failed fetching presets:', err);
2050
+ }
2051
+ }
2052
+
2053
+ let deviceModelCatalog = { checkpoints: [], vae: [], loras: [], text_encoders: [], all: [] };
2054
+
2055
+ async function loadDeviceModelCatalog() {
2056
+ try {
2057
+ const resp = await fetch('/api/models');
2058
+ if (!resp.ok) return;
2059
+ const data = await resp.json();
2060
+ if (data.model_catalog) {
2061
+ deviceModelCatalog = data.model_catalog;
2062
+ }
2063
+ } catch (e) {
2064
+ console.warn('Failed loading device model catalog:', e);
2065
+ }
2066
+ }
2067
+
2068
+ function syncSlider(numId, val) {
2069
+ const el = document.getElementById(numId);
2070
+ if (el) el.value = val;
2071
+ }
2072
+
2073
+ function syncNumber(sliderId, val) {
2074
+ const el = document.getElementById(sliderId);
2075
+ if (el) el.value = val;
2076
+ }
2077
+
2078
+ function onModelSelectChange(key, selEl) {
2079
+ const customWrap = document.getElementById('customPathWrap_' + key);
2080
+ if (!customWrap) return;
2081
+ if (selEl.value === '__custom__') {
2082
+ customWrap.style.display = 'block';
2083
+ const inp = document.getElementById('editProp_' + key + '_custom');
2084
+ if (inp) inp.focus();
2085
+ } else {
2086
+ customWrap.style.display = 'none';
2087
+ }
2088
+ }
2089
+
2090
+ function onDimSelectChange(key, selEl) {
2091
+ const customWrap = document.getElementById('customDimWrap_' + key);
2092
+ if (!customWrap) return;
2093
+ if (selEl.value === '__custom__') {
2094
+ customWrap.style.display = 'block';
2095
+ const inp = document.getElementById('editProp_' + key + '_custom');
2096
+ if (inp) inp.focus();
2097
+ } else {
2098
+ customWrap.style.display = 'none';
2099
+ }
2100
+ }
2101
+
2102
+ let currentSheetState = 'closed';
2103
+
2104
+ function setSheetState(state) {
2105
+ currentSheetState = state;
2106
+ const sheet = document.getElementById('nodeInspectorSheet');
2107
+ const overlay = document.getElementById('sheetOverlay');
2108
+ if (!sheet) return;
2109
+ sheet.classList.remove('peek', 'half', 'full', 'open');
2110
+ if (state !== 'closed') {
2111
+ sheet.classList.add('open', state);
2112
+ }
2113
+ if (overlay) {
2114
+ overlay.classList.toggle('active', state === 'full');
2115
+ }
2116
+ }
2117
+
2118
+ function cycleSheetState() {
2119
+ if (currentSheetState === 'closed' || currentSheetState === 'peek') {
2120
+ setSheetState('half');
2121
+ } else if (currentSheetState === 'half') {
2122
+ setSheetState('full');
2123
+ } else {
2124
+ setSheetState('peek');
2125
+ }
2126
+ }
2127
+
2128
+ function setupBottomSheetDrag() {
2129
+ const handle = document.getElementById('sheetDragHandle');
2130
+ if (!handle) return;
2131
+ let startY = 0;
2132
+ let startHeight = 0;
2133
+ let isDragging = false;
2134
+ const sheet = document.getElementById('nodeInspectorSheet');
2135
+
2136
+ const onStart = (clientY) => {
2137
+ isDragging = true;
2138
+ startY = clientY;
2139
+ startHeight = sheet.offsetHeight;
2140
+ sheet.style.transition = 'none';
2141
+ };
2142
+
2143
+ const onMove = (clientY) => {
2144
+ if (!isDragging) return;
2145
+ const deltaY = startY - clientY;
2146
+ const newHeight = Math.max(60, Math.min(window.innerHeight * 0.88, startHeight + deltaY));
2147
+ sheet.style.height = newHeight + 'px';
2148
+ };
2149
+
2150
+ const onEnd = () => {
2151
+ if (!isDragging) return;
2152
+ isDragging = false;
2153
+ sheet.style.transition = '';
2154
+ const currentH = sheet.offsetHeight;
2155
+ sheet.style.height = '';
2156
+ const vh = window.innerHeight;
2157
+ if (currentH < 110) {
2158
+ setSheetState('peek');
2159
+ } else if (currentH < vh * 0.65) {
2160
+ setSheetState('half');
2161
+ } else {
2162
+ setSheetState('full');
2163
+ }
2164
+ };
2165
+
2166
+ handle.addEventListener('touchstart', e => {
2167
+ if (e.touches.length === 1) onStart(e.touches[0].clientY);
2168
+ }, { passive: true });
2169
+ window.addEventListener('touchmove', e => {
2170
+ if (isDragging && e.touches.length === 1) onMove(e.touches[0].clientY);
2171
+ }, { passive: true });
2172
+ window.addEventListener('touchend', () => { onEnd(); });
2173
+
2174
+ handle.addEventListener('mousedown', e => {
2175
+ onStart(e.clientY);
2176
+ const moveHandler = ev => onMove(ev.clientY);
2177
+ const upHandler = () => {
2178
+ onEnd();
2179
+ window.removeEventListener('mousemove', moveHandler);
2180
+ window.removeEventListener('mouseup', upHandler);
2181
+ };
2182
+ window.addEventListener('mousemove', moveHandler);
2183
+ window.addEventListener('mouseup', upHandler);
2184
+ });
2185
+ }
2186
+
2187
+ async function selectPresetWorkflow(wfId, elem, autoScroll = true) {
2188
+ currentPresetId = wfId;
2189
+ try {
2190
+ localStorage.setItem('amfy_selected_workflow', wfId);
2191
+ localStorage.setItem('amvi_selected_workflow', wfId);
2192
+ } catch (e) {}
2193
+ document.querySelectorAll('.wf-card').forEach(c => c.classList.remove('active'));
2194
+ if (elem) {
2195
+ elem.classList.add('active');
2196
+ } else {
2197
+ const target = document.getElementById('wfCard_' + wfId);
2198
+ if (target) target.classList.add('active');
2199
+ }
2200
+ try {
2201
+ const resp = await fetch('/api/workflows/' + wfId);
2202
+ if (!resp.ok) throw new Error('HTTP ' + resp.status);
2203
+ const data = await resp.json();
2204
+ loadedWorkflowData = data;
2205
+ await inspectAndRenderWorkflow(data);
2206
+
2207
+ // 1. Smooth auto-scroll down to DAG Pipeline if requested
2208
+ if (autoScroll) {
2209
+ const wfInfoEl = document.getElementById('wfInfo');
2210
+ if (wfInfoEl) {
2211
+ wfInfoEl.scrollIntoView({ behavior: 'smooth', block: 'start' });
2212
+ }
2213
+ }
2214
+ } catch (err) {
2215
+ console.error('Failed loading workflow:', err);
2216
+ }
2217
+ }
2218
+
2219
+ let currentInspectionData = null;
2220
+ let selectedNodeId = null;
2221
+ let currentDagView = 'flow';
2222
+
2223
+ function switchDagView(mode) {
2224
+ currentDagView = mode;
2225
+ document.getElementById('btnViewFlow').classList.toggle('active', mode === 'flow');
2226
+ document.getElementById('btnViewMatrix').classList.toggle('active', mode === 'matrix');
2227
+ document.getElementById('btnViewList').classList.toggle('active', mode === 'list');
2228
+ document.getElementById('dagFlowView').style.display = mode === 'flow' ? 'flex' : 'none';
2229
+ document.getElementById('dagMatrixView').style.display = mode === 'matrix' ? 'flex' : 'none';
2230
+ document.getElementById('dagListView').style.display = mode === 'list' ? 'flex' : 'none';
2231
+ if (mode === 'matrix' && currentInspectionData) {
2232
+ renderTopologyMatrix(currentInspectionData);
2233
+ }
2234
+ }
2235
+
2236
+ function getNodeCategory(classType) {
2237
+ const c = (classType || '').toLowerCase();
2238
+ if (c.includes('loader') || c.includes('checkpoint')) return { cls: 'loader', label: 'LOADER' };
2239
+ if (c.includes('clip') || c.includes('text') || c.includes('conditioning')) return { cls: 'conditioning', label: 'PROMPT' };
2240
+ if (c.includes('sampler')) return { cls: 'sampler', label: 'SAMPLER' };
2241
+ if (c.includes('vae')) return { cls: 'decode', label: 'DECODER' };
2242
+ if (c.includes('detailer')) return { cls: 'detailer', label: 'DETAILER' };
2243
+ if (c.includes('save') || c.includes('preview')) return { cls: 'save', label: 'OUTPUT' };
2244
+ return { cls: '', label: classType };
2245
+ }
2246
+
2247
+ function renderInspectionView(data) {
2248
+ document.getElementById('wfInfo').style.display = 'block';
2249
+ document.getElementById('wfStepCount').innerText = data.total_nodes;
2250
+
2251
+ const flowContainer = document.getElementById('dagFlowView');
2252
+ flowContainer.innerHTML = '';
2253
+
2254
+ const listContainer = document.getElementById('dagListView');
2255
+ listContainer.innerHTML = '';
2256
+
2257
+ const totalSteps = data.execution_order.length;
2258
+
2259
+ data.execution_order.forEach((step, idx) => {
2260
+ const nid = step.node_id;
2261
+ const nodeDetail = (data.nodes_detail && data.nodes_detail[nid]) || step;
2262
+ const cat = getNodeCategory(step.class_type);
2263
+
2264
+ const flowCard = document.createElement('div');
2265
+ flowCard.className = 'flow-node-card';
2266
+ flowCard.id = 'flowNode_' + nid;
2267
+ flowCard.dataset.nodeId = nid;
2268
+ flowCard.onclick = () => inspectNode(nid);
2269
+
2270
+ let paramChips = [];
2271
+ if (nodeDetail.parameters) {
2272
+ for (const [k, v] of Object.entries(nodeDetail.parameters)) {
2273
+ if (k !== 'text' && typeof v !== 'object') {
2274
+ let vStr = String(v);
2275
+ if (vStr.length > 20) vStr = vStr.substring(0, 18) + '..';
2276
+ paramChips.push(`<span class="flow-param-chip">${escapeHtml(k)}: ${escapeHtml(vStr)}</span>`);
2277
+ }
2278
+ }
2279
+ }
2280
+
2281
+ // Multi-Branch IN ports
2282
+ let inChips = [];
2283
+ if (nodeDetail.connections_in && nodeDetail.connections_in.length > 0) {
2284
+ nodeDetail.connections_in.forEach(c => {
2285
+ inChips.push(`<span class="port-chip in">${escapeHtml(c.slot)} ← #${c.from_node}</span>`);
2286
+ });
2287
+ }
2288
+
2289
+ // Multi-Branch OUT ports
2290
+ let outChips = [];
2291
+ if (nodeDetail.connections_out && nodeDetail.connections_out.length > 0) {
2292
+ const slotMap = {};
2293
+ nodeDetail.connections_out.forEach(c => {
2294
+ if (!slotMap[c.slot]) slotMap[c.slot] = [];
2295
+ slotMap[c.slot].push('#' + c.to_node);
2296
+ });
2297
+ for (const [slot, targets] of Object.entries(slotMap)) {
2298
+ outChips.push(`<span class="port-chip out">${escapeHtml(slot)} ➔ ${targets.join(', ')}</span>`);
2299
+ }
2300
+ }
2301
+
2302
+ let portsHtml = '';
2303
+ if (inChips.length > 0) {
2304
+ portsHtml += `<div class="flow-ports-in"><span class="ports-label">IN (${nodeDetail.connections_in.length}):</span>${inChips.join('')}</div>`;
2305
+ }
2306
+ if (outChips.length > 0) {
2307
+ portsHtml += `<div class="flow-ports-out"><span class="ports-label">OUT (${nodeDetail.connections_out.length}):</span>${outChips.join('')}</div>`;
2308
+ }
2309
+
2310
+ flowCard.innerHTML = `
2311
+ <div class="flow-node-header">
2312
+ <span class="flow-node-title">
2313
+ <span class="node-num">${step.step}</span>
2314
+ <span>${escapeHtml(step.title || step.class_type)}</span>
2315
+ </span>
2316
+ <span class="flow-node-badge ${cat.cls}">${cat.label}</span>
2317
+ </div>
2318
+ ${paramChips.length > 0 ? `<div class="flow-node-params">${paramChips.slice(0, 3).join('')}</div>` : ''}
2319
+ ${portsHtml}
2320
+ `;
2321
+ flowContainer.appendChild(flowCard);
2322
+
2323
+ if (idx < totalSteps - 1) {
2324
+ const arrow = document.createElement('div');
2325
+ arrow.className = 'flow-arrow-down';
2326
+ arrow.innerText = '↓';
2327
+ flowContainer.appendChild(arrow);
2328
+ }
2329
+
2330
+ const pill = document.createElement('div');
2331
+ pill.className = 'node-pill';
2332
+ pill.id = 'listNode_' + nid;
2333
+ pill.style.cursor = 'pointer';
2334
+ pill.onclick = () => inspectNode(nid);
2335
+ pill.innerHTML = `
2336
+ <span class="node-num">${step.step}</span>
2337
+ <span style="flex:1;">[#${nid}] ${escapeHtml(step.title || step.class_type)}</span>
2338
+ <span class="flow-node-badge ${cat.cls}">${cat.label}</span>
2339
+ `;
2340
+ listContainer.appendChild(pill);
2341
+ });
2342
+
2343
+ renderTopologyMatrix(data);
2344
+ }
2345
+
2346
+ function renderTopologyMatrix(data) {
2347
+ const matrixContainer = document.getElementById('dagMatrixView');
2348
+ if (!matrixContainer) return;
2349
+ matrixContainer.innerHTML = '';
2350
+
2351
+ const layers = [
2352
+ { id: 'gen', title: 'Layer 1: Input & Generators (입력 및 생성)', nodes: [] },
2353
+ { id: 'cond', title: 'Layer 2: Conditioning & Prompts (병렬 분기)', nodes: [], isParallel: true },
2354
+ { id: 'sampler', title: 'Layer 3: Latent Diffusion Core (확산 코어 수렴)', nodes: [] },
2355
+ { id: 'decode', title: 'Layer 4: Latent Space Decode (잠재 복원)', nodes: [] },
2356
+ { id: 'output', title: 'Layer 5: Refinement & Image Output (보정 및 최종 출력)', nodes: [] }
2357
+ ];
2358
+
2359
+ data.execution_order.forEach(step => {
2360
+ const c = (step.class_type || '').toLowerCase();
2361
+ if (c.includes('loader') || c.includes('checkpoint') || c.includes('latent') || c.includes('lora')) {
2362
+ layers[0].nodes.push(step);
2363
+ } else if (c.includes('clip') || c.includes('conditioning') || c.includes('text')) {
2364
+ layers[1].nodes.push(step);
2365
+ } else if (c.includes('sampler')) {
2366
+ layers[2].nodes.push(step);
2367
+ } else if (c.includes('vae')) {
2368
+ layers[3].nodes.push(step);
2369
+ } else {
2370
+ layers[4].nodes.push(step);
2371
+ }
2372
+ });
2373
+
2374
+ layers.forEach(layer => {
2375
+ if (layer.nodes.length === 0) return;
2376
+ const layerCard = document.createElement('div');
2377
+ layerCard.className = 'matrix-layer';
2378
+
2379
+ let convergenceHtml = '';
2380
+ if (layer.id === 'sampler') {
2381
+ convergenceHtml = '<div class="matrix-convergence-chip">⛙ Converges 4 Inputs: MODEL + POS + NEG + LATENT</div>';
2382
+ } else if (layer.id === 'decode') {
2383
+ convergenceHtml = '<div class="matrix-convergence-chip">⛙ Converges 2 Inputs: LATENT_SAMPLES + VAE</div>';
2384
+ }
2385
+
2386
+ layerCard.innerHTML = `
2387
+ <div class="matrix-layer-title">
2388
+ <span>${layer.title}</span>
2389
+ <span>${layer.nodes.length} Nodes</span>
2390
+ </div>
2391
+ ${convergenceHtml}
2392
+ `;
2393
+
2394
+ const row = document.createElement('div');
2395
+ row.className = layer.isParallel ? 'matrix-row-parallel' : 'dag-flow-container';
2396
+ row.style.gap = '6px';
2397
+
2398
+ layer.nodes.forEach(step => {
2399
+ const nid = step.node_id;
2400
+ const nodeDetail = (data.nodes_detail && data.nodes_detail[nid]) || step;
2401
+ const cat = getNodeCategory(step.class_type);
2402
+
2403
+ const card = document.createElement('div');
2404
+ card.className = 'flow-node-card';
2405
+ card.id = 'matrixNode_' + nid;
2406
+ card.dataset.nodeId = nid;
2407
+ card.onclick = () => inspectNode(nid);
2408
+
2409
+ let inCount = (nodeDetail.connections_in || []).length;
2410
+ let outCount = (nodeDetail.connections_out || []).length;
2411
+
2412
+ card.innerHTML = `
2413
+ <div class="flow-node-header">
2414
+ <span class="flow-node-title">
2415
+ <span class="node-num">${step.step}</span>
2416
+ <span style="font-size:12px;">${escapeHtml(step.title || step.class_type)}</span>
2417
+ </span>
2418
+ <span class="flow-node-badge ${cat.cls}">${cat.label}</span>
2419
+ </div>
2420
+ <div style="display:flex;gap:6px;font-size:10px;color:var(--text-muted);margin-top:2px;">
2421
+ <span>IN: ${inCount} slots</span> | <span>OUT: ${outCount} slots</span>
2422
+ </div>
2423
+ `;
2424
+ row.appendChild(card);
2425
+ });
2426
+
2427
+ layerCard.appendChild(row);
2428
+ matrixContainer.appendChild(layerCard);
2429
+ });
2430
+ }
2431
+
2432
+ async function inspectAndRenderWorkflow(wfJson) {
2433
+ try {
2434
+ const resp = await fetch('/api/workflow/inspect', {
2435
+ method: 'POST',
2436
+ headers: {'Content-Type': 'application/json'},
2437
+ body: JSON.stringify(wfJson)
2438
+ });
2439
+ const data = await resp.json();
2440
+ if (data.error) {
2441
+ alert('Workflow Parse Error: ' + data.error);
2442
+ return;
2443
+ }
2444
+ currentInspectionData = data;
2445
+ renderInspectionView(data);
2446
+
2447
+ // Auto-inspect first node or KSampler
2448
+ const samplerStep = data.execution_order.find(s => s.class_type.toLowerCase().includes('sampler'));
2449
+ const targetNodeId = samplerStep ? samplerStep.node_id : data.execution_order[0].node_id;
2450
+ inspectNode(targetNodeId);
2451
+
2452
+ } catch (e) {
2453
+ alert('Workflow Inspection Failed: ' + e.message);
2454
+ }
2455
+ }
2456
+
2457
+ function inspectNode(nodeId) {
2458
+ if (!currentInspectionData || !currentInspectionData.nodes_detail) return;
2459
+ selectedNodeId = nodeId;
2460
+ const node = currentInspectionData.nodes_detail[nodeId];
2461
+ if (!node) return;
2462
+
2463
+ // Multi-Branch Upstream & Downstream Resolution
2464
+ const sourceNodeIds = new Set();
2465
+ if (node.connections_in) {
2466
+ node.connections_in.forEach(c => sourceNodeIds.add(String(c.from_node)));
2467
+ }
2468
+ const targetNodeIds = new Set();
2469
+ if (node.connections_out) {
2470
+ node.connections_out.forEach(c => targetNodeIds.add(String(c.to_node)));
2471
+ }
2472
+
2473
+ // Apply branch illumination and dimming to all flow and matrix cards
2474
+ currentInspectionData.execution_order.forEach(step => {
2475
+ const sNid = String(step.node_id);
2476
+ const flowCard = document.getElementById('flowNode_' + sNid);
2477
+ const matrixCard = document.getElementById('matrixNode_' + sNid);
2478
+
2479
+ [flowCard, matrixCard].forEach(card => {
2480
+ if (!card) return;
2481
+ card.classList.remove('selected', 'flow-node-source', 'flow-node-target', 'flow-node-dimmed');
2482
+ if (sNid === String(nodeId)) {
2483
+ card.classList.add('selected');
2484
+ } else if (sourceNodeIds.has(sNid)) {
2485
+ card.classList.add('flow-node-source');
2486
+ } else if (targetNodeIds.has(sNid)) {
2487
+ card.classList.add('flow-node-target');
2488
+ } else {
2489
+ card.classList.add('flow-node-dimmed');
2490
+ }
2491
+ });
2492
+ });
2493
+
2494
+ document.querySelectorAll('.node-pill').forEach(p => p.style.borderColor = 'var(--card-border)');
2495
+ const activePill = document.getElementById('listNode_' + nodeId);
2496
+ if (activePill) activePill.style.borderColor = 'var(--accent)';
2497
+
2498
+ // Open floating bottom sheet in half peek if closed
2499
+ if (currentSheetState === 'closed') {
2500
+ setSheetState('half');
2501
+ }
2502
+
2503
+ const cat = getNodeCategory(node.class_type);
2504
+ document.getElementById('inspStage').innerText = `Step ${node.step} of ${currentInspectionData.total_nodes}`;
2505
+ document.getElementById('inspTitle').innerText = `[#${node.node_id}] ${node.title || node.class_type}`;
2506
+ const badge = document.getElementById('inspClassBadge');
2507
+ badge.className = 'flow-node-badge ' + cat.cls;
2508
+ badge.innerText = cat.label;
2509
+
2510
+ document.getElementById('inspRole').innerText = node.role || 'Executes this pipeline transformation stage.';
2511
+
2512
+ // Parameters
2513
+ const promptBox = document.getElementById('inspPromptBox');
2514
+ const paramsGrid = document.getElementById('inspParamsGrid');
2515
+ paramsGrid.innerHTML = '';
2516
+ const feedback = document.getElementById('nodeFeedbackMsg');
2517
+ if (feedback) feedback.style.display = 'none';
2518
+
2519
+ if (node.parameters && node.parameters.text !== undefined) {
2520
+ promptBox.style.display = 'block';
2521
+ promptBox.innerHTML = `
2522
+ <div style="font-size: 11px; font-weight: 700; color: var(--accent); margin-bottom: 4px;">PROMPT TEXT (수정 가능):</div>
2523
+ <textarea id="editProp_text" class="custom-textarea" rows="3">${escapeHtml(String(node.parameters.text))}</textarea>
2524
+ `;
2525
+ } else {
2526
+ promptBox.style.display = 'none';
2527
+ }
2528
+
2529
+ let hasProps = false;
2530
+ if (node.parameters) {
2531
+ for (const [k, v] of Object.entries(node.parameters)) {
2532
+ if (k === 'text') continue;
2533
+ hasProps = true;
2534
+ const prop = document.createElement('div');
2535
+ const kLow = k.toLowerCase();
2536
+
2537
+ let inputHtml = '';
2538
+
2539
+ // 1. Checkpoint / Diffusion Model Selector
2540
+ if (['ckpt_name', 'model_name', 'checkpoint', 'unet_name'].includes(kLow)) {
2541
+ prop.className = 'inspector-prop full-span';
2542
+ let ckptOptions = [];
2543
+ const availableCkpts = (deviceModelCatalog.checkpoints && deviceModelCatalog.checkpoints.length > 0)
2544
+ ? deviceModelCatalog.checkpoints
2545
+ : [
2546
+ { name: 'realistic.gguf', size_mb: 1546.8 },
2547
+ { name: 'z_image_turbo-Q4_0.gguf', size_mb: 3512.7 },
2548
+ { name: 'z_image_turbo-Q2_K.gguf', size_mb: 2472.3 },
2549
+ { name: 'sdxs.gguf', size_mb: 651.2 },
2550
+ { name: 'anime.gguf', size_mb: 1550.0 },
2551
+ { name: 'counterfeit-v3-lcm.gguf', size_mb: 1494.2 },
2552
+ { name: 'ghostmix.Q4_K_M.gguf', size_mb: 461.8 },
2553
+ { name: 'anything-v5.q4_0.gguf', size_mb: 1497.7 },
2554
+ { name: 'anima-preview-Q4_K.gguf', size_mb: 1147.4 }
2555
+ ];
2556
+
2557
+ let matched = false;
2558
+ availableCkpts.forEach(m => {
2559
+ const isSel = (String(v) === m.name);
2560
+ if (isSel) matched = true;
2561
+ ckptOptions.push(`<option value="${escapeHtml(m.name)}" ${isSel ? 'selected' : ''}>${escapeHtml(m.name)} (${m.size_mb} MB)</option>`);
2562
+ });
2563
+
2564
+ if (!matched && v) {
2565
+ ckptOptions.unshift(`<option value="${escapeHtml(String(v))}" selected>${escapeHtml(String(v))} (Preset Default)</option>`);
2566
+ }
2567
+ ckptOptions.push(`<option value="__custom__">✏️ Direct File Path / Custom...</option>`);
2568
+
2569
+ inputHtml = `
2570
+ <select id="editProp_${k}" class="custom-select" onchange="onModelSelectChange('${k}', this)">
2571
+ ${ckptOptions.join('')}
2572
+ </select>
2573
+ <div id="customPathWrap_${k}" class="custom-path-box">
2574
+ <input type="text" id="editProp_${k}_custom" class="custom-input" placeholder="e.g. realistic.gguf or absolute path" value="${escapeHtml(String(v))}">
2575
+ </div>
2576
+ `;
2577
+ prop.innerHTML = `
2578
+ <span class="inspector-prop-key">${escapeHtml(k)} <span class="model-badge-tag">DEVICE MODELS (${availableCkpts.length})</span></span>
2579
+ ${inputHtml}
2580
+ `;
2581
+ paramsGrid.appendChild(prop);
2582
+ continue;
2583
+ }
2584
+
2585
+ // 2. VAE Model Selector
2586
+ if (['vae_name', 'vae'].includes(kLow)) {
2587
+ prop.className = 'inspector-prop full-span';
2588
+ let vaeOptions = [
2589
+ `<option value="Baked-in" ${String(v) === 'Baked-in' ? 'selected' : ''}>Baked-in (내장 VAE)</option>`
2590
+ ];
2591
+ const availableVae = (deviceModelCatalog.vae && deviceModelCatalog.vae.length > 0)
2592
+ ? deviceModelCatalog.vae
2593
+ : [
2594
+ { name: 'taef1.safetensors', size_mb: 9.4 },
2595
+ { name: 'z_image_ae.safetensors', size_mb: 319.8 }
2596
+ ];
2597
+
2598
+ let matched = (String(v) === 'Baked-in');
2599
+ availableVae.forEach(m => {
2600
+ const isSel = (String(v) === m.name);
2601
+ if (isSel) matched = true;
2602
+ vaeOptions.push(`<option value="${escapeHtml(m.name)}" ${isSel ? 'selected' : ''}>${escapeHtml(m.name)} (${m.size_mb} MB)</option>`);
2603
+ });
2604
+
2605
+ if (!matched && v) {
2606
+ vaeOptions.unshift(`<option value="${escapeHtml(String(v))}" selected>${escapeHtml(String(v))} (Preset Default)</option>`);
2607
+ }
2608
+ vaeOptions.push(`<option value="__custom__">✏️ Direct File Path / Custom...</option>`);
2609
+
2610
+ inputHtml = `
2611
+ <select id="editProp_${k}" class="custom-select" onchange="onModelSelectChange('${k}', this)">
2612
+ ${vaeOptions.join('')}
2613
+ </select>
2614
+ <div id="customPathWrap_${k}" class="custom-path-box">
2615
+ <input type="text" id="editProp_${k}_custom" class="custom-input" placeholder="e.g. taef1.safetensors" value="${escapeHtml(String(v))}">
2616
+ </div>
2617
+ `;
2618
+ prop.innerHTML = `
2619
+ <span class="inspector-prop-key">${escapeHtml(k)} <span class="model-badge-tag">VAE WEIGHTS</span></span>
2620
+ ${inputHtml}
2621
+ `;
2622
+ paramsGrid.appendChild(prop);
2623
+ continue;
2624
+ }
2625
+
2626
+ // 3. LoRA Model Selector
2627
+ if (['lora_name', 'lora'].includes(kLow)) {
2628
+ prop.className = 'inspector-prop full-span';
2629
+ let loraOptions = [
2630
+ `<option value="None" ${String(v) === 'None' ? 'selected' : ''}>None (미사용)</option>`
2631
+ ];
2632
+ const availableLoras = deviceModelCatalog.loras || [];
2633
+ let matched = (String(v) === 'None');
2634
+ availableLoras.forEach(m => {
2635
+ const isSel = (String(v) === m.name);
2636
+ if (isSel) matched = true;
2637
+ loraOptions.push(`<option value="${escapeHtml(m.name)}" ${isSel ? 'selected' : ''}>${escapeHtml(m.name)} (${m.size_mb} MB)</option>`);
2638
+ });
2639
+ if (!matched && v) {
2640
+ loraOptions.unshift(`<option value="${escapeHtml(String(v))}" selected>${escapeHtml(String(v))}</option>`);
2641
+ }
2642
+ loraOptions.push(`<option value="__custom__">✏️ Direct File Path / Custom...</option>`);
2643
+
2644
+ inputHtml = `
2645
+ <select id="editProp_${k}" class="custom-select" onchange="onModelSelectChange('${k}', this)">
2646
+ ${loraOptions.join('')}
2647
+ </select>
2648
+ <div id="customPathWrap_${k}" class="custom-path-box">
2649
+ <input type="text" id="editProp_${k}_custom" class="custom-input" placeholder="e.g. detail_tweaker.safetensors" value="${escapeHtml(String(v))}">
2650
+ </div>
2651
+ `;
2652
+ prop.innerHTML = `
2653
+ <span class="inspector-prop-key">${escapeHtml(k)} <span class="model-badge-tag">LORA</span></span>
2654
+ ${inputHtml}
2655
+ `;
2656
+ paramsGrid.appendChild(prop);
2657
+ continue;
2658
+ }
2659
+
2660
+ // 4. Sliders with Live Synchronized Number Inputs
2661
+ prop.className = 'inspector-prop';
2662
+ if (k === 'steps') {
2663
+ inputHtml = `
2664
+ <div class="param-slider-wrap">
2665
+ <input type="range" id="editProp_steps_slider" min="1" max="100" value="${v}" oninput="syncSlider('editProp_steps', this.value)">
2666
+ <input type="number" id="editProp_steps" class="custom-input param-num-compact" min="1" max="100" value="${v}" oninput="syncNumber('editProp_steps_slider', this.value)">
2667
+ </div>`;
2668
+ } else if (k === 'cfg') {
2669
+ inputHtml = `
2670
+ <div class="param-slider-wrap">
2671
+ <input type="range" id="editProp_cfg_slider" min="1.0" max="30.0" step="0.5" value="${v}" oninput="syncSlider('editProp_cfg', this.value)">
2672
+ <input type="number" id="editProp_cfg" class="custom-input param-num-compact" min="1.0" max="30.0" step="0.5" value="${v}" oninput="syncNumber('editProp_cfg_slider', this.value)">
2673
+ </div>`;
2674
+ } else if (k === 'denoise') {
2675
+ inputHtml = `
2676
+ <div class="param-slider-wrap">
2677
+ <input type="range" id="editProp_denoise_slider" min="0.0" max="1.0" step="0.05" value="${v}" oninput="syncSlider('editProp_denoise', this.value)">
2678
+ <input type="number" id="editProp_denoise" class="custom-input param-num-compact" min="0.0" max="1.0" step="0.05" value="${v}" oninput="syncNumber('editProp_denoise_slider', this.value)">
2679
+ </div>`;
2680
+ } else if (k === 'bbox_threshold') {
2681
+ inputHtml = `
2682
+ <div class="param-slider-wrap">
2683
+ <input type="range" id="editProp_bbox_threshold_slider" min="0.1" max="0.95" step="0.05" value="${v}" oninput="syncSlider('editProp_bbox_threshold', this.value)">
2684
+ <input type="number" id="editProp_bbox_threshold" class="custom-input param-num-compact" min="0.1" max="0.95" step="0.05" value="${v}" oninput="syncNumber('editProp_bbox_threshold_slider', this.value)">
2685
+ </div>`;
2686
+ } else if (k === 'seed') {
2687
+ inputHtml = `
2688
+ <div style="display:flex;gap:4px;align-items:center;">
2689
+ <input type="number" id="editProp_seed" class="custom-input" style="flex:1;" value="${v}">
2690
+ <button type="button" class="action-btn" style="padding:4px 8px;font-size:11px;" title="난수 시드 발생" onclick="randomizeInspectorSeed()">🎲</button>
2691
+ </div>`;
2692
+ } else if (k === 'sampler_name') {
2693
+ const samplers = ['euler_a', 'euler', 'dpmpp_2m', 'dpmpp_2m_sde', 'dpmpp_sde', 'dpmpp_2s_ancestral', 'heun', 'lms', 'ddim', 'uni_pc', 'flow_euler'];
2694
+ const opts = samplers.map(s => `<option value="${s}" ${s === v ? 'selected' : ''}>${s}</option>`).join('');
2695
+ inputHtml = `<select id="editProp_sampler_name" class="custom-select">${opts}</select>`;
2696
+ } else if (k === 'scheduler') {
2697
+ const schedulers = ['karras', 'normal', 'exponential', 'simple', 'sgm_uniform', 'turbo', 'linear', 'ddim_uniform'];
2698
+ const opts = schedulers.map(s => `<option value="${s}" ${s === v ? 'selected' : ''}>${s}</option>`).join('');
2699
+ inputHtml = `<select id="editProp_scheduler" class="custom-select">${opts}</select>`;
2700
+ } else if (k === 'width' || k === 'height' || k === 'guide_size') {
2701
+ const dims = [256, 384, 512, 640, 768, 896, 1024, 1280];
2702
+ let matched = false;
2703
+ let opts = dims.map(d => {
2704
+ if (d == v) matched = true;
2705
+ return `<option value="${d}" ${d == v ? 'selected' : ''}>${d}px</option>`;
2706
+ }).join('');
2707
+ if (!matched && v) {
2708
+ opts = `<option value="${v}" selected>${v}px (Custom)</option>` + opts;
2709
+ }
2710
+ opts += `<option value="__custom__">✏️ Direct px...</option>`;
2711
+ inputHtml = `
2712
+ <select id="editProp_${k}" class="custom-select" onchange="onDimSelectChange('${k}', this)">${opts}</select>
2713
+ <div id="customDimWrap_${k}" class="custom-path-box">
2714
+ <input type="number" id="editProp_${k}_custom" class="custom-input" placeholder="e.g. 512" value="${v}">
2715
+ </div>
2716
+ `;
2717
+ } else if (typeof v === 'boolean' || ['true', 'false'].includes(String(v).toLowerCase())) {
2718
+ const bVal = (String(v).toLowerCase() === 'true');
2719
+ inputHtml = `
2720
+ <select id="editProp_${k}" class="custom-select">
2721
+ <option value="true" ${bVal ? 'selected' : ''}>True (활성화)</option>
2722
+ <option value="false" ${!bVal ? 'selected' : ''}>False (비활성화)</option>
2723
+ </select>`;
2724
+ } else if (typeof v === 'number') {
2725
+ inputHtml = `<input type="number" id="editProp_${k}" class="custom-input" value="${v}">`;
2726
+ } else {
2727
+ inputHtml = `<input type="text" id="editProp_${k}" class="custom-input" value="${escapeHtml(String(v))}">`;
2728
+ }
2729
+
2730
+ prop.innerHTML = `
2731
+ <span class="inspector-prop-key">${escapeHtml(k)}</span>
2732
+ ${inputHtml}
2733
+ `;
2734
+ paramsGrid.appendChild(prop);
2735
+ }
2736
+ }
2737
+ if (!hasProps && promptBox.style.display === 'none') {
2738
+ paramsGrid.innerHTML = '<span style="font-size: 12px; color: var(--text-muted); grid-column: span 2;">Default model parameters applied</span>';
2739
+ }
2740
+
2741
+ // Multi-Branch Wiring Explorer
2742
+ const wiringBox = document.getElementById('inspWiringBox');
2743
+ wiringBox.innerHTML = '';
2744
+
2745
+ let hasWiring = false;
2746
+ if (node.connections_in && node.connections_in.length > 0) {
2747
+ hasWiring = true;
2748
+ node.connections_in.forEach(c => {
2749
+ const row = document.createElement('div');
2750
+ row.className = 'wiring-row';
2751
+ row.innerHTML = `<span class="slot-name">📥 ${escapeHtml(c.slot)}</span><span class="target-node">from [#${c.from_node}] ${escapeHtml(c.from_title || '')}</span>`;
2752
+ wiringBox.appendChild(row);
2753
+ });
2754
+ }
2755
+
2756
+ if (node.connections_out && node.connections_out.length > 0) {
2757
+ hasWiring = true;
2758
+ node.connections_out.forEach(c => {
2759
+ const row = document.createElement('div');
2760
+ row.className = 'wiring-row';
2761
+ row.innerHTML = `<span class="slot-name">📤 ${escapeHtml(c.slot)}</span><span class="target-node">to [#${c.to_node}] ${escapeHtml(c.to_title || '')}</span>`;
2762
+ wiringBox.appendChild(row);
2763
+ });
2764
+ }
2765
+
2766
+ if (!hasWiring) {
2767
+ wiringBox.innerHTML = '<span style="font-size: 11px; color: var(--text-muted);">Root / Self-contained node</span>';
2768
+ }
2769
+ }
2770
+
2771
+ function randomizeInspectorSeed() {
2772
+ const el = document.getElementById('editProp_seed');
2773
+ if (el) {
2774
+ const randSeed = Math.floor(Math.random() * 2147483647);
2775
+ el.value = randSeed;
2776
+ }
2777
+ }
2778
+
2779
+ async function applyNodeChanges() {
2780
+ if (!loadedWorkflowData || !selectedNodeId || !currentInspectionData) return;
2781
+ const node = currentInspectionData.nodes_detail[selectedNodeId];
2782
+ if (!node) return;
2783
+
2784
+ const newParams = {};
2785
+ if (node.parameters && node.parameters.text !== undefined) {
2786
+ const textEl = document.getElementById('editProp_text');
2787
+ if (textEl) newParams.text = textEl.value;
2788
+ }
2789
+
2790
+ if (node.parameters) {
2791
+ for (const k of Object.keys(node.parameters)) {
2792
+ if (k === 'text') continue;
2793
+ const el = document.getElementById('editProp_' + k);
2794
+ if (el) {
2795
+ let val = el.value;
2796
+ // Handle custom path / dimension input
2797
+ if (val === '__custom__') {
2798
+ const customEl = document.getElementById('editProp_' + k + '_custom');
2799
+ if (customEl && customEl.value.trim() !== '') {
2800
+ val = customEl.value.trim();
2801
+ }
2802
+ }
2803
+ // Parse typed values
2804
+ if (typeof node.parameters[k] === 'boolean' || val === 'true' || val === 'false') {
2805
+ val = (String(val).toLowerCase() === 'true');
2806
+ } else if (['steps', 'seed', 'width', 'height', 'batch_size', 'guide_size'].includes(k)) {
2807
+ val = parseInt(val, 10);
2808
+ } else if (['cfg', 'denoise', 'bbox_threshold'].includes(k)) {
2809
+ val = parseFloat(val);
2810
+ } else if (typeof node.parameters[k] === 'number') {
2811
+ val = Number(val);
2812
+ }
2813
+ newParams[k] = val;
2814
+ }
2815
+ }
2816
+ }
2817
+
2818
+ try {
2819
+ const resp = await fetch('/api/workflow/update_node', {
2820
+ method: 'POST',
2821
+ headers: {'Content-Type': 'application/json'},
2822
+ body: JSON.stringify({
2823
+ workflow: loadedWorkflowData,
2824
+ node_id: selectedNodeId,
2825
+ parameters: newParams
2826
+ })
2827
+ });
2828
+ const data = await resp.json();
2829
+ if (!resp.ok || data.error) {
2830
+ throw new Error(data.error || 'Failed updating node');
2831
+ }
2832
+
2833
+ loadedWorkflowData = data.workflow;
2834
+ currentInspectionData = data.inspection;
2835
+
2836
+ renderInspectionView(data.inspection);
2837
+ inspectNode(selectedNodeId);
2838
+
2839
+ const msg = document.getElementById('nodeFeedbackMsg');
2840
+ msg.innerText = `✓ Node #${selectedNodeId} (${node.title || node.class_type}) updated in active workflow.`;
2841
+ msg.style.display = 'block';
2842
+ setTimeout(() => { msg.style.display = 'none'; }, 3000);
2843
+ } catch (err) {
2844
+ alert('Failed to update node: ' + err.message);
2845
+ }
2846
+ }
2847
+
2848
+ function resetNodeChanges() {
2849
+ if (selectedNodeId) inspectNode(selectedNodeId);
2850
+ }
2851
+
2852
+ function exportWorkflowJson() {
2853
+ if (!loadedWorkflowData) {
2854
+ alert('No active workflow loaded to export.');
2855
+ return;
2856
+ }
2857
+ const wfName = (loadedWorkflowData.name || 'custom_workflow').toLowerCase().replace(/[^a-z0-9_-]/g, '_');
2858
+ const dataStr = JSON.stringify(loadedWorkflowData, null, 2);
2859
+ const blob = new Blob([dataStr], { type: 'application/json' });
2860
+ const url = URL.createObjectURL(blob);
2861
+ const a = document.createElement('a');
2862
+ a.href = url;
2863
+ a.download = wfName + '.json';
2864
+ document.body.appendChild(a);
2865
+ a.click();
2866
+ document.body.removeChild(a);
2867
+ URL.revokeObjectURL(url);
2868
+ }
2869
+
2870
+ function openSavePresetModal() {
2871
+ if (!loadedWorkflowData) {
2872
+ alert('No active workflow loaded to save.');
2873
+ return;
2874
+ }
2875
+ const defName = loadedWorkflowData.name || 'Custom Workflow';
2876
+ const defId = defName.toLowerCase().replace(/[^a-z0-9_-]/g, '_') + '_tuned';
2877
+ document.getElementById('savePresetNameInput').value = defName + ' (Tuned)';
2878
+ document.getElementById('savePresetIdInput').value = defId;
2879
+ document.getElementById('savePresetModal').style.display = 'flex';
2880
+ }
2881
+
2882
+ function closeSavePresetModal() {
2883
+ document.getElementById('savePresetModal').style.display = 'none';
2884
+ }
2885
+
2886
+ async function confirmSavePreset() {
2887
+ const name = document.getElementById('savePresetNameInput').value.trim();
2888
+ const id = document.getElementById('savePresetIdInput').value.trim().toLowerCase().replace(/[^a-z0-9_-]/g, '_');
2889
+ if (!name || !id) {
2890
+ alert('Please provide both Preset Name and Identifier.');
2891
+ return;
2892
+ }
2893
+ try {
2894
+ const resp = await fetch('/api/workflow/save', {
2895
+ method: 'POST',
2896
+ headers: {'Content-Type': 'application/json'},
2897
+ body: JSON.stringify({
2898
+ workflow: loadedWorkflowData,
2899
+ id: id,
2900
+ name: name,
2901
+ description: 'User tuned custom workflow on device'
2902
+ })
2903
+ });
2904
+ const data = await resp.json();
2905
+ if (!resp.ok || data.error) {
2906
+ throw new Error(data.error || 'Failed saving preset');
2907
+ }
2908
+ closeSavePresetModal();
2909
+ alert('Saved as custom preset "' + name + '" on device!');
2910
+ await loadPresetList();
2911
+ selectPresetWorkflow(id);
2912
+ } catch (err) {
2913
+ alert('Error saving preset: ' + err.message);
2914
+ }
2915
+ }
2916
+
2917
+ function escapeHtml(str) {
2918
+ return String(str).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
2919
+ }
2920
+
2921
+ function handleWorkflowFile(input) {
2922
+ const file = input.files[0];
2923
+ if (!file) return;
2924
+ const reader = new FileReader();
2925
+ reader.onload = async (e) => {
2926
+ try {
2927
+ loadedWorkflowData = JSON.parse(e.target.result);
2928
+ document.querySelectorAll('.wf-card').forEach(c => c.classList.remove('active'));
2929
+ await inspectAndRenderWorkflow(loadedWorkflowData);
2930
+ } catch (err) {
2931
+ alert('JSON Syntax Error: ' + err.message);
2932
+ }
2933
+ };
2934
+ reader.readAsText(file);
2935
+ }
2936
+
2937
+ let isRunning = false;
2938
+ let clientTickerInterval = null;
2939
+ let backgroundHudInterval = null;
2940
+ let currentQueueSnapshot = { total: 0, items: [] };
2941
+
2942
+ function startClientTicker(serverStartTime) {
2943
+ if (clientTickerInterval) clearInterval(clientTickerInterval);
2944
+ const baseStart = (serverStartTime && serverStartTime > 0) ? (serverStartTime * 1000) : Date.now();
2945
+ clientTickerInterval = setInterval(() => {
2946
+ if (!isRunning) {
2947
+ clearInterval(clientTickerInterval);
2948
+ clientTickerInterval = null;
2949
+ return;
2950
+ }
2951
+ const elapsedSec = Math.max(0, (Date.now() - baseStart) / 1000);
2952
+ const formatted = elapsedSec.toFixed(1) + 's';
2953
+ const hudTime = document.getElementById('hudTimeVal');
2954
+ if (hudTime) hudTime.innerText = formatted;
2955
+ const liveElapsed = document.getElementById('liveElapsedEtaText');
2956
+ if (liveElapsed) liveElapsed.innerText = 'Elapsed: ' + formatted;
2957
+ }, 100);
2958
+ }
2959
+
2960
+ async function onGenerateClick() {
2961
+ const genBtn = document.getElementById('generateBtn');
2962
+ document.getElementById('previewBox').classList.remove('active');
2963
+ document.getElementById('progressBox').classList.add('active');
2964
+
2965
+ let endpoint = '/api/generate';
2966
+ let payload = {};
2967
+
2968
+ if (activeTab === 'quick') {
2969
+ endpoint = '/api/generate';
2970
+ const safeModeElem = document.getElementById('safeModeSwitch');
2971
+ const isSafe = safeModeElem ? safeModeElem.checked : true;
2972
+ payload = {
2973
+ prompt: document.getElementById('promptInput').value,
2974
+ model: currentModel,
2975
+ steps: parseInt(document.getElementById('stepsInput').value),
2976
+ cfg: parseFloat(document.getElementById('cfgInput').value),
2977
+ detail_faces: document.getElementById('detailerSwitch').checked,
2978
+ safe_mode: isSafe,
2979
+ gpu_duty_cycle: isSafe ? 90 : 100,
2980
+ device: 'gpu'
2981
+ };
2982
+ } else {
2983
+ if (!loadedWorkflowData) {
2984
+ alert('Please select an AmfyUI / ComfyUI workflow JSON file first.');
2985
+ document.getElementById('progressBox').classList.remove('active');
2986
+ return;
2987
+ }
2988
+ endpoint = '/api/workflow/run';
2989
+ payload = { workflow: loadedWorkflowData, device: 'gpu' };
2990
+ }
2991
+
2992
+ try {
2993
+ const resp = await fetch(endpoint, {
2994
+ method: 'POST',
2995
+ headers: {'Content-Type': 'application/json'},
2996
+ body: JSON.stringify(payload)
2997
+ });
2998
+ const res = await resp.json();
2999
+ if (res.status === 'queued') {
3000
+ const qEl = document.getElementById('hudQueueVal');
3001
+ if (qEl) qEl.innerText = `${res.queue_total} pending`;
3002
+ const activeNodeTitle = document.getElementById('activeNodeTitle');
3003
+ if (activeNodeTitle) {
3004
+ activeNodeTitle.innerText = `+ Enqueued as Task #${res.queue_position} (${res.job_id})`;
3005
+ document.getElementById('activeNodeRow').style.display = 'flex';
3006
+ }
3007
+ }
3008
+ } catch (err) {
3009
+ console.error('Request dispatch failed:', err);
3010
+ }
3011
+
3012
+ startProgressPolling();
3013
+ }
3014
+
3015
+ function startProgressPolling() {
3016
+ if (pollInterval) clearInterval(pollInterval);
3017
+ pollInterval = setInterval(async () => {
3018
+ try {
3019
+ const resp = await fetch('/api/progress');
3020
+ const data = await resp.json();
3021
+ updateUIWithProgress(data);
3022
+ } catch (e) {
3023
+ console.error(e);
3024
+ }
3025
+ }, 400);
3026
+ }
3027
+
3028
+ function updateUIWithProgress(data) {
3029
+ const badge = document.getElementById('statusBadge');
3030
+ const statusText = document.getElementById('statusText');
3031
+ const genBtn = document.getElementById('generateBtn');
3032
+
3033
+ // 1. Live Hardware Resource HUD Updates
3034
+ if (data.metrics) {
3035
+ const m = data.metrics;
3036
+ const ramEl = document.getElementById('hudRamVal');
3037
+ if (ramEl) {
3038
+ ramEl.innerText = `${m.ram_used_gb}/${m.ram_total_gb} GB (${m.ram_percent}%)`;
3039
+ }
3040
+ const cpuEl = document.getElementById('hudCpuVal');
3041
+ if (cpuEl) {
3042
+ cpuEl.innerText = `${m.cpu_percent}%`;
3043
+ }
3044
+ const gpuEl = document.getElementById('hudGpuVal');
3045
+ if (gpuEl) {
3046
+ const gpuPct = (m.gpu_percent !== null && m.gpu_percent !== undefined) ? m.gpu_percent : (data.progress && data.progress.gpu_busy);
3047
+ gpuEl.innerText = (gpuPct !== null && gpuPct !== undefined) ? `${gpuPct}%` : '0%';
3048
+ }
3049
+ }
3050
+
3051
+ // 2. Queue HUD Chip Update
3052
+ if (data.queue) {
3053
+ currentQueueSnapshot = data.queue;
3054
+ const qEl = document.getElementById('hudQueueVal');
3055
+ if (qEl) {
3056
+ qEl.innerText = `${data.queue.total} pending`;
3057
+ }
3058
+ }
3059
+
3060
+ // 3. Execution State Tracking
3061
+ if (data.status === 'running') {
3062
+ isRunning = true;
3063
+ badge.className = 'status-badge running';
3064
+ statusText.innerText = 'Running';
3065
+ const progBox = document.getElementById('progressBox');
3066
+ if (progBox) progBox.classList.add('active');
3067
+
3068
+ // Keep button active to allow queueing the next pipeline task
3069
+ genBtn.disabled = false;
3070
+ genBtn.classList.add('enqueue-mode');
3071
+ genBtn.innerHTML = `<span>+ Enqueue Next Task (대기열 추가)</span>`;
3072
+
3073
+ if (!clientTickerInterval && data.start_time) {
3074
+ startClientTicker(data.start_time);
3075
+ }
3076
+
3077
+ const p = data.progress || {};
3078
+ document.getElementById('barFill').style.width = (p.percent || 0) + '%';
3079
+ document.getElementById('stepCounter').innerText = `${p.step || 0} / ${p.total_steps || 0} (${(p.percent || 0).toFixed(1)}%)`;
3080
+ document.getElementById('phaseText').innerText = p.message || p.phase || 'Executing...';
3081
+
3082
+ // Live Node & Task Stage Tracker
3083
+ const nodeRow = document.getElementById('activeNodeRow');
3084
+ const nodeBadge = document.getElementById('activeNodeBadge');
3085
+ const nodeTitle = document.getElementById('activeNodeTitle');
3086
+ if (p.current_node) {
3087
+ nodeRow.style.display = 'flex';
3088
+ nodeBadge.innerText = `Node ${p.current_node.index || 1}/${p.current_node.total || 1}`;
3089
+ nodeTitle.innerText = `${p.current_node.title || p.current_node.class_type || 'Processing'}`;
3090
+ } else {
3091
+ nodeRow.style.display = 'none';
3092
+ }
3093
+
3094
+ // Live DAG Node Pulse & Completion Highlighting
3095
+ highlightActiveNodesInDAG(p.current_node, p.executed_nodes || []);
3096
+
3097
+ let speedStr = p.speed_s_per_it ? `${p.speed_s_per_it.toFixed(1)}s/it` : '';
3098
+ let etaStr = (p.eta_seconds !== null && p.eta_seconds !== undefined) ? `ETA: ${Math.round(p.eta_seconds)}s` : '';
3099
+ document.getElementById('speedEtaText').innerText = [speedStr, etaStr].filter(Boolean).join(' | ') || 'Executing pipeline...';
3100
+ } else if (data.status === 'complete') {
3101
+ isRunning = false;
3102
+ if (clientTickerInterval) { clearInterval(clientTickerInterval); clientTickerInterval = null; }
3103
+ badge.className = 'status-badge';
3104
+ statusText.innerText = 'Ready';
3105
+ genBtn.disabled = false;
3106
+ genBtn.classList.remove('enqueue-mode');
3107
+ switchTab(activeTab);
3108
+
3109
+ clearNodeHighlights();
3110
+ document.getElementById('progressBox').classList.remove('active');
3111
+
3112
+ if (data.result && data.result.url) {
3113
+ const prevBox = document.getElementById('previewBox');
3114
+ const img = document.getElementById('resultImg');
3115
+ img.src = data.result.url;
3116
+ document.getElementById('downloadLink').href = data.result.url;
3117
+ const elapsedSec = (data.result.elapsed_sec !== undefined) ? data.result.elapsed_sec : (data.elapsed || 0);
3118
+ const elapsedStr = `${Number(elapsedSec).toFixed(1)}s`;
3119
+ document.getElementById('elapsedText').innerText = `Elapsed: ${elapsedStr}`;
3120
+ const hudTime = document.getElementById('hudTimeVal');
3121
+ if (hudTime) hudTime.innerText = elapsedStr;
3122
+ prevBox.classList.add('active');
3123
+ }
3124
+ } else if (data.status === 'error') {
3125
+ isRunning = false;
3126
+ if (clientTickerInterval) { clearInterval(clientTickerInterval); clientTickerInterval = null; }
3127
+ badge.className = 'status-badge';
3128
+ statusText.innerText = 'Error';
3129
+ genBtn.disabled = false;
3130
+ genBtn.classList.remove('enqueue-mode');
3131
+ switchTab(activeTab);
3132
+ clearNodeHighlights();
3133
+ document.getElementById('progressBox').classList.remove('active');
3134
+ alert('Generation Failed: ' + (data.error || 'Unknown error'));
3135
+ } else if (data.status === 'idle') {
3136
+ isRunning = false;
3137
+ if (clientTickerInterval) { clearInterval(clientTickerInterval); clientTickerInterval = null; }
3138
+ badge.className = 'status-badge';
3139
+ statusText.innerText = 'Ready';
3140
+ genBtn.disabled = false;
3141
+ genBtn.classList.remove('enqueue-mode');
3142
+ switchTab(activeTab);
3143
+ clearNodeHighlights();
3144
+
3145
+ // Restore last completed image and elapsed time on reload if available
3146
+ if (data.result && data.result.url) {
3147
+ const prevBox = document.getElementById('previewBox');
3148
+ const img = document.getElementById('resultImg');
3149
+ img.src = data.result.url;
3150
+ document.getElementById('downloadLink').href = data.result.url;
3151
+ const elapsedSec = (data.result.elapsed_sec !== undefined) ? data.result.elapsed_sec : (data.elapsed || 0);
3152
+ const elapsedStr = `${Number(elapsedSec).toFixed(1)}s`;
3153
+ document.getElementById('elapsedText').innerText = `Elapsed: ${elapsedStr}`;
3154
+ const hudTime = document.getElementById('hudTimeVal');
3155
+ if (hudTime) hudTime.innerText = elapsedStr;
3156
+ prevBox.classList.add('active');
3157
+ }
3158
+ }
3159
+ }
3160
+
3161
+ function highlightActiveNodesInDAG(currentNode, executedNodes) {
3162
+ const activeId = currentNode ? String(currentNode.id) : null;
3163
+ const doneSet = new Set((executedNodes || []).map(x => String(x)));
3164
+
3165
+ document.querySelectorAll('.flow-node-card').forEach(card => {
3166
+ const nid = String(card.dataset.nodeId);
3167
+ card.classList.toggle('flow-node-executing', nid === activeId);
3168
+ card.classList.toggle('flow-node-completed', doneSet.has(nid) && nid !== activeId);
3169
+ });
3170
+ }
3171
+
3172
+ function clearNodeHighlights() {
3173
+ document.querySelectorAll('.flow-node-card').forEach(card => {
3174
+ card.classList.remove('flow-node-executing');
3175
+ card.classList.remove('flow-node-completed');
3176
+ });
3177
+ }
3178
+
3179
+ // Pipeline Queue Modal Management
3180
+ function openQueueModal() {
3181
+ renderQueueList();
3182
+ document.getElementById('queueModal').style.display = 'flex';
3183
+ }
3184
+
3185
+ function closeQueueModal() {
3186
+ document.getElementById('queueModal').style.display = 'none';
3187
+ }
3188
+
3189
+ function renderQueueList() {
3190
+ const container = document.getElementById('queueListContainer');
3191
+ if (!container) return;
3192
+ if (!currentQueueSnapshot || !currentQueueSnapshot.items || currentQueueSnapshot.items.length === 0) {
3193
+ container.innerHTML = '<div style="font-size: 12px; color: var(--text-muted); text-align: center; padding: 14px;">현재 대기 중인 작업이 없습니다.</div>';
3194
+ return;
3195
+ }
3196
+ container.innerHTML = currentQueueSnapshot.items.map((job, idx) => `
3197
+ <div class="queue-item">
3198
+ <div style="min-width:0; flex:1;">
3199
+ <div style="font-size: 11px; font-weight: 700; color: #38bdf8;">#${idx + 1} [${job.type.toUpperCase()}]</div>
3200
+ <div style="font-size: 13px; font-weight: 600; color: #f8fafc; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${escapeHtml(job.name)}</div>
3201
+ </div>
3202
+ <button type="button" class="sheet-btn close" style="padding: 4px 8px; font-size: 11px;" onclick="cancelQueueItem('${job.id}')" title="대기열에서 제거">✕</button>
3203
+ </div>
3204
+ `).join('');
3205
+ }
3206
+
3207
+ async function cancelQueueItem(jobId) {
3208
+ try {
3209
+ const resp = await fetch('/api/queue/cancel', {
3210
+ method: 'POST',
3211
+ headers: {'Content-Type': 'application/json'},
3212
+ body: JSON.stringify({ job_id: jobId })
3213
+ });
3214
+ const res = await resp.json();
3215
+ if (res.success) {
3216
+ const qResp = await fetch('/api/queue');
3217
+ const qData = await qResp.json();
3218
+ currentQueueSnapshot = qData.queue;
3219
+ const qEl = document.getElementById('hudQueueVal');
3220
+ if (qEl) qEl.innerText = `${currentQueueSnapshot.total} pending`;
3221
+ renderQueueList();
3222
+ }
3223
+ } catch (err) {
3224
+ alert('Failed to cancel job: ' + err.message);
3225
+ }
3226
+ }
3227
+
3228
+ async function clearQueue() {
3229
+ if (!confirm('대기열의 모든 작업을 취소하시겠습니까?')) return;
3230
+ try {
3231
+ const resp = await fetch('/api/queue/clear', {
3232
+ method: 'POST',
3233
+ headers: {'Content-Type': 'application/json'}
3234
+ });
3235
+ const res = await resp.json();
3236
+ if (res.success) {
3237
+ currentQueueSnapshot = { total: 0, items: [] };
3238
+ const qEl = document.getElementById('hudQueueVal');
3239
+ if (qEl) qEl.innerText = '0 pending';
3240
+ renderQueueList();
3241
+ }
3242
+ } catch (err) {
3243
+ alert('Failed to clear queue: ' + err.message);
3244
+ }
3245
+ }
3246
+
3247
+ // Idle Resource HUD Background Poller (every 2.5s)
3248
+ function startBackgroundHudPolling() {
3249
+ if (backgroundHudInterval) clearInterval(backgroundHudInterval);
3250
+ backgroundHudInterval = setInterval(async () => {
3251
+ if (isRunning) return;
3252
+ try {
3253
+ const resp = await fetch('/api/progress');
3254
+ const data = await resp.json();
3255
+ if (data.metrics) {
3256
+ const m = data.metrics;
3257
+ const ramEl = document.getElementById('hudRamVal');
3258
+ if (ramEl) ramEl.innerText = `${m.ram_used_gb}/${m.ram_total_gb} GB (${m.ram_percent}%)`;
3259
+ const cpuEl = document.getElementById('hudCpuVal');
3260
+ if (cpuEl) cpuEl.innerText = `${m.cpu_percent}%`;
3261
+ const gpuEl = document.getElementById('hudGpuVal');
3262
+ if (gpuEl) gpuEl.innerText = (m.gpu_percent !== null && m.gpu_percent !== undefined) ? `${m.gpu_percent}%` : '0%';
3263
+ }
3264
+ if (data.queue) {
3265
+ currentQueueSnapshot = data.queue;
3266
+ const qEl = document.getElementById('hudQueueVal');
3267
+ if (qEl) qEl.innerText = `${data.queue.total} pending`;
3268
+ }
3269
+ if (data.status === 'running') {
3270
+ updateUIWithProgress(data);
3271
+ }
3272
+ } catch (e) {}
3273
+ }, 2500);
3274
+ }
3275
+
3276
+ async function probeInitialServerState() {
3277
+ try {
3278
+ const resp = await fetch('/api/progress');
3279
+ if (!resp.ok) return;
3280
+ const data = await resp.json();
3281
+ updateUIWithProgress(data);
3282
+ if (data.status === 'running') {
3283
+ startProgressPolling();
3284
+ }
3285
+ } catch (e) {
3286
+ console.warn('Initial server state probe warning:', e);
3287
+ }
3288
+ }
3289
+
3290
+ function shareToGallery() {
3291
+ alert('Artifact synchronized to device storage: /sdcard/Pictures/TermuxDiffusion/');
3292
+ }
3293
+
3294
+ // Auto-select standard workflow and initialize draggable sheet on load
3295
+ window.addEventListener('DOMContentLoaded', async () => {
3296
+ setupBottomSheetDrag();
3297
+ await loadDeviceModelCatalog();
3298
+ await loadPresetList();
3299
+ const savedWf = localStorage.getItem('amfy_selected_workflow') || localStorage.getItem('amvi_selected_workflow') || 'standard_photoreal';
3300
+ await selectPresetWorkflow(savedWf, null, false);
3301
+ startBackgroundHudPolling();
3302
+ await probeInitialServerState();
3303
+ });
3304
+ </script>
3305
+ </body>
3306
+ </html>
3307
+ """
3308
+ self.send_response(200)
3309
+ self.send_header("Content-Type", "text/html; charset=utf-8")
3310
+ self.send_header("Content-Length", str(len(html.encode("utf-8"))))
3311
+ self.end_headers()
3312
+ self.wfile.write(html.encode("utf-8"))
3313
+
3314
+
3315
+ class DiffusionWebServer:
3316
+ """Production daemon wrapper for Termux Diffusion AmfyUI Mobile Server."""
3317
+
3318
+ def __init__(self, host: str = "0.0.0.0", port: int = 11553) -> None:
3319
+ self.host = host
3320
+ self.port = port
3321
+ self.server: Optional[ThreadingHTTPServer] = None
3322
+ self._thread: Optional[threading.Thread] = None
3323
+
3324
+ def start(self, blocking: bool = True) -> None:
3325
+ """Starts the HTTP server."""
3326
+ if hasattr(sys.stdout, "reconfigure"):
3327
+ try:
3328
+ sys.stdout.reconfigure(line_buffering=True)
3329
+ except Exception:
3330
+ pass
3331
+ if hasattr(sys.stderr, "reconfigure"):
3332
+ try:
3333
+ sys.stderr.reconfigure(line_buffering=True)
3334
+ except Exception:
3335
+ pass
3336
+
3337
+ ThreadingHTTPServer.allow_reuse_address = True
3338
+ self.server = ThreadingHTTPServer((self.host, self.port), DiffusionWebHandler)
3339
+ actual_port = self.server.server_port
3340
+ logger.info("[DiffusionWebServer] AmfyUI Server live on http://%s:%d", self.host, actual_port)
3341
+ print(f"\n==================================================================")
3342
+ print(f" AMEVA AmfyUI Mobile Studio Live (Galaxy S25/S21 Viewport)")
3343
+ print(f" Local Device URL : http://localhost:{actual_port}")
3344
+ print(f" Network / Tailscale: http://{self.host}:{actual_port}")
3345
+ print(f"==================================================================\n")
3346
+ sys.stdout.flush()
3347
+
3348
+ if blocking:
3349
+ try:
3350
+ self.server.serve_forever()
3351
+ except KeyboardInterrupt:
3352
+ print("\n[DiffusionWebServer] Stopping server...")
3353
+ self.stop()
3354
+ else:
3355
+ self._thread = threading.Thread(target=self.server.serve_forever, daemon=True)
3356
+ self._thread.start()
3357
+
3358
+ def stop(self) -> None:
3359
+ """Stops the running HTTP server."""
3360
+ if self.server:
3361
+ self.server.shutdown()
3362
+ self.server.server_close()
3363
+ logger.info("[DiffusionWebServer] Server terminated.")
3364
+
3365
+
3366
+ def run_web_server(host: str = "0.0.0.0", port: int = 11553, blocking: bool = True) -> DiffusionWebServer:
3367
+ """Convenience entry point to launch the AmfyUI mobile web server."""
3368
+ server = DiffusionWebServer(host=host, port=port)
3369
+ server.start(blocking=blocking)
3370
+ return server