MEDIUM-tier v4 feature ("Which model was that?" answered three days
later): a button in Models & Pipeline renders the currently loaded
pipeline as a PNG -- every slot's node class and typed-in values, wired
inputs marked as wired rather than printed as a stale value, and the
host facts (torch/CUDA/GPU/attention) that decide whether the setup
reproduces elsewhere. Downloadable, copyable to clipboard, and the
report is embedded in the PNG's own tEXt chunk for later machine
reading.
core/settings_card.py is a hard cut of v4's version, not a verbatim
port: v4 spent most of its length (_OWNED_BY, _EDITOR_DEFAULTS,
_pass_rows, _sampling_sections) describing FunPackStudio and the
LTX-AV Scene Chain Sampler's own hardcoded knob tables -- neither node
exists in v5's module-driven pipeline, so there is nothing left to
describe there. What's genuinely generic (walking a slot's real
inputs, detecting a wired one via core.graph.is_link, the PNG
renderer) is what's kept, adapted to v5's slot shape ({id, node,
group, inputs}, a wire arriving as [source_id, index] rather than a
separate input_sources/wires lookup) and v5's existing core/sysinfo.py
for host facts (whose field names already matched v4's needs).
New POST /api/settings-card route: stateless like /api/pipeline, the
CLIENT sends its own live pipeline (window.PipelineState.slots()) since
that's the one copy that includes any unsaved edit; the server supplies
only what it alone knows (host facts) and the render.
Frontend: app/legacy/api.js's settingsCard stub (present since the
Phase A port, explicitly marked "not built in v5 yet") replaced with a
real POST+blob call; app/legacy/models.js's Export-settings modal
ported from v4 near-verbatim, reading PS.slots()/project name instead
of a project id the old per-project models config needed. Verified
live: real pipeline, real host facts (this Mac's actual Apple MPS/
Python/ComfyUI versions), wired inputs correctly marked.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
311 lines
19 KiB
JavaScript
311 lines
19 KiB
JavaScript
// Thin backend client, rewired against v5's real routes (core/routes.py).
|
|
//
|
|
// Three kinds of function live here, per the port plan:
|
|
// (a) compatible, same shape -- just a different URL.
|
|
// (b) compatible route, different shape -- a small adapter in the body.
|
|
// (c) no v5 equivalent yet -- a stub. Calling one REJECTS with a
|
|
// plain Error naming why, same as any other failed request. v4's ~60
|
|
// caller files were already written defensively for exactly this --
|
|
// every call site that matters is already inside a try/catch or a
|
|
// .catch() with a sensible fallback, because a real request can always
|
|
// fail on a rental. A stub that instead *resolved* to a sentinel value
|
|
// would skip all of that existing handling and hand callers a value
|
|
// shaped nothing like what they expect, which breaks quietly further
|
|
// downstream instead of failing where the call happened -- worse, not
|
|
// better, for "one unbuilt module can't take the rest down with it."
|
|
(function () {
|
|
const BASE = window.location.pathname.replace(/\/+$/, "").replace(/\/index\.html$/, "");
|
|
const FUNPACK = "/funpack";
|
|
const API = (p) => `${FUNPACK}${p}`; // v5 mounts everything under core/config.py's UI_PREFIX ("/funpack")
|
|
|
|
function readApiError(res, payload) {
|
|
if (payload && typeof payload === "object") {
|
|
const detail = payload.detail;
|
|
if (typeof detail === "string" && detail.trim()) return detail.trim();
|
|
if (Array.isArray(detail)) {
|
|
const parts = detail.map((item) => {
|
|
if (item && typeof item === "object" && item.msg) return String(item.msg);
|
|
return JSON.stringify(item);
|
|
}).filter(Boolean);
|
|
if (parts.length) return parts.join("; ");
|
|
}
|
|
if (payload.error) return String(payload.error);
|
|
if (Array.isArray(payload.problems) && payload.problems.length) return payload.problems.join("; ");
|
|
// /api/pipeline's malformed-request 400 (core/routes.py's shape_problems
|
|
// check) uses "refused" rather than "problems" -- its own sibling 400s
|
|
// in the same handler use "problems", but this one doesn't, so both are
|
|
// checked rather than trusting the route to be internally consistent.
|
|
if (Array.isArray(payload.refused) && payload.refused.length) return payload.refused.join("; ");
|
|
}
|
|
const status = res && res.status ? `HTTP ${res.status}` : "";
|
|
const statusText = (res && res.statusText ? res.statusText : "").trim();
|
|
return statusText || status || "Request failed";
|
|
}
|
|
|
|
async function j(method, url, body) {
|
|
const opts = { method, headers: {} };
|
|
if (body !== undefined) {
|
|
opts.headers["Content-Type"] = "application/json";
|
|
opts.body = JSON.stringify(body);
|
|
}
|
|
const res = await fetch(url, opts);
|
|
if (!res.ok) {
|
|
let payload = null;
|
|
try { payload = await res.json(); } catch (_) {}
|
|
throw new Error(readApiError(res, payload));
|
|
}
|
|
return res.status === 204 ? null : res.json();
|
|
}
|
|
|
|
// A module v5 has not built yet. See the header: this rejects, on purpose,
|
|
// so the try/catch every real caller already has around a fallible request
|
|
// is what handles it -- not a new contract those ~60 files never learned.
|
|
const unsupported = (reason) => Promise.reject(new Error(reason));
|
|
// A plain (non-async) URL getter has no promise to reject -- a placeholder
|
|
// anchor is the closest equivalent; whatever it's set as `href` on simply
|
|
// goes nowhere when clicked, which is a control staying inert, not broken.
|
|
const unsupportedUrl = () => "#";
|
|
|
|
const ClientAPI = {
|
|
health: () => j("GET", API("/api/health")),
|
|
|
|
// --- module manifest / live pipeline (new in v5, no v4 equivalent) -----
|
|
// What the Engine Settings panel renders from: every module that loaded,
|
|
// its category, and its typed settings schema. `traits`, when given, is
|
|
// a comma-joined list -- modules the current model can't use are excluded
|
|
// server-side rather than shown disabled (core/traits.py's split()).
|
|
modules: (traits) => j("GET", API("/api/modules" + (traits ? `?traits=${encodeURIComponent(traits)}` : ""))),
|
|
// The live, editable pipeline -- {slots, refused, incomplete, queueable}.
|
|
// Stateless on the server (see routes.py): the caller holds `slots` in
|
|
// memory across edits and re-sends the full list each time.
|
|
pipeline: () => j("GET", API("/api/pipeline")),
|
|
editPipeline: (body) => j("POST", API("/api/pipeline"), body),
|
|
|
|
// --- projects (a) ------------------------------------------------------
|
|
listProjects: () => j("GET", API("/api/projects")), // {projects:[...]} -- callers unwrap .projects themselves
|
|
createProject: (name) => j("POST", API("/api/projects"), { name }),
|
|
getProject: (id) => j("GET", API(`/api/projects/${id}`)),
|
|
saveProject: (id, data) => j("PUT", API(`/api/projects/${id}`), data),
|
|
deleteProject: (id) => j("DELETE", API(`/api/projects/${id}`)),
|
|
downloadProjectUrl: (id) => API(`/api/projects/${id}/download`),
|
|
importProject: (data) => j("POST", API("/api/projects/import"), data),
|
|
|
|
// --- prompt preview (b) -------------------------------------------------
|
|
// v4's `preview()` rendered a whole scene-by-scene montage plan server
|
|
// side; v5 has no render/stitch stage yet (HIGH #2, not built). Stub.
|
|
preview: () => unsupported("no render/stitch stage yet"),
|
|
// `parsePrompt` becomes an adapter onto /api/prompt/expand: fetch the
|
|
// project for its anchor/postfix/variables, then expand THIS text the
|
|
// same way generation would.
|
|
async parsePrompt(id, prompt) {
|
|
const project = await ClientAPI.getProject(id).catch(() => null);
|
|
return j("POST", API("/api/prompt/expand"), {
|
|
text: prompt,
|
|
anchor: project?.anchor || "",
|
|
postfix: project?.postfix || "",
|
|
postfix_enabled: project?.postfix_enabled !== false,
|
|
variables: project?.variables || [],
|
|
});
|
|
},
|
|
|
|
// --- transitions library (c) -- no equivalent in v5 --------------------
|
|
transitions: () => unsupported("no transitions library in v5 yet"),
|
|
saveTransition: () => unsupported("no transitions library in v5 yet"),
|
|
deleteTransition: () => unsupported("no transitions library in v5 yet"),
|
|
exportTransitionsUrl: unsupportedUrl,
|
|
importTransitions: () => unsupported("no transitions library in v5 yet"),
|
|
clearTransitions: () => unsupported("no transitions library in v5 yet"),
|
|
|
|
// --- prompt shortcuts (a/b) ---------------------------------------------
|
|
shortcuts: () => j("GET", API("/api/shortcuts")), // {shortcuts:[...]} -- callers unwrap .shortcuts themselves
|
|
suggestionStats: () => unsupported("no revolver in v5 yet"),
|
|
saveCategory: () => unsupported("shortcut categories are per-shortcut fields in v5, not a separate CRUD"),
|
|
saveShortcut: (item) => j("POST", API("/api/shortcuts"), item),
|
|
deleteShortcut: (name) => j("DELETE", API(`/api/shortcuts/${encodeURIComponent(name)}`)),
|
|
exportShortcutsUrl: unsupportedUrl,
|
|
importShortcuts: () => unsupported("no import route in v5 yet"),
|
|
clearShortcuts: () => j("POST", API("/api/shortcuts/clear"), {}),
|
|
revolverSettings: () => unsupported("no revolver in v5 yet"),
|
|
setRevolverSettings: () => unsupported("no revolver in v5 yet"),
|
|
|
|
// --- Composer file manager / NLE library (c) ----------------------------
|
|
listFiles: () => unsupported("the Composer file manager was retired with the composer shell"),
|
|
deleteFile: () => unsupported("the Composer file manager was retired with the composer shell"),
|
|
clearFiles: () => unsupported("the Composer file manager was retired with the composer shell"),
|
|
nleLibrary: () => unsupported("no render/stitch stage yet"),
|
|
|
|
// --- node packs (a) ------------------------------------------------------
|
|
customNodes: async () => (await j("GET", API("/api/packs"))),
|
|
customNodesCheck: () => j("POST", API("/api/packs/check"), {}),
|
|
customNodeInstall: (url) => j("POST", API("/api/packs/install"), { url }),
|
|
customNodeUpdate: (name) => j("POST", API("/api/packs/update"), { name }),
|
|
customNodeRemove: (name) => j("POST", API("/api/packs/remove"), { name }),
|
|
|
|
// --- media bin (a) ---------------------------------------------------------
|
|
listMedia: () => j("GET", API("/api/media")), // {media:[...]} -- callers unwrap .media themselves
|
|
mediaUrl: (id) => API(`/api/media/${encodeURIComponent(id)}/file`),
|
|
deleteMedia: (id) => j("DELETE", API(`/api/media/${encodeURIComponent(id)}`)),
|
|
renameMedia: () => unsupported("media has no name field in v5 -- it is addressed by id"),
|
|
importClipToMediaBin: () => unsupported("no render/stitch stage yet, so there is no clip to import"),
|
|
async uploadMedia(file) {
|
|
const fd = new FormData(); fd.append("file", file, file.name);
|
|
const res = await fetch(API("/api/media"), { method: "POST", body: fd });
|
|
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).detail || res.statusText);
|
|
const body = await res.json();
|
|
return body.media && body.media[0] ? body.media[0] : body;
|
|
},
|
|
|
|
// --- models / node slots (b/c) -------------------------------------------
|
|
// v4's Studio-shaped "roles" and "candidates per role" have no v5
|
|
// equivalent -- v5's pipeline is a flat list of swappable slots, not a
|
|
// fixed set of named roles. Real replacements below; the rest stub until
|
|
// the Models & Pipeline panel itself is rewritten against this shape
|
|
// (tracked separately -- passing mismatched data through here would make
|
|
// that panel fail in a way that looks like a bug in the panel, not an
|
|
// honest "not built yet").
|
|
nodeRoles: () => unsupported("v5 has slots, not fixed roles -- see pipelinePorts()"),
|
|
nodeCandidates: () => unsupported("v5 has slots, not fixed roles -- see nodeSpec()/pipelinePorts()"),
|
|
allNodes: () => unsupported("no whole-catalogue route in v5 -- nothing scans every installed node at once"),
|
|
nodeSpec: async (cls) => {
|
|
const body = await j("GET", API(`/api/nodes?classes=${encodeURIComponent(cls)}`));
|
|
return body.nodes ? body.nodes[cls] : null;
|
|
},
|
|
// Several at once -- {nodes: {className: spec|null}} -- for a pipeline
|
|
// view describing every slot's node in one request instead of one per row.
|
|
describeNodes: (classes) => j("GET", API(`/api/nodes?classes=${encodeURIComponent((classes || []).join(","))}`)),
|
|
// v5's /api/pipeline is real, but its shape ({slots, refused, incomplete,
|
|
// queueable}) is nothing like v4's ({ports, core_producers, requirements,
|
|
// wiring, default_slots}) -- returning it under this name would answer
|
|
// with real JSON that quietly means nothing to the one caller (models.js)
|
|
// that reads it, which is worse than an honest stub: it never surfaces as
|
|
// "not built", just as permanently-empty ports/wiring. Stub until the
|
|
// Models & Pipeline panel is rewritten to read the real shape directly.
|
|
pipelinePorts: () => unsupported("v5's pipeline shape does not match v4's ports/wiring model yet"),
|
|
pipelineDeps: () => unsupported("v5 offers manual pack install only -- no auto-detect for the loaded pipeline"),
|
|
pipelineDepsInstall: () => unsupported("v5 offers manual pack install only -- no auto-detect for the loaded pipeline"),
|
|
pipelineDepsInstallManager: () => unsupported("v5 offers manual pack install only -- no auto-detect for the loaded pipeline"),
|
|
pipelineDepsInstallStatus: () => unsupported("v5 offers manual pack install only -- no auto-detect for the loaded pipeline"),
|
|
pipelineDepsInstallCancel: () => unsupported("v5 offers manual pack install only -- no auto-detect for the loaded pipeline"),
|
|
imageTargets: () => unsupported("not built in v5 yet"),
|
|
// Same mismatch as pipelinePorts above -- v4's caller reads `.nodes`,
|
|
// which /api/pipeline's real response does not have.
|
|
coreGraph: () => unsupported("v5's pipeline shape does not match v4's core-graph model yet"),
|
|
getModels: () => unsupported("the Models panel needs its own rewrite against v5's pipeline-slot shape"),
|
|
saveModels: () => unsupported("the Models panel needs its own rewrite against v5's pipeline-slot shape"),
|
|
// The settings card is a PNG, not JSON -- fetched as a blob so the modal
|
|
// can show it, download it and put it on the clipboard from the one
|
|
// response. `slots` is the CALLER's live pipeline (v5's pipeline has no
|
|
// server-side copy to fall back on, see /api/pipeline's own docs) --
|
|
// window.PipelineState.slots() is what every other pipeline-editing
|
|
// caller already sends the same way.
|
|
settingsCard: async (slots, projectName, theme) => {
|
|
const res = await fetch(API("/api/settings-card"), {
|
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ slots: slots || [], project_name: projectName || null, theme: theme || "dark" }),
|
|
});
|
|
if (!res.ok) {
|
|
let payload = null;
|
|
try { payload = await res.json(); } catch (_) {}
|
|
throw new Error(readApiError(res, payload));
|
|
}
|
|
return res.blob();
|
|
},
|
|
refreshModels: () => unsupported("v5 has no model-file cache to refresh -- it reads the folders live"),
|
|
parseWorkflow: () => unsupported("workflow import is LOW priority, not built yet"),
|
|
applyWorkflow: () => unsupported("workflow import is LOW priority, not built yet"),
|
|
|
|
// --- updates / system (a) ------------------------------------------------
|
|
restart: () => j("POST", API("/api/git/restart"), {}),
|
|
systemInfo: () => j("GET", API("/api/system")),
|
|
gitStatus: () => j("GET", API("/api/git/status")),
|
|
gitUpdate: (branch) => j("POST", API("/api/git/update"), branch ? { branch } : {}),
|
|
gitCheckout: (branch) => j("POST", API("/api/git/checkout"), { branch }),
|
|
gitRollback: () => j("POST", API("/api/git/rollback"), {}),
|
|
|
|
// --- model-family detection (a) -- new in v5, no v4 equivalent ---------
|
|
probeFamily: (file) => j("GET", API(`/api/probe?file=${encodeURIComponent(file)}`)),
|
|
|
|
// --- generate / run (c) --------------------------------------------------
|
|
// v5 deliberately has no server-side queue/status route (run.js's own
|
|
// header comment: "two things tracking what is running is two things
|
|
// that can disagree"). Real generation goes through app/shell/session.js
|
|
// + run.js talking to ComfyUI's own /prompt and websocket directly --
|
|
// that is a UI-flow rewrite (the Generate button's click handler), not
|
|
// an api.js body swap, and is tracked as its own step in the port plan.
|
|
generate: () => unsupported("Generate is wired directly through session.js/run.js, not this seam -- pending the button rewrite"),
|
|
status: () => unsupported("no server-side run status in v5 -- see run.js"),
|
|
progress: () => unsupported("no server-side run status in v5 -- see run.js"),
|
|
active: () => unsupported("no server-side run status in v5 -- see run.js"),
|
|
ratingLabels: () => unsupported("v5's ratings are the fixed pair liked/disliked -- nothing to label"),
|
|
log: (limit) => j("GET", API("/api/log" + (limit ? `?limit=${limit}` : ""))),
|
|
interrupt: () => j("POST", "/interrupt"), // ComfyUI's own native route, not FunPack's
|
|
|
|
// --- temp files (a) -------------------------------------------------------
|
|
listTemp: () => j("GET", API("/api/temp")),
|
|
|
|
// --- trajectory probe / REINS / block-influence / detail probe (c) ------
|
|
// All LOW priority research tooling per the roadmap; none built in v5.
|
|
probeStatus: () => unsupported("trajectory probe is LOW priority, not built yet"),
|
|
probeSetEnabled: () => unsupported("trajectory probe is LOW priority, not built yet"),
|
|
probeClear: () => unsupported("trajectory probe is LOW priority, not built yet"),
|
|
probeAnalyse: () => unsupported("trajectory probe is LOW priority, not built yet"),
|
|
probeExport: async () => { throw new Error("trajectory probe is LOW priority, not built yet"); },
|
|
probeImport: () => unsupported("trajectory probe is LOW priority, not built yet"),
|
|
reinsStatus: () => unsupported("REINS is LOW priority, not built yet"),
|
|
reinsSweep: () => unsupported("REINS is LOW priority, not built yet"),
|
|
reinsClear: () => unsupported("REINS is LOW priority, not built yet"),
|
|
reinsRateSlot: () => unsupported("REINS is LOW priority, not built yet"),
|
|
reinsDiscardSlot: () => unsupported("REINS is LOW priority, not built yet"),
|
|
reinsExport: async () => { throw new Error("REINS is LOW priority, not built yet"); },
|
|
blockInfluenceStatus: () => unsupported("block influence probe is LOW priority, not built yet"),
|
|
blockInfluenceSetEnabled: () => unsupported("block influence probe is LOW priority, not built yet"),
|
|
blockInfluenceClear: () => unsupported("block influence probe is LOW priority, not built yet"),
|
|
blockInfluenceExport: async () => { throw new Error("block influence probe is LOW priority, not built yet"); },
|
|
detailProbeStatus: () => unsupported("detail probe is LOW priority, not built yet"),
|
|
detailProbeSetEnabled: () => unsupported("detail probe is LOW priority, not built yet"),
|
|
detailProbeClear: () => unsupported("detail probe is LOW priority, not built yet"),
|
|
|
|
// --- ComfyUI's own temp-output viewer (a) -- unrelated to FunPack's prefix
|
|
tempFileUrl: (f) => "/view?" + new URLSearchParams({
|
|
filename: f.filename, subfolder: f.subfolder || "", type: "temp", t: String(f.mtime || Date.now()),
|
|
}).toString(),
|
|
async downloadTempFile(f) {
|
|
const res = await fetch(this.tempFileUrl(f), { cache: "no-store" });
|
|
if (!res.ok) throw new Error(`Could not fetch ${f.filename} (HTTP ${res.status})`);
|
|
const blob = await res.blob();
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement("a");
|
|
link.href = url;
|
|
link.download = f.filename;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
link.remove();
|
|
URL.revokeObjectURL(url);
|
|
},
|
|
|
|
// --- render / stitch / export (c) -- HIGH #2, not built ------------------
|
|
renderFinal: () => unsupported("no render/stitch stage yet"),
|
|
renderFinalStatus: () => unsupported("no render/stitch stage yet"),
|
|
exportClip: () => unsupported("no render/stitch stage yet"),
|
|
exportClipsCombined: () => unsupported("no render/stitch stage yet"),
|
|
exportClipsStatus: () => unsupported("no render/stitch stage yet"),
|
|
// (b): v5 has no per-project /result route, but a finished output is just
|
|
// a file ComfyUI itself saved -- its own /view endpoint serves it exactly
|
|
// the way it serves a temp preview.
|
|
resultUrl: (_id, m) => "/view?" + new URLSearchParams({
|
|
filename: m.filename, subfolder: m.subfolder || "", type: m.type || "output",
|
|
}).toString(),
|
|
previewSegmentUrl: unsupportedUrl,
|
|
|
|
// --- refinement keys / absolute taste store (c) -- not built in v5 -------
|
|
refinementKeys: () => unsupported("refinement keys are not built in v5 yet"),
|
|
importRefinementKey: () => unsupported("refinement keys are not built in v5 yet"),
|
|
exportRefinementKeyFile: async () => { throw new Error("refinement keys are not built in v5 yet"); },
|
|
deleteRefinementKey: () => unsupported("refinement keys are not built in v5 yet"),
|
|
absoluteStoreInfo: () => unsupported("refinement keys are not built in v5 yet"),
|
|
clearAbsoluteStore: () => unsupported("refinement keys are not built in v5 yet"),
|
|
};
|
|
|
|
window.MovieEditorAPI = ClientAPI;
|
|
})();
|