177 lines
8.2 KiB
JavaScript
177 lines
8.2 KiB
JavaScript
// Re-pose background runner. Builds the recipe graph off-screen, queues it, and
|
|
// tracks execution — the user's open workflow is NEVER touched (same contract as
|
|
// upscale-background.js). On success the result is recorded as a lineage CHILD of
|
|
// the source image (parent_filename) so it appears under the family in the gallery.
|
|
|
|
import { app } from "../../../scripts/app.js";
|
|
import { api } from "../../../scripts/api.js";
|
|
import { recordGeneration, externallyTrackedPrompts, armExternalQueue, disarmExternalQueue } from "./history.js";
|
|
import { buildReposeGraph } from "./repose-from-image.js";
|
|
|
|
function toast(severity, detail, life = 8000) {
|
|
app.extensionManager?.toast?.add({ severity, summary: "Re-pose", detail, life });
|
|
}
|
|
|
|
function createTracker() {
|
|
const listeners = new Set();
|
|
const tracker = {
|
|
state: { phase: "building" },
|
|
subscribe(fn) { listeners.add(fn); fn(tracker.state); return () => listeners.delete(fn); },
|
|
emit(state) { tracker.state = state; for (const fn of listeners) { try { fn(state); } catch {} } },
|
|
};
|
|
return tracker;
|
|
}
|
|
|
|
function viewUrl(img) {
|
|
if (!img?.filename) return "";
|
|
const q = new URLSearchParams({ filename: img.filename, subfolder: img.subfolder || "", type: img.type || "output" });
|
|
return api.apiURL ? api.apiURL(`/view?${q}`) : `/view?${q}`;
|
|
}
|
|
|
|
// opts: { recipe (caps entry), modelFilename, prompt, neg, sampler{}, controlMapFilename,
|
|
// referenceFilename, parentFilename }
|
|
export function runReposeInBackground(opts) {
|
|
const tracker = createTracker();
|
|
const run = { cancelled: false, promptId: null, executionStarted: false, cleanup: null };
|
|
|
|
tracker.cancel = async () => {
|
|
if (run.cancelled) return;
|
|
run.cancelled = true;
|
|
tracker.emit({ phase: "cancelled" });
|
|
toast("info", "Re-pose cancelled.");
|
|
try {
|
|
if (run.promptId) {
|
|
if (run.executionStarted) await api.interrupt();
|
|
else {
|
|
await api.fetchApi("/queue", {
|
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ delete: [run.promptId] }),
|
|
});
|
|
run.cleanup?.();
|
|
}
|
|
}
|
|
} catch (e) { console.warn("[Re-pose] cancel request failed", e); }
|
|
};
|
|
|
|
(async () => {
|
|
const recipe = opts.recipe;
|
|
tracker.emit({ phase: "building" });
|
|
const built = await buildReposeGraph({
|
|
templateId: recipe.templateId,
|
|
modelFilename: opts.modelFilename,
|
|
loraStrength: opts.loraStrength ?? recipe.loraStrength,
|
|
referenceFilename: opts.referenceFilename,
|
|
controlMapFilename: opts.controlMapFilename,
|
|
promptDoc: opts.promptDoc,
|
|
sampler: opts.sampler,
|
|
megapixels: opts.megapixels ?? recipe.megapixels,
|
|
});
|
|
if (!built) { tracker.emit({ phase: "error", message: "couldn't build the re-pose graph" }); return; }
|
|
const { graph, workflowId } = built;
|
|
if (run.cancelled) return;
|
|
|
|
tracker.emit({ phase: "queueing" });
|
|
const serialized = await app.graphToPrompt(graph);
|
|
// DIAGNOSTIC: the raw doc handed to PromptChain_PromptChain in the queued
|
|
// prompt. The server's compile_prompt turns this into the encoder strings —
|
|
// strips `//` sections, splits on `Negative Prompt:`, expands tags/outfits —
|
|
// the same single-source compile inpaint/upscale route through. The encoder
|
|
// text now arrives as a LINK from this node (not a literal), so the doc is
|
|
// verified HERE; the compiled result is the server's job.
|
|
try {
|
|
const pcEntry = Object.entries(serialized.output).find(([, n]) => n.class_type === "PromptChain_PromptChain");
|
|
console.log("[Re-pose] prompt → PromptChain compile:", pcEntry?.[1]?.inputs?.prompt ?? "(no PromptChain node!)");
|
|
} catch { /* logging only */ }
|
|
armExternalQueue();
|
|
let promptId = null;
|
|
try {
|
|
const previewMethod = app.extensionManager?.setting?.get?.("Comfy.Execution.PreviewMethod")
|
|
?? app.ui?.settings?.getSettingValue?.("Comfy.Execution.PreviewMethod") ?? "default";
|
|
const res = await api.queuePrompt(0, { output: serialized.output, workflow: serialized.workflow }, { previewMethod });
|
|
promptId = res?.prompt_id || null;
|
|
} catch (e) {
|
|
disarmExternalQueue();
|
|
const message = e?.response?.error?.message || e?.message || "queue rejected the prompt";
|
|
tracker.emit({ phase: "error", message });
|
|
toast("error", `Re-pose failed to queue: ${message}`);
|
|
return;
|
|
}
|
|
if (promptId) externallyTrackedPrompts.add(promptId);
|
|
disarmExternalQueue();
|
|
run.promptId = promptId;
|
|
if (!promptId) { tracker.emit({ phase: "error", message: "queue returned no prompt id" }); return; }
|
|
if (run.cancelled) {
|
|
try {
|
|
await api.fetchApi("/queue", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ delete: [promptId] }) });
|
|
} catch {}
|
|
externallyTrackedPrompts.delete(promptId);
|
|
return;
|
|
}
|
|
|
|
let lastImage = null, lastPreview = null;
|
|
const onStart = ({ detail }) => { if (detail?.prompt_id === promptId) run.executionStarted = true; };
|
|
const onProgress = ({ detail }) => {
|
|
// Scope to OUR prompt: a previous (cancelled) render still draining on the
|
|
// GPU emits step events too — without this filter its steps drive THIS run's
|
|
// bar and it looks like the render "resumed where it left off". Also mark
|
|
// executionStarted here so a fast Cancel reliably interrupts (covers a
|
|
// missed execution_start event).
|
|
if (detail?.prompt_id !== promptId || run.cancelled) return;
|
|
run.executionStarted = true;
|
|
// Preserve previewUrl across ticks so the bar and the streaming preview coexist.
|
|
tracker.emit({ ...tracker.state, phase: "running", value: detail?.value || 0, max: detail?.max || 0 });
|
|
};
|
|
const onPreview = ({ detail }) => {
|
|
// b_preview carries no prompt_id — gate on OUR run actually executing so a
|
|
// still-draining previous run's preview frames don't bleed into this one.
|
|
if (run.cancelled || !run.executionStarted || !(detail instanceof Blob)) return;
|
|
if (lastPreview) URL.revokeObjectURL(lastPreview);
|
|
lastPreview = URL.createObjectURL(detail);
|
|
tracker.emit({ ...tracker.state, phase: "running", previewUrl: lastPreview });
|
|
};
|
|
const onExecuted = ({ detail }) => {
|
|
if (detail?.prompt_id !== promptId || !detail?.output?.images?.length) return;
|
|
lastImage = detail.output.images[detail.output.images.length - 1];
|
|
};
|
|
const cleanup = () => {
|
|
api.removeEventListener("execution_start", onStart);
|
|
api.removeEventListener("progress", onProgress);
|
|
api.removeEventListener("b_preview", onPreview);
|
|
api.removeEventListener("executed", onExecuted);
|
|
api.removeEventListener("execution_success", onSuccess);
|
|
api.removeEventListener("execution_error", onError);
|
|
api.removeEventListener("execution_interrupted", onError);
|
|
if (promptId) externallyTrackedPrompts.delete(promptId);
|
|
};
|
|
run.cleanup = cleanup;
|
|
const onSuccess = async ({ detail }) => {
|
|
if (detail?.prompt_id !== promptId) return;
|
|
cleanup();
|
|
if (!lastImage) { tracker.emit({ phase: "error", message: "re-pose produced no image" }); return; }
|
|
const entry = await recordGeneration(workflowId, lastImage.filename, lastImage.subfolder, "output", {
|
|
parent_filename: opts.parentFilename || "",
|
|
});
|
|
tracker.emit({ phase: "done", resultUrl: viewUrl(lastImage), resultHash: entry?.hash || null, entry });
|
|
};
|
|
const onError = ({ detail }) => {
|
|
if (detail?.prompt_id !== promptId) return;
|
|
cleanup();
|
|
tracker.emit({ phase: "error", message: detail?.exception_message || "re-pose render failed" });
|
|
};
|
|
api.addEventListener("execution_start", onStart);
|
|
api.addEventListener("progress", onProgress);
|
|
api.addEventListener("b_preview", onPreview);
|
|
api.addEventListener("executed", onExecuted);
|
|
api.addEventListener("execution_success", onSuccess);
|
|
api.addEventListener("execution_error", onError);
|
|
api.addEventListener("execution_interrupted", onError);
|
|
tracker.emit({ phase: "running" });
|
|
})().catch((e) => {
|
|
console.error("[Re-pose] background run failed", e);
|
|
tracker.emit({ phase: "error", message: e?.message || "unexpected failure" });
|
|
toast("error", `Re-pose failed: ${e?.message || e}`);
|
|
});
|
|
|
|
return tracker;
|
|
}
|