Files
olivv-cs-ComfyUI-FunPack/movie_editor/frontend/api.js
T
digital-garbageandClaude Opus 4.8 2934afec7e Store refinement keys as readable <key>.json (no hashing); overwrite-or-cancel on import
The key name is now the filename: a key "123" is refinements/123.json, listed by
stem and resolved directly by name everywhere (Studio, loader, Editor, sampler).
This fixes shortcuts spawning ghost keys — a dropped/loaded <key>.json is found
by the refiner's own path lookup with no scanning or adoption needed. Sidecars
keep a <key>.<mode> suffix (value function, sampler context, latents); only keys
that aren't a valid filename fall back to the old <prefix>_<md5> hash.

Clean break: legacy hashed files are not migrated (and are hidden from the key
list rather than shown as hash names). Exports now download as <key>.json so a
re-dropped export is already canonical — no tolerant filename/inner-field
reconciliation layer needed.

Import now prompts overwrite-or-cancel on a name collision (409 -> confirm ->
retry with overwrite), in both the Editor and the Refinement Key Loader node.
413 chunked upload (Vast.ai / Runpod proxy body caps) retained.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 18:05:53 +03:00

250 lines
12 KiB
JavaScript

// Thin backend client. Served by ComfyUI, so all URLs are same-origin (relative):
// the browser automatically uses whatever host:port ComfyUI runs on.
(function () {
// Base = the directory this app is served from, e.g. /funpack/movie
const BASE = window.location.pathname.replace(/\/+$/, "").replace(/\/index\.html$/, "");
const API = (p) => `${BASE}/api${p}`;
const FUNPACK = "/funpack";
async function funpackFetch(method, path, body) {
const opts = { method, headers: {}, cache: "no-store" };
if (body !== undefined) {
opts.headers["Content-Type"] = "application/json";
opts.body = JSON.stringify(body);
}
const res = await fetch(path, opts);
if (!res.ok) {
let payload = null;
try { payload = await res.json(); } catch (_) {}
throw new Error(readApiError(res, payload));
}
return res;
}
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 (payload.parse_errors && typeof payload.parse_errors === "object") {
return Object.entries(payload.parse_errors).map(([k, v]) => `${k}: ${v}`).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();
}
const ClientAPI = {
health: () => j("GET", API("/health")),
// projects
listProjects: () => j("GET", API("/projects")),
createProject: (name) => j("POST", API("/projects"), { name }),
getProject: (id) => j("GET", API(`/projects/${id}`)),
saveProject: (id, data) => j("PUT", API(`/projects/${id}`), data),
deleteProject: (id) => j("DELETE", API(`/projects/${id}`)),
downloadProjectUrl: (id) => API(`/projects/${id}/download`),
importProject: (data) => j("POST", API("/projects/import"), data),
// timeline preview
preview: (id, includeExcluded, forGeneration = true) =>
j("GET", API(`/projects/${id}/preview?include_excluded=${includeExcluded ? "true" : "false"}&for_generation=${forGeneration ? "true" : "false"}`)),
parsePrompt: (id, prompt) => j("POST", API(`/projects/${id}/parse`), { prompt }),
// libraries
transitions: () => j("GET", API("/library/transitions")),
saveTransition: (item) => j("POST", API("/library/transitions"), item),
deleteTransition: (name) => j("DELETE", API(`/library/transitions/${encodeURIComponent(name)}`)),
exportTransitionsUrl: () => API("/library/transitions/export"),
importTransitions: (data) => j("POST", API("/library/transitions/import"), data),
shortcuts: () => j("GET", API("/library/shortcuts")),
saveShortcut: (item) => j("POST", API("/library/shortcuts"), item),
deleteShortcut: (name) => j("DELETE", API(`/library/shortcuts/${encodeURIComponent(name)}`)),
exportShortcutsUrl: () => API("/library/shortcuts/export"),
importShortcuts: (data) => j("POST", API("/library/shortcuts/import"), data),
characters: () => j("GET", API("/library/characters")),
saveCharacter: (item) => j("POST", API("/library/characters"), item),
deleteCharacter: (id) => j("DELETE", API(`/library/characters/${encodeURIComponent(id)}`)),
nleLibrary: () => j("GET", API("/library/nle")),
// media bin
listMedia: () => j("GET", API("/media")),
mediaUrl: (id) => API(`/media/${encodeURIComponent(id)}`),
deleteMedia: (id) => j("DELETE", API(`/media/${encodeURIComponent(id)}`)),
renameMedia: (id, name) => j("PATCH", API(`/media/${encodeURIComponent(id)}`), { name }),
importClipToMediaBin: (clip, name) => j("POST", API("/media/import-clip"), { clip, name: name || null }),
async uploadMedia(file) {
const fd = new FormData(); fd.append("file", file, file.name);
const res = await fetch(API("/media"), { method: "POST", body: fd });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).detail || res.statusText);
return res.json();
},
// models / node slots
nodeRoles: () => j("GET", API("/node-roles")),
nodeCandidates: (role, refresh) => j("GET", API(`/node-candidates/${role}${refresh ? "?refresh=true" : ""}`)),
allNodes: () => j("GET", API("/all-nodes")),
nodeSpec: (cls) => j("GET", API(`/node/${encodeURIComponent(cls)}`)),
pipelinePorts: () => j("GET", API("/pipeline-ports")),
pipelineDeps: () => j("GET", API("/pipeline-deps")),
pipelineDepsInstall: (packIds) => j("POST", API("/pipeline-deps/install"), { pack_ids: packIds }),
pipelineDepsInstallManager: () => j("POST", API("/pipeline-deps/install"), { install_manager: true }),
pipelineDepsInstallStatus: (jobId) => j("GET", API(`/pipeline-deps/install/${encodeURIComponent(jobId)}`)),
pipelineDepsInstallCancel: (jobId) => j("POST", API(`/pipeline-deps/install/${encodeURIComponent(jobId)}/cancel`), {}),
imageTargets: (pid) => j("GET", API("/image-targets" + (pid ? `?pid=${encodeURIComponent(pid)}` : ""))),
// Models config is per-project; pass the project id. Falls back to the global
// default route (used as the seed/template) when no project is given.
coreGraph: (pid) => j("GET", API("/core-graph" + (pid ? `?pid=${encodeURIComponent(pid)}` : ""))),
getModels: (pid) => j("GET", API(pid ? `/projects/${pid}/models` : "/models")),
saveModels: (pid, data) => j("PUT", API(pid ? `/projects/${pid}/models` : "/models"), data),
refreshModels: () => j("POST", API("/models/refresh")),
parseWorkflow: (workflow) => j("POST", API("/workflow/parse"), { workflow }),
applyWorkflow: (pid, workflow, bindings) =>
j("POST", API(`/projects/${pid}/workflow/apply`), { workflow, bindings }),
restart: () => j("POST", API("/restart")),
gitStatus: () => j("GET", API("/git/status")),
gitUpdate: (branch) => j("POST", API("/git/update"), branch ? { branch } : {}),
gitCheckout: (branch) => j("POST", API("/git/checkout"), { branch }),
// generate (a single scene, or an explicit run of scene ids = one chain request)
generate: (id, onlyScene, sceneIds, resetSession) =>
j("POST", API(`/projects/${id}/generate`), { only_scene: onlyScene || null, scene_ids: sceneIds || null, reset_session: !!resetSession }),
status: (id, promptId) => j("GET", API(`/projects/${id}/status/${promptId}`)),
progress: () => j("GET", API("/progress")),
ratingLabels: () => j("GET", API("/rating-labels")),
log: (limit) => j("GET", API("/log" + (limit ? `?limit=${limit}` : ""))),
interrupt: () => j("POST", API("/interrupt")),
renderFinal: (id, clips) => j("POST", API(`/projects/${id}/render`), { clips }),
renderFinalStatus: (id, jobId) => j("GET", API(`/projects/${id}/render/${encodeURIComponent(jobId)}`)),
exportClip: (id, clip) => j("POST", API(`/projects/${id}/export-clip`), { clip }),
exportClipsCombined: (id, clips) => j("POST", API(`/projects/${id}/export-clips`), { clips }),
exportClipsStatus: (id, jobId) => j("GET", API(`/projects/${id}/export-clips/${encodeURIComponent(jobId)}`)),
resultUrl: (id, m) =>
API(`/projects/${id}/result?filename=${encodeURIComponent(m.filename)}`) +
`&subfolder=${encodeURIComponent(m.subfolder || "")}&type=${encodeURIComponent(m.type || "output")}`,
previewSegmentUrl: (id, sceneId, spec) => {
let u = API(`/projects/${id}/preview-segment/${encodeURIComponent(sceneId)}`);
const m = spec?.media;
if (m?.filename) {
const q = new URLSearchParams({
filename: m.filename,
subfolder: m.subfolder || "",
type: m.type || "output",
render_in: String(spec.renderIn != null ? spec.renderIn : 0),
});
u += "?" + q.toString();
}
return u;
},
// FunPack refinement keys (ComfyUI root routes, same as Studio / Refinement Key Loader)
refinementKeys: async () => {
const res = await funpackFetch("GET", `${FUNPACK}/refinement_keys?cache_bust=${Date.now()}`);
return res.json();
},
importRefinementKey: async (data, opts = {}) => {
const ow = opts.overwrite ? "&overwrite=true" : "";
const json = JSON.stringify(data);
const bytes = new TextEncoder().encode(json);
// A 409 means a key of that name already exists — surface it as a tagged
// error so the caller can ask the user to overwrite (then retry).
const finish = async (res) => {
if (res.status === 409) {
let p = null; try { p = await res.json(); } catch (_) {}
const err = new Error((p && p.error) || "Refinement key already exists.");
err.exists = true; err.key = (p && p.key) || "";
throw err;
}
if (!res.ok) {
let p = null; try { p = await res.json(); } catch (_) {}
throw new Error(readApiError(res, p));
}
return res.json();
};
// Reverse proxies on Vast.ai / Runpod cap the request body well below
// ComfyUI's 100 MB, so a one-shot POST of a large key gets HTTP 413 at the
// proxy. Keep a fast single POST for small keys; stream big ones in chunks
// (each far under any proxy limit) so they can never be rejected.
const CHUNK = 256 * 1024;
if (bytes.length <= CHUNK) {
const res = await fetch(`${FUNPACK}/refinement_keys/import?_=${Date.now()}${ow}`, {
method: "POST", cache: "no-store",
headers: { "Content-Type": "application/json" }, body: json,
});
if (res.status !== 413) return finish(res); // 413 → fall through to chunked
}
const uploadId = `${Date.now()}_${Math.random().toString(36).slice(2)}`;
const postChunk = async (path, body) => {
const res = await fetch(path, {
method: "POST", cache: "no-store",
headers: { "Content-Type": "application/octet-stream" }, body,
});
if (!res.ok) {
let payload = null;
try { payload = await res.json(); } catch (_) {}
throw new Error(readApiError(res, payload));
}
return res;
};
let index = 0;
for (let off = 0; off < bytes.length; off += CHUNK, index++) {
// Slice by bytes (not string chars) so a multi-byte char is never split
// across the seam where the two halves get re-encoded independently.
await postChunk(
`${FUNPACK}/refinement_keys/import_chunk?upload_id=${encodeURIComponent(uploadId)}&index=${index}`,
bytes.slice(off, off + CHUNK),
);
}
return finish(await fetch(
`${FUNPACK}/refinement_keys/import_finalize?upload_id=${encodeURIComponent(uploadId)}${ow}`,
{ method: "POST", cache: "no-store" },
));
},
async exportRefinementKeyFile(key) {
const res = await fetch(
`${FUNPACK}/refinement_keys/export?key=${encodeURIComponent(key)}&cache_bust=${Date.now()}`,
{ cache: "no-store" },
);
if (!res.ok) {
let payload = null;
try { payload = await res.json(); } catch (_) {}
throw new Error(readApiError(res, payload));
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${key}.json`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
},
};
window.MovieEditorAPI = ClientAPI;
})();