Shared video stream and NVIDIA helpers move to core (video_pipe, nvidia); the add-on needs nvidia-vfx 0.2+, moves an older one up and never moves a newer one back; Upscale and Interpolate cannot feed each other in Always mode. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tw9fjW75aYB8UyVRZo6fCr
212 lines
15 KiB
JavaScript
212 lines
15 KiB
JavaScript
// The server's routes, one line each. An error carries the sentence the server gave, not a bare status.
|
|
const BASE = "/fun";
|
|
const TRAIN = "/api/m/training_lora";
|
|
|
|
function why(res, payload) {
|
|
const p = payload && typeof payload === "object" ? payload : {};
|
|
const detail = Array.isArray(p.detail) ? p.detail.map((d) => (d && d.msg) || JSON.stringify(d)).join("; ") : p.detail;
|
|
const list = (p.problems && p.problems.length ? p.problems : p.refused) || [];
|
|
return [detail, p.why, p.error, list.join("; ")].find((s) => typeof s === "string" && s.trim()) || `HTTP ${res.status}`;
|
|
}
|
|
|
|
export async function call(method, path, body) {
|
|
const res = await fetch(BASE + path, {
|
|
method, headers: body === undefined ? {} : { "Content-Type": "application/json" },
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
});
|
|
const payload = await res.json().catch(() => null);
|
|
if (!res.ok) throw Object.assign(new Error(why(res, payload)), { status: res.status }); // status: a refusal (4xx) is not an outage
|
|
// A 200 that is not JSON is not FunUI answering (ComfyUI restarting, a proxy's page, a route this server does not have):
|
|
// saying so beats every caller failing on "r is null".
|
|
if (payload === null && res.status !== 204) throw Object.assign(new Error(`FunUI did not answer ${path} (ComfyUI may be restarting, or this page is older than the server: reload the page).`), { status: 502 });
|
|
return payload;
|
|
}
|
|
|
|
/** POST a form. fetch cannot report bytes sent, so use XHR where it exists (browsers) and fetch elsewhere.
|
|
* Resolves a fetch-like {ok, status, json()}. */
|
|
function post(url, form, onFraction) {
|
|
if (typeof XMLHttpRequest === "undefined") return fetch(url, { method: "POST", body: form });
|
|
return new Promise((resolve, reject) => {
|
|
const xhr = new XMLHttpRequest();
|
|
xhr.open("POST", url);
|
|
xhr.upload.onprogress = (e) => { if (e.lengthComputable) onFraction(e.loaded / e.total); };
|
|
xhr.onerror = () => reject(new Error("the upload was interrupted"));
|
|
xhr.onload = () => resolve({ ok: xhr.status >= 200 && xhr.status < 300, status: xhr.status, statusText: xhr.statusText, json: async () => JSON.parse(xhr.responseText) });
|
|
xhr.send(form);
|
|
});
|
|
}
|
|
|
|
export const api = {
|
|
pipeline: () => call("GET", "/api/pipeline"),
|
|
editPipeline: (body) => call("POST", "/api/pipeline", body),
|
|
modules: (traits) => call("GET", `/api/modules${traits != null ? `?traits=${encodeURIComponent(traits)}` : ""}`),
|
|
releaseModule: (id) => call("POST", "/api/control/release", { id }),
|
|
addons: () => call("GET", "/api/addons").then((d) => d.addons || []),
|
|
setAddon: (id, enabled) => call("POST", "/api/addons", { id, enabled }).then((d) => d.addons || []),
|
|
searchNodes: (q, limit = 40) => call("GET", `/api/nodes/search?q=${encodeURIComponent(q || "")}&limit=${limit}`),
|
|
wireTargets: (node) => call("GET", `/api/wire-targets?node=${encodeURIComponent(node)}`),
|
|
pipelinePresets: () => call("GET", "/api/pipeline/presets"),
|
|
setMediaSubject: (id, subject) => call("PATCH", `/api/media/${encodeURIComponent(id)}`, { subject }),
|
|
readiness: () => call("GET", "/api/readiness"),
|
|
importWorkflow: (workflow) => call("POST", "/api/pipeline/import", { workflow }),
|
|
/** The pipeline as a PNG (loaders, typed-in values, host torch/CUDA). -> a Blob. */
|
|
async settingsCard(slots, projectName, theme) {
|
|
const res = await fetch(`${BASE}/api/settings-card`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ slots, project_name: projectName || null, theme: theme || "dark" }) });
|
|
if (!res.ok) throw new Error(why(res, await res.json().catch(() => null)));
|
|
return res.blob();
|
|
},
|
|
describeNodes: (classes) => call("GET", `/api/nodes?classes=${encodeURIComponent(classes.join(","))}`),
|
|
system: () => call("GET", "/api/system"),
|
|
storyMarkers: () => call("GET", "/api/story/markers"),
|
|
saveStoryMarkers: (markers) => call("POST", "/api/story/markers", { markers }),
|
|
storySplit: (text) => call("POST", "/api/story/split", { text }),
|
|
shortcuts: () => call("GET", "/api/shortcuts"),
|
|
saveShortcut: (shortcut, originalName) => call("POST", "/api/shortcuts", { ...shortcut, original_name: originalName }),
|
|
clearShortcuts: () => call("POST", "/api/shortcuts/clear", {}),
|
|
importShortcuts: (data, mode) => call("POST", "/api/shortcuts/import", { data, mode }),
|
|
addShortcutCategory: (category, sub_category) => call("POST", "/api/shortcuts/category", { category, sub_category }),
|
|
deleteShortcut: (name) => call("DELETE", `/api/shortcuts/${encodeURIComponent(name)}`),
|
|
revolver: () => call("GET", "/api/shortcuts/revolver"),
|
|
setRevolver: (enabled, random) => call("POST", "/api/shortcuts/revolver", { enabled, random }),
|
|
enhancerDefaults: () => call("GET", "/api/m/conditioning_prompt_enhancer/defaults"),
|
|
enhancerEncoders: () => call("GET", "/api/m/conditioning_prompt_enhancer/encoders"),
|
|
enhancerRuns: () => call("GET", "/api/m/conditioning_prompt_enhancer/runs"),
|
|
rateTaste: (promptId, rating, axis) => call("POST", "/api/m/taste/rate", { prompt_id: promptId, rating, axis: axis || null }),
|
|
newTasteGeneration: (promptId) => call("POST", "/api/m/taste/generation", promptId ? { prompt_id: promptId } : {}),
|
|
shotMemory: () => call("GET", "/api/m/conditioning_shot_camera/memory"),
|
|
forgetShotMemory: (kind, name) => call("POST", "/api/m/conditioning_shot_camera/forget", { kind, name }),
|
|
tasteKeys: () => call("GET", "/api/m/taste/keys"),
|
|
torchBuild: () => call("GET", "/api/m/loader_diffusion_model/torch"),
|
|
blockInfluence: (key) => call("GET", `/api/m/block_influence/status?key=${encodeURIComponent(key || "default")}`),
|
|
blockInfluenceGroups: (key) => call("GET", `/api/m/block_influence/groups?key=${encodeURIComponent(key || "default")}`),
|
|
clearBlockInfluence: (key) => call("POST", "/api/m/block_influence/clear", { key: key || "default" }),
|
|
blockInfluenceExportUrl: (key) => `${BASE}/api/m/block_influence/export?key=${encodeURIComponent(key || "default")}`,
|
|
setBlockInfluence: (on) => call("POST", `/api/m/block_influence/enabled?key=default`, { enabled: !!on }),
|
|
libraryFiles: () => call("GET", "/api/files"),
|
|
deleteLibraryFile: (name) => call("DELETE", `/api/files/${encodeURIComponent(name)}`),
|
|
tasteKindsOf: (name) => call("GET", `/api/m/taste/keys/${encodeURIComponent(name)}/kinds`),
|
|
clearTasteKind: (name, kind) => call("DELETE", `/api/m/taste/keys/${encodeURIComponent(name)}/kinds/${encodeURIComponent(kind)}`),
|
|
deleteTasteKey: (name) => call("DELETE", `/api/m/taste/keys/${encodeURIComponent(name)}`),
|
|
tasteKeyUrl: (name) => `${BASE}/api/m/taste/keys/${encodeURIComponent(name)}/export`,
|
|
/** The zip as the raw body; `exists: true` comes back (not thrown) when the name is taken and `overwrite` was not asked. */
|
|
async importTasteKey(name, file, overwrite) {
|
|
const res = await fetch(`${BASE}/api/m/taste/keys/import?name=${encodeURIComponent(name)}${overwrite ? "&overwrite=1" : ""}`, { method: "POST", body: file });
|
|
const payload = await res.json().catch(() => null);
|
|
if (res.status === 409) return { exists: true };
|
|
if (!res.ok) throw new Error((payload && payload.why) || `HTTP ${res.status}`);
|
|
return payload;
|
|
},
|
|
// LoRA training (the add-on module): training sets, rated results, taggers, and a run.
|
|
trainState: () => call("GET", `${TRAIN}/state`),
|
|
trainSets: () => call("GET", `${TRAIN}/sets`),
|
|
trainCreateSet: (name) => call("POST", `${TRAIN}/sets`, { name }),
|
|
trainSet: (name) => call("GET", `${TRAIN}/sets/${encodeURIComponent(name)}`),
|
|
trainSaveSet: (name, doc) => call("PUT", `${TRAIN}/sets/${encodeURIComponent(name)}`, doc),
|
|
trainDeleteSet: (name) => call("DELETE", `${TRAIN}/sets/${encodeURIComponent(name)}`),
|
|
trainAdd: (name, entries) => call("POST", `${TRAIN}/sets/${encodeURIComponent(name)}/add`, { entries }),
|
|
trainBulk: (name, body) => call("POST", `${TRAIN}/sets/${encodeURIComponent(name)}/bulk`, body),
|
|
trainClips: (srcs) => call("POST", `${TRAIN}/clips`, { srcs }),
|
|
trainRated: (include) => call("GET", `${TRAIN}/rated${include && include.length ? `?include=${encodeURIComponent(include.join(","))}` : ""}`),
|
|
trainTaggers: () => call("GET", `${TRAIN}/taggers`),
|
|
trainDownloadTagger: (tagger) => call("POST", `${TRAIN}/taggers/download`, { tagger }),
|
|
trainRemoveTagger: (tagger) => call("POST", `${TRAIN}/taggers/remove`, { tagger }),
|
|
trainAutotag: (body) => call("POST", `${TRAIN}/autotag`, body),
|
|
trainAutotagState: () => call("GET", `${TRAIN}/autotag`),
|
|
trainAutotagCancel: () => call("POST", `${TRAIN}/autotag/cancel`, {}),
|
|
trainPlan: (set, slots, settings) => call("POST", `${TRAIN}/plan`, { set, slots, settings }),
|
|
trainRun: (since) => call("GET", `${TRAIN}/run?since=${since || 0}`),
|
|
trainPause: () => call("POST", `${TRAIN}/run/pause`, {}),
|
|
trainStop: () => call("POST", `${TRAIN}/run/stop`, {}),
|
|
trainRuns: () => call("GET", `${TRAIN}/runs`).then((d) => d.runs || []),
|
|
trainRunRecord: (id) => call("GET", `${TRAIN}/runs/${encodeURIComponent(id)}`),
|
|
trainLoraUrl: (id, file) => `${BASE}${TRAIN}/runs/${encodeURIComponent(id)}/lora${file ? `?file=${encodeURIComponent(file)}` : ""}`,
|
|
trainForgetRun: (id) => call("DELETE", `${TRAIN}/runs/${encodeURIComponent(id)}`),
|
|
/** Any graph as a ComfyUI job. -> the prompt id. */
|
|
async queueGraph(graph) {
|
|
const res = await fetch("/prompt", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prompt: graph }) });
|
|
const body = await res.json().catch(() => ({}));
|
|
if (!res.ok || !body.prompt_id) {
|
|
const nodes = Object.values(body.node_errors || {}).flatMap((n) => (n.errors || []).map((x) => x.message));
|
|
throw new Error([(body.error || {}).message, ...nodes].filter(Boolean).join("; ") || `ComfyUI refused the job (HTTP ${res.status})`);
|
|
}
|
|
return body.prompt_id;
|
|
},
|
|
upscaleModels: () => call("GET", "/api/m/render/upscale_models"),
|
|
/** The upscale is an ordinary ComfyUI job: queued on /prompt, read back from /history. -> the prompt id. */
|
|
async queueUpscale(media, model, rtx) {
|
|
const graph = { 1: { class_type: "FunUIUpscaleVideo", inputs: { filename: media.filename, subfolder: media.subfolder || "", type: media.type === "temp" ? "temp" : "output", upscale_model: model, ...(rtx ? { rtx_options: JSON.stringify(rtx) } : {}) } } };
|
|
const res = await fetch("/prompt", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prompt: graph }) });
|
|
const body = await res.json().catch(() => ({}));
|
|
if (!res.ok || !body.prompt_id) {
|
|
const nodes = Object.values(body.node_errors || {}).flatMap((n) => (n.errors || []).map((x) => x.message));
|
|
throw new Error([(body.error || {}).message, ...nodes].filter(Boolean).join("; ") || `ComfyUI refused the job (HTTP ${res.status})`);
|
|
}
|
|
return body.prompt_id;
|
|
},
|
|
frameGeneration: () => call("GET", "/api/m/interpolate/frame_generation"),
|
|
/** Interpolation is an ordinary ComfyUI job too; read it back with upscaleResult. -> the prompt id. */
|
|
queueInterpolate(media, opts) {
|
|
return this.queueGraph({ 1: { class_type: "FunUIInterpolateVideo", inputs: { filename: media.filename, subfolder: media.subfolder || "", type: media.type === "temp" ? "temp" : "output", frame_options: JSON.stringify(opts || {}) } } });
|
|
},
|
|
/** Whether ComfyUI's queue holds a run of this project (queued from any tab). Unreadable: false, the queue decides. */
|
|
async projectQueued(projectId) {
|
|
try {
|
|
const q = await (await fetch("/queue", { signal: AbortSignal.timeout(5000) })).json(); // a stalled server: not a hang
|
|
return [...(q.queue_running || []), ...(q.queue_pending || [])].some((r) => (r[3] || {}).funui_project_id === projectId);
|
|
} catch { return false; }
|
|
},
|
|
/** null while it is queued or running, else {videos} | {error} | {gone} | {retry} (a dropped reply is not a failed job). */
|
|
async upscaleResult(promptId) {
|
|
let entry = null;
|
|
try { const res = await fetch(`/history/${encodeURIComponent(promptId)}`); entry = res.ok ? (await res.json())[promptId] : null; } catch { return { retry: true }; }
|
|
if (!entry) {
|
|
try {
|
|
const q = await (await fetch("/queue")).json();
|
|
return [...(q.queue_running || []), ...(q.queue_pending || [])].some((r) => r[1] === promptId) ? null : { gone: true };
|
|
} catch { return { retry: true }; }
|
|
}
|
|
const st = entry.status || {};
|
|
if (st.status_str === "error") { const m = (st.messages || []).find((x) => x[0] === "execution_error"); return { error: (m && m[1] && m[1].exception_message) || "failed inside ComfyUI" }; }
|
|
if (!st.completed) return null;
|
|
const videos = Object.values(entry.outputs || {}).flatMap((o) => o.videos || []);
|
|
return videos.length ? { videos } : { error: "no video came out, check the ComfyUI terminal" };
|
|
},
|
|
renderLibrary: () => call("GET", "/api/m/render/library"),
|
|
lastFrame: (pid, body) => call("POST", `/api/m/render/projects/${encodeURIComponent(pid)}/last-frame`, body),
|
|
suggestionStats: () => call("GET", "/api/shortcuts/suggestion_stats"),
|
|
expandPrompt: (body) => call("POST", "/api/prompt/expand", { ...body, seed: 1 }),
|
|
packs: () => call("GET", "/api/packs"),
|
|
packProviders: (classes) => call("GET", `/api/packs/providers?classes=${encodeURIComponent(classes.join(","))}`),
|
|
pack: (action, body) => call("POST", `/api/packs/${action}`, body || {}),
|
|
git: (action, body) => call("POST", `/api/git/${action}`, body || {}),
|
|
health: () => call("GET", "/api/health"),
|
|
requirements: () => call("POST", "/api/requirements"),
|
|
torchSwap: () => call("POST", "/api/torch/cuda13"),
|
|
torchSwapState: () => call("GET", "/api/torch/cuda13"),
|
|
gitFull: () => call("GET", "/api/git/status"),
|
|
gitStatus: () => call("GET", "/api/git/status?remote=0"),
|
|
media: () => call("GET", "/api/media"),
|
|
renameMedia: (id, name) => call("PATCH", `/api/media/${encodeURIComponent(id)}`, { name }),
|
|
deleteMedia: (id) => call("DELETE", `/api/media/${encodeURIComponent(id)}`),
|
|
/** Upload files as media, one request each so one refusal (too big, wrong type) does not sink the rest.
|
|
* Resolves {media, problems}. Not JSON, so not through call(). */
|
|
async uploadMedia(files, onProgress) { // onProgress(done, total, name, fraction) before each file, then again as bytes go out
|
|
const out = { media: [], problems: [] };
|
|
let done = 0;
|
|
for (const file of files) {
|
|
const at = done++;
|
|
if (onProgress) onProgress(at, files.length, file.name, 0);
|
|
const form = new FormData();
|
|
form.append("file", file, file.name);
|
|
try {
|
|
const res = await post(`${BASE}/api/media`, form, (f) => onProgress && onProgress(at, files.length, file.name, f));
|
|
const payload = await res.json().catch(() => null);
|
|
if (!res.ok) throw Object.assign(new Error(why(res, payload)), { status: res.status }); // status: a refusal (4xx) is not an outage
|
|
out.media.push(...(payload.media || [])); out.problems.push(...(payload.problems || []));
|
|
} catch (err) { out.problems.push(`${file.name}: ${err.message}`); }
|
|
}
|
|
return out;
|
|
},
|
|
probeFamily: (file) => call("GET", `/api/probe?file=${encodeURIComponent(file)}`),
|
|
};
|