diff --git a/app/ui/features/continuity/index.js b/app/ui/features/continuity/index.js index f3aabf76..46267272 100644 --- a/app/ui/features/continuity/index.js +++ b/app/ui/features/continuity/index.js @@ -28,8 +28,9 @@ export default { return { inputs: withIt.overrides, notes: withIt.unwired > without.unwired ? ["The identity picture was not used: this pipeline has no free reference input for it."] : [] }; }; const carryHook = async ({ project, scene, slots }) => { - if (app.pipeline.allOff?.()) return {}; // Disable all enhancements means a plain run - const mine = (app.pipeline.currentValues() || {}).continuity; + // Read from the pipeline this run was frozen with, not the live one an edit may have changed since the click. + if (app.pipeline.allOff?.(slots)) return {}; // Disable all enhancements means a plain run + const mine = (app.pipeline.currentValues(slots) || {}).continuity; if (!mine) return {}; // the module that holds the choices is not here: this does nothing at all const carry = mine.carry !== false, guard = mine.dark_guard !== false; const slot = startSlot(slots); diff --git a/app/ui/shell/api.js b/app/ui/shell/api.js index f1472e53..361f515f 100644 --- a/app/ui/shell/api.js +++ b/app/ui/shell/api.js @@ -14,7 +14,7 @@ export async function call(method, path, body) { body: body === undefined ? undefined : JSON.stringify(body), }); const payload = await res.json().catch(() => null); - if (!res.ok) throw new Error(why(res, payload)); + if (!res.ok) throw Object.assign(new Error(why(res, payload)), { status: res.status }); // status: a refusal (4xx) is not an outage return payload; } @@ -118,7 +118,7 @@ export const api = { try { const res = await fetch(`${BASE}/api/media`, { method: "POST", body: form }); const payload = await res.json().catch(() => null); - if (!res.ok) throw new Error(why(res, payload)); + if (!res.ok) throw Object.assign(new Error(why(res, payload)), { status: res.status }); // status: a refusal (4xx) is not an outage out.media.push(...(payload.media || [])); out.problems.push(...(payload.problems || [])); } catch (err) { out.problems.push(`${file.name}: ${err.message}`); } } diff --git a/app/ui/shell/pipeline_link.js b/app/ui/shell/pipeline_link.js index f761bf53..b9013b78 100644 --- a/app/ui/shell/pipeline_link.js +++ b/app/ui/shell/pipeline_link.js @@ -38,7 +38,7 @@ export function linkPipeline({ project, pipeline, onOpen, say = () => {} }) { retry = setTimeout(adopt, 5000); } else if (!pipeline.slots() || (pipeline.unreachable && pipeline.unreachable())) { // ComfyUI is down: keep asking, slowly; and if a panel's own load gets there first, its announcement puts this project's in. - waiting = true; + waiting = pipeline.slots() ? "retry" : "load"; // "load": nothing loaded at all, so a panel's own load may get there first retry = setTimeout(adopt, 15000); if (retries === 4) say("ComfyUI is not answering, so this project's pipeline is not loaded yet: it goes in as soon as ComfyUI answers."); } else { @@ -47,7 +47,7 @@ export function linkPipeline({ project, pipeline, onOpen, say = () => {} }) { } pipeline.subscribe((slots) => { - if (waiting && owner === null) { retries = 0; adopt(); return; } + if (waiting === "load" && owner === null) { retries = 0; adopt(); return; } if (!project.project || project.project.id !== owner) return; store(project.project.models || {}, slots); }); diff --git a/app/ui/shell/pipeline_state.js b/app/ui/shell/pipeline_state.js index 96244b1c..8b65e808 100644 --- a/app/ui/shell/pipeline_state.js +++ b/app/ui/shell/pipeline_state.js @@ -104,10 +104,10 @@ export function createPipelineState(rawApi, { answerMs = ANSWER_MS } = {}) { // plain object -- and only accepts a decoded key that names a module this // session already knows is installed, so an unrelated node's incidentally // JSON-shaped string can't inject a bogus entry that round-trips forever. - function valuesAlreadyPlaced() { + function valuesAlreadyPlaced(from = slots) { const known = new Set([...Object.keys(modulesById), "_off"]); // _off: the modules this project turned off const merged = {}; - (slots || []).forEach((slot) => { + (from || []).forEach((slot) => { Object.values(slot.inputs || {}).forEach((v) => { if (typeof v !== "string") return; let parsed; @@ -273,18 +273,19 @@ export function createPipelineState(rawApi, { answerMs = ANSWER_MS } = {}) { // can never hold a stale copy of each other's CONFIRMED state between // them -- the pending overlay above is the one deliberate exception, // needed so an in-flight edit from either editor isn't lost by the other. - function currentValues() { + // `from`: a pipeline other than the live one (Generate's, frozen at the click); edits not yet landed then do not count. + function currentValues(from) { const merged = {}; Object.values(modulesById).forEach((m) => { const own = {}; Object.entries(m.settings || {}).forEach(([name, spec]) => { own[name] = spec.default; }); if (Object.keys(own).length) merged[m.id] = own; }); - const already = valuesAlreadyPlaced(); + const already = valuesAlreadyPlaced(from || slots); Object.entries(already).forEach(([moduleId, own]) => { merged[moduleId] = { ...(merged[moduleId] || {}), ...own }; }); - Object.entries(pendingValues).forEach(([moduleId, own]) => { + Object.entries(from ? {} : pendingValues).forEach(([moduleId, own]) => { merged[moduleId] = { ...(merged[moduleId] || {}), ...own }; }); return merged; @@ -304,7 +305,7 @@ export function createPipelineState(rawApi, { answerMs = ANSWER_MS } = {}) { } // "Disable all enhancements": one project switch that makes every run plain, for A/B tests. The per-module // choices are kept underneath, so switching it back restores exactly what was on. - const allOff = () => Boolean((currentValues()._off || {}).all); + const allOff = (from) => Boolean((currentValues(from)._off || {}).all); const setAllOff = (on) => setModuleValue("_off", "all", Boolean(on)); // The screen is the truth: an edit is written into the live slots the moment it is made (the same blob the // server's place() writes), so a run started now reads what is shown without waiting for any round trip. @@ -591,7 +592,10 @@ export function createPipelineState(rawApi, { answerMs = ANSWER_MS } = {}) { try { await refreshManifest(); } catch (_) { /* the next save retries it */ } return true; } - } catch (_) { unreachable = true; /* the request failed (not a refusal); try the next layering */ } + } catch (e) { + // No answer, or a gateway's (a tunnel, a restarting ComfyUI): try again later. An answer refusing it is a refusal. + if (!e || !e.status || e.status >= 502) unreachable = true; + } } unreachableLast = unreachable; // Nothing saved was accepted (a failed request, or a file the server refuses): the default diff --git a/app/ui/shell/tests/pipeline_state.test.js b/app/ui/shell/tests/pipeline_state.test.js index 9ecc9f53..0cc6ed6a 100644 --- a/app/ui/shell/tests/pipeline_state.test.js +++ b/app/ui/shell/tests/pipeline_state.test.js @@ -485,3 +485,25 @@ test("a project opened while ComfyUI is down never gets an earlier project's wai await PS.ensureLoaded(); assert.strictEqual(PS.slots().find((s) => s.id === "model").inputs.file, ""); }); + +test("a saved pipeline the server answers with a refusal is a refusal, not an outage; no answer is an outage", async () => { + for (const [err, outage] of [[Object.assign(new Error("slot 0 role 0 has no input"), { status: 400 }), false], [new Error("Failed to fetch"), true], [Object.assign(new Error("Bad gateway"), { status: 502 }), true]]) { + const PS = load([]); + await PS.ensureLoaded(); + const real = lastApi.editPipeline; + lastApi.editPipeline = async () => { throw err; }; + assert.strictEqual(await PS.adopt([{ id: "model", node: "Loader", inputs: { file: "x" } }]), false); + assert.strictEqual(PS.unreachable(), outage, err.message); + lastApi.editPipeline = real; + } +}); + +test("settings can be read from a frozen pipeline, apart from the live one and edits not yet landed", async () => { + const PS = load([]); + lastApi.modules = async () => ({ modules: [{ id: "continuity", settings: { carry: { type: "bool", default: true } } }] }); + await PS.adopt([{ id: "settings", node: "Sink", inputs: { settings: JSON.stringify({ continuity: { carry: true } }) } }]); + const frozen = structuredClone(PS.slots()); + await PS.adopt([{ id: "settings", node: "Sink", inputs: { settings: JSON.stringify({ continuity: { carry: false }, _off: { all: true } }) } }]); + assert.deepStrictEqual([PS.currentValues(frozen).continuity.carry, PS.allOff(frozen)], [true, false]); + assert.deepStrictEqual([PS.currentValues().continuity.carry, PS.allOff()], [false, true]); +}); diff --git a/core/readiness.py b/core/readiness.py index d176787e..c9aee144 100644 --- a/core/readiness.py +++ b/core/readiness.py @@ -62,7 +62,7 @@ def machine(disk_free_gb=None) -> List[dict]: else: props = torch.cuda.get_device_properties(gpu_index()) out.append(row("ok", f"{props.name}, {props.total_memory / 1024 ** 3:.0f} GB, sm_{props.major}{props.minor}")) - if not torch.cuda.is_bf16_supported(): + if props.major < 8: # bf16 arrived with Ampere (sm_80), on the card ComfyUI uses out.append(row("fail", "This GPU does not support bf16, which the H3 VAEs need.")) if props.major >= 12 and have("xformers"): out.append(row("warn", "xformers has no masked-attention kernel for this GPU: start ComfyUI with --disable-xformers --use-sage-attention.")) diff --git a/core/tests/test_readiness.py b/core/tests/test_readiness.py index 1ce4072b..3320fc84 100644 --- a/core/tests/test_readiness.py +++ b/core/tests/test_readiness.py @@ -52,6 +52,5 @@ def test_the_gpu_row_names_the_card_comfyui_uses(monkeypatch): monkeypatch.setattr(r, "gpu_index", lambda: 1) monkeypatch.setattr(torch.cuda, "get_device_properties", lambda i: asked.append(i) or types.SimpleNamespace(name=f"GPU{i}", total_memory=32 * 1024 ** 3, major=12, minor=0)) - monkeypatch.setattr(torch.cuda, "is_bf16_supported", lambda: True) rows = r.machine() assert asked == [1] and any("GPU1" in x["text"] for x in rows)