Sync to 0.1.2: onboarding install-UX (progress bars + live rate + Cancel); .comfyignore/size hygiene; strip leaked dev paths

This commit is contained in:
chrmc40
2026-06-22 21:58:20 -07:00
parent 799c3f5448
commit cc861e0200
4 changed files with 149 additions and 48 deletions
+14
View File
@@ -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
+23 -5
View File
@@ -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)
+78 -9
View File
@@ -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);
+34 -34
View File
@@ -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 = ""