From cc861e020025346599e834629fd9dc8788085d28 Mon Sep 17 00:00:00 2001 From: chrmc40 Date: Mon, 22 Jun 2026 21:58:20 -0700 Subject: [PATCH] Sync to 0.1.2: onboarding install-UX (progress bars + live rate + Cancel); .comfyignore/size hygiene; strip leaked dev paths --- core/install_sections.py | 14 +++++++ core/node_install_api.py | 28 ++++++++++--- js/lib/onboarding.js | 87 +++++++++++++++++++++++++++++++++++----- pyproject.toml | 68 +++++++++++++++---------------- 4 files changed, 149 insertions(+), 48 deletions(-) diff --git a/core/install_sections.py b/core/install_sections.py index 6e92111..2a8e7a7 100644 --- a/core/install_sections.py +++ b/core/install_sections.py @@ -26,6 +26,7 @@ from aiohttp import web from .api_utils import error_response, parse_json from .node_install_api import ( + INSTALL_CANCEL, NODE_PACKS, _CRITICAL, _bundled_packs_dir, _custom_nodes_dir, _log, _open_sse, _pack_status, _pkg_version, _restore_critical, _run, _send, install_pack, @@ -199,9 +200,13 @@ async def _api_section_install(request): resp = await _open_sse(request) snapshot = {p: _pkg_version(p) for p in _CRITICAL} installed_nodepack = False + INSTALL_CANCEL.clear() # fresh run — drop any stale cancel from a prior install try: await _log(resp, f"Installing {sec['label']}") for m in members: + if INSTALL_CANCEL.is_set(): + await _send(resp, {"cancelled": True}) + return resp ms = _member_status(m) await _send(resp, {"member": ms["label"], "state": ms["state"]}) if ms["state"] == "installed": @@ -234,6 +239,15 @@ async def _api_section_install(request): return resp +@routes.post("/promptchain/install/cancel") +async def _api_install_cancel(request): + """Signal the running section install to stop. _download checks this per + chunk and the section loop checks it between members, so the in-flight file + bails (keeping a .part for resume) and no further members start.""" + INSTALL_CANCEL.set() + return web.json_response({"ok": True}) + + # ── update check ────────────────────────────────────────────────── # Every 3rd-party pack we install is VENDORED in bundled_packs/ at a pinned # version, so "check for updates" = did PromptChain ship a newer copy than the diff --git a/core/node_install_api.py b/core/node_install_api.py index c86c42c..5b3a503 100644 --- a/core/node_install_api.py +++ b/core/node_install_api.py @@ -455,6 +455,11 @@ _DL_RETRIES = 5 # LFS files (multi-GB) routinely get cut mid-stream by the HF CDN. _DL_TRANSIENT = (aiohttp.ClientError, asyncio.TimeoutError, ConnectionError) +# Cooperative cancel for the install flow: POST /promptchain/install/cancel sets +# this; _download checks it per chunk and the section loop checks it between +# members. Cleared at the start of each section install. +INSTALL_CANCEL = asyncio.Event() + async def _download(resp: web.StreamResponse, url: str, dest: Path) -> bool: """Stream a model file to dest, emitting download progress as SSE. @@ -491,17 +496,30 @@ async def _download(resp: web.StreamResponse, url: str, dest: Path) -> bool: await _send(resp, {"error": f"refusing {dest.name}: {total} bytes exceeds cap"}) return False last_pct = -1 + last_emit = 0.0 + _loop = asyncio.get_running_loop() + t0 = _loop.time() + start_bytes = done with open(tmp, mode) as f: async for chunk in r.content.iter_chunked(1 << 20): + if INSTALL_CANCEL.is_set(): + await _log(resp, f"{dest.name}: cancelled") + return False f.write(chunk) done += len(chunk) if done > _MAX_MODEL_BYTES: raise RuntimeError("exceeded size cap (server lied about Content-Length)") - if total: - pct = int(done / total * 100) - if pct != last_pct: - last_pct = pct - await _send(resp, {"stage": "download", "file": dest.name, "pct": pct}) + now = _loop.time() + pct = int(done / total * 100) if total else 0 + # emit per integer-percent OR every 0.3s, so the bar and + # the byte/sec rate stay live even on a slow large file + if pct != last_pct or now - last_emit >= 0.3: + last_pct = pct + last_emit = now + elapsed = now - t0 + bps = int((done - start_bytes) / elapsed) if elapsed > 0 else 0 + await _send(resp, {"stage": "download", "file": dest.name, + "pct": pct, "done": done, "total": total, "bps": bps}) if total and tmp.stat().st_size != total: raise RuntimeError(f"incomplete ({tmp.stat().st_size}/{total} bytes)") tmp.replace(dest) diff --git a/js/lib/onboarding.js b/js/lib/onboarding.js index 3976c48..11c5925 100644 --- a/js/lib/onboarding.js +++ b/js/lib/onboarding.js @@ -770,6 +770,23 @@ function buildChecklistScreen(overlay, card) { } // One install-step row reflecting a section's detected health. +function fmtBytes(n) { + if (!n || n < 0) return "0 B"; + const u = ["B", "KB", "MB", "GB"]; + let i = 0; + while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; } + return `${n < 10 && i > 0 ? n.toFixed(1) : Math.round(n)} ${u[i]}`; +} + +// Render the live download stat for the status cell: "12.3 MB/s · 1.2 GB / 3.2 GB". +function fmtProgress(p) { + const parts = []; + if (p.bps) parts.push(`${fmtBytes(p.bps)}/s`); + if (p.total) parts.push(`${fmtBytes(p.done || 0)} / ${fmtBytes(p.total)}`); + if (!parts.length) parts.push(`${p.pct || 0}%`); + return parts.join(" · "); +} + function renderSectionRow(sec, parent) { const section = document.createElement("div"); section.className = "pcr-onboarding-section"; @@ -797,8 +814,17 @@ function renderSectionRow(sec, parent) { d.textContent = sec.desc + deferredNote; text.append(t, d); + // Live download progress bar — hidden until this row starts a byte-counted + // download (bundled-pack copies have no byte stream, so they stay text-only). + const barTrack = document.createElement("div"); + barTrack.style.cssText = "display:none;height:4px;border-radius:2px;background:rgba(255,255,255,.12);margin-top:7px;overflow:hidden;"; + const barFill = document.createElement("div"); + barFill.style.cssText = "height:100%;width:0%;border-radius:2px;background:#e8821e;transition:width .2s ease;"; + barTrack.appendChild(barFill); + text.appendChild(barTrack); + const status = document.createElement("div"); - status.style.cssText = "font-size:12px;white-space:nowrap;max-width:210px;overflow:hidden;text-overflow:ellipsis;text-align:right;margin-top:2px;"; + status.style.cssText = "font-size:12px;white-space:nowrap;max-width:240px;overflow:hidden;text-overflow:ellipsis;text-align:right;margin-top:2px;"; if (installed) { status.style.color = "#6c6"; status.textContent = "✓ Installed"; } else if (sec.health === "partial") { status.style.color = "#e0a13a"; status.textContent = `${sec.installed_count} of ${sec.total} installed`; } @@ -807,7 +833,7 @@ function renderSectionRow(sec, parent) { if (e.target !== cb && !cb.disabled) cb.checked = !cb.checked; }); parent.appendChild(section); - return { sec, cb, status }; + return { sec, cb, status, barTrack, barFill }; } // Consume one per-pack install SSE stream, reporting stage text + log lines. @@ -817,6 +843,7 @@ async function installOnePack(spec, onStage, onLog) { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(spec.body), + signal: spec.signal, }); if (!res.ok || !res.body) { const detail = await res.text().catch(() => ""); @@ -846,7 +873,7 @@ async function installOnePack(spec, onStage, onLog) { try { evt = JSON.parse(chunk.slice(5).trim()); } catch { continue; } if (evt.error) throw new Error(evt.error); if (evt.line) { onLog(" " + evt.line); continue; } - if (evt.stage === "download") { onStage(`Downloading ${evt.file}… ${evt.pct ?? 0}%`); continue; } + if (evt.stage === "download") { onStage({ pct: evt.pct ?? 0, done: evt.done, total: evt.total, bps: evt.bps, file: evt.file }); continue; } if (evt.stage === "prefetch") { onStage(`Pre-fetching ${evt.file || "extra weights"}…`); continue; } if (evt.stage === "models") { onStage(`Downloading ${evt.count || ""} model file${evt.count > 1 ? "s" : ""}…`); continue; } if (evt.stage) { onStage(STAGE[evt.stage] || evt.stage); continue; } @@ -862,25 +889,64 @@ async function runInstall({ overlay, card, rows, progressLine, log, nav, skip, i if (nav) nav.style.pointerEvents = "none"; // no step-nav back-nav mid-install if (skip) skip.disabled = true; install.disabled = true; + install.textContent = "Installing…"; + install.style.opacity = "0.55"; + install.style.cursor = "default"; rows.forEach((r) => { r.cb.disabled = true; }); progressLine.style.display = "block"; + // Cancel: aborts the in-flight fetch AND tells the server to stop. The current + // file finishes its chunk (keeping a .part for resume), then it bails. + const controller = new AbortController(); + let cancelled = false; + const cancelBtn = navButton("Cancel", false); + cancelBtn.style.marginRight = "8px"; + cancelBtn.addEventListener("click", () => { + if (cancelled) return; + cancelled = true; + cancelBtn.disabled = true; + cancelBtn.textContent = "Cancelling…"; + controller.abort(); + fetch("/promptchain/install/cancel", { method: "POST" }).catch(() => {}); + progressLine.textContent = "Cancelling… the current file finishes, then it stops."; + }); + install.before(cancelBtn); + let anyFailed = false; for (let i = 0; i < selected.length; i++) { - const { sec, status } = selected[i]; + if (cancelled) break; + const { sec, status, barTrack, barFill } = selected[i]; progressLine.textContent = `Installing ${i + 1} of ${selected.length}…`; status.style.color = "#7bd"; status.textContent = "⏳ starting…"; + if (barTrack) { barTrack.style.display = "none"; barFill.style.width = "0%"; } log(`▸ ${sec.label}`); try { // One SSE per section. The section installer skips already-present members // server-side, so a partial section only fetches what's still missing. await installOnePack( - { url: "/promptchain/install/install", body: { section: sec.id } }, - (s) => { status.textContent = s; }, log); + { url: "/promptchain/install/install", body: { section: sec.id }, signal: controller.signal }, + (s) => { + if (typeof s === "string") { + if (barTrack) barTrack.style.display = "none"; + status.textContent = s; + } else { + // byte-counted download → fill the bar, show rate + size in the cell + if (barTrack && s.total) { barTrack.style.display = "block"; barFill.style.width = (s.pct || 0) + "%"; } + status.textContent = fmtProgress(s); + } + }, log); status.style.color = "#6c6"; status.textContent = "✓ done"; + if (barTrack) barTrack.style.display = "none"; } catch (e) { + if (barTrack) barTrack.style.display = "none"; + if (cancelled || e.name === "AbortError") { + cancelled = true; + status.style.color = "#999"; + status.textContent = "— cancelled"; + break; + } anyFailed = true; status.style.color = "#e77"; status.textContent = "✗ failed"; @@ -889,13 +955,16 @@ async function runInstall({ overlay, card, rows, progressLine, log, nav, skip, i } await completeOnboarding(); + cancelBtn.remove(); // Done — the copied-out packs need a restart to register their nodes. Replace // the Install button entirely (not repurpose it — its addEventListener handler // can't be cleared by onclick=null and would re-fire runInstall on click). - progressLine.textContent = anyFailed - ? "Finished with some errors (see log). Restart ComfyUI to load what installed." - : "All set! Restart ComfyUI to load the new features."; + progressLine.textContent = cancelled + ? "Cancelled. Restart ComfyUI to load whatever finished installing." + : anyFailed + ? "Finished with some errors (see log). Restart ComfyUI to load what installed." + : "All set! Restart ComfyUI to load the new features."; if (skip) skip.style.display = "none"; const restartBtn = navButton("Restart ComfyUI", true); diff --git a/pyproject.toml b/pyproject.toml index 46bbd91..54c51a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,34 +1,34 @@ -# PromptChain — ComfyUI custom node pack. -# ComfyUI Registry manifest: https://docs.comfy.org/registry/publishing -# `dependencies` is kept in sync with requirements.txt (run `comfy node init`, -# or it is mirrored manually) — requirements.txt is the source of truth. - -[project] -name = "comfyui-promptchain" -version = "0.1.1" -description = "Prompt-authoring and image-iteration suite for ComfyUI: structured prompts, 3D posing, model awareness, regional control, and layered editing." -license = { file = "LICENSE" } -requires-python = ">=3.10" -# Mirrors requirements.txt (the source of truth): core load-time dep + optional -# feature deps (PuLID / Face Detailer / AI Selection), declared so they install -# at install time instead of the node running pip at runtime. -dependencies = [ - "typing_extensions", - "insightface>=1.0", - "onnxruntime", - "facexlib", - "timm", - "ftfy", - "ultralytics", - "scikit-image", - "pi-heif", -] - -[project.urls] -Repository = "https://github.com/mobcat40/ComfyUI-PromptChain" - -[tool.comfy] -# Publisher created at registry.comfy.org (immutable; appears in the node URL). -PublisherId = "mobcat40" -DisplayName = "ComfyUI-PromptChain" -Icon = "" +# PromptChain — ComfyUI custom node pack. +# ComfyUI Registry manifest: https://docs.comfy.org/registry/publishing +# `dependencies` is kept in sync with requirements.txt (run `comfy node init`, +# or it is mirrored manually) — requirements.txt is the source of truth. + +[project] +name = "comfyui-promptchain" +version = "0.1.2" +description = "Prompt-authoring and image-iteration suite for ComfyUI: structured prompts, 3D posing, model awareness, regional control, and layered editing." +license = { file = "LICENSE" } +requires-python = ">=3.10" +# Mirrors requirements.txt (the source of truth): core load-time dep + optional +# feature deps (PuLID / Face Detailer / AI Selection), declared so they install +# at install time instead of the node running pip at runtime. +dependencies = [ + "typing_extensions", + "insightface>=1.0", + "onnxruntime", + "facexlib", + "timm", + "ftfy", + "ultralytics", + "scikit-image", + "pi-heif", +] + +[project.urls] +Repository = "https://github.com/mobcat40/ComfyUI-PromptChain" + +[tool.comfy] +# Publisher created at registry.comfy.org (immutable; appears in the node URL). +PublisherId = "mobcat40" +DisplayName = "ComfyUI-PromptChain" +Icon = ""