diff --git a/ProjectStructure.md b/ProjectStructure.md index 1a82765..ba96c67 100644 --- a/ProjectStructure.md +++ b/ProjectStructure.md @@ -53,6 +53,7 @@ ComfyUI-Ultimate-Sampler-Grid/ ├── network_utils.py # ALL outbound network requests (CivitAI, HuggingFace VAE, Distribution LAN) (280 lines) ├── upscale_runner.py # Dashboard async upscaling background thread (371 lines) ├── html_generator.py # Reads /resources/ to build the Dashboard HTML (57 lines) +├── run_api.py # Pure helpers for POST /config_tester/run + run_status (no server imports) │ ├── distribution_manager.py # Distributed Job Queue Coordinator/Master (634 lines) ├── distribution_worker.py # Worker Thread for Remote Processing (813 lines) @@ -72,6 +73,7 @@ ComfyUI-Ultimate-Sampler-Grid/ │ ├── template.html # HTML Skeleton for the generated report (662 lines) │ ├── report.css # Styling (Infinite Canvas, Cards, Modals) (1642 lines) │ ├── logic_init.js # Bootstrapper (Load JSON, Init State) (104 lines) +│ ├── logic_standalone.js # Standalone-page mode: own websocket, run-API GENERATE NEW, openStandalone() (loaded last) │ ├── logic_state.js # State Store (Redux-style: filters, favorites) (104 lines) │ ├── logic_pipeline.js # Data Processing (Filter -> Sort -> Layout) (510 lines) │ ├── logic_virtual.js # Virtual DOM / Infinite Scroller Engine (925 lines) @@ -204,6 +206,9 @@ All routes registered via `PromptServer.instance.routes`. | POST | `/config_tester/save_changes` | 234 | Delta save — only changed items `{session_name, changed_items}` | | POST | `/config_tester/save_manifest` | 308 | Full merge save `{session_name, manifest}` | | POST | `/config_tester/get_session_html` | 390 | Generate dashboard HTML on-demand `{session_name, node_id}` | +| GET | `/config_tester/dashboard` | - | Dashboard SPA as a top-level page, optional `?session=` (standalone mode, `USCG_STANDALONE`, own websocket via `resources/logic_standalone.js`) | +| POST | `/config_tester/run` | - | Headless run: `{configs_json \| config_name, session_name?, overwrite_existing?, force? (default true), front?, client_id?}` -> builds Generator+Dashboard prompt, enqueues in-process like `/prompt`. Helpers in `run_api.py` | +| GET | `/config_tester/run_status` | - | `?prompt_id=` -> `pending/running/success/error` + session/dashboard_url/item_count | | POST | `/config_tester/export_favorites` | 435 | Export favorited images with options (metadata, workflow, organize) | | POST | `/config_tester/delete_non_favorites` | 662 | Delete all non-favorited images from session | | POST | `/config_tester/scan_directory` | 746 | Scan external dir, create symlinks, generate manifest | @@ -274,7 +279,7 @@ All routes registered via `PromptServer.instance.routes`. * **`html_generator.py`** (57 lines) * `get_html_template(title, manifest_data, node_id)` — reads all `/resources/` files, inlines into `template.html` - * **JS Load Order:** `logic_state.js` → `logic_utils.js` → `logic_ui.js` → `logic_virtual.js` → `logic_pipeline.js` → `logic_events.js` → `logic_init.js` + * **JS Load Order:** `logic_state.js` → `logic_utils.js` → `logic_ui.js` → `logic_virtual.js` → `logic_pipeline.js` → `logic_events.js` → `logic_init.js` → `logic_standalone.js` * Replaces placeholders: `__CSS_CONTENT__`, `__JS_CONTENT__`, `__TITLE__`, `__NODE_ID__` * Zero external HTTP requests — everything inlined diff --git a/README.md b/README.md index 2c3aa9e..a6896bc 100644 --- a/README.md +++ b/README.md @@ -854,6 +854,21 @@ Clicking **REVISE** on a card opens the studio view: 4. **Bottom:** "Related Variants" reel showing other images with the same seed. 5. **GENERATE NEW:** Queues the specific config you just edited. +### Standalone Dashboard (phone / second monitor) + +The dashboard is also served as a normal web page, with the same live updates as the node: + +``` +http://:8188/config_tester/dashboard?session= +``` + +- Without `?session=` you get the session landing cards; picking a session updates the URL, so refresh keeps your place. +- Open it from the node with the **↗** button in the dashboard header (it opens the page for the session you are viewing). +- It opens its own ComfyUI websocket, so new images and the ETA bar update live while a run is going. After your phone sleeps, it reloads the session when you come back, without duplicating cards. Reverse proxies that do not upgrade websockets (some Tailscale Serve / nginx setups) lose live updates; the **R** key and the resume reload still work. +- Touch screens get 44px buttons, and the lightbox gets a bottom bar (Prev / Favorite / Reject / Revise / Next). The in-node view is unchanged apart from the extra header button. +- **GENERATE NEW** in the lightbox queues through the run API below, into the session you are viewing. "Pack workflow" in export needs the ComfyUI tab and is disabled here. +- Like ComfyUI itself, the page has no authentication. Only expose it on a network you trust. + --- ## 🎯 Example Workflows @@ -1344,6 +1359,34 @@ These endpoints are used internally by the distribution system: | GET | `/uscg-distribution/download_model` | Download model file for model sync | | POST | `/uscg-distribution/heartbeat` | Worker keepalive signal | +### Run API (headless runs: Discord bot, session hub, curl) + +`POST /config_tester/run` builds the two-node prompt (Generator -> Dashboard) and queues it the same way ComfyUI's own `/prompt` does. No workflow JSON is needed. + +| Field | Meaning | +|---|---| +| `configs_json` | Configs as an object `{"configs":[...]}`, a bare list, or a JSON string of either. | +| `config_name` | Alternative: a Config Builder file saved in `output/ultimate-configs` (with or without `.json`). Give exactly one of `configs_json` / `config_name`. | +| `session_name` | Output session (`output/benchmarks/`). Letters, digits, `_`, `-` only. Defaults to the config's own session name, then `my_session`. | +| `overwrite_existing` | Optional bool, same as the Builder's Run Settings. Skip/resume still protects existing images when false. | +| `force` | Default **true**: a nonce is added so ComfyUI's node cache cannot short-circuit the run. `false` lets an identical request return the cached result with no new images. | +| `front` | Optional bool: queue ahead of waiting jobs. | +| `client_id` | Optional: events are still broadcast to every websocket; this tags the queue entry. | + +``` +curl -X POST http://HOST:8188/config_tester/run -H "Content-Type: application/json" \ + -d '{"config_name":"my_flux_sweep","session_name":"flux_sweep_nov"}' +curl -X POST http://HOST:8188/config_tester/run -H "Content-Type: application/json" \ + -d '{"configs_json":{"configs":[{"model":"x.safetensors","sampler":"euler","scheduler":"normal","steps":20,"cfg":7,"positive":"a cat","negative":"","seed":1}]},"session_name":"bot_run"}' +curl "http://HOST:8188/config_tester/run_status?prompt_id=" +``` + +Response: `{"prompt_id", "number", "session_name", "dashboard_path", "dashboard_url", "node_errors"}`. Errors: `400` (bad JSON/configs, both or neither source, empty session, or ComfyUI validation failure with its `node_errors`), `403`, `404` (unknown `config_name`), `500`. + +`GET /config_tester/run_status?prompt_id=` returns `state` (`pending` | `running` | `success` | `error`), `queue_position`, `queue_remaining`, a small `status` summary (including the error message when a node failed), and, for runs started through `/run`, `session_name`, `dashboard_url` and `item_count`. Unknown ids return `404` with `state: "unknown"` (history is in memory and lost on restart). + +Bot pattern: `POST /run`, post `dashboard_url` to the channel, poll `run_status` every 10-30 s (or pass `client_id` and listen on `/ws?clientId=...` for `ultimate_grid.progress`). `/run` is unauthenticated, the same exposure as ComfyUI's `/prompt`. + --- ## File Locations 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/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/report.css b/resources/report.css index f6c9720..8acbc36 100644 --- a/resources/report.css +++ b/resources/report.css @@ -760,20 +760,29 @@ body { } .reject-btn { - width: 32px; - height: 32px; - font-size: 16px; + width: 44px; + height: 44px; + font-size: 18px; } - + .favorite-btn { - width: 36px; - height: 36px; - font-size: 20px; + width: 44px; + height: 44px; + font-size: 22px; } - + + .upscale-btn { + width: 44px; + height: 44px; + font-size: 18px; + top: 110px; + } + .revise-btn { - padding: 8px 12px; + min-height: 44px; + padding: 0 12px; font-size: 11px; + top: 58px; } } @@ -1734,4 +1743,81 @@ body { .analytics-count { font-size: 11px; } -} \ No newline at end of file +} + +/* ========================================================================== + TOUCH / PHONE LAYOUT + Scoped to a coarse pointer, or to the standalone page (body.uscg-standalone) + at phone width. Never width alone: node iframes are narrow too and must look + exactly as before. + ========================================================================== */ +#modal-action-bar { display: none; } + +@media (hover: none) and (pointer: coarse) { + /* header */ + #header { height: 44px; } + #header .fs-btn, + #header .action-btn, + .filter-popup-btn { min-width: 40px; min-height: 40px; } + .cog-menu-panel { top: 48px; } + + /* lightbox */ + .modal-close-x { width: 44px; height: 44px; } + .modal-nav-btn { min-width: 44px; min-height: 44px; } + .modal-kbd-hint { display: none; } + .controls .action-btn, + .controls .modal-action-btn { min-height: 44px; } + + #modal-action-bar { + display: flex; + position: sticky; + bottom: 0; + gap: 6px; + padding: 8px 8px calc(8px + env(safe-area-inset-bottom)); + background: rgba(0, 0, 0, 0.9); + border-top: 1px solid #333; + z-index: 1002; + flex-shrink: 0; + } + #modal-action-bar button { + flex: 1; + min-height: 48px; + font-size: 18px; + background: #222; + color: #eee; + border: 1px solid #444; + border-radius: 6px; + cursor: pointer; + padding: 0 4px; + } + #modal { overflow-y: auto; padding-bottom: 72px; } +} + +@media (max-width: 600px) { + body.uscg-standalone #modal-action-bar { + display: flex; + position: sticky; + bottom: 0; + gap: 6px; + padding: 8px 8px calc(8px + env(safe-area-inset-bottom)); + background: rgba(0, 0, 0, 0.9); + border-top: 1px solid #333; + z-index: 1002; + flex-shrink: 0; + } + body.uscg-standalone #modal-action-bar button { + flex: 1; + min-height: 48px; + font-size: 18px; + background: #222; + color: #eee; + border: 1px solid #444; + border-radius: 6px; + cursor: pointer; + padding: 0 4px; + } + body.uscg-standalone #modal { overflow-y: auto; padding-bottom: 72px; } + + body.uscg-standalone .session-card { width: calc(50% - 6px) !important; } + body.uscg-standalone #session-landing { padding: 16px 12px !important; } +} 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__