Files
olivv-cs-ComfyUI-FunPack/app/ui/shell/api.js
T
Claude a36af5f3a1 fix: a video or audio reference is routed from the inputs the model node offers, not from preset data
Works for any H3 Reference to Video pipeline, edited or not. Drops the preset routing data and the refresh that carried it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CHWxqHtxwWrq4kEyFTaHds
2026-10-21 02:43:47 +00:00

204 lines
14 KiB
JavaScript

// The server's routes, one line each. An error carries the sentence the server gave, not a bare status.
const BASE = "/funpack";
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
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: "FunPackUpscaleVideo", 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;
},
/** 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] || {}).funpack_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)}`),
};