From 6705245b130aa3be2eb1ebb57e9c393c4d89041f Mon Sep 17 00:00:00 2001 From: Jason Hoku Date: Tue, 3 Nov 2026 23:56:43 -0800 Subject: [PATCH 1/3] feat(api): headless run API and standalone dashboard routes Adds GET /config_tester/dashboard (SPA as a top-level page, optional ?session=), POST /config_tester/run (builds the Generator -> Dashboard prompt and enqueues it in-process exactly like PromptServer.post_prompt; force defaults to true) and GET /config_tester/run_status. Pure helpers live in run_api.py (no server imports) with unit tests. Co-Authored-By: Claude Fable 5.1 --- __init__.py | 213 ++++++++++++++++++++++++++++++++++++++++++ run_api.py | 144 ++++++++++++++++++++++++++++ tests/test_run_api.py | 182 ++++++++++++++++++++++++++++++++++++ 3 files changed, 539 insertions(+) create mode 100644 run_api.py create mode 100644 tests/test_run_api.py diff --git a/__init__.py b/__init__.py index 2a6478c..65d8f54 100644 --- a/__init__.py +++ b/__init__.py @@ -7,6 +7,9 @@ import os import urllib.parse import folder_paths import shutil +import uuid +import time +import inspect from .sampler_node import SamplerGridTester from .dashboard_node import SamplerConfigDashboardViewer from .html_generator import get_html_template @@ -14,6 +17,10 @@ from .config_builder_node import UltimateConfigBuilder from .json_text_node import SmartJSONTextNode from .metadata_packer import pack_metadata_into_image from .directory_scanner import scan_directory_for_images +from .run_api import ( + RunTracker, sanitize_session_name_strict, normalize_configs_payload, apply_run_overrides, + build_run_prompt, resolve_saved_config, saved_config_to_configs_obj, summarize_history, find_in_queue, +) import sys # Register a sys.modules alias for cross-companion imports. @@ -539,6 +546,212 @@ async def get_session_html(request): except Exception as e: return web.Response(status=500, text=str(e)) +# ============================================================================= +# API: STANDALONE DASHBOARD PAGE + HEADLESS RUN API +# ============================================================================= +_run_tracker = RunTracker() + +@server.PromptServer.instance.routes.get("/config_tester/dashboard") +async def standalone_dashboard(request): + """ + Serve the dashboard SPA as a top-level page (phone / second-monitor friendly). + Optional ?session=; without it the SPA shows its session landing cards. + """ + try: + session_name = sanitize_session_name_strict(request.query.get("session", "")) + headers = {"Cache-Control": "no-store"} + empty_manifest = {"items": [], "meta": {"model": "", "positive": "", "negative": ""}, "session_name": ""} + + if not session_name: + html = get_html_template("", empty_manifest, "standalone", standalone=True) + return web.Response(status=200, text=html, content_type="text/html", charset="utf-8", headers=headers) + + base_dir = os.path.join(folder_paths.get_output_directory(), "benchmarks", session_name) + if not _is_path_within(base_dir, _get_benchmarks_base()): + return web.Response(status=403, text="Forbidden: path outside benchmarks directory") + + manifest_path = os.path.join(base_dir, "manifest.json") + if not os.path.exists(manifest_path): + # Unknown session: landing page (picker); the title stays empty so the SPA does not + # pretend a session is loaded. + html = get_html_template("", empty_manifest, "standalone", standalone=True) + return web.Response(status=200, text=html, content_type="text/html", charset="utf-8", headers=headers) + + with open(manifest_path, "r", encoding="utf-8") as f: + manifest = json.load(f) + html = get_html_template(session_name, manifest, "standalone", standalone=True) + return web.Response(status=200, text=html, content_type="text/html", charset="utf-8", headers=headers) + except Exception as e: + print(f"[GridTester] ❌ /dashboard failed: {e}") + return web.Response(status=500, text=str(e)) + + +@server.PromptServer.instance.routes.post("/config_tester/run") +async def run_headless(request): + """ + Queue a USCG run without the ComfyUI graph editor (Discord bot / session hub / curl). + Builds the 2-node prompt (Generator -> Dashboard) and enqueues it the way + PromptServer's own POST /prompt handler does (in-process, no HTTP self-call). + Body: {configs_json?: object|string|list, config_name?: str, session_name?: str, + overwrite_existing?: bool, force?: bool (default true), front?: bool, client_id?: str} + force=false lets ComfyUI's node cache short-circuit an identical request. + """ + try: + try: + body = await request.json() + except Exception: + return web.json_response({"error": "Request body must be JSON"}, status=400) + if not isinstance(body, dict): + return web.json_response({"error": "Request body must be a JSON object"}, status=400) + + has_inline = body.get("configs_json") not in (None, "") + has_named = bool(body.get("config_name")) + if has_inline == has_named: + return web.json_response({"error": "Provide exactly one of 'configs_json' or 'config_name'"}, status=400) + + try: + if has_named: + try: + cfg_path = resolve_saved_config(CONFIGS_DIR, body["config_name"]) + except FileNotFoundError: + return web.json_response({"error": f"Config '{body['config_name']}' not found"}, status=404) + except PermissionError: + return web.json_response({"error": "Forbidden"}, status=403) + with open(cfg_path, "r", encoding="utf-8") as f: + saved = json.load(f) + obj = normalize_configs_payload( + saved_config_to_configs_obj(saved, UltimateConfigBuilder.state_to_configs_json)) + else: + obj = normalize_configs_payload(body["configs_json"]) + except ValueError as e: + return web.json_response({"error": str(e)}, status=400) + + force = body.get("force", True) + force = True if force is None else bool(force) + overwrite = body.get("overwrite_existing") + obj, session_name = apply_run_overrides( + obj, + session_name=body.get("session_name") or None, + overwrite_existing=overwrite if overwrite is None else bool(overwrite), + nonce=uuid.uuid4().hex if force else None, + ) + if not session_name: + return web.json_response({"error": "session_name is empty after sanitising (allowed: letters, digits, _ and -)"}, status=400) + + configs_str = json.dumps(obj, indent=2, ensure_ascii=False) + prompt = build_run_prompt(configs_str, session_name) + + client_id = body.get("client_id") + client_id = str(client_id) if client_id else None + front = bool(body.get("front", False)) + + import execution # ComfyUI core; imported lazily so this package still loads without it + # --- enqueue: mirrors PromptServer.post_prompt (ComfyUI 0.42.5, server.py) --- + ps = server.PromptServer.instance + json_data = {"prompt": prompt} + if client_id: + json_data["client_id"] = client_id + if front: + json_data["front"] = True + json_data = ps.trigger_on_prompt(json_data) # same hook third-party on_prompt handlers use + if not isinstance(json_data, dict) or "prompt" not in json_data: + return web.json_response({"error": "An on_prompt handler rejected the run"}, status=400) + prompt = json_data["prompt"] + if "number" in json_data: + number = float(json_data["number"]) + else: + number = ps.number + if json_data.get("front"): + number = -number + ps.number += 1 + + prompt_id = str(uuid.uuid4()) + if hasattr(ps, "node_replace_manager"): + ps.node_replace_manager.apply_replacements(prompt) + + # 0.42.5: async validate_prompt(prompt_id, prompt, partial_execution_list). Older builds + # take (prompt) or (prompt_id, prompt) and may be sync. + n_params = len(inspect.signature(execution.validate_prompt).parameters) + if n_params >= 3: + valid = execution.validate_prompt(prompt_id, prompt, None) + elif n_params == 2: + valid = execution.validate_prompt(prompt_id, prompt) + else: + valid = execution.validate_prompt(prompt) + if inspect.isawaitable(valid): + valid = await valid + if not valid[0]: + print(f"[GridTester] ⚠️ /run rejected by ComfyUI validation: {valid[1]}") + return web.json_response({"error": valid[1], "node_errors": valid[3]}, status=400) + + extra_data = {} + if client_id: + extra_data["client_id"] = client_id + extra_data["create_time"] = int(time.time() * 1000) # milliseconds, like post_prompt + item = (number, prompt_id, prompt, extra_data, valid[2]) + if hasattr(execution, "SENSITIVE_EXTRA_DATA_KEYS"): + item = item + ({},) # trailing `sensitive` dict (empty: nothing sensitive to strip) + ps.prompt_queue.put(item) + + _run_tracker.add(prompt_id, session_name) + dashboard_path = "/config_tester/dashboard?session=" + urllib.parse.quote(session_name) + print(f"[GridTester] πŸš€ /run queued {prompt_id} session={session_name}") + return web.json_response({ + "prompt_id": prompt_id, + "number": number, + "session_name": session_name, + "dashboard_path": dashboard_path, + "dashboard_url": f"{request.scheme}://{request.host}{dashboard_path}", + "node_errors": valid[3], + }) + except Exception as e: + print(f"[GridTester] ❌ /run failed: {e}") + return web.json_response({"error": str(e)}, status=500) + + +@server.PromptServer.instance.routes.get("/config_tester/run_status") +async def run_status(request): + """State of a run queued through /config_tester/run: pending | running | success | error | unknown.""" + try: + prompt_id = request.query.get("prompt_id", "") + if not prompt_id: + return web.json_response({"error": "Missing prompt_id"}, status=400) + pq = server.PromptServer.instance.prompt_queue + getter = getattr(pq, "get_current_queue_volatile", None) or pq.get_current_queue + running, pending = getter() + result = {"prompt_id": prompt_id, "queue_remaining": len(running) + len(pending)} + + hist = pq.get_history(prompt_id=prompt_id).get(prompt_id) + found = find_in_queue(running, pending, prompt_id) + if found: + result["state"] = found[0] + if found[0] == "pending": + result["queue_position"] = found[1] + elif hist is not None: + summary = summarize_history(hist) + result["status"] = summary + result["state"] = "error" if summary["status"] == "error" else "success" + else: + result["state"] = "unknown" + return web.json_response(result, status=404) + + session_name = _run_tracker.get(prompt_id) + if session_name: + dashboard_path = "/config_tester/dashboard?session=" + urllib.parse.quote(session_name) + result["session_name"] = session_name + result["dashboard_url"] = f"{request.scheme}://{request.host}{dashboard_path}" + manifest_path = os.path.join(folder_paths.get_output_directory(), "benchmarks", session_name, "manifest.json") + if _is_path_within(manifest_path, _get_benchmarks_base()) and os.path.exists(manifest_path): + try: + with open(manifest_path, "r", encoding="utf-8") as f: + result["item_count"] = len(json.load(f).get("items", [])) + except Exception: + pass + return web.json_response(result) + except Exception as e: + print(f"[GridTester] ❌ /run_status failed: {e}") + return web.json_response({"error": str(e)}, status=500) + # ============================================================================= # API: EXPORT FAVORITES # ============================================================================= diff --git a/run_api.py b/run_api.py new file mode 100644 index 0000000..68dbf28 --- /dev/null +++ b/run_api.py @@ -0,0 +1,144 @@ +"""Pure helpers for the headless run API (POST /config_tester/run, GET /config_tester/run_status). + +No aiohttp / ComfyUI imports at module level so the helpers are unit-testable on their own. +The routes themselves live in __init__.py. +""" +import json +import os +import re +from collections import OrderedDict + +DEFAULT_SESSION = "my_session" # the Generator node's own default (sampler_node.py) +_MAX_TRACKED_RUNS = 200 + + +def sanitize_session_name_strict(name): + """Same regex as delete_session / save_manifest / get_session_html. '' when nothing is left.""" + return re.sub(r'[^\w\-]', '', str(name or '')) + + +def normalize_configs_payload(configs_json): + """Accept a dict with a `configs` list, a bare list, or a JSON string of either. + + Returns a dict that always has a non-empty `configs` list; other top-level keys + (`_distribution`, `_session_settings`, ...) are preserved. Raises ValueError otherwise. + """ + obj = configs_json + if isinstance(obj, (str, bytes)): + try: + obj = json.loads(obj) + except (TypeError, ValueError) as e: + raise ValueError(f"configs_json is not valid JSON: {e}") + if isinstance(obj, list): + obj = {"configs": obj} + if not isinstance(obj, dict): + raise ValueError("configs_json must be an object with a 'configs' list, a list of configs, or a JSON string of either") + configs = obj.get("configs") + if not isinstance(configs, list) or not configs: + raise ValueError("configs_json must contain a non-empty 'configs' list") + return obj + + +def apply_run_overrides(obj, session_name=None, overwrite_existing=None, nonce=None): + """Apply run-level overrides. Returns (obj, sanitised_session_name). + + Session precedence: explicit session_name, then _session_settings.session_name, then the + Generator default. The sanitised name is written back to _session_settings.session_name + because that is what the Generator actually reads. `nonce` goes into an inert top-level + key; it changes IS_CHANGED's hash so ComfyUI's node cache cannot short-circuit the run. + """ + settings = obj.get("_session_settings") + if not isinstance(settings, dict): + settings = {} + obj["_session_settings"] = settings + chosen = session_name or settings.get("session_name") or DEFAULT_SESSION + session = sanitize_session_name_strict(chosen) + settings["session_name"] = session + if overwrite_existing is not None: + settings["overwrite_existing"] = bool(overwrite_existing) + if nonce is not None: + obj["_run_nonce"] = str(nonce) + return obj, session + + +def build_run_prompt(configs_str, session_name): + """The 2-node API-format prompt: Generator -> Dashboard (which owns the output).""" + return { + "1": {"class_type": "UltimateSamplerGrid", "inputs": {"configs_json": configs_str}}, + "2": {"class_type": "UltimateGridDashboard", + "inputs": {"session_name": session_name, "dashboard_html": ["1", 0]}}, + } + + +def resolve_saved_config(configs_dir, name): + """Mirror of the /configbuilder/load_config path handling. Returns the file path. + + Raises FileNotFoundError (missing) or PermissionError (escapes configs_dir). + """ + filename = str(name or "") + if not filename.endswith(".json"): + filename += ".json" + filename = os.path.basename(filename) + filepath = os.path.join(configs_dir, filename) + real_path = os.path.realpath(filepath) + real_base = os.path.realpath(configs_dir) + if not (real_path == real_base or real_path.startswith(real_base + os.sep)): + raise PermissionError("config path escapes the configs directory") + if not os.path.isfile(filepath): + raise FileNotFoundError(f"config '{filename}' not found") + return filepath + + +def saved_config_to_configs_obj(data, state_to_configs_json): + """Saved Builder files hold the Builder STATE; convert via the single source of truth.""" + if isinstance(data, dict): + if "config_arrays" in data: + return json.loads(state_to_configs_json(data)) + if "configs" in data: + return data + raise ValueError("saved config is neither a Builder state (config_arrays) nor a configs JSON (configs)") + + +def summarize_history(entry): + """Small status summary of a history entry. Never returns `outputs` or `prompt`.""" + status = (entry or {}).get("status") or {} + out = {"status": status.get("status_str", "unknown"), "completed": bool(status.get("completed", False))} + for msg in status.get("messages") or []: + try: + if msg[0] == "execution_error": + d = msg[1] or {} + out["error"] = { + "node_id": d.get("node_id"), + "node_type": d.get("node_type"), + "exception_message": d.get("exception_message"), + } + break + except (IndexError, TypeError, KeyError): + continue + return out + + +def find_in_queue(running, pending, prompt_id): + """('running', 0), ('pending', position) or None. Items are tuples (number, prompt_id, ...).""" + for item in running or []: + if len(item) > 1 and item[1] == prompt_id: + return ("running", 0) + for pos, item in enumerate(sorted(pending or [], key=lambda i: i[0])): + if len(item) > 1 and item[1] == prompt_id: + return ("pending", pos) + return None + + +class RunTracker: + """Bounded prompt_id -> session_name map (oldest evicted first).""" + def __init__(self, cap=_MAX_TRACKED_RUNS): + self._cap = cap + self._d = OrderedDict() + + def add(self, prompt_id, session): + self._d[prompt_id] = session + while len(self._d) > self._cap: + self._d.popitem(last=False) + + def get(self, prompt_id): + return self._d.get(prompt_id) diff --git a/tests/test_run_api.py b/tests/test_run_api.py new file mode 100644 index 0000000..66c88dc --- /dev/null +++ b/tests/test_run_api.py @@ -0,0 +1,182 @@ +"""Pure helpers behind POST /config_tester/run and GET /config_tester/run_status.""" +import importlib.util +import json +import os + +import pytest + +_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +_PKG = os.path.basename(_ROOT) + + +def _load(name, filename): + spec = importlib.util.spec_from_file_location(f"{_PKG}.{name}_real", os.path.join(_ROOT, filename)) + mod = importlib.util.module_from_spec(spec) + mod.__package__ = _PKG + spec.loader.exec_module(mod) + return mod + + +run_api = _load("run_api", "run_api.py") + + +# ---- sanitize --------------------------------------------------------------- +@pytest.mark.parametrize("raw,expected", [ + ("../../etc", "etc"), + ("a b/c", "abc"), + ("ΓΌnΓ―", "ΓΌnΓ―"), + ("", ""), + (None, ""), + ("my-session_1", "my-session_1"), + ("..", ""), +]) +def test_sanitize(raw, expected): + assert run_api.sanitize_session_name_strict(raw) == expected + + +# ---- normalize -------------------------------------------------------------- +def test_normalize_accepts_all_shapes(): + cfg = [{"model": "x"}] + assert run_api.normalize_configs_payload({"configs": cfg}) == {"configs": cfg} + assert run_api.normalize_configs_payload(cfg) == {"configs": cfg} + assert run_api.normalize_configs_payload(json.dumps({"configs": cfg})) == {"configs": cfg} + assert run_api.normalize_configs_payload(json.dumps(cfg)) == {"configs": cfg} + + +@pytest.mark.parametrize("bad", ["not json", {}, {"configs": []}, 42, [], None, {"configs": "x"}]) +def test_normalize_rejects(bad): + with pytest.raises(ValueError): + run_api.normalize_configs_payload(bad) + + +def test_normalize_preserves_other_keys(): + out = run_api.normalize_configs_payload({"configs": [{"a": 1}], "_distribution": {"enabled": True}}) + assert out["_distribution"] == {"enabled": True} + + +# ---- overrides -------------------------------------------------------------- +def test_session_precedence(): + obj, s = run_api.apply_run_overrides({"configs": [1], "_session_settings": {"session_name": "from_settings"}}, + session_name="from_body") + assert s == "from_body" and obj["_session_settings"]["session_name"] == "from_body" + obj, s = run_api.apply_run_overrides({"configs": [1], "_session_settings": {"session_name": "from_settings"}}) + assert s == "from_settings" + obj, s = run_api.apply_run_overrides({"configs": [1]}) + assert s == "my_session" + assert obj["_session_settings"]["session_name"] == "my_session" # dict created when absent + + +def test_session_is_sanitised_and_can_end_empty(): + _, s = run_api.apply_run_overrides({"configs": [1]}, session_name="../../x y") + assert s == "xy" + _, s = run_api.apply_run_overrides({"configs": [1]}, session_name="///") + assert s == "" + + +def test_overwrite_only_when_given(): + obj, _ = run_api.apply_run_overrides({"configs": [1]}) + assert "overwrite_existing" not in obj["_session_settings"] + obj, _ = run_api.apply_run_overrides({"configs": [1]}, overwrite_existing=False) + assert obj["_session_settings"]["overwrite_existing"] is False + obj, _ = run_api.apply_run_overrides({"configs": [1]}, overwrite_existing=True) + assert obj["_session_settings"]["overwrite_existing"] is True + + +def test_nonce_changes_serialised_string(): + a, _ = run_api.apply_run_overrides({"configs": [1]}, session_name="s") + b, _ = run_api.apply_run_overrides({"configs": [1]}, session_name="s", nonce="abc") + assert "_run_nonce" not in a and b["_run_nonce"] == "abc" + assert json.dumps(a) != json.dumps(b) + + +def test_non_dict_settings_replaced(): + obj, s = run_api.apply_run_overrides({"configs": [1], "_session_settings": "junk"}, session_name="s") + assert obj["_session_settings"] == {"session_name": "s"} + + +# ---- prompt ----------------------------------------------------------------- +def test_build_run_prompt_exact(): + cs = json.dumps({"configs": [{"a": 1}]}, indent=2) + assert run_api.build_run_prompt(cs, "sess") == { + "1": {"class_type": "UltimateSamplerGrid", "inputs": {"configs_json": cs}}, + "2": {"class_type": "UltimateGridDashboard", "inputs": {"session_name": "sess", "dashboard_html": ["1", 0]}}, + } + assert json.loads(run_api.build_run_prompt(cs, "s")["1"]["inputs"]["configs_json"]) == {"configs": [{"a": 1}]} + + +# ---- saved configs ---------------------------------------------------------- +def test_saved_state_uses_injected_converter(): + calls = [] + + def fake(state): + calls.append(state) + return json.dumps({"configs": [{"model": "m"}]}) + + out = run_api.saved_config_to_configs_obj({"config_arrays": [], "x": 1}, fake) + assert out == {"configs": [{"model": "m"}]} and calls[0]["x"] == 1 + + +def test_saved_configs_shape_passes_through(): + data = {"configs": [{"a": 1}]} + assert run_api.saved_config_to_configs_obj(data, lambda s: pytest.fail("must not call")) is data + + +@pytest.mark.parametrize("junk", [{}, {"foo": 1}, [], "x", None]) +def test_saved_junk_raises(junk): + with pytest.raises(ValueError): + run_api.saved_config_to_configs_obj(junk, lambda s: "{}") + + +def test_resolve_saved_config(tmp_path): + (tmp_path / "ok.json").write_text("{}") + assert run_api.resolve_saved_config(str(tmp_path), "ok") == str(tmp_path / "ok.json") + assert run_api.resolve_saved_config(str(tmp_path), "ok.json") == str(tmp_path / "ok.json") + # traversal reduces to the basename inside the dir + assert run_api.resolve_saved_config(str(tmp_path), "../../ok") == str(tmp_path / "ok.json") + with pytest.raises(FileNotFoundError): + run_api.resolve_saved_config(str(tmp_path), "../missing") + with pytest.raises(FileNotFoundError): + run_api.resolve_saved_config(str(tmp_path), "") + + +# ---- status helpers --------------------------------------------------------- +def test_summarize_history_never_leaks(): + entry = { + "prompt": [1, "pid", {"1": {"inputs": {"configs_json": "SECRET"}}}, {}, ["2"]], + "outputs": {"2": {"dashboard_html": ["HUGE"]}}, + "status": {"status_str": "error", "completed": False, "messages": [ + ["execution_start", {"prompt_id": "pid"}], + ["execution_error", {"node_id": "1", "node_type": "UltimateSamplerGrid", "exception_message": "boom", + "traceback": ["x"]}], + ]}, + } + s = run_api.summarize_history(entry) + assert s["status"] == "error" and s["completed"] is False + assert s["error"] == {"node_id": "1", "node_type": "UltimateSamplerGrid", "exception_message": "boom"} + blob = json.dumps(s) + assert "SECRET" not in blob and "HUGE" not in blob and "traceback" not in blob + + +def test_summarize_history_success_and_malformed(): + ok = run_api.summarize_history({"status": {"status_str": "success", "completed": True, "messages": []}}) + assert ok == {"status": "success", "completed": True} + assert run_api.summarize_history({})["status"] == "unknown" + run_api.summarize_history({"status": {"messages": [None, [], "x"]}}) # must not raise + + +def test_find_in_queue_five_and_six_tuples(): + running = [(0, "run", {}, {}, ["2"], {})] + pending = [(5, "late", {}, {}, ["2"]), (2, "early", {}, {}, ["2"], {}), (3, "mid", {}, {}, ["2"])] + assert run_api.find_in_queue(running, pending, "run") == ("running", 0) + assert run_api.find_in_queue(running, pending, "early") == ("pending", 0) + assert run_api.find_in_queue(running, pending, "mid") == ("pending", 1) + assert run_api.find_in_queue(running, pending, "late") == ("pending", 2) + assert run_api.find_in_queue(running, pending, "nope") is None + assert run_api.find_in_queue(None, None, "x") is None + + +def test_run_tracker_is_bounded(): + t = run_api.RunTracker(cap=3) + for i in range(5): + t.add(f"p{i}", f"s{i}") + assert t.get("p0") is None and t.get("p1") is None and t.get("p4") == "s4" From 660bdc17cf664d7d78ff4f2c4f76dc8e33ddbe52 Mon Sep 17 00:00:00 2001 From: Jason Hoku Date: Tue, 3 Nov 2026 23:58:33 -0800 Subject: [PATCH 2/3] feat(dashboard): standalone page mode, own live websocket, open-standalone button html_generator gains a standalone flag (USCG_STANDALONE) and escapes " --- html_generator.py | 10 +- resources/logic_events.js | 3 +- resources/logic_standalone.js | 170 ++++++++++++++++++++++++++++++++++ resources/logic_ui.js | 75 +++++++++++---- resources/logic_utils.js | 24 +++++ resources/template.html | 18 +++- tests/test_standalone_html.py | 79 ++++++++++++++++ 7 files changed, 354 insertions(+), 25 deletions(-) create mode 100644 resources/logic_standalone.js create mode 100644 tests/test_standalone_html.py diff --git a/html_generator.py b/html_generator.py index e8f9ccc..bae1d4f 100644 --- a/html_generator.py +++ b/html_generator.py @@ -1,13 +1,17 @@ import os import json -def get_html_template(title, manifest_data, node_id): +def get_html_template(title, manifest_data, node_id, standalone=False): # 1. Normalize Data if isinstance(manifest_data, list): manifest_data = {"items": manifest_data, "meta": {"model": "", "positive": "", "negative": ""}} # Indent JSON for readability json_str = json.dumps(manifest_data, indent=2) + # The manifest is inlined inside a " would + # end it early (page break / script injection). "<\/" is an equivalent JSON escape, so + # JSON.parse (web/dashboard.js's incremental refresh) still reads the original text. + json_str = json_str.replace(" { if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); e.stopPropagation(); - window.parent.postMessage({ type: 'forward_ctrl_s' }, '*'); + // Standalone page: nothing to forward to (edits already auto-save), just keep the browser's Save dialog away. + if (!USCG_STANDALONE) window.parent.postMessage({ type: 'forward_ctrl_s' }, '*'); } }); diff --git a/resources/logic_standalone.js b/resources/logic_standalone.js new file mode 100644 index 0000000..373dc1f --- /dev/null +++ b/resources/logic_standalone.js @@ -0,0 +1,170 @@ +/** + * STANDALONE MODE (dashboard opened as a top-level page: /config_tester/dashboard) + * Everything that talks to the ComfyUI tab (postMessage bridge in web/dashboard.js) is replaced here: + * - live updates: own ComfyUI websocket, re-dispatched as the SAME message shapes the bridge sends, + * so logic_events.js renders them with no duplicated logic + * - "GENERATE NEW" in the lightbox queues through POST /config_tester/run + * - the node iframe's header button opens this page via openStandalone() + * Loaded LAST (after logic_init.js). All behaviour is inside `if (USCG_STANDALONE)`; the node iframe is untouched. + */ + +// Header button (node iframe only): open this dashboard as its own page. +function openStandalone() { + var sess = (document.getElementById('session-input') || {}).value || ''; + var base = document.baseURI; + if (!base || base.indexOf('http') !== 0) { + try { base = window.parent.location.href; } catch (e) { base = ''; } + } + var url; + try { + url = new URL('/config_tester/dashboard' + (sess ? '?session=' + encodeURIComponent(sess) : ''), base).href; + } catch (e) { + alert('Could not work out the dashboard URL: ' + e.message); + return; + } + // No `noopener` feature: it makes window.open() always return null, hiding blocked popups. + var w = window.open(url, '_blank'); + if (w) { + try { w.opener = null; } catch (e) { /* cross-origin guard */ } + } else { + prompt('Open this URL in your browser', url); + } +} + +// Lightbox "GENERATE NEW" without a ComfyUI graph: queue via the run API into the session being viewed. +async function uscgRunViaApi(btn, configOutput) { + var sess = window._uscgLoadedSession || (document.getElementById('session-input') || {}).value || ''; + var original = btn ? btn.innerText : ''; + var flash = function (text, ms) { + if (!btn) return; + btn.innerText = text; + setTimeout(function () { btn.innerText = original || 'GENERATE NEW'; }, ms); + }; + try { + var body = { configs_json: configOutput, force: true }; + if (sess) body.session_name = sess; + var r = await fetch('/config_tester/run', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + var j = null; + try { j = await r.json(); } catch (e) { /* non-JSON error body */ } + if (!r.ok) { + var msg = j && j.error ? (typeof j.error === 'string' ? j.error : (j.error.message || JSON.stringify(j.error))) : ('HTTP ' + r.status); + console.error('[Revise] /config_tester/run failed:', j); + flash('ERROR: ' + String(msg).slice(0, 60), 3000); + return; + } + console.log('[Revise] queued via /config_tester/run:', j.prompt_id); + flash('QUEUED!', 1000); + } catch (e) { + console.error('[Revise] run API error:', e); + flash('ERROR: ' + (e && e.message ? e.message : e), 3000); + } +} + +if (USCG_STANDALONE) { + (function () { + document.body.classList.add('uscg-standalone'); + + var openBtn = document.getElementById('open-standalone-btn'); + if (openBtn) openBtn.style.display = 'none'; + + var pack = document.getElementById('pack-workflow-checkbox'); + if (pack) { + pack.checked = false; + pack.disabled = true; + pack.title = 'Needs the ComfyUI tab (open the dashboard from the node to pack the workflow)'; + } + + // The session this page currently shows ('' on the landing page). + var titleEl = document.getElementById('session-input'); + window._uscgLoadedSession = (titleEl && titleEl.value) ? titleEl.value : ''; + if (window._uscgLoadedSession) document.title = 'USCG - ' + window._uscgLoadedSession; + + function knownIds() { + var s = new Set(); + var items = (typeof fullManifest !== 'undefined' && fullManifest && fullManifest.items) || []; + for (var i = 0; i < items.length; i++) s.add(items[i].id); + return s; + } + + function dispatch(type, data) { + window.postMessage({ type: type, data: data }, location.origin); + } + + function onEvent(name, data) { + if (!data) return; + var isUpdate = (name === 'ultimate_grid.update' || name === 'ultimate_grid.update_data'); + var isProgress = (name === 'ultimate_grid.progress'); + if (!isUpdate && !isProgress) return; + + // Landing page: follow a newly started session (mirrors the node bridge's "no session loaded" case). + if (!window._uscgLoadedSession) { + if (name === 'ultimate_grid.update' && data.session_name) { + loadSessionFromPicker(data.session_name); + } + return; + } + if (data.session_name !== window._uscgLoadedSession) return; + + if (isProgress) { dispatch('progress_update', data); return; } + + if (data.new_items && data.new_items.length) { + // processNewData does not dedupe: a resume reload plus a late event would double the cards. + var have = knownIds(); + var fresh = data.new_items.filter(function (it) { return !have.has(it.id); }); + if (!fresh.length) return; + var copy = {}; + for (var k in data) copy[k] = data[k]; + copy.new_items = fresh; + dispatch('update_data', copy); + } else if (data.manifest) { + dispatch('update_data', data); + } + } + + // ---- websocket with backoff + resync on resume ------------------------------------- + var ws = null, backoff = 1000, dropped = false, resyncTimer = null; + var clientId = 'uscg-dash-' + Math.random().toString(16).slice(2, 10) + Date.now().toString(16); + + function resync() { + if (!window._uscgLoadedSession) return; + if (resyncTimer) return; // debounce: a reconnect and a visibility change must not both reload + resyncTimer = setTimeout(function () { + resyncTimer = null; + if (navigator.onLine === false) return; + loadSession(); + }, 2000); + } + + function connect() { + var url = (location.protocol === 'https:' ? 'wss' : 'ws') + '://' + location.host + '/ws?clientId=' + clientId; + try { ws = new WebSocket(url); } catch (e) { schedule(); return; } + ws.onopen = function () { + backoff = 1000; + if (dropped) { dropped = false; resync(); } + }; + ws.onmessage = function (ev) { + if (typeof ev.data !== 'string') return; // binary preview frames + var msg; + try { msg = JSON.parse(ev.data); } catch (e) { return; } + if (msg && msg.type) onEvent(msg.type, msg.data); + }; + ws.onclose = function () { dropped = true; schedule(); }; + ws.onerror = function () { try { ws.close(); } catch (e) { /* already closed */ } }; + } + + function schedule() { + setTimeout(connect, backoff); + backoff = Math.min(backoff * 2, 30000); + } + + document.addEventListener('visibilitychange', function () { + if (document.visibilityState === 'visible') resync(); + }); + + connect(); + })(); +} diff --git a/resources/logic_ui.js b/resources/logic_ui.js index 31f922c..986bcdb 100644 --- a/resources/logic_ui.js +++ b/resources/logic_ui.js @@ -1493,25 +1493,7 @@ function openM(id) { e.code >= 'Digit1' && e.code <= 'Digit9') { e.preventDefault(); e.stopPropagation(); - const item = activeData ? activeData.find(x => x.id === currentId) : null; - if (item) { - item.favorited = !item.favorited; - if (typeof markItemChanged === 'function') markItemChanged(item); - if (typeof scheduleJSONUpdate === 'function') scheduleJSONUpdate(); - // Visual feedback on the modal image - const mImg = document.getElementById('m-img'); - if (mImg) { - const color = item.favorited ? '#00cc44' : '#cc4444'; - mImg.style.transition = 'box-shadow 0.3s'; - mImg.style.boxShadow = `0 0 30px ${color}`; - setTimeout(() => { mImg.style.boxShadow = ''; }, 400); - } - // Update the navigation counter tooltip if displayed - const counterEl = document.getElementById('modal-nav-counter'); - if (counterEl) { - counterEl.title = item.favorited ? 'Favorited' : 'Not favorited'; - } - } + toggleModalFavorite(); } break; } @@ -1522,6 +1504,61 @@ function openM(id) { _updateModalCounter(); } +// Favorite the item shown in the lightbox (1-9 keys and the phone action bar). +function toggleModalFavorite() { + const item = activeData ? activeData.find(x => x.id === window.currentModalId) : null; + if (!item) return; + item.favorited = !item.favorited; + if (typeof markItemChanged === 'function') markItemChanged(item); + if (typeof scheduleJSONUpdate === 'function') scheduleJSONUpdate(); + // Keep the rendered card's star in sync when the card is in the DOM + const card = (typeof nodeMap !== 'undefined' && nodeMap) ? nodeMap.get(item.id) : null; + const favBtn = card ? card.querySelector('.favorite-btn') : null; + if (favBtn) { + favBtn.classList.toggle('favorited', item.favorited); + favBtn.innerText = item.favorited ? 'β˜…' : 'β˜†'; + } + // Visual feedback on the modal image + const mImg = document.getElementById('m-img'); + if (mImg) { + const color = item.favorited ? '#00cc44' : '#cc4444'; + mImg.style.transition = 'box-shadow 0.3s'; + mImg.style.boxShadow = `0 0 30px ${color}`; + setTimeout(() => { mImg.style.boxShadow = ''; }, 400); + } + // Update the navigation counter tooltip if displayed + const counterEl = document.getElementById('modal-nav-counter'); + if (counterEl) { + counterEl.title = item.favorited ? 'Favorited' : 'Not favorited'; + } + const favActionBtn = document.getElementById('modal-fav-btn'); + if (favActionBtn) favActionBtn.innerText = item.favorited ? 'β˜… Favorited' : 'β˜† Favorite'; +} + +// Lightbox prev/next by delta through the filtered/sorted set (phone action bar). +function modalNav(delta) { + if (!processedData) return; + const idx = processedData.findIndex(x => x.id === window.currentModalId); + const target = processedData[idx + delta]; + if (idx !== -1 && target) openM(target.id); +} + +// Reject the lightbox item, then move on to the neighbour (or close when it was the last one). +function rejectModalItem() { + const item = activeData ? activeData.find(x => x.id === window.currentModalId) : null; + if (!item) return; + item.rejected = true; + if (typeof markItemChanged === 'function') markItemChanged(item); + const idx = processedData ? processedData.findIndex(x => x.id === item.id) : -1; + const next = idx !== -1 ? (processedData[idx + 1] || processedData[idx - 1]) : null; + // Re-run the pipeline after the neighbour is chosen: it drops the rejected item from the grid. + setTimeout(() => { + if (typeof updateDataPipeline === 'function') updateDataPipeline(); + if (typeof scheduleJSONUpdate === 'function') scheduleJSONUpdate(); + }, 100); + if (next && next.id !== item.id) openM(next.id); else closeM(); +} + // Update the modal nav counter display function _updateModalCounter() { const counterEl = document.getElementById('modal-nav-counter'); diff --git a/resources/logic_utils.js b/resources/logic_utils.js index ef736ed..b98cf0e 100644 --- a/resources/logic_utils.js +++ b/resources/logic_utils.js @@ -5,6 +5,14 @@ */ function toggleFullscreen() { + if (USCG_STANDALONE) { + // Top-level page: use the browser Fullscreen API (iOS Safari has no element fullscreen; harmless no-op there). + try { + if (document.fullscreenElement) document.exitFullscreen(); + else if (document.documentElement.requestFullscreen) document.documentElement.requestFullscreen(); + } catch (e) { /* unsupported */ } + return; + } // Send current session name so fullscreen reload uses the right session var currentSession = document.getElementById('session-input')?.value || ''; window.parent.postMessage({ type: 'toggle_fullscreen', node_id: TARGET_NODE_ID, session_name: currentSession }, '*'); @@ -152,6 +160,13 @@ async function loadSession() { if (landing) landing.style.display = 'none'; if (viewport) viewport.style.display = 'block'; + // Standalone page: keep the URL on the session being shown so F5 / reload-after-delete stay on it. + if (USCG_STANDALONE) { + window._uscgLoadedSession = sess; + try { history.replaceState(null, '', '?session=' + encodeURIComponent(sess)); } catch (e) { /* ignore */ } + document.title = 'USCG - ' + sess; + } + console.log('[Load] βœ… Session loaded successfully'); @@ -287,6 +302,7 @@ async function fetchAndShowSessions() { cardsContainer.innerHTML = ''; sessions.forEach(s => { const card = document.createElement('div'); + card.className = 'session-card'; card.style.cssText = 'width: 180px; background: #2a2a2a; border: 1px solid #444; border-radius: 8px; overflow: hidden; cursor: pointer; transition: border-color 0.2s, transform 0.15s;'; card.onmouseenter = () => { card.style.borderColor = '#0af'; card.style.transform = 'translateY(-2px)'; }; card.onmouseleave = () => { card.style.borderColor = '#444'; card.style.transform = 'none'; }; @@ -783,6 +799,14 @@ async function triggerGen(btn) { console.log("[Revise] Sending config to UltimateSamplerGrid:", config); + // No ComfyUI graph to drive (standalone page, or no parent app): queue through the run API instead. + let hasParentApp = false; + try { hasParentApp = !!(window.parent && window.parent.app); } catch (e) { /* cross-origin parent */ } + if (USCG_STANDALONE || !hasParentApp) { + await uscgRunViaApi(btn, configOutput); + return; + } + try { const app = window.parent.app; const graph = app.graph; diff --git a/resources/template.html b/resources/template.html index d87af90..408f846 100644 --- a/resources/template.html +++ b/resources/template.html @@ -2,6 +2,7 @@ + USCG - __TITLE__