Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
407 lines
17 KiB
JavaScript
407 lines
17 KiB
JavaScript
// A generation, from Generate to a picture on screen.
|
|
//
|
|
// ComfyUI's own queue and its own socket, not a second one alongside them.
|
|
// FunPack has no queue of its own and must not grow one: two things tracking
|
|
// what is running is two things that can disagree, and the one the user
|
|
// believes is whichever spoke last. So this speaks to /prompt, /interrupt and
|
|
// /ws exactly as any ComfyUI client does, and holds only what the UI needs to
|
|
// draw -- which is a view of the server's state and never a copy of it.
|
|
//
|
|
// Everything the outside world provides is injected: `fetch`, `connect`, the
|
|
// client id. A run can then be driven end to end in a test without a server,
|
|
// and the test exercises this file rather than a mock of it.
|
|
|
|
export const IDLE = "idle";
|
|
export const QUEUED = "queued";
|
|
export const RUNNING = "running";
|
|
export const DONE = "done";
|
|
export const FAILED = "failed";
|
|
export const CANCELLED = "cancelled";
|
|
|
|
/** The socket, which is a different question from what a run is doing. */
|
|
export const OFFLINE = "offline"; // not opened yet
|
|
export const LIVE = "live";
|
|
export const LOST = "lost"; // dropped, and trying again
|
|
|
|
/** How long to wait before the next attempt: 1s, 2s, 4s ... capped at 30. */
|
|
export const backoff = (attempt) => Math.min(30_000, 1000 * 2 ** Math.max(0, attempt));
|
|
|
|
/** Where ComfyUI serves a produced file from. */
|
|
export function viewUrl(image, base = "") {
|
|
const q = new URLSearchParams({
|
|
filename: image.filename || "",
|
|
subfolder: image.subfolder || "",
|
|
type: image.type || "output",
|
|
});
|
|
return `${base}/view?${q}`;
|
|
}
|
|
|
|
export function createRun({
|
|
fetch: doFetch = globalThis.fetch,
|
|
connect,
|
|
clientId,
|
|
base = "",
|
|
} = {}) {
|
|
const listeners = new Set();
|
|
let state = {
|
|
phase: IDLE, promptId: null, progress: null, images: [], audio: [],
|
|
error: null, node: null,
|
|
// Whether this page can still hear ComfyUI at all. Part of the run state
|
|
// rather than a second thing to subscribe to: one state object is what
|
|
// stops two parts of the UI disagreeing about what is happening.
|
|
connection: OFFLINE,
|
|
};
|
|
|
|
const emit = (next) => {
|
|
state = { ...state, ...next };
|
|
for (const fn of listeners) {
|
|
try { fn(state); } catch { /* one listener must not stop the others */ }
|
|
}
|
|
watch();
|
|
};
|
|
|
|
// The socket is the fast path, not the only one: a run whose finish arrived while the socket was down (a tunnel
|
|
// blip, a reload) would otherwise sit at "Generating" forever. ComfyUI's history is the record; asked on
|
|
// reconnect, on adopt, and every half minute while a run is going.
|
|
let poll = null;
|
|
function watch() {
|
|
const going = state.promptId && (state.phase === QUEUED || state.phase === RUNNING);
|
|
if (going && !poll) { poll = setInterval(reconcile, 30000); if (poll.unref) poll.unref(); } // unref: never what keeps a process alive
|
|
if (!going && poll) { clearInterval(poll); poll = null; }
|
|
}
|
|
async function reconcile() {
|
|
const id = state.promptId;
|
|
if (!id || !(state.phase === QUEUED || state.phase === RUNNING) || typeof doFetch !== "function") return;
|
|
let entry;
|
|
try { const res = await doFetch(`${base}/history/${encodeURIComponent(id)}`); entry = res.ok ? (await res.json())[id] : null; } catch { return; }
|
|
if (!entry || state.promptId !== id || !(state.phase === QUEUED || state.phase === RUNNING)) return; // still going, or moved on
|
|
const st = entry.status || {}, outs = Object.values(entry.outputs || {});
|
|
if (st.status_str === "error") {
|
|
const m = ((st.messages || []).find((x) => x[0] === "execution_error") || [])[1] || {};
|
|
emit({ phase: FAILED, progress: null, error: { node: m.node_type || m.node_id || null, message: m.exception_message || "the run failed", traceback: m.traceback || null } });
|
|
} else if (st.completed) {
|
|
emit({ images: outs.flatMap((o) => o.images || []), audio: outs.flatMap((o) => o.audio || []), phase: DONE, progress: null, node: null });
|
|
}
|
|
}
|
|
|
|
// Only messages carrying OUR prompt id are acted on. ComfyUI broadcasts some
|
|
// of them (an interrupt is broadcast to every client), so a second tab
|
|
// generating would otherwise drive this one's progress bar and, worse, mark
|
|
// this run finished when it was somebody else's that ended.
|
|
//
|
|
// Strict about the id being KNOWN, which it is not for the length of the
|
|
// /prompt round trip. Treating "no id yet" as "everything is mine" adopted a
|
|
// stranger's result during that window -- this run went to Done, showing
|
|
// somebody else's picture, before it had started.
|
|
const mine = (data) => Boolean(state.promptId) && Boolean(data)
|
|
&& data.prompt_id === state.promptId;
|
|
|
|
// Messages that arrived before the id did. Ours cannot be told apart from
|
|
// anyone else's yet, so they are kept and sorted out once the id is known --
|
|
// dropping them would lose a run that finished inside the round trip.
|
|
// Bounded, because on a busy shared server this fills with other people's
|
|
// traffic and nothing here needs the older end of it.
|
|
const PENDING = 200;
|
|
let pending = [];
|
|
|
|
function settle(promptId) {
|
|
emit({ promptId });
|
|
const held = pending;
|
|
pending = [];
|
|
for (const message of held) handle(message);
|
|
// A cancel asked for while the id was unknown was never sent, because a
|
|
// null prompt_id is a GLOBAL interrupt at ComfyUI: it stops whatever any
|
|
// client is running. Now that the run has a name, it can be stopped by it.
|
|
if (cancelWanted) sendCancel();
|
|
}
|
|
|
|
function handle(message) {
|
|
const { type, data } = message || {};
|
|
if (!type) return;
|
|
|
|
// Not yet named: hold it rather than guess whose it is.
|
|
//
|
|
// Whenever the id is unknown, not only while queueing. A page that reloads
|
|
// during a generation opens its socket first and learns which run is its
|
|
// own a moment later, and everything arriving in between was being dropped
|
|
// -- including the whole finish of a run that ended inside that moment,
|
|
// which then left the app at Ready with the result lost.
|
|
if (!state.promptId) {
|
|
pending.push(message);
|
|
if (pending.length > PENDING) pending.shift();
|
|
return;
|
|
}
|
|
|
|
switch (type) {
|
|
case "execution_start":
|
|
if (mine(data)) emit({ phase: RUNNING, progress: null });
|
|
break;
|
|
|
|
case "progress_state": {
|
|
if (!mine(data)) break;
|
|
// One entry per active node; the run's progress is the node that is
|
|
// actually working. Summing them would report a percentage of a total
|
|
// nobody can know before the graph has run.
|
|
const nodes = Object.values(data.nodes || {});
|
|
const active = nodes.find((n) => n.state === "running") || nodes[nodes.length - 1];
|
|
if (active && active.max) {
|
|
emit({ phase: RUNNING, node: active.display_node_id ?? active.node_id,
|
|
progress: { value: active.value || 0, max: active.max } });
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "executed": {
|
|
if (!mine(data)) break;
|
|
const output = data.output || {};
|
|
// Appended, not replaced: a graph may save more than once, and the
|
|
// second save is not a correction of the first.
|
|
if (output.images) emit({ images: [...state.images, ...output.images] });
|
|
if (output.audio) emit({ audio: [...state.audio, ...output.audio] });
|
|
break;
|
|
}
|
|
|
|
case "execution_success":
|
|
if (mine(data)) emit({ phase: DONE, progress: null, node: null });
|
|
break;
|
|
|
|
case "execution_error":
|
|
if (!mine(data)) break;
|
|
emit({
|
|
phase: FAILED, progress: null,
|
|
// ComfyUI's own words. Rewriting them would lose the one thing that
|
|
// says which node and why.
|
|
error: {
|
|
node: data.node_type || data.node_id,
|
|
message: data.exception_message || "the run failed",
|
|
traceback: data.traceback || null,
|
|
},
|
|
});
|
|
break;
|
|
|
|
case "execution_interrupted":
|
|
// Broadcast, so the prompt id is the only way to know it was ours.
|
|
if (data && data.prompt_id === state.promptId) {
|
|
emit({ phase: CANCELLED, progress: null, node: null });
|
|
}
|
|
break;
|
|
|
|
default:
|
|
break; // not ours to interpret
|
|
}
|
|
}
|
|
|
|
// A cancel asked for before the run had a name.
|
|
let cancelWanted = false;
|
|
|
|
async function sendCancel() {
|
|
cancelWanted = false;
|
|
const id = state.promptId;
|
|
|
|
// ComfyUI's own state-agnostic cancel: it interrupts a job that is running
|
|
// and dequeues one that is only pending, atomically, and answers
|
|
// {"cancelled": false} for an id that has already finished.
|
|
//
|
|
// /interrupt alone was not enough and looked like it was. It only consults
|
|
// the RUNNING half of the queue, so cancelling a job waiting behind another
|
|
// one returned 200, logged "not currently running, skipping interrupt", and
|
|
// let the job run in full when its turn came -- a cancel that did nothing
|
|
// and said nothing, which is worse than a cancel that fails.
|
|
let response;
|
|
try {
|
|
response = await doFetch(`${base}/api/jobs/${encodeURIComponent(id)}/cancel`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
} catch {
|
|
response = null;
|
|
}
|
|
if (response && response.ok) {
|
|
// A job that was only PENDING is dequeued and never runs, so no
|
|
// execution_interrupted is ever sent -- nothing would arrive to move the
|
|
// UI off "Queued", and the run would look stuck forever after a cancel
|
|
// that worked. A RUNNING job is left to the socket, which says when it
|
|
// actually stopped rather than when it was asked to.
|
|
const body = await response.json().catch(() => ({}));
|
|
if (body && body.cancelled && state.phase === QUEUED) {
|
|
emit({ phase: CANCELLED, progress: null, node: null });
|
|
}
|
|
return response;
|
|
}
|
|
|
|
// An older ComfyUI without that route. Interrupting is all it can do, and
|
|
// it is right for the case people hit most: a run that is under way.
|
|
return doFetch(`${base}/interrupt`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ prompt_id: id }),
|
|
});
|
|
}
|
|
|
|
let socket = null;
|
|
let attempt = 0;
|
|
let waiting = null;
|
|
|
|
// A CLOSED socket is still an object. "Already listening" therefore cannot be
|
|
// "there is a socket": every drop -- a laptop sleeping, a tunnel to a rented
|
|
// box hiccuping, ComfyUI restarting -- was permanent for that tab, and
|
|
// silent. Progress and results simply stopped arriving while the GPU carried
|
|
// on, and only a manual reload brought them back.
|
|
//
|
|
// readyState is undefined on a stand-in socket, and a stand-in is a live one.
|
|
const alive = (s) => Boolean(s) && (s.readyState === undefined || s.readyState <= 1);
|
|
|
|
function listen() {
|
|
if (typeof connect !== "function") return null;
|
|
if (alive(socket)) return socket;
|
|
|
|
if (waiting) { clearTimeout(waiting); waiting = null; }
|
|
socket = connect(clientId);
|
|
if (!socket) return null;
|
|
|
|
socket.addEventListener("open", () => { attempt = 0; emit({ connection: LIVE }); reconcile(); });
|
|
socket.addEventListener("message", (event) => {
|
|
// Binary frames are previews, which nothing here consumes yet. A parse
|
|
// failure must not take the socket down with it.
|
|
if (typeof event.data !== "string") return;
|
|
try { handle(JSON.parse(event.data)); } catch { /* not for us */ }
|
|
});
|
|
|
|
// close AND error: a refused connection fires error without ever closing on
|
|
// some browsers, and a dropped one closes without an error on others.
|
|
const dropped = (which) => () => {
|
|
if (which !== socket) return; // an older socket letting go
|
|
emit({ connection: LOST });
|
|
retry();
|
|
};
|
|
socket.addEventListener("close", dropped(socket));
|
|
socket.addEventListener("error", dropped(socket));
|
|
return socket;
|
|
}
|
|
|
|
function retry() {
|
|
if (waiting) return;
|
|
const wait = backoff(attempt);
|
|
attempt += 1;
|
|
waiting = setTimeout(() => { waiting = null; listen(); }, wait);
|
|
// Not a reason to keep a process alive: under a test runner a pending timer
|
|
// is the difference between a suite that finishes and one that hangs.
|
|
if (waiting && typeof waiting.unref === "function") waiting.unref();
|
|
}
|
|
|
|
return {
|
|
get state() { return { ...state }; },
|
|
|
|
/** Prompt ids seen on the socket while this page had no run of its own.
|
|
* Whoever is reattaching can ask ComfyUI which of them belongs here. */
|
|
seen: () => [...new Set(pending.map((m) => m && m.data && m.data.prompt_id)
|
|
.filter(Boolean))],
|
|
subscribe(fn) {
|
|
listeners.add(fn);
|
|
// Guarded like every later delivery. Handing a subscriber the current
|
|
// state is a delivery too, and doing it bare meant one bad listener took
|
|
// down whoever was wiring the UI up -- at startup, before anything is on
|
|
// screen to say so.
|
|
try { fn(state); } catch { /* the same rule as emit */ }
|
|
return () => listeners.delete(fn);
|
|
},
|
|
handle, // for tests and for a socket owned elsewhere
|
|
listen,
|
|
/** Stop trying. Only a page being torn down wants this. */
|
|
stopListening() {
|
|
if (waiting) { clearTimeout(waiting); waiting = null; }
|
|
if (socket && typeof socket.close === "function") socket.close();
|
|
socket = null;
|
|
},
|
|
|
|
/**
|
|
* Queue a prompt. Resolves with the prompt id, or throws with the reason.
|
|
*
|
|
* `extra`, when given, rides in `extra_data` alongside `client_id` --
|
|
* ComfyUI stores and echoes it back on `/queue` and `/history`, which is
|
|
* the only way a reload can learn what a run in flight belongs to.
|
|
*/
|
|
async start(prompt, { extra } = {}) {
|
|
listen();
|
|
pending = [];
|
|
cancelWanted = false;
|
|
emit({ phase: QUEUED, promptId: null, progress: null, images: [], audio: [],
|
|
error: null, node: null });
|
|
|
|
let response;
|
|
try {
|
|
response = await doFetch(`${base}/prompt`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(extra ? { prompt, client_id: clientId, extra_data: extra }
|
|
: { prompt, client_id: clientId }),
|
|
});
|
|
} catch (err) {
|
|
// The queue could not be reached at all -- no ComfyUI, or the dev
|
|
// server, which serves the app and has no queue behind it.
|
|
emit({ phase: FAILED, error: { message: `the queue could not be reached: ${err.message}` } });
|
|
throw err;
|
|
}
|
|
|
|
const body = await response.json().catch(() => ({}));
|
|
if (!response.ok) {
|
|
// ComfyUI refuses a bad graph here, BEFORE anything loads a model, and
|
|
// says which node and why. That is the message worth showing.
|
|
const detail = body.error && (body.error.message || body.error.type);
|
|
emit({ phase: FAILED, error: {
|
|
message: detail || `the queue refused this graph (${response.status})`,
|
|
nodes: body.node_errors || null,
|
|
} });
|
|
throw new Error(detail || `queue refused: ${response.status}`);
|
|
}
|
|
|
|
settle(body.prompt_id || null);
|
|
return body.prompt_id;
|
|
},
|
|
|
|
/** Ask for the run to stop. */
|
|
async cancel() {
|
|
if (state.phase !== QUEUED && state.phase !== RUNNING) return false;
|
|
if (!state.promptId) {
|
|
// Nothing to name yet. Remembered rather than sent: ComfyUI reads a
|
|
// missing prompt_id as "interrupt whatever is running", which on a
|
|
// shared box is someone else's generation, and on this one is not
|
|
// necessarily the run the user is looking at.
|
|
cancelWanted = true;
|
|
return true;
|
|
}
|
|
await sendCancel();
|
|
// Not marked cancelled here: the server says when it actually stopped,
|
|
// and claiming it early is how a UI ends up showing "cancelled" over a
|
|
// run that is still burning GPU time.
|
|
return true;
|
|
},
|
|
|
|
/**
|
|
* Take over a run already in flight -- after a reload, the generation the
|
|
* previous page load queued. The id comes from ComfyUI's own queue, so this
|
|
* adopts a real run rather than assuming one.
|
|
*/
|
|
adopt(promptId, { running = true } = {}) {
|
|
if (!promptId) return false;
|
|
listen();
|
|
// A job waiting its turn is queued, not running. Saying "working" over a
|
|
// run that has not started is the same small lie as saying "cancelled"
|
|
// over one that has not stopped.
|
|
emit({ phase: running ? RUNNING : QUEUED, images: [], audio: [],
|
|
error: null, node: null });
|
|
settle(promptId);
|
|
reconcile(); // a run that finished before this page heard of it
|
|
return true;
|
|
},
|
|
|
|
images: () => state.images.map((image) => viewUrl(image, base)),
|
|
reset() {
|
|
pending = [];
|
|
cancelWanted = false;
|
|
emit({ phase: IDLE, promptId: null, progress: null, images: [],
|
|
audio: [], error: null, node: null });
|
|
},
|
|
};
|
|
}
|