From bb404d988dbbc6a393006ecbb2e794a795fc6afd Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:34:58 +0300 Subject: [PATCH] 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 --- app/legacy/generate_bridge.js | 99 ++++++++ app/legacy/index.html | 4 + app/legacy/store.js | 453 +++++++++++++++++----------------- 3 files changed, 336 insertions(+), 220 deletions(-) create mode 100644 app/legacy/generate_bridge.js diff --git a/app/legacy/generate_bridge.js b/app/legacy/generate_bridge.js new file mode 100644 index 00000000..7686587a --- /dev/null +++ b/app/legacy/generate_bridge.js @@ -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(); + }, +}; diff --git a/app/legacy/index.html b/app/legacy/index.html index 72047951..efc4faf5 100644 --- a/app/legacy/index.html +++ b/app/legacy/index.html @@ -121,6 +121,10 @@ + + diff --git a/app/legacy/store.js b/app/legacy/store.js index 2bd0a0cb..29434ae3 100644 --- a/app/legacy/store.js +++ b/app/legacy/store.js @@ -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.