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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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 = ""
|
||||
|
||||
Reference in New Issue
Block a user