Files
olivv-cs-ComfyUI-FunPack/app/legacy/api.js
T

429 lines
26 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.why) return String(payload.why);
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"),
// The Story box's text, cut into scenes by the marker words (core/story.py).
// The split is the server's -- one implementation -- and the anchor is NOT
// part of a story, so it is never reported back: store.js leaves it alone.
async parsePrompt(_id, prompt) {
const { scenes } = await j("POST", API("/api/story/split"), { text: prompt });
return { parsed_verbatim: { anchor: "", scenes: scenes.map((text) => ({ text })), transitions: [] } };
},
storyMarkers: () => j("GET", API("/api/story/markers")), // {markers:[...]}
saveStoryMarkers: (markers) => j("POST", API("/api/story/markers"), { markers }),
// --- 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: () => j("GET", API("/api/shortcuts/suggestion_stats")),
saveCategory: (item) => j("POST", API("/api/shortcuts/category"), item),
saveShortcut: (item) => j("POST", API("/api/shortcuts"), item),
deleteShortcut: (name) => j("DELETE", API(`/api/shortcuts/${encodeURIComponent(name)}`)),
exportShortcutsUrl: () => API("/api/shortcuts/export"),
importShortcuts: (data, mode) => j("POST", API("/api/shortcuts/import"), { data, mode }),
clearShortcuts: () => j("POST", API("/api/shortcuts/clear"), {}),
revolverSettings: () => j("GET", API("/api/shortcuts/revolver")),
setRevolverSettings: (payload) => j("POST", API("/api/shortcuts/revolver"), payload),
// --- 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: () => j("GET", API("/api/m/render/library")),
// --- 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: (id, name) => j("PATCH", API(`/api/media/${encodeURIComponent(id)}`), { name }),
// A small server-cached JPEG (made on first request) instead of the full original: for grid
// thumbnails only, never for playback, preview or export.
mediaThumbUrl: (id) => API(`/api/media/${encodeURIComponent(id)}/thumb`),
importClipToMediaBin: (clip, name) => j("POST", API("/api/m/render/import-clip"), { clip, name: name || null }),
// XMLHttpRequest, not fetch: fetch has no cross-browser upload-progress event, and a multi-MB
// video over a slow connection is exactly where the caller most wants something moving.
// onProgress(loadedBytes, totalBytes) fires repeatedly; totalBytes is 0 if the browser cannot say.
uploadMedia(file, onProgress) {
return new Promise((resolve, reject) => {
const fd = new FormData(); fd.append("file", file, file.name);
const xhr = new XMLHttpRequest();
xhr.open("POST", API("/api/media"));
if (onProgress) xhr.upload.onprogress = (e) => onProgress(e.loaded, e.lengthComputable ? e.total : 0);
xhr.onload = () => {
let body = {};
try { body = JSON.parse(xhr.responseText || "{}"); } catch (_) { /* not JSON */ }
if (xhr.status >= 200 && xhr.status < 300) {
if (body.media && body.media[0]) resolve(body.media[0]);
else if (body.problems && body.problems.length) reject(new Error(body.problems.join("; ")));
else resolve(body);
}
else reject(new Error((body.problems && body.problems.join("; ")) || body.detail || xhr.statusText || `HTTP ${xhr.status}`));
};
xhr.onerror = () => reject(new Error("Network error during upload"));
xhr.send(fd);
});
},
// --- 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.
searchNodes: (q, limit) => j("GET", API(`/api/nodes/search?q=${encodeURIComponent(q || "")}&limit=${limit || 40}`)),
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"),
upscaleModels: () => j("GET", API("/api/m/render/upscale_models")),
// The upscale is an ordinary ComfyUI job: queued on /prompt, read back from /history.
queueUpscale: async (media, model) => {
const graph = { 1: { class_type: "FunPackUpscaleVideo", inputs: {
filename: media.filename, subfolder: media.subfolder || "",
type: media.type === "temp" ? "temp" : "output", upscale_model: model } } };
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 e = body.error || {};
const nodes = Object.values(body.node_errors || {}).flatMap((n) => (n.errors || []).map((x) => x.message));
throw new Error([e.message, ...nodes].filter(Boolean).join("; ") || `ComfyUI refused the job (HTTP ${res.status})`);
}
return body.prompt_id;
},
// null while it is still queued or running, else {error} or {videos}.
upscaleResult: async (promptId) => {
let entry = null;
try {
const res = await fetch(`/history/${encodeURIComponent(promptId)}`);
entry = res.ok ? (await res.json())[promptId] : null;
} catch (_) { return { retry: true }; } // a dropped reply is not a failed job
if (!entry) {
// Not in history: still queued/running, or gone (dequeued, ComfyUI restarted).
try {
const q = await (await fetch("/queue")).json();
const live = [...(q.queue_running || []), ...(q.queue_pending || [])].some((r) => r[1] === promptId);
return live ? null : { gone: true };
} catch (_) { return { retry: true }; }
}
const st = entry.status || {};
if (st.status_str === "error") {
const msg = (st.messages || []).find((m) => m[0] === "execution_error");
return { error: (msg && msg[1] && msg[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" };
},
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"); },
phraseProbeStatus: () => j("GET", API("/api/m/phrase_probe/status")),
phraseProbeSetEnabled: (enabled) => j("POST", API("/api/m/phrase_probe/enabled"), { enabled: !!enabled }),
phraseProbeClear: () => j("POST", API("/api/m/phrase_probe/clear"), {}),
phraseProbeExportUrl: () => API("/api/m/phrase_probe/export"),
blockInfluenceStatus: (key) => j("GET", API("/api/m/block_influence/status") + `?key=${encodeURIComponent(key || "default")}`),
blockInfluenceSetEnabled: (key, enabled) => j("POST", API("/api/m/block_influence/enabled"), { key: key || "default", enabled: !!enabled }),
blockInfluenceClear: (key) => j("POST", API("/api/m/block_influence/clear"), { key: key || "default" }),
async blockInfluenceExport(key) {
const k = key || "default";
const res = await fetch(API("/api/m/block_influence/export") + `?key=${encodeURIComponent(k)}&t=${Date.now()}`, { cache: "no-store" });
if (!res.ok) {
let payload = null;
try { payload = await res.json(); } catch (_) {}
throw new Error(readApiError(res, payload));
}
const url = URL.createObjectURL(await res.blob());
const link = document.createElement("a");
link.href = url;
link.download = `${k}.block_influence.pt`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
},
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: modules/output/render --------------------
renderFinal: (id, clips) => j("POST", API(`/api/m/render/projects/${id}/render`), { clips }),
renderFinalStatus: (id, jobId) => j("GET", API(`/api/m/render/projects/${id}/render/${encodeURIComponent(jobId)}`)),
exportClip: (id, clip) => j("POST", API(`/api/m/render/projects/${id}/export-clip`), { clip }),
exportClipsCombined: (id, clips) => j("POST", API(`/api/m/render/projects/${id}/export-clips`), { clips }),
exportClipsStatus: (id, jobId) => j("GET", API(`/api/m/render/projects/${id}/export-clips/${encodeURIComponent(jobId)}`)),
// Served by FunPack, not ComfyUI's /view: a render whose index is at the END of the file
// (what ComfyUI's own saver writes) cannot be seeked in a browser until it is remuxed.
resultUrl: (_id, m) => API("/api/m/render/result?" + new URLSearchParams({
filename: m.filename, subfolder: m.subfolder || "", type: m.type || "output",
}).toString()),
previewSegmentUrl: (id, sceneId, spec) => {
let u = API(`/api/m/render/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),
});
// dur is in the URL for two reasons: a removed scene (a ghost) has no scene server-side, so
// its trim window must travel in the query; and segments are cached for an hour, so a
// timeline trim must produce a different URL. Anything else that changes the bytes
// (reverse) must be in the URL too, for the same reason.
if (spec.dur != null) q.set("dur", String(spec.dur));
if (spec.srcIn != null) q.set("src_in", String(spec.srcIn));
if (spec.reverse) q.set("rev", "1");
u += "?" + q.toString();
}
return u;
},
// --- taste keys: modules/system/taste --------------------------------------
rateTaste: (prompt_id, rating, axis) => j("POST", API("/api/m/taste/rate"), { prompt_id, rating, axis: axis || null }),
tasteKeys: () => j("GET", API("/api/m/taste/keys")),
// --- refinement keys / absolute taste store (c) -- not built in v5 -------
refinementKeys: () => j("GET", API("/api/m/taste/keys")),
// A key travels as a zip (the exported file), sent as the raw body: it can be tens of MB.
importRefinementKey: async (file, opts) => {
// A downloaded copy may be "fox (1).zip": reduce it to a name a key can have.
const name = String(file.name || "key").replace(/\.zip$/i, "").replace(/[^A-Za-z0-9 _.-]+/g, "_")
.replace(/^[^A-Za-z0-9]+/, "").replace(/[. ]+$/, "").slice(0, 64) || "key";
const q = new URLSearchParams({ name });
if (opts && opts.overwrite) q.set("overwrite", "1");
const res = await fetch(API("/api/m/taste/keys/import?" + q), { method: "POST", body: file });
const body = await res.json().catch(() => ({}));
if (!res.ok) { const e = new Error(body.why || `HTTP ${res.status}`); e.exists = !!body.exists; e.key = body.key; throw e; }
return body;
},
exportRefinementKeyFile: async (key) => {
const res = await fetch(API(`/api/m/taste/keys/${encodeURIComponent(key)}/export`));
if (!res.ok) { const b = await res.json().catch(() => ({})); throw new Error(b.why || `HTTP ${res.status}`); }
const url = URL.createObjectURL(await res.blob());
const link = document.createElement("a");
link.href = url; link.download = `${key}.zip`;
document.body.appendChild(link); link.click(); link.remove();
URL.revokeObjectURL(url);
},
releaseModule: (id) => j("POST", API("/api/control/release"), { id }),
deleteRefinementKey: (key) => j("DELETE", API(`/api/m/taste/keys/${encodeURIComponent(key)}`)),
absoluteStoreInfo: () => unsupported("refinement keys are not built in v5 yet"),
clearAbsoluteStore: () => unsupported("refinement keys are not built in v5 yet"),
};
window.MovieEditorAPI = ClientAPI;
})();