feat: wire Generate/Cancel/reattach through v5's real check->queue->terminal flow

Adds generate_bridge.js, a small ES-module shim over the untouched
app/shell/run.js + session.js + pipeline.js + client.js, and rewires
store.js's generate()/generateMontage()/generateSelected()/interrupt()/
init() reattach to go through it instead of the now-permanently-stubbed
API.generate/status/active. PipelineState (pipeline_state.js) stays the
one client-side copy of the live pipeline; the bridge only adds the
run/queue/socket layer on top of it.

Single scene (or the whole active timeline, for a non-chain pipeline like
H3) per real queue -- v5 has no chain-sampler/montage stage. REINS captures
and the anchor-guide i2v bypass are said loudly (console.warn) rather than
silently dropped, since neither has a v5 equivalent yet. Reference-image
wiring is left as a placeholder: legacy's saveProject/getProject adapter
doesn't round-trip source_image/references yet, so there is nothing to
wire in from there today.

Live-verified against the real dev server: a real Generate click built the
scene's prompt, POSTed it through /funpack/api/pipeline's check, and
surfaced the genuine refusal reason (an unset model loader) in the existing
status UI, with the button correctly re-enabled after. 688/689 JS tests
and 548/548 Python tests pass (the one JS failure is the pre-existing,
unrelated projects.test.js import-race flake).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-09-15 11:34:58 +03:00
co-authored by Claude Sonnet 5
parent c6559765b7
commit bb404d988d
3 changed files with 336 additions and 220 deletions
+99
View File
@@ -0,0 +1,99 @@
// Bridges v5's real check -> queue -> terminal generation flow into the classic-
// script v4 world. app/shell/run.js, session.js, pipeline.js and client.js are
// unmodified here -- they have no DOM/composer dependency (see the port plan's
// "Preserved and rewired" list) -- this file is only the small ES-module shim
// that hands their globals-free API to v4's global-script callers.
//
// Single scene at a time, on purpose: v5 has no chain-sampler/montage stage
// (postponed, LTX-only -- see the port plan), so "Generate" here means what
// app/boot.js's own generateCurrentScene/generateAll mean -- one real queue per
// scene, run to a terminal state before the next one starts. store.js supplies
// the scene loop; this only knows how to run ONE.
import { createRun, viewUrl, DONE, FAILED, CANCELLED } from "../shell/run.js";
import { clientId, connect, queuedFor, finishedFor } from "../shell/client.js";
import { wire, waitForTerminal } from "../shell/session.js";
import { check } from "../shell/pipeline.js";
const PS = window.PipelineState;
const id = clientId();
const run = createRun({ clientId: id, connect });
// The five hooks session.js's wire()/createGenerator call on `page.transport`.
// Nothing here owns UI -- store.js already renders generation status through
// its own `state.gen` -- so these just fan out to whoever subscribes via
// GenerateBridge.on(...).
const listeners = { say: [], warn: [], hold: [], release: [], disabled: [], adopt: [] };
function fire(kind, arg) { (listeners[kind] || []).forEach((fn) => { try { fn(arg); } catch (_) {} }); }
const transport = {
generate: { setDisabled: (v) => fire("disabled", v) },
say: (msg) => fire("say", msg),
warn: (msg) => fire("warn", msg),
draw: () => {}, // run.subscribe() below already covers this
hold: (msg) => fire("hold", msg),
release: (state) => fire("release", state),
};
// Which scene/project a run belongs to. Set by generate() at click time; read
// by run.start() at the moment IT actually queues (see session.js's own
// comment on why this can't be read only after the fact).
let ranFor = null;
let ranForProject = null;
// The inputs the NEXT queued run should send -- built by the caller (store.js
// knows the scene text, anchor, postfix, variables; this file does not) and
// handed to generate() right before it runs, not assembled here.
let pendingInputs = null;
const session = wire({
run, page: { transport }, check, id, queuedFor, finishedFor,
// PipelineState (pipeline_state.js) is the one client-side copy of the live
// pipeline, shared with Engine Settings and Models & Pipeline. A second copy
// here would be the exact bug pipeline_state.js was extracted to fix.
slots: () => PS.slots(),
// Module settings are already IN `slots` by the time Generate runs -- every
// Engine Settings edit is saved through PS.save(), which writes into the
// sink node's input (see pipeline_state.js). `values` exists for the
// composer's own role-mounted dials, which legacy does not use.
values: () => ({}),
inputs: async () => pendingInputs || {},
extra: () => (ranForProject && ranFor
? { funpack_scene_id: ranFor, funpack_project_id: ranForProject } : null),
onAdopt: (sceneId, projectId) => {
ranFor = sceneId; ranForProject = projectId;
// A run this PAGE queued sets ranFor itself, at click time (see generate()
// below). A run only found on reload -- nobody here called generate() for
// it -- has no other way to tell store.js which scene it belongs to.
fire("adopt", { sceneId, projectId });
},
});
/** The slot+role pair a pipeline role points at, or null if nothing offers it. */
function slotForRole(at) {
for (const slot of PS.slots() || []) {
for (const role of slot.roles || []) {
if (role.at === at) return { slot, role };
}
}
return null;
}
window.GenerateBridge = {
DONE, FAILED, CANCELLED,
viewUrl: (image) => viewUrl(image),
state: () => run.state,
subscribe: (fn) => run.subscribe(fn),
on: (kind, fn) => {
(listeners[kind] || (listeners[kind] = [])).push(fn);
return () => { listeners[kind] = (listeners[kind] || []).filter((f) => f !== fn); };
},
cancel: () => run.cancel(),
waitForTerminal: () => waitForTerminal(run),
ready: session.ready,
slotForRole,
/** Queue one scene's generation. `inputs` is {slotId: {inputName: value}}. */
async generate({ sceneId, projectId, inputs } = {}) {
ranFor = sceneId || null;
ranForProject = projectId || null;
pendingInputs = inputs || null;
return session.generate();
},
};
+4
View File
@@ -121,6 +121,10 @@
<script src="api.js"></script>
<script src="dom.js"></script>
<script src="pipeline_state.js"></script>
<!-- Module scripts always defer, so this runs after every classic script
above regardless of position -- placed here only for readability next
to the pipeline state it depends on (window.PipelineState). -->
<script type="module" src="generate_bridge.js"></script>
<script src="settings_window.js"></script>
<script src="appearance_settings.js"></script>
<script src="floating_window.js"></script>
+233 -220
View File
@@ -3038,8 +3038,6 @@
return raw;
}
let pollTimer = null;
let progressTimer = null;
let pollStart = 0;
// Wall clock for the WHOLE operation the user started, as distinct from pollStart, which
// restarts on every run of a multi-run montage. The transport buttons count from the press
@@ -3060,83 +3058,17 @@
function _markGenInFlight(ids) { (ids || []).forEach((id) => _genInFlightIds.add(id)); }
function _clearGenInFlight(ids) { (ids || []).forEach((id) => _genInFlightIds.delete(id)); }
function _clearGenTimers() { clearInterval(pollTimer); clearInterval(progressTimer); }
// Ask ComfyUI to stop the current generation. The running poll then resolves as
// "Interrupted" and the run loop stops.
// Ask ComfyUI to stop the current generation. GenerateBridge's own run.cancel()
// (app/shell/run.js) knows the real state-agnostic /api/jobs/.../cancel route
// and falls back to /interrupt on an older ComfyUI -- reused rather than
// duplicated here (see that file's own comment on why plain /interrupt alone
// silently does nothing for a job still waiting behind another one).
async function interrupt() {
_interrupted = true;
updateGenProgress({ msg: "Interrupting…" });
try { await API.interrupt(); } catch (_) {}
}
// Re-attach to a generation that was already running when the UI (re)loaded. The job
// lives in ComfyUI's still-running queue; bridge.active_generation hands back its
// prompt id + the project/scenes it targets. We restore the "running" gen state so
// Generate stays blocked and Interrupt is offered — exactly like a live run — and,
// when the matching project is loaded, record the result onto its scenes on completion.
async function resumeRunningGeneration(act) {
if (!act || !act.running || !act.prompt_id) return false;
_interrupted = false;
// A reloaded montage can have further runs still queued behind the running one. Re-attach
// to each in turn so Generate stays blocked (and outputs get recorded) until the queue
// drains — not just for the one prompt that happened to be running at reload.
let cur = act;
let lastPromptId = null;
// Re-attaching means we never saw the press, so the button's clock can only count from
// here. Flagged approximate so it reads "1m 4s+" rather than claiming a total it can't know.
_genClockStart("all", true);
try {
while (cur && cur.running && cur.prompt_id && cur.prompt_id !== lastPromptId && !_interrupted) {
lastPromptId = cur.prompt_id;
pollStart = Date.now();
const sceneIds = (cur.scene_ids && cur.scene_ids.length)
? cur.scene_ids
: (cur.only_scene ? [cur.only_scene] : []);
const more = cur.pending > 0 ? ` · ${cur.pending} run(s) queued` : "";
set({ gen: { state: "running", promptId: cur.prompt_id, media: [], msg: `Generation running (reconnected after reload)…${more}` } });
if (state.project && cur.pid && state.project.id === cur.pid && sceneIds.length) {
// Full re-attach: poll history and place the output on the run's scenes when done.
_markGenInFlight(sceneIds);
try { await _pollPromise(cur.prompt_id, sceneIds, "Generation running (reconnected)"); }
finally { _clearGenInFlight(sceneIds); }
} else {
// No matching project loaded (or single-scene run on another project): we can't map
// the output, but still surface Interrupt and clear busy when it stops.
await _monitorActive(cur.prompt_id);
}
if (_interrupted) break;
// Another queued run may now be executing — re-attach to it.
try { cur = await API.active(); } catch (_) { cur = null; }
}
} finally { _genClockStop(); }
return true;
}
// Lightweight queue watcher used when we can't fully re-attach (see above). Polls
// /active until the prompt leaves the queue or the user interrupts.
function _monitorActive(promptId) {
return new Promise((resolve) => {
_clearGenTimers();
pollTimer = setInterval(async () => {
if (_interrupted) {
_clearGenTimers();
set({ gen: { state: "idle", promptId, media: [], msg: "Interrupted." } });
resolve();
return;
}
try {
const a = await API.active();
if (!a || !a.running || a.prompt_id !== promptId) {
_clearGenTimers();
set({ gen: { state: "done", promptId, media: [], msg: "Generation finished. Open Settings ▸ Temp files to view or save the output, then regenerate to place it." } });
resolve();
} else {
updateGenProgress({ msg: `Generation running (reconnected) ${_elapsed()}` });
}
} catch (_) { /* transient — keep watching */ }
}, 2000);
});
const GB = window.GenerateBridge;
if (GB) { try { await GB.cancel(); } catch (_) {} return; }
try { await API.interrupt(); } catch (_) {} // bridge not loaded — last resort
}
function _elapsed() {
@@ -3449,83 +3381,160 @@
}
}
// Poll a single queued prompt to completion. Resolves true on success, false on error.
function _pollPromise(promptId, targetSceneIds, prefix) {
prefix = prefix || "Generating…";
return new Promise((resolve) => {
_clearGenTimers();
let pendingStreak = 0;
let transientStreak = 0;
// Faster step-progress poll (sampler current/total steps, plus what it says it's
// doing — "scene 2/3 · pass 2 of 2"). The message is rebuilt from prefix + elapsed
// the same way the slower poll below builds it, rather than regex-stripping the last
// one off the end: a suffix the strip pattern doesn't know about (the phase label)
// would otherwise be re-appended every tick and grow without limit.
progressTimer = setInterval(async () => {
if (_interrupted) return;
try {
const pr = await API.progress();
if (pr && pr.max > 0) {
const phase = pr.label ? ` · ${pr.label}` : "";
updateGenProgress({
step: pr.value,
maxStep: pr.max,
phase: pr.label || "",
msg: `${prefix} ${_elapsed()} · sampling ${pr.value}/${pr.max}${phase}`,
});
}
} catch (_) {}
}, 700);
pollTimer = setInterval(async () => {
if (_interrupted) {
_clearGenTimers();
set({ gen: { state: "idle", promptId, media: [], msg: "Interrupted." } });
resolve(false);
return;
}
try {
const s = await API.status(state.project.id, promptId);
transientStreak = 0;
if (s.state === "error") {
_clearGenTimers();
const msg = s.error ? `ComfyUI error: ${s.error}` : "Generation failed inside ComfyUI — check the ComfyUI terminal for details.";
set({ gen: { state: "error", promptId, media: [], msg } });
resolve(false);
} else if (s.state === "completed") {
_clearGenTimers();
_recordSegment(s.media, targetSceneIds, { sceneLayout: s.scene_layout });
set({ gen: { state: "done", promptId, media: s.media, msg: s.media.length ? "" : "Completed but no output media found — check ComfyUI terminal." } });
resolve(true);
} else {
// "pending" after "running" means the job left the queue without a history
// entry — it likely crashed or was interrupted by ComfyUI.
if (s.state === "pending") pendingStreak++; else pendingStreak = 0;
if (pendingStreak >= 3) {
_clearGenTimers();
set({ gen: { state: "error", promptId, media: [], msg: "Job disappeared from ComfyUI queue — it may have crashed or been interrupted. Check the ComfyUI terminal." } });
resolve(false);
return;
}
const step = (state.gen.maxStep > 0) ? ` · sampling ${state.gen.step}/${state.gen.maxStep}` : "";
const phase = state.gen.phase ? ` · ${state.gen.phase}` : "";
updateGenProgress({ state: s.state, msg: `${prefix} ${_elapsed()}${step}${phase}` });
}
} catch (e) {
if (_isTransientTunnelError(e)) {
transientStreak++;
if (transientStreak < 120) {
updateGenProgress({
msg: `${prefix} ${_elapsed()} · tunnel reconnect (${transientStreak})…`,
});
return;
}
}
_clearGenTimers();
set({ gen: { ...state.gen, state: "error", msg: _friendlyGenError(e.message) } });
resolve(false);
}
}, 2000);
// The extension is what actually tells kinds apart, not which ComfyUI output
// key a file arrived under -- SaveVideo hands its file back through the same
// generic "images" list the frontend's own gallery mechanism reuses for every
// output kind (see app/shell/run.js's `handle()`), so the key alone can't
// tell a produced video from a produced image.
function _kindForFilename(name) {
const ext = String(name || "").split(".").pop().toLowerCase();
if (ext === "gif") return "gifs";
if (["mp4", "webm", "mov", "mkv"].includes(ext)) return "videos";
if (["mp3", "wav", "flac", "ogg", "m4a"].includes(ext)) return "audio";
return "images";
}
// Which scene(s) + status prefix the run GenerateBridge is currently driving
// belongs to -- set by _generateRun right before it queues, or by the
// "adopt" handler below when the run was found on reload instead of started
// by this page.
let _genRunPrefix = "Generating…";
let _genRunSceneIds = [];
let _genBridgeWired = false;
// One persistent subscription translating GenerateBridge's real run state
// (app/shell/run.js -- ComfyUI's own /prompt + /ws, not a second queue of
// FunPack's own) into the state.gen shape every existing status/progress/
// clock UI already renders. Wired once; every _generateRun call after that
// just sets what the NEXT status message should say and lets this paint it.
function _wireGenerateBridge() {
if (_genBridgeWired) return;
const GB = window.GenerateBridge;
if (!GB) return;
_genBridgeWired = true;
GB.on("say", (msg) => {
if (!msg) return;
set({ gen: { state: "error", promptId: (state.gen && state.gen.promptId) || null, media: [], msg } });
});
GB.on("hold", (msg) => {
set({ gen: { state: "queuing", promptId: null, media: [], msg: msg || "" } });
});
GB.on("release", (runState) => {
// Only idle is worth clearing back to Ready here -- any other phase is
// already being painted by the subscribe() below, and stomping it with
// a stale "release" delivery would flash the status backwards.
if (runState.phase === "idle") set({ gen: { state: "idle", promptId: null, media: [], msg: "" } });
});
// A run this page did not start itself -- found on reload via ComfyUI's
// own /queue or /history, not anything remembered client-side (see
// app/shell/client.js). There is no prefix for it, and no scene selected
// it, so both are set from what the bridge found instead of a click.
GB.on("adopt", ({ sceneId, projectId }) => {
_genRunPrefix = "Generation";
_genRunSceneIds = sceneId ? [sceneId] : [];
if (projectId && (!state.project || state.project.id !== projectId)
&& (state.projects || []).some((p) => p.id === projectId)) {
loadProject(projectId).then(notify);
}
});
GB.subscribe((runState) => {
const { phase, promptId, progress, images, audio, error } = runState;
if (phase === "idle") return; // the resting state — nothing new to draw
if (phase === "queued") {
set({ gen: { state: "queuing", promptId, media: [], msg: `${_genRunPrefix}: queuing…` } });
} else if (phase === "running") {
const step = progress ? ` · sampling ${progress.value}/${progress.max}` : "";
set({
gen: {
state: "running", promptId, media: [],
step: progress ? progress.value : 0, maxStep: progress ? progress.max : 0,
msg: `${_genRunPrefix}: generating… ${_elapsed()}${step}`,
},
});
} else if (phase === "done") {
const mediaList = [...(images || []), ...(audio || [])]
.map((f) => ({ ...f, kind: _kindForFilename(f.filename) }));
if (mediaList.length && _genRunSceneIds.length) _recordSegment(mediaList, _genRunSceneIds, {});
set({
gen: {
state: "done", promptId, media: mediaList,
msg: mediaList.length ? "" : "Completed but no output media found — check the ComfyUI terminal.",
},
});
} else if (phase === "failed") {
set({
gen: {
state: "error", promptId, media: [],
msg: error ? `ComfyUI error: ${error.message}` : "Generation failed inside ComfyUI — check the ComfyUI terminal for details.",
},
});
} else if (phase === "cancelled") {
set({ gen: { state: "idle", promptId, media: [], msg: "Interrupted." } });
}
});
}
// What a run actually sends for the prompt: the scene's own text for a
// single-scene run, or the full verbatim montage (anchor + every active
// scene's text, transitions included) for a multi-scene one -- the same
// split `generate()`/`generateMontage()` already make below. Plus
// project.video's width/height, wherever the pipeline asks for them, by
// role.input name -- same convention app/boot.js's syncVideo() uses.
//
// Reference-image wiring (assets.source_image/reference_N, app/boot.js's
// wireReferences()) is NOT built here yet: v4's legacy project/scene shape
// doesn't round-trip source_image/references through api.js's
// getProject/saveProject adapter today, so there is nothing yet to wire —
// a placeholder left for the next pass, not a silent gap.
async function _buildQueueInputs(targetSceneIds) {
const raw = {};
const GB = window.GenerateBridge;
if (!GB || !state.project) return raw;
for (const slot of (window.PipelineState.slots() || [])) {
for (const role of (slot.roles || [])) {
if (role.at !== "project.video") continue;
const v = state.project[role.input];
if (v === undefined) continue;
raw[slot.id] = { ...(raw[slot.id] || {}), [role.input]: v };
}
}
const found = GB.slotForRole("generation.prompt");
if (!found) return raw;
const text = targetSceneIds.length > 1
? buildGlobalPromptFromTimeline(state.project)
: (() => {
const sc = scene(targetSceneIds[0]);
const anchor = (state.project.anchor || "").trim();
const own = ((sc && sc.text) || "").trim();
return [anchor, own].filter(Boolean).join(" ");
})();
if (text == null) return raw;
try {
const res = await fetch("/funpack/api/prompt/expand", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text, anchor: state.project.anchor || "", postfix: state.project.postfix || "",
postfix_enabled: !!state.project.postfix_enabled, variables: state.project.variables || [],
// No per-run sampler seed is reachable from here (nothing threads a
// real generation seed through this path yet) — a fresh one per
// queue gives real variety across separate Generate clicks, same
// as app/boot.js's own queueInputs().
seed: Math.floor(Math.random() * 2 ** 31) || 1,
}),
});
const expanded = res.ok ? (await res.json()).text : text;
raw[found.slot.id] = { ...(raw[found.slot.id] || {}), [found.role.input]: expanded };
} catch (_) {
// The literal typed text still queues — the run just runs without
// anchor/shortcuts/$variables applied, rather than being blocked by a
// prompt-craft feature failing.
raw[found.slot.id] = { ...(raw[found.slot.id] || {}), [found.role.input]: text };
}
return raw;
}
async function _pollFfmpegJob(statusFn, jobId, clipCount, busyLabel) {
@@ -3630,6 +3639,8 @@
// "Reference image 2" (input_sources "prevframe") auto-fills from whatever scene rendered
// immediately before the one being generated — only meaningful for a single-scene run.
// Currently unused: v5's reference-image wiring (assets.reference_N) isn't built for
// legacy scenes yet (see _buildQueueInputs's own comment) — kept for when it is.
function _prevSceneMedia(onlyScene) {
if (!onlyScene) return null;
const scenes = (state.project?.scenes || []).filter((s) => !s.excluded);
@@ -3639,65 +3650,71 @@
return media && media.filename ? { filename: media.filename, subfolder: media.subfolder || "" } : null;
}
// Generate one run through v5's real check -> queue -> terminal flow
// (GenerateBridge, wired in generate_bridge.js over app/shell/run.js and
// session.js). `resetSession` (Studio-session reset) and `extraOverrides`
// (REINS captures, the anchor-guide i2v bypass) are v4 Studio/Chain-Sampler
// machinery with no v5 module providing them yet -- said loudly rather than
// silently dropped, per the port plan's "leave placeholders for features".
async function _generateRun(sceneIds, onlyScene, prefix, resetSession, extraOverrides) {
_interrupted = false;
_markGenInFlight(sceneIds);
set({ gen: { state: "queuing", promptId: null, media: [], msg: `${prefix}: queuing…`, step: 0, maxStep: 0 } });
try {
const overrides = [...(_anchorGuideNodeOverrides(sceneIds) || []), ...(extraOverrides || [])];
const r = await _retryOnTunnel(
() => API.generate(state.project.id, onlyScene || null, onlyScene ? null : sceneIds, !!resetSession, overrides, _prevSceneMedia(onlyScene)),
10,
);
if (!r.prompt_id) { set({ gen: { ...state.gen, state: "error", msg: "No prompt id returned." } }); return false; }
_queueRenderPrompts(sceneIds);
if (r.validation && state.project) {
state.project.generation_meta = {
...(state.project.generation_meta || {}),
prompt_hash: r.validation.prompt_hash,
run_hash: r.validation.run_hash,
};
}
// The builder reports values the user SET that did not reach the graph — a linked
// input pointing at a node that was replaced, a widget renamed out from under it. The
// run is valid, it just isn't the one they configured, and nothing used to say so:
// the whole report was being thrown away here.
const ignored = (r.report && r.report.ignored) || [];
// Positive confirmation, console-only: which linked inputs fired and with what value.
// Checking that a project setting actually reached the graph should not require
// reading the queued prompt, but it is not worth a UI element on every run either.
const fired = ((r.report && r.report.wired) || []).filter((w) => w.startsWith("linked "));
if (fired.length) console.info("[FunPack] linked inputs sent this run:\n " + fired.join("\n "));
const notices = [];
if (ignored.length) {
notices.push(ignored.length === 1
? ignored[0]
: `${ignored.length} settings didn't reach the pipeline — ${ignored[0]}`);
}
// A predecessor render existed but its last frame/video couldn't be retrieved (temp
// file gone, no ffmpeg) — must not look identical to "scene 1, nothing to chain from".
// A scene can have both a prevframe AND a prevvideo socket, so both can fail at once —
// collect every match, not just the first, or one silently drops.
const prevChainMisses = ((r.report && r.report.unsatisfied) || [])
.filter((u) => u.includes("previous scene's last frame") || u.includes("previous scene's video"));
notices.push(...prevChainMisses);
if (notices.length) state.notice = notices.join(" — ");
pollStart = Date.now();
let runMsg = `${prefix}: generating…`;
if (r.prompt_repairs_cleared) {
const anchorOnly = r.validation?.anchors_changed_since_last_queue && !r.validation?.text_changed_since_last_queue;
runMsg += anchorOnly
? " · guides refreshed, stale repairs cleared"
: " · stale repairs cleared (training kept)";
}
else if (r.reset_session) runMsg += " · Studio session reset";
set({ gen: { state: "running", promptId: r.prompt_id, media: [], msg: runMsg } });
return await _pollPromise(r.prompt_id, sceneIds, prefix);
} catch (e) {
set({ gen: { state: "error", promptId: null, media: [], msg: _friendlyGenError(e.message) } });
const GB = window.GenerateBridge;
if (!GB) {
set({ gen: { state: "error", promptId: null, media: [], msg: "Generation isn't wired up yet — reload the page." } });
return false;
}
_wireGenerateBridge();
if (resetSession) {
console.warn("[FunPack] a Studio session reset was requested, but v5 has no Studio session to reset yet — ignored.");
}
const overrides = [...(_anchorGuideNodeOverrides(sceneIds) || []), ...(extraOverrides || [])];
if (overrides.length) {
console.warn("[FunPack] this run asked for per-node overrides (REINS captures / the anchor-guide "
+ "i2v bypass) that v5's generate flow does not support yet -- ignored:", overrides);
}
const targetSceneIds = (sceneIds || []).filter(Boolean);
if (!targetSceneIds.length) {
set({ gen: { state: "error", promptId: null, media: [], msg: "No scenes to generate." } });
return false;
}
_genRunPrefix = prefix || "Generating…";
_genRunSceneIds = targetSceneIds;
_markGenInFlight(targetSceneIds);
set({ gen: { state: "queuing", promptId: null, media: [], msg: `${_genRunPrefix}: queuing…`, step: 0, maxStep: 0 } });
try {
let inputs;
try {
inputs = await _buildQueueInputs(targetSceneIds);
} catch (e) {
set({ gen: { state: "error", promptId: null, media: [], msg: `Could not build the prompt: ${e.message}` } });
return false;
}
const waiting = GB.waitForTerminal();
let queued;
try {
queued = await GB.generate({
sceneId: onlyScene || targetSceneIds[0],
projectId: state.project ? state.project.id : null,
inputs,
});
} catch (e) {
waiting.cancel();
set({ gen: { state: "error", promptId: null, media: [], msg: _friendlyGenError(e.message) } });
return false;
}
if (!queued) {
// Refused before it ever reached run.start() (an incomplete pipeline,
// or the queue itself saying no) -- the bridge's own "say" hook
// already turned the reason into state.gen, and nothing will ever
// transition the run this was waiting on.
waiting.cancel();
return false;
}
_queueRenderPrompts(targetSceneIds);
pollStart = Date.now();
return (await waiting) === GB.DONE;
} finally {
_clearGenInFlight(sceneIds);
_clearGenInFlight(targetSceneIds);
}
}
@@ -4628,21 +4645,17 @@
await loadModels();
window.addEventListener("funpack-models-changed", loadModels);
await refreshProjectList();
// Re-attach to a generation already running in ComfyUI (UI was reloaded mid-run).
// Load its project first so Generate is blocked, Interrupt is shown, and the result
// lands on the right scenes — then skip the welcome page, there's work in flight.
// Re-attach to a generation already running in ComfyUI (UI was reloaded mid-run) is
// GenerateBridge's own job (generate_bridge.js, over app/shell/session.js's wire()) --
// it asks ComfyUI's own /queue and /history directly, not anything remembered here,
// and starts the moment its module loads. `ready` resolves once it knows whether it
// found one; the "adopt" hook (see _wireGenerateBridge) loads the right project and
// scene for it as soon as it does. Skip the welcome page in that case — there's work
// in flight.
let resumed = false;
_wireGenerateBridge();
try {
const act = await API.active();
if (act && act.running && act.prompt_id) {
if (act.pid && (state.projects || []).some((p) => p.id === act.pid)
&& (!state.project || state.project.id !== act.pid)) {
await loadProject(act.pid);
}
notify();
resumeRunningGeneration(act); // fire-and-forget poll loop
resumed = true;
}
if (window.GenerateBridge) resumed = await window.GenerateBridge.ready;
} catch (_) { /* queue unreachable — boot normally */ }
notify();
// One front door: the full-screen welcome, unless a running generation was resumed.