Download ComfyUI/custom_nodes/koolook/web/setup_runner_simulator.js from vidfom/aimusic: direct link, hf CLI and curl.
- Browser
- Download file 34.6 kB
-
https://huggingface.co/vidfom/aimusic/resolve/main/ComfyUI/custom_nodes/koolook/web/setup_runner_simulator.js
- Command line
-
hf download hf://vidfom/aimusic/ComfyUI/custom_nodes/koolook/web/setup_runner_simulator.js
-
curl -L -o setup_runner_simulator.js https://huggingface.co/vidfom/aimusic/resolve/main/ComfyUI/custom_nodes/koolook/web/setup_runner_simulator.js
34.6 kB
| // 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); | |
| } | |