| import { buildTokens, findVideoDescriptor, parseWorkflow, renderWorkflow } from "./core.js"; |
|
|
| const $ = (id) => document.getElementById(id); |
| const state = { |
| stopRequested: false, |
| objectUrl: null, |
| }; |
|
|
| function cleanBaseUrl() { |
| const raw = $("backend-url").value.trim().replace(/\/+$/, ""); |
| let url; |
| try { |
| url = new URL(raw); |
| } catch { |
| throw new Error("Enter a valid ComfyUI API URL."); |
| } |
| if (!["http:", "https:"].includes(url.protocol)) { |
| throw new Error("The ComfyUI URL must use HTTP or HTTPS."); |
| } |
| if (window.location.protocol === "https:" && url.protocol !== "https:") { |
| throw new Error("This HTTPS Space cannot call an HTTP backend because browsers block mixed content."); |
| } |
| return raw; |
| } |
|
|
| function requestHeaders(includeJson = false) { |
| const headers = {}; |
| const token = $("backend-token").value.trim(); |
| if (token) headers.Authorization = `Bearer ${token}`; |
| if (includeJson) headers["Content-Type"] = "application/json"; |
| return headers; |
| } |
|
|
| async function apiFetch(path, options = {}) { |
| const base = cleanBaseUrl(); |
| try { |
| return await fetch(`${base}${path}`, options); |
| } catch (error) { |
| throw new Error( |
| `Browser could not reach the backend. Check HTTPS, DNS and CORS for ${window.location.origin}. ${error.message}`, |
| ); |
| } |
| } |
|
|
| function setBadge(element, text, kind) { |
| element.textContent = text; |
| element.className = `badge ${kind}`; |
| } |
|
|
| function setStatus(message, progress = null, kind = "working") { |
| $("status").textContent = message; |
| setBadge($("output-state"), kind === "error" ? "Error" : kind === "success" ? "Ready" : "Working", kind); |
| if (progress !== null) $("progress-bar").style.width = `${Math.max(0, Math.min(100, progress))}%`; |
| } |
|
|
| function validateResponse(response, label) { |
| if (!response.ok) throw new Error(`${label} failed: HTTP ${response.status} ${response.statusText}`); |
| return response; |
| } |
|
|
| async function testBackend() { |
| const button = $("test-backend"); |
| button.disabled = true; |
| setBadge($("connection-badge"), "Testing", "working"); |
| try { |
| parseWorkflow($("workflow-json").value); |
| const response = await apiFetch("/system_stats", { |
| headers: requestHeaders(), |
| mode: "cors", |
| }); |
| validateResponse(response, "Backend test"); |
| await response.json(); |
| setBadge($("connection-badge"), "Connected", "success"); |
| setStatus("Backend and API-format workflow are ready.", 0, "success"); |
| } catch (error) { |
| setBadge($("connection-badge"), "Failed", "error"); |
| setStatus(error.message, 0, "error"); |
| } finally { |
| button.disabled = false; |
| } |
| } |
|
|
| function valuesFromForm() { |
| const prompt = $("prompt").value.trim(); |
| if (!prompt) throw new Error("Enter a prompt."); |
| const duration = Number($("duration").value); |
| const loraStrength = Number($("lora-strength").value); |
| const steps = Number($("steps").value); |
| const cfg = Number($("cfg").value); |
| if (duration < 4 || duration > 15) throw new Error("Duration must be between 4 and 15 seconds."); |
| if (loraStrength < 0 || loraStrength > 1.5) throw new Error("LoRA strength must be between 0 and 1.5."); |
| if (steps < 4 || steps > 30) throw new Error("Steps must be between 4 and 30."); |
| if (cfg < 1 || cfg > 8) throw new Error("CFG must be between 1 and 8."); |
| let seed = Number($("seed").value); |
| if ($("randomize-seed").checked) { |
| const random = new Uint32Array(2); |
| crypto.getRandomValues(random); |
| seed = random[0] * 2 ** 21 + (random[1] & (2 ** 21 - 1)); |
| $("seed").value = String(seed); |
| } |
| return { |
| prompt, |
| negativePrompt: $("negative-prompt").value, |
| loraName: $("lora-file").value, |
| loraStrength, |
| resolution: $("resolution").value, |
| duration, |
| steps, |
| cfg, |
| seed, |
| }; |
| } |
|
|
| async function queuePrompt(workflow) { |
| const response = await apiFetch("/prompt", { |
| method: "POST", |
| headers: requestHeaders(true), |
| mode: "cors", |
| body: JSON.stringify({ prompt: workflow, client_id: crypto.randomUUID() }), |
| }); |
| validateResponse(response, "Workflow submission"); |
| const payload = await response.json(); |
| if (!payload.prompt_id) { |
| throw new Error(`ComfyUI returned no prompt_id: ${JSON.stringify(payload.node_errors ?? payload.error ?? payload)}`); |
| } |
| return String(payload.prompt_id); |
| } |
|
|
| const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); |
|
|
| async function waitForHistory(promptId, timeoutMilliseconds = 30 * 60 * 1000) { |
| const started = Date.now(); |
| while (Date.now() - started < timeoutMilliseconds) { |
| if (state.stopRequested) throw new Error("Polling stopped. The ComfyUI task may still be running."); |
| const response = await apiFetch(`/history/${encodeURIComponent(promptId)}`, { |
| headers: requestHeaders(), |
| mode: "cors", |
| }); |
| validateResponse(response, "History request"); |
| const payload = await response.json(); |
| const entry = payload[promptId]; |
| const elapsed = Date.now() - started; |
| setStatus(`ComfyUI task ${promptId.slice(0, 8)} is running…`, 8 + (elapsed / timeoutMilliseconds) * 82); |
| if (entry) { |
| const status = entry.status ?? {}; |
| if (status.status_str === "error" || status.completed === false) { |
| throw new Error(`ComfyUI execution failed: ${JSON.stringify(status.messages ?? status)}`); |
| } |
| if (entry.outputs && Object.keys(entry.outputs).length) return entry; |
| } |
| await sleep(3000); |
| } |
| throw new Error("ComfyUI task timed out after 30 minutes."); |
| } |
|
|
| async function downloadVideo(descriptor) { |
| const params = new URLSearchParams(descriptor); |
| const response = await apiFetch(`/view?${params}`, { |
| headers: requestHeaders(), |
| mode: "cors", |
| }); |
| validateResponse(response, "Video download"); |
| return response.blob(); |
| } |
|
|
| function showVideo(blob, descriptor) { |
| if (state.objectUrl) URL.revokeObjectURL(state.objectUrl); |
| state.objectUrl = URL.createObjectURL(blob); |
| const video = $("output-video"); |
| video.src = state.objectUrl; |
| video.hidden = false; |
| $("empty-state").hidden = true; |
| const download = $("download"); |
| download.href = state.objectUrl; |
| download.download = descriptor.filename.split("/").pop() || "pinkfluffybunny-h3.mp4"; |
| download.hidden = false; |
| } |
|
|
| async function generate() { |
| const generateButton = $("generate"); |
| try { |
| if (!$("authorization-confirmed").checked) { |
| throw new Error("Confirm the backend's MiniMax H3 license authorization before submitting."); |
| } |
| cleanBaseUrl(); |
| const workflow = parseWorkflow($("workflow-json").value); |
| const values = valuesFromForm(); |
| const tokens = buildTokens(values); |
| const rendered = renderWorkflow(workflow, tokens); |
| state.stopRequested = false; |
| generateButton.disabled = true; |
| $("stop").disabled = false; |
| setStatus("Submitting the parameterized workflow…", 3); |
| const promptId = await queuePrompt(rendered); |
| setStatus(`Queued as ${promptId}. Waiting for ComfyUI…`, 7); |
| const entry = await waitForHistory(promptId); |
| $("history-output").textContent = JSON.stringify(entry, null, 2); |
| const descriptor = findVideoDescriptor(entry); |
| if (!descriptor) { |
| throw new Error("ComfyUI finished but no MP4/WebM/MOV/MKV output was found. End the workflow with SaveVideo."); |
| } |
| setStatus("Downloading the generated video…", 95); |
| const blob = await downloadVideo(descriptor); |
| showVideo(blob, descriptor); |
| setStatus( |
| `Video ready · seed ${tokens["{{SEED}}"]} · ${tokens["{{LENGTH}}"]} frames · LoRA ${tokens["{{LORA_STRENGTH}}"]}`, |
| 100, |
| "success", |
| ); |
| } catch (error) { |
| setStatus(error.message, 0, "error"); |
| } finally { |
| generateButton.disabled = false; |
| $("stop").disabled = true; |
| } |
| } |
|
|
| $("workflow-file").addEventListener("change", async (event) => { |
| const [file] = event.target.files; |
| if (!file) return; |
| try { |
| $("workflow-json").value = await file.text(); |
| parseWorkflow($("workflow-json").value); |
| setBadge($("connection-badge"), "Workflow loaded", "working"); |
| setStatus(`Loaded ${file.name}. Test the backend next.`, 0, "working"); |
| } catch (error) { |
| setStatus(error.message, 0, "error"); |
| } |
| }); |
|
|
| $("test-backend").addEventListener("click", testBackend); |
| $("generate").addEventListener("click", generate); |
| $("stop").addEventListener("click", () => { |
| state.stopRequested = true; |
| $("stop").disabled = true; |
| }); |
|
|