Files
malkuthro-ComfyUI-Koolook/web/setup_runner_simulator.js
6f158c9020 fix(nodes): Easy_LoadVideo empty-branch degrade (bundles #243 publish-tab cleanup) (#244)
* docs(setups): add setup runner quickstart runbook

Standalone reference for opening the published-setup mockup frontend
(setup runner simulator): the `<comfy-address>/koolook/setup_runner_simulator.html`
URL pattern, the live-registry vs setup-file (inspect-only) load paths,
and demo mode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(sidebar): close throwaway workflow tabs left by publish capture

Publishing a setup captures ComfyUI's API-format prompt by loading the
graph onto the canvas (graphToPrompt) and reloading the prior canvas.
In current Comfy each loadGraphData spawns an "Unsaved Workflow" tab, so
every publish left two stray tabs behind.

captureWorkflowApiPrompt now snapshots the open workflows before capture
and, afterward, re-activates the user's original tab and closes whatever
the capture opened, via app.extensionManager.workflow. All of it is
guarded and best-effort: the workflow-store API is Comfy-version
specific, so if it is absent capture still succeeds and behavior falls
back to today (no crash, no broken publish).

Covered by a source-pattern test. Needs live verification against the
1.45 frontend (dev-sync) to confirm the closeWorkflow API shape.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(nodes): Easy_LoadVideo degrades to empty on an unselected folder branch

When an upstream mode switch routes a path into Easy_LoadVideo, both the
EXR sequence loader and the video loader execute eagerly. On the EXR
branch the video loader was handed a folder (no filename) and raised
ValueError, crashing the whole prompt -- even though that branch is
discarded downstream. Lenient sequence loaders just ignore a wrong-type
path; the video loader was the only one that hard-failed.

_compose_input_video_path now returns EMPTY_BRANCH_SENTINEL instead of
raising when input_path resolves to an existing directory. load_video
turns that into a correctly-shaped empty result (IMAGE zeros,
frame_count 0, empty AUDIO, empty video_info) matching VHS_LoadVideoPath
returns, so the dead branch no longer crashes the prompt. VALIDATE_INPUTS
and IS_CHANGED handle the sentinel too. A path that does not exist is
still a genuine mistake and keeps raising.

torch is imported lazily in the empty-result builder so the path logic
stays importable/testable without torch.

Covered by unit tests for the sentinel (absolute + relative dirs), the
still-raises case, and a load_video integration test that asserts the
empty result and that the loader never runs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(setups): expose prompt as an always-on field in the publish frontend

The publish surface modelled `prompt` as a 4th source mode (mutually
exclusive with EXR/QT/Img) and hid it (visible:false), so a published
setup couldn't offer "pick a source AND describe the shot". This makes
prompt a standalone, always-on input.

- published_surface.js: prompt becomes visible + standalone + multiline,
  rendered independently of the source switch; the author's prompt-widget
  text is surfaced as a `placeholder` hint (submitted default is empty so
  an untouched hint is never sent), with a `help` line. The switch
  excludes standalone fields from its source options.
- setup_runner_simulator.js: renders always-on standalone inputs alongside
  the selected source (textarea + placeholder + help), includes them in the
  run payload, and updates the demo setup.
- setup_runner_simulator.html: .field-help styling.
- contract doc: documents the always-on prompt behaviour.
- launch.json: portability fix (hardcoded C:\Python313 -> python).

Contract test updated + a new test asserting prompt is always-on, carries
the placeholder, and is excluded from the switch source options (runs on
CI where Node is available). Behaviour verified in the runner simulator
(demo mode) in a browser: textarea + hint, EXR/QT/Img-only switch, prompt
stays visible across source changes, payload includes the typed prompt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(setups): mirror always-on prompt field in the Python publish inference

The publish surface is inferred server-side in Python (koolook_setups.py
_infer_app_surface, called by publishSetup), which overrides the client.
The earlier change only updated the JS (published_surface.js), so
re-publishing still produced the old surface with a hidden prompt.

Mirror the JS here: prompt is a standalone, always-on input (visible +
multiline) with the author's prompt-widget text as a placeholder hint and
an empty submitted default; it is excluded from the source switch options.
Keeps koolook_setups.py and published_surface.js in sync.

Registry test updated to the new shape. Server + publish-contract suites
green (115); full non-sidebar suite green (530).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(setups): keep the runner simulator polling through long renders

The poller used a fixed 30s total budget, so a multi-minute render (e.g.
428s) timed out mid-run with "Timed out waiting for run" even though
ComfyUI was still rendering and the run later landed as succeeded.

timeoutMs is now a "no-progress" window: while ComfyUI keeps reporting the
run as queued/running, the deadline is pushed forward, so a long-but-active
render never spuriously times out; only a stalled/vanished run gives up.
Default raised 30s -> 120s. The run status line shows elapsed seconds while
active so a long wait visibly ticks instead of looking frozen.

Happy path (queued -> running -> succeeded) unchanged; verified in the
runner simulator (demo mode) in a browser.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(setups): serve simulator assets with no-store so dev-syncs take effect

The runner-simulator HTML/JS route returned no cache headers, so browsers
cached the assets and kept running stale JS after a dev-sync -- e.g. the
old 30s poller persisted client-side even though the server was serving
the long-render fix, which looked like "the fix didn't land". Serve these
dev-synced assets with Cache-Control: no-store so a normal reload always
picks up the current code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(setups): add live run/poll smoke check for the published-setup API

tools/check_setup_run.py queues a published setup through the same routes
the runner simulator uses and polls the run endpoint with the same
deadline-extension logic, printing the status timeline. Lets us self-verify
end-to-end run tracking against a live ComfyUI (e.g. "does a long render
reach succeeded?") without driving the browser UI.

Used it to confirm the run/poll server flow is correct (terminal status
detected in ~2s; run-000001 reported succeeded), isolating the simulator's
earlier "Timed out" to stale browser cache rather than a server bug.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(nodes): Koolook_NextVersion auto-detects the next output version

Adds filesystem-aware version auto-detection so a re-run lands on a fresh
version instead of failing with "Output file already exists and overwrite
is disabled" (the real EasyAIPipeline failure seen on the RMGB setup).

- koolook_versioning.next_version_token(directory, name, ...): scans for
  existing <name>_vNNN files AND sequence-folders (handles name_v002.png,
  name_v002.0001.exr, name_v003/), returns highest+1 zero-padded; missing/
  empty dir -> start (v001); exact base-name match so other shots never
  bump this one; empty name matches bare vNNN; configurable prefix/padding.
- Koolook_NextVersion node (Koolook/Publish): folder + name -> next version
  token. Wire once into both the EXR and video version inputs so branches
  stay in sync. IS_CHANGED returns NaN to re-scan every run.

Tested: helper edge cases + node registration/run. Preflight clean (20
node IDs). Full non-sidebar suite green (539).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(nodes): 'auto' version keyword for EasyAIPipeline + Easy_VideoCombine

No new node or wiring needed: type 'auto' (or 'next') in the version field
and the node scans its own output folder for existing <name>_vNNN entries
and writes the next free version -- fixing "Output file already exists and
overwrite is disabled" without manual version bumps.

- koolook_versioning.is_auto_version(): detects the auto/next keyword.
- EasyAIPipeline (build_pipeline_outputs): on auto, scans the version-folder
  parent (base, or base/shot/method when subfolders are on) for vNNN dirs and
  picks the next; version computed after path segments so the scan dir is known.
- Easy_VideoCombine: on auto, resolves the token after the output dir + name
  root are composed, scanning that dir for <name>_vNNN outputs.
- Non-auto/typed/wired tokens and disable_versioning are unchanged.

Tests: is_auto_version keywords + EasyAIPipeline auto (v003 after v001/v002,
v001 first run). Full non-sidebar suite green (542), preflight clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(nodes): drop redundant Koolook_NextVersion node

The 'auto' version keyword on EasyAIPipeline + Easy_VideoCombine covers
auto-detection without any new node or wiring, so the standalone
Koolook_NextVersion node is redundant. Remove it (and its node test) to keep
the node list clean; the shared next_version_token helper stays (the 'auto'
keyword uses it).

Node-ID count back to 19; full non-sidebar suite green (541), preflight clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(tools): satisfy security gate for setup smoke check

* fix(pr244): address verified review findings

* fix(video): fail if relative auto-version scan root is unavailable

---------

Co-authored-by: Cristian Predut <cristian.predut@x.baconcph.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: AI Machine <ai.machine@x.baconcph.com>
2026-06-19 00:10:59 +02:00

822 lines
34 KiB
JavaScript

// SPDX-FileCopyrightText: 2025-2026 Kforge Labs <https://github.com/malkuthro/ComfyUI-Koolook>
// SPDX-License-Identifier: GPL-3.0-only
const TERMINAL_RUN_STATUSES = new Set(["succeeded", "failed", "lost"]);
const ACTIVE_RUN_STATUSES = new Set(["queued", "running"]);
const DEFAULT_LOCAL_COMFY_BASE = "http://127.0.0.1:8188";
const LOCAL_SETUP_FILE_FLAG = "__koolookLocalSetupFile";
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function normalizeBaseUrl(value) {
return String(value || "").trim().replace(/\/+$/, "");
}
function apiUrl(baseUrl, path) {
return `${normalizeBaseUrl(baseUrl)}${path}`;
}
function isFilePage() {
return globalThis.location?.protocol === "file:";
}
function defaultBaseUrl() {
return isFilePage() ? DEFAULT_LOCAL_COMFY_BASE : "";
}
async function readJsonResponse(response, fallbackMessage) {
let body;
try {
body = await response.json();
} catch (error) {
const message = fallbackMessage || `Request failed (${response.status}).`;
const responseError = new Error(`${message} Response was not valid JSON.`);
responseError.status = response.status;
throw responseError;
}
if (!response.ok || body.ok === false) {
const errors = Array.isArray(body.errors) && body.errors.length
? body.errors
: [fallbackMessage || `Request failed (${response.status}).`];
const error = new Error(errors.join("; "));
error.payload = body;
error.status = response.status;
throw error;
}
return body;
}
function responseShapeError(message, payload) {
const error = new Error(message);
error.payload = payload;
return error;
}
function requireObject(value, message) {
if (value && typeof value === "object" && !Array.isArray(value)) return value;
throw responseShapeError(message, value);
}
function isPublishedSetupRecord(value) {
return value
&& typeof value === "object"
&& !Array.isArray(value)
&& typeof value.id === "string"
&& value.metadata
&& typeof value.metadata === "object"
&& value.visualGraph
&& typeof value.visualGraph === "object"
&& value.apiPrompt
&& typeof value.apiPrompt === "object"
&& !Array.isArray(value.apiPrompt)
&& value.setupSurface
&& typeof value.setupSurface === "object"
&& value.source
&& typeof value.source === "object";
}
function setupFileValidationError(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return "record must be an object";
}
if (typeof value.id !== "string" || !value.id.trim()) return "id must be text";
if (!value.metadata || typeof value.metadata !== "object" || Array.isArray(value.metadata)) return "metadata must be an object";
if (!value.visualGraph || typeof value.visualGraph !== "object" || Array.isArray(value.visualGraph)) return "visualGraph must be an object";
if (!value.apiPrompt || typeof value.apiPrompt !== "object" || Array.isArray(value.apiPrompt)) return "apiPrompt must be an object";
if (!value.setupSurface || typeof value.setupSurface !== "object" || Array.isArray(value.setupSurface)) return "setupSurface must be an object";
if (!value.source || typeof value.source !== "object" || Array.isArray(value.source)) return "source must be an object";
return "record is invalid";
}
export function setupsFromPublishedSetupFile(value) {
let setups;
if (Array.isArray(value)) {
setups = value;
} else if (value && typeof value === "object" && !Array.isArray(value) && ("id" in value || "schemaVersion" in value)) {
if (!isPublishedSetupRecord(value)) {
throw responseShapeError(`Setup file contains an invalid published setup record: ${setupFileValidationError(value)}.`, value);
}
setups = [value];
} else if (value && typeof value === "object" && Array.isArray(value.setups)) {
setups = value.setups;
} else {
throw responseShapeError("Setup file must contain one published setup record or an object with a setups array.", value);
}
const invalid = setups.find(setup => !isPublishedSetupRecord(setup));
if (invalid) {
throw responseShapeError(`Setup file contains an invalid published setup record: ${setupFileValidationError(invalid)}.`, invalid);
}
return setups;
}
export function localSetupFileRecords(value) {
return setupsFromPublishedSetupFile(value).map(setup => ({
...setup,
[LOCAL_SETUP_FILE_FLAG]: true,
}));
}
export function setupCanRunFromRegistry(setup) {
return !Boolean(setup?.[LOCAL_SETUP_FILE_FLAG]);
}
export function localSetupRunMessage(setup) {
const title = setupDisplayTitle(setup);
return [
`"${title}" was loaded from a file for contract review.`,
"",
"The simulator can render local setup files, but Run uses the live Koolook registry API.",
"Publish this setup into the running ComfyUI registry, then load it from the API before running it.",
].join("\n");
}
function requireRunPayload(value, message, { requirePromptId = false } = {}) {
const run = requireObject(value, message);
if (typeof run.runId !== "string" || !run.runId.trim()) {
throw responseShapeError(`${message} Missing runId.`, run);
}
if (typeof run.status !== "string" || !run.status.trim()) {
throw responseShapeError(`${message} Missing status.`, run);
}
if (requirePromptId && (typeof run.promptId !== "string" || !run.promptId.trim())) {
throw responseShapeError(`${message} Missing promptId.`, run);
}
if ("promptId" in run && run.promptId != null && typeof run.promptId !== "string") {
throw responseShapeError(`${message} promptId must be text when present.`, run);
}
return run;
}
export async function listPublishedSetups({ baseUrl = "", fetchImpl = fetch } = {}) {
const response = await fetchImpl(apiUrl(baseUrl, "/koolook/api/setups"));
const body = await readJsonResponse(response, `Setup list request failed (${response.status}).`);
if (Array.isArray(body)) return body;
if (body && typeof body === "object" && Array.isArray(body.setups)) return body.setups;
throw responseShapeError("Setup list response must be an array or an object with a setups array.", body);
}
export async function getPublishedSetup({ setupId, baseUrl = "", fetchImpl = fetch }) {
const id = typeof setupId === "string" ? setupId.trim() : "";
if (!id) throw new Error("Published setup id is required.");
const response = await fetchImpl(apiUrl(baseUrl, `/koolook/api/setups/${encodeURIComponent(id)}`));
const body = await readJsonResponse(response, `Setup detail request failed (${response.status}).`);
return requireObject(body, "Setup detail response must be an object.");
}
export async function runPublishedSetup({
setupId,
inputs = {},
baseUrl = "",
fetchImpl = fetch,
}) {
const id = typeof setupId === "string" ? setupId.trim() : "";
if (!id) throw new Error("Published setup id is required.");
const response = await fetchImpl(apiUrl(baseUrl, `/koolook/api/setups/${encodeURIComponent(id)}/run`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ inputs: inputs && typeof inputs === "object" ? inputs : {} }),
});
const body = await readJsonResponse(response, `Run request failed (${response.status}).`);
const payload = requireObject(body, "Run response must be an object.");
return requireRunPayload(payload.run, "Run response must include a valid run object.", {
requirePromptId: true,
});
}
export async function getPublishedSetupRun({ runId, baseUrl = "", fetchImpl = fetch }) {
const id = typeof runId === "string" ? runId.trim() : "";
if (!id) throw new Error("Published setup run id is required.");
const response = await fetchImpl(apiUrl(baseUrl, `/koolook/api/runs/${encodeURIComponent(id)}`));
const body = await readJsonResponse(response, `Run status request failed (${response.status}).`);
const payload = requireObject(body, "Run status response must be an object.");
return requireRunPayload(payload.run, "Run status response must include a valid run object.");
}
export async function runAndPollPublishedSetup({
setupId,
inputs = {},
baseUrl = "",
fetchImpl = fetch,
intervalMs = 1000,
timeoutMs = 120000,
sleepImpl = sleep,
onUpdate,
}) {
const queued = await runPublishedSetup({ setupId, inputs, baseUrl, fetchImpl });
if (typeof onUpdate === "function") onUpdate(queued);
const runId = queued?.runId;
if (!runId || TERMINAL_RUN_STATUSES.has(String(queued.status || "").toLowerCase())) {
return queued;
}
let lastRun = queued;
let lastProgress = runProgressSignature(queued);
// Renders can run for many minutes, so timeoutMs is a "no progress" window
// rather than a total budget. The deadline resets only when the run payload
// changes in a meaningful way; the same queued/running response forever is
// treated as stalled instead of active progress.
let deadline = Date.now() + timeoutMs;
while (Date.now() <= deadline) {
await sleepImpl(intervalMs);
lastRun = await getPublishedSetupRun({ runId, baseUrl, fetchImpl });
if (typeof onUpdate === "function") onUpdate(lastRun);
const status = String(lastRun?.status || "").toLowerCase();
if (TERMINAL_RUN_STATUSES.has(status)) return lastRun;
if (ACTIVE_RUN_STATUSES.has(status)) {
const progress = runProgressSignature(lastRun);
if (progress !== lastProgress) {
lastProgress = progress;
deadline = Date.now() + timeoutMs;
}
}
}
const error = new Error(`Timed out waiting for run ${runId}.`);
error.run = lastRun;
throw error;
}
function runProgressSignature(run) {
if (!run || typeof run !== "object") return "";
const outputs = Array.isArray(run.outputs)
? run.outputs.map(output => ({
key: output?.key,
itemCount: Array.isArray(output?.items) ? output.items.length : 0,
values: Array.isArray(output?.items)
? output.items.map(item => item?.value ?? "").filter(Boolean)
: [],
}))
: [];
return JSON.stringify({
status: String(run.status || "").toLowerCase(),
promptId: run.promptId || "",
queuePosition: run.queuePosition ?? run.position ?? null,
updatedAt: run.updatedAt || run.timestamp || run.lastUpdate || "",
outputs,
});
}
export function formatRun(run) {
if (!run || typeof run !== "object") return "No run payload.";
const lines = [];
if (run.runId) lines.push(`Run: ${run.runId}`);
if (run.setupId) lines.push(`Setup: ${run.setupId}`);
if (run.promptId) lines.push(`Prompt: ${run.promptId}`);
if (run.status) lines.push(`Status: ${run.status}`);
const resultLines = summarizeRunResultLines(run);
if (resultLines.length) {
lines.push("");
lines.push("Result:");
lines.push(...resultLines);
}
if (Array.isArray(run.outputs) && run.outputs.length) {
lines.push("");
lines.push("Outputs:");
lines.push(JSON.stringify(run.outputs, null, 2));
}
const details = {};
for (const [key, value] of Object.entries(run)) {
if (!["runId", "setupId", "promptId", "status", "outputs"].includes(key)) {
details[key] = value;
}
}
if (Object.keys(details).length) {
lines.push("");
lines.push("Details:");
lines.push(JSON.stringify(details, null, 2));
}
return lines.join("\n") || JSON.stringify(run, null, 2);
}
export function formatError(error) {
if (error?.payload && typeof error.payload === "object") {
return JSON.stringify(error.payload, null, 2);
}
if (error?.run && typeof error.run === "object") {
return `${error.message}\n\nLast status:\n${formatRun(error.run)}`;
}
return error?.message || "Request failed.";
}
export function summarizeRunResultLines(run) {
if (!Array.isArray(run?.outputs)) return [];
const lines = [];
for (const output of run.outputs) {
const label = output?.label || output?.key || "Output";
const items = Array.isArray(output?.items) ? output.items : [];
for (const item of items) {
if (item?.value == null || item.value === "") continue;
lines.push(`${label}: ${String(item.value)}`);
}
}
return lines;
}
export function setupDisplayTitle(setup) {
return setup?.metadata?.title || setup?.title || setup?.id || "(untitled)";
}
function appSurface(setup) {
const app = setup?.setupSurface?.app;
return app && typeof app === "object" ? app : {};
}
export function setupHasAppSurface(setup) {
const app = appSurface(setup);
return Boolean(
app.switch
|| (Array.isArray(app.inputs) && app.inputs.length)
|| (Array.isArray(app.outputs) && app.outputs.length)
);
}
function fieldsByKey(fields) {
const out = {};
if (!Array.isArray(fields)) return out;
for (const field of fields) {
if (field && typeof field === "object" && typeof field.key === "string" && field.key) {
out[field.key] = field;
}
}
return out;
}
function visibleSwitchOptions(app) {
const options = Array.isArray(app?.switch?.options) ? app.switch.options : [];
return options.filter(option => option && typeof option === "object" && option.visible !== false);
}
function defaultSwitchValue(app) {
const visibleOptions = visibleSwitchOptions(app);
const defaultValue = app?.switch?.default;
if (visibleOptions.some(option => option.value === defaultValue)) return defaultValue;
return visibleOptions.length ? visibleOptions[0].value : defaultValue;
}
function fieldDefault(field) {
return field?.default == null ? "" : field.default;
}
export function defaultRunInputsFromSetup(setup) {
const app = appSurface(setup);
const inputs = {};
const switchField = app.switch && typeof app.switch === "object" ? app.switch : null;
const selectedSwitch = switchField ? defaultSwitchValue(app) : undefined;
if (switchField?.key && selectedSwitch !== undefined) {
inputs[switchField.key] = selectedSwitch;
}
const inputFields = fieldsByKey(app.inputs);
const selectedOption = visibleSwitchOptions(app).find(option => option.value === selectedSwitch);
if (selectedOption?.input && inputFields[selectedOption.input]) {
inputs[selectedOption.input] = fieldDefault(inputFields[selectedOption.input]);
} else if (Array.isArray(app.inputs)) {
for (const field of app.inputs) {
if (field?.visible !== false && field?.key) inputs[field.key] = fieldDefault(field);
}
}
if (Array.isArray(app.inputs)) {
for (const field of app.inputs) {
if (field?.standalone && field?.visible !== false && field?.key) {
inputs[field.key] = fieldDefault(field);
}
}
}
if (Array.isArray(app.outputs)) {
for (const field of app.outputs) {
if (field?.visible !== false && field?.key) inputs[field.key] = fieldDefault(field);
}
}
return inputs;
}
export function runInputsFromAppValues(setup, values = {}) {
const app = appSurface(setup);
const inputs = {};
const switchField = app.switch && typeof app.switch === "object" ? app.switch : null;
const selectedSwitch = switchField?.key && switchField.key in values
? values[switchField.key]
: defaultSwitchValue(app);
if (switchField?.key && selectedSwitch !== undefined) {
inputs[switchField.key] = selectedSwitch;
}
const inputFields = fieldsByKey(app.inputs);
const selectedOption = visibleSwitchOptions(app).find(option => String(option.value) === String(selectedSwitch));
if (selectedOption?.input && inputFields[selectedOption.input]) {
inputs[selectedOption.input] = values[selectedOption.input] ?? fieldDefault(inputFields[selectedOption.input]);
} else if (Array.isArray(app.inputs)) {
for (const field of app.inputs) {
if (field?.visible !== false && field?.key) {
inputs[field.key] = values[field.key] ?? fieldDefault(field);
}
}
}
if (Array.isArray(app.inputs)) {
for (const field of app.inputs) {
if (field?.standalone && field?.visible !== false && field?.key) {
inputs[field.key] = values[field.key] ?? fieldDefault(field);
}
}
}
if (Array.isArray(app.outputs)) {
for (const field of app.outputs) {
if (field?.visible !== false && field?.key) {
inputs[field.key] = values[field.key] ?? fieldDefault(field);
}
}
}
return inputs;
}
function formatFetchFailure(error, baseUrl) {
const message = formatError(error);
if (!/failed to fetch|load failed|networkerror/i.test(message)) return message;
const base = baseUrl || DEFAULT_LOCAL_COMFY_BASE;
return [
message,
"",
"The simulator could not reach the Koolook API.",
`Check that ComfyUI is running at ${base}, or open this page from the Koolook route:`,
`${base}/koolook/setup_runner_simulator.html`,
].join("\n");
}
let demoRunInputs = {};
function demoFetch(url, options = {}) {
const path = String(url).replace(/^https?:\/\/[^/]+/, "");
const ok = body => ({ ok: true, status: 200, async json() { return body; } });
if (path.endsWith("/koolook/api/setups") && !options.method) {
return ok({
ok: true,
setups: [
{
id: "director-demo",
title: "Director Demo",
description: "Sample published setup",
category: "Video",
tags: ["demo"],
validation: { status: "valid" },
},
],
});
}
if (path.endsWith("/koolook/api/setups/director-demo")) {
return ok({
id: "director-demo",
metadata: { title: "Director Demo", description: "Sample published setup" },
inputContract: { inputs: [] },
outputContract: { outputs: [{ key: "video", label: "Video", type: "video" }] },
setupSurface: {
app: {
switch: {
key: "switch",
label: "Input type",
default: 2,
options: [
{ value: 0, label: "EXR", input: "sequence_folder", visible: true },
{ value: 1, label: "QT", input: "qt_file", visible: true },
{ value: 2, label: "Img", input: "single_file", visible: true },
{ value: 3, label: "Prompt", input: "prompt", visible: false },
],
},
inputs: [
{ key: "sequence_folder", label: "Sequence folder", visible: true, default: "/shots/demo/plates" },
{ key: "qt_file", label: "QT file", visible: true, default: "/shots/demo/source.mov" },
{ key: "single_file", label: "Single file", visible: true, default: "/shots/demo/source.png" },
{
key: "prompt",
label: "Prompt",
visible: true,
standalone: true,
multiline: true,
default: "",
placeholder: "a bear talking to the camera in a sunny forest",
help: "Describe the shot in one simple line: subject + action + setting.",
},
],
outputs: [
{ key: "folder", label: "Output folder", visible: true, default: "/shots/demo/output" },
{ key: "name", label: "Output name", visible: true, default: "demo" },
{ key: "version", label: "Version", visible: true, default: "1" },
],
results: [{ key: "result", label: "Result", visible: true, default: "" }],
},
},
validation: { status: "valid" },
});
}
if (path.endsWith("/koolook/api/setups/director-demo/run")) {
const request = JSON.parse(options.body || "{}");
demoRunInputs = request.inputs && typeof request.inputs === "object" ? request.inputs : {};
return ok({ ok: true, run: { runId: "run-000001", promptId: "prompt-123", status: "queued" } });
}
if (path.endsWith("/koolook/api/runs/run-000001")) {
const folder = String(demoRunInputs.folder || "/shots/demo/output").replace(/\/+$/, "");
const name = String(demoRunInputs.name || "demo");
const rawVersion = String(demoRunInputs.version || "1");
const version = rawVersion.padStart(3, "0");
const extension = Number(demoRunInputs.switch) === 1 ? "mov" : "png";
return ok({
ok: true,
run: {
runId: "run-000001",
setupId: "director-demo",
promptId: "prompt-123",
status: "succeeded",
comfyStatus: { completed: true, status_str: "success" },
outputs: [
{
key: "result",
label: "Result",
type: "result",
items: [{ nodeId: "300", kind: "text", value: `${folder}/${name}_v${version}.${extension}` }],
},
],
},
});
}
return { ok: false, status: 404, async json() { return { ok: false, errors: ["demo route not found"] }; } };
}
function bindSimulator(documentRef, fetchImpl) {
const root = documentRef.querySelector("[data-simulator]");
if (!root) return;
const baseInput = root.querySelector("[data-base-url]");
const loadBtn = root.querySelector("[data-load-setups]");
const fileInput = root.querySelector("[data-setup-file]");
const setupSelect = root.querySelector("[data-setup-select]");
const inputsText = root.querySelector("[data-inputs-json]");
const appForm = root.querySelector("[data-app-form]");
const rawInputs = root.querySelector("[data-raw-inputs]");
const runBtn = root.querySelector("[data-run-setup]");
const statusText = root.querySelector("[data-status]");
const detailText = root.querySelector("[data-detail]");
const routeLink = root.querySelector("[data-route-link]");
const state = { setups: [], selectedSetup: null, formActive: false, selectedSetupCanRun: false };
const baseUrl = () => baseInput.value.trim();
const setStatus = value => { statusText.textContent = value; };
const setDetail = value => { detailText.textContent = value; };
function populateSetups(setups) {
state.setups = Array.isArray(setups) ? setups : [];
setupSelect.innerHTML = "";
for (const setup of state.setups) {
const opt = documentRef.createElement("option");
opt.value = setup.id || "";
opt.textContent = setupDisplayTitle(setup);
setupSelect.appendChild(opt);
}
runBtn.disabled = true;
}
function updatePayloadPreview() {
if (!state.selectedSetup) {
inputsText.value = "{}";
return;
}
if (state.formActive) {
inputsText.value = JSON.stringify(collectFormInputs(), null, 2);
}
}
function fieldLabelText(field) {
return field?.label || field?.key || "Field";
}
function createInputField(field, { name, value } = {}) {
const label = documentRef.createElement("label");
label.dataset.fieldKey = name || field.key;
label.textContent = fieldLabelText(field);
const input = documentRef.createElement(field?.multiline ? "textarea" : "input");
input.dataset.appField = name || field.key;
input.value = value == null ? "" : String(value);
if (field?.placeholder) input.placeholder = String(field.placeholder);
label.appendChild(input);
if (field?.help) {
const help = documentRef.createElement("small");
help.className = "field-help";
help.textContent = String(field.help);
label.appendChild(help);
}
input.addEventListener("input", updatePayloadPreview);
return label;
}
function renderAppForm(setup) {
if (!appForm) return;
appForm.innerHTML = "";
state.selectedSetup = setup;
state.selectedSetupCanRun = setupCanRunFromRegistry(setup);
state.formActive = setupHasAppSurface(setup);
if (inputsText) inputsText.readOnly = state.formActive;
if (rawInputs) rawInputs.open = !state.formActive;
const app = appSurface(setup);
const inputFields = fieldsByKey(app.inputs);
const options = visibleSwitchOptions(app);
if (!state.formActive) {
appForm.textContent = "This setup does not expose an app form yet. Use raw inputs JSON below.";
if (inputsText) inputsText.value = "{}";
return;
}
const heading = documentRef.createElement("div");
heading.className = "form-heading";
heading.textContent = "External app form";
appForm.appendChild(heading);
if (app.switch && options.length) {
const switchLabel = documentRef.createElement("label");
switchLabel.textContent = app.switch.label || "Input type";
const select = documentRef.createElement("select");
select.dataset.appSwitch = app.switch.key || "switch";
const selectedDefault = defaultSwitchValue(app);
for (const option of options) {
const opt = documentRef.createElement("option");
opt.value = String(option.value);
opt.textContent = option.label || String(option.value);
if (String(option.value) === String(selectedDefault)) opt.selected = true;
select.appendChild(opt);
}
switchLabel.appendChild(select);
appForm.appendChild(switchLabel);
const sourceWrap = documentRef.createElement("div");
sourceWrap.dataset.sourceFieldWrap = "1";
appForm.appendChild(sourceWrap);
const renderSourceField = () => {
sourceWrap.innerHTML = "";
const selected = options.find(option => String(option.value) === String(select.value));
const field = selected?.input ? inputFields[selected.input] : null;
if (field) sourceWrap.appendChild(createInputField(field, { name: selected.input, value: fieldDefault(field) }));
updatePayloadPreview();
};
select.addEventListener("change", renderSourceField);
renderSourceField();
// Always-on inputs (e.g. the prompt) render alongside the source,
// independent of the selected switch mode.
for (const field of app.inputs || []) {
if (field?.standalone && field?.visible !== false && field?.key) {
appForm.appendChild(createInputField(field, { value: fieldDefault(field) }));
}
}
} else if (Array.isArray(app.inputs)) {
for (const field of app.inputs) {
if (field?.visible !== false && field?.key) {
appForm.appendChild(createInputField(field, { value: fieldDefault(field) }));
}
}
}
const visibleOutputs = Array.isArray(app.outputs)
? app.outputs.filter(field => field?.visible !== false && field?.key)
: [];
if (visibleOutputs.length) {
const outputHeading = documentRef.createElement("div");
outputHeading.className = "form-heading";
outputHeading.textContent = "Output";
appForm.appendChild(outputHeading);
for (const field of visibleOutputs) {
appForm.appendChild(createInputField(field, { value: fieldDefault(field) }));
}
}
updatePayloadPreview();
}
function collectFormInputs() {
if (!state.selectedSetup) return {};
const values = {};
const switchEl = appForm?.querySelector("[data-app-switch]");
if (switchEl) {
const numeric = Number(switchEl.value);
values[switchEl.dataset.appSwitch] = Number.isNaN(numeric) ? switchEl.value : numeric;
}
for (const input of appForm?.querySelectorAll("[data-app-field]") || []) {
values[input.dataset.appField] = input.value;
}
return runInputsFromAppValues(state.selectedSetup, values);
}
async function loadSetups() {
setStatus("Loading setups...");
setDetail("");
runBtn.disabled = true;
try {
state.setups = await listPublishedSetups({ baseUrl: baseUrl(), fetchImpl });
populateSetups(state.setups);
setStatus(state.setups.length ? `${state.setups.length} setup(s) loaded.` : "No published setups found.");
if (state.setups.length) await showSelectedSetup();
else runBtn.disabled = true;
} catch (error) {
setStatus("Setup list failed.");
setDetail(formatFetchFailure(error, baseUrl()));
}
}
async function showSelectedSetup() {
if (!setupSelect.value) return;
try {
const localSetup = state.setups.find(setup => setup?.id === setupSelect.value && isPublishedSetupRecord(setup));
const setup = localSetup || await getPublishedSetup({ setupId: setupSelect.value, baseUrl: baseUrl(), fetchImpl });
renderAppForm(setup);
runBtn.disabled = !state.selectedSetupCanRun;
setDetail(state.selectedSetupCanRun
? JSON.stringify(setup, null, 2)
: `${localSetupRunMessage(setup)}\n\n${JSON.stringify(setup, null, 2)}`);
} catch (error) {
setDetail(formatFetchFailure(error, baseUrl()));
runBtn.disabled = true;
}
}
async function loadSetupFile() {
const file = fileInput?.files?.[0];
if (!file) return;
setStatus("Loading setup file...");
try {
const parsed = JSON.parse(await file.text());
populateSetups(localSetupFileRecords(parsed));
setStatus(`${state.setups.length} setup(s) loaded from file.`);
if (state.setups.length) await showSelectedSetup();
else runBtn.disabled = true;
} catch (error) {
setStatus("Setup file failed.");
setDetail(formatError(error));
runBtn.disabled = true;
}
}
async function runSelectedSetup() {
if (!setupSelect.value) return;
if (!state.selectedSetupCanRun) {
setStatus("Setup file is inspect-only.");
setDetail(localSetupRunMessage(state.selectedSetup));
runBtn.disabled = true;
return;
}
let inputs;
try {
inputs = state.formActive ? collectFormInputs() : JSON.parse(inputsText.value || "{}");
} catch (error) {
setStatus("Inputs JSON is invalid.");
setDetail(error.message);
return;
}
runBtn.disabled = true;
setStatus("Queueing setup...");
const startedAt = Date.now();
try {
const finalRun = await runAndPollPublishedSetup({
setupId: setupSelect.value,
inputs,
baseUrl: baseUrl(),
fetchImpl,
onUpdate: run => {
const elapsed = Math.round((Date.now() - startedAt) / 1000);
const active = ACTIVE_RUN_STATUSES.has(String(run.status || "").toLowerCase());
setStatus(`Run ${run.status || "unknown"}${active ? ` (${elapsed}s)` : ""}`);
setDetail(formatRun(run));
},
});
setStatus(`Run ${finalRun?.status || "finished"}`);
setDetail(formatRun(finalRun));
} catch (error) {
setStatus("Run failed.");
setDetail(formatFetchFailure(error, baseUrl()));
} finally {
runBtn.disabled = state.setups.length === 0 || !state.selectedSetupCanRun;
}
}
loadBtn.addEventListener("click", loadSetups);
if (fileInput) fileInput.addEventListener("change", loadSetupFile);
setupSelect.addEventListener("change", showSelectedSetup);
runBtn.addEventListener("click", runSelectedSetup);
const routeBase = isFilePage() ? DEFAULT_LOCAL_COMFY_BASE : globalThis.location.origin;
if (routeLink) routeLink.href = `${routeBase}/koolook/setup_runner_simulator.html`;
baseInput.value = defaultBaseUrl();
if (isFilePage()) {
setStatus("Enter or confirm the ComfyUI API base, then load setups.");
setDetail([
"You opened this page from disk.",
"",
"For the live API path, run ComfyUI and either:",
`- keep ${DEFAULT_LOCAL_COMFY_BASE} as the API base and click Load setups`,
`- open ${DEFAULT_LOCAL_COMFY_BASE}/koolook/setup_runner_simulator.html`,
"",
"If you only want to verify the simulator UI, open demo mode:",
"setup_runner_simulator.html?demo=1",
].join("\n"));
}
if (new URLSearchParams(globalThis.location?.search || "").get("demo") === "1") {
loadSetups();
}
}
if (typeof document !== "undefined") {
const fetchImpl = new URLSearchParams(globalThis.location?.search || "").get("demo") === "1"
? demoFetch
: fetch;
bindSimulator(document, fetchImpl);
}