Add FunPack Easy Gen (simplified single-scene UI) and a /funpack Hub

Easy Gen is a new Fooocus-style frontend at /funpack/easy — big preview,
one prompt box, Upload, Generate, and an Advanced settings panel — built
entirely on the Movie Editor's existing project format and API (no new
backend endpoints), so projects stay interchangeable between the two UIs.

The Hub at /funpack links to both UIs and surfaces version/branch/commit
plus an Update/Restart action reusing the Editor's git_update.py flow.

Startup now prints full host:port URLs for all three (Hub, Editor, Easy
Gen) via a new config.comfy_display_url() helper.

Also: settings_window.js's About section now labels itself via
window.FunPackAppName instead of hardcoding "Cutting Room", and Easy
Gen's boot-error banner accumulates distinct errors instead of
overwriting, so a cascading failure (e.g. dom.js not loading) surfaces
its root cause instead of just the last script in load order.
This commit is contained in:
digital-garbage
2026-07-23 16:37:26 +03:00
parent 726d88a547
commit 9791cb8ccd
16 changed files with 1484 additions and 2 deletions
+8
View File
@@ -26,10 +26,18 @@ if __package__:
from . import batch_training # noqa: F401 registers /funpack/batch/* routes
except Exception as _e:
print(f"[FunPack] batch_training routes unavailable: {_e}")
try:
from . import hub # noqa: F401 registers /funpack/ (the UI picker)
except Exception as _e:
print(f"[FunPack] Hub route unavailable: {_e}")
try:
from .movie_editor import server as _movie_editor_server # noqa: F401 registers /funpack/movie/* routes
except Exception as _e:
print(f"[FunPack] Movie Editor routes unavailable: {_e}")
try:
from . import easy_gen # noqa: F401 registers /funpack/easy/* routes
except Exception as _e:
print(f"[FunPack] Easy Gen routes unavailable: {_e}")
else:
# Standalone tests may not have the full ComfyUI/CUDA runtime loaded.
from conditioning import (
+1
View File
@@ -0,0 +1 @@
from . import server # noqa: F401 registers /funpack/easy/* routes
+319
View File
@@ -0,0 +1,319 @@
// Thin backend client — FORKED from movie_editor/frontend/api.js on purpose:
// Easy Gen is served from /funpack/easy/ but there is no /funpack/easy/api/*
// backend (see easy_gen/server.py) — Easy Gen reuses the Movie Editor's
// project/generate/media/models API as-is, so BASE must stay pinned to
// /funpack/movie regardless of the page's own URL. Keep every other method
// here in sync with the Editor's api.js; only this constant differs.
(function () {
const BASE = "/funpack/movie";
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, mode) => j("POST", API(`/library/transitions/import?mode=${mode || "merge"}`), data),
clearTransitions: () => j("POST", API("/library/transitions/clear"), {}),
shortcuts: () => j("GET", API("/library/shortcuts")),
suggestionStats: () => j("GET", API("/library/suggestion_stats")),
saveCategory: (payload) => j("POST", API("/library/categories"), payload),
saveShortcut: (item) => j("POST", API("/library/shortcuts"), item),
deleteShortcut: (name) => j("DELETE", API(`/library/shortcuts/${encodeURIComponent(name)}`)),
exportShortcutsUrl: () => API("/library/shortcuts/export"),
importShortcuts: (data, mode) => j("POST", API(`/library/shortcuts/import?mode=${mode || "merge"}`), data),
clearShortcuts: () => j("POST", API("/library/shortcuts/clear"), {}),
revolverSettings: () => j("GET", API("/library/revolver")),
setRevolverSettings: (payload) => j("POST", API("/library/revolver"), payload),
// FunPack file manager (Composer ▸ Files)
listFiles: () => j("GET", API("/files")),
deleteFile: (group, name) => j("DELETE", API(`/files/${encodeURIComponent(group)}/${encodeURIComponent(name)}`)),
clearFiles: (group) => j("POST", API(`/files/${encodeURIComponent(group)}/clear`), {}),
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, nodeOverrides) =>
j("POST", API(`/projects/${id}/generate`), { only_scene: onlyScene || null, scene_ids: sceneIds || null, reset_session: !!resetSession, node_overrides: nodeOverrides || null }),
status: (id, promptId) => j("GET", API(`/projects/${id}/status/${promptId}`)),
progress: () => j("GET", API("/progress")),
// The editor's own in-flight generation, recovered from ComfyUI's queue (survives a UI reload).
active: () => j("GET", API("/active")),
ratingLabels: () => j("GET", API("/rating-labels")),
log: (limit) => j("GET", API("/log" + (limit ? `?limit=${limit}` : ""))),
interrupt: () => j("POST", API("/interrupt")),
// ComfyUI temp folder browser (scene previews & other transient outputs, wiped on restart).
listTemp: () => j("GET", API("/temp")),
// Served by ComfyUI's own same-origin /view endpoint — no editor route needed.
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);
},
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),
});
// dur is in the URL for two reasons: ghosts have no scene server-side (the trim
// window must travel in the query), and it versions the browser cache — segments
// are cached for an hour, so a timeline trim must produce a different URL.
if (spec.dur != null) q.set("dur", String(spec.dur));
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);
},
// Atomically delete a refinement key and ALL its sidecars (value function,
// blessed attention / K-V banks, creativity latent, velocity store). Deleting
// only the visible <key>.json orphans those, and they keep steering future runs
// across restarts — the backend sweep is what makes "key deleted" mean it.
async deleteRefinementKey(key) {
const res = await funpackFetch(
"POST", `${FUNPACK}/refinement_keys/delete?key=${encodeURIComponent(key)}&_=${Date.now()}`,
);
if (!res.ok) {
let payload = null;
try { payload = await res.json(); } catch (_) {}
throw new Error(readApiError(res, payload));
}
return res.json();
},
// The keyless Absolute "global taste" store — learns from every rated generation
// across all prompts, invisible in the key list, applied only in absolute/both
// steer mode, and otherwise only wiped by Session Reset.
async absoluteStoreInfo() {
const res = await funpackFetch("GET", `${FUNPACK}/refinement_keys/absolute?_=${Date.now()}`);
return res.json();
},
async clearAbsoluteStore() {
const res = await funpackFetch("POST", `${FUNPACK}/refinement_keys/clear_absolute?_=${Date.now()}`);
if (!res.ok) {
let payload = null;
try { payload = await res.json(); } catch (_) {}
throw new Error(readApiError(res, payload));
}
return res.json();
},
};
window.MovieEditorAPI = ClientAPI;
})();
+130
View File
@@ -0,0 +1,130 @@
// Easy Gen entrypoint: wires the big-preview / prompt / upload / generate bar
// to the shared easy_store.js + preview.js, and opens the "FunPack Easy Gen"
// project picker on boot. No re-attach-on-reload support yet (the Editor's
// /api/active flow) — a reload while a run is in flight loses the progress
// view, though the run itself still completes server-side.
(function () {
const S = window.Store;
const API = window.MovieEditorAPI;
const LAST_PROJECT_KEY = "funpack_easy_last_project";
const nameEl = document.getElementById("easy-project-name");
const healthEl = document.getElementById("easy-health-chip");
const promptEl = document.getElementById("easy-prompt");
const uploadBtn = document.getElementById("easy-upload-btn");
const uploadInput = document.getElementById("easy-upload-input");
const generateBtn = document.getElementById("easy-generate-btn");
const advancedBtn = document.getElementById("easy-advanced-btn");
const projectsBtn = document.getElementById("easy-projects-btn");
const preview = window.EasyPreview.create(document.getElementById("easy-preview"));
let saveTimer = null;
function scheduleSave() {
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(() => { S.save().catch(() => {}); }, 800);
}
let promptFocused = false;
promptEl.addEventListener("focus", () => { promptFocused = true; });
promptEl.addEventListener("blur", () => { promptFocused = false; });
promptEl.addEventListener("input", () => {
S.setPromptText(promptEl.value);
scheduleSave();
});
let lastProjectId = null;
function render(st) {
const p = st.project;
nameEl.textContent = p ? p.name : "No project";
generateBtn.disabled = !p;
uploadBtn.disabled = !p;
if (p && !promptFocused) {
const text = p.scenes[0]?.text || "";
if (promptEl.value !== text) promptEl.value = text;
}
uploadBtn.textContent = p && p.scenes[0]?.source?.type !== "empty" ? "⬆ Uploaded ✕" : "⬆ Upload";
const pid = p ? p.id : null;
if (pid !== lastProjectId) {
lastProjectId = pid;
preview.stopPolling();
preview.showEmpty();
generateBtn.textContent = "Generate";
}
}
S.subscribe(render);
uploadBtn.onclick = () => {
const st = S.get();
const attached = st.project && st.project.scenes[0]?.source?.type !== "empty";
if (attached) {
S.setSceneMedia(null, null);
scheduleSave();
return;
}
uploadInput.click();
};
uploadInput.onchange = async () => {
const file = uploadInput.files?.[0];
uploadInput.value = "";
if (!file) return;
uploadBtn.disabled = true;
uploadBtn.textContent = "Uploading…";
try {
const media = await API.uploadMedia(file);
const kind = file.type.startsWith("video") ? "video" : "image";
S.setSceneMedia(media.id, kind);
await S.save();
} catch (e) {
alert("Upload failed: " + (e.message || e));
} finally {
render(S.get());
}
};
generateBtn.onclick = async () => {
const st = S.get();
if (!st.project) return;
if (!(st.project.scenes[0]?.text || "").trim()) {
alert("Type a prompt first.");
return;
}
generateBtn.disabled = true;
generateBtn.textContent = "Generating…";
try {
await S.save();
const resetSession = S.takeResetSessionFlag();
const res = await API.generate(st.project.id, null, null, resetSession, null);
preview.watch(st.project.id, res.prompt_id, {
onDone: () => { generateBtn.disabled = false; generateBtn.textContent = "Generate"; },
});
} catch (e) {
preview.showError("Generate failed: " + (e.message || e));
generateBtn.disabled = false;
generateBtn.textContent = "Generate";
}
};
advancedBtn.onclick = () => window.SettingsWindow.open("generation");
projectsBtn.onclick = () => window.ProjectMenu.open({ dismissable: !!S.get().project });
async function boot() {
S.refreshHealth();
setInterval(() => S.refreshHealth(), 15000);
S.subscribe((st) => {
healthEl.textContent = st.health?.ok ? "● online" : "● offline";
healthEl.classList.toggle("bad", !(st.health && st.health.ok));
if (st.project) localStorage.setItem(LAST_PROJECT_KEY, st.project.id);
});
const lastId = localStorage.getItem(LAST_PROJECT_KEY);
if (lastId) {
try { await S.loadProject(lastId); return; } catch (_) { /* fall through to picker */ }
}
window.ProjectMenu.open({ dismissable: false });
}
boot();
})();
+62
View File
@@ -0,0 +1,62 @@
/* Easy Gen layout on top of the Editor's shared styles.css (design tokens,
.btn/.modal/.sw-*/.welcome-* rules) — big preview, prompt bar, nothing else. */
html, body.easy-body {
margin: 0; height: 100%; background: var(--ink-0); color: var(--text);
font-family: var(--font-ui);
}
.easy-app {
height: 100vh; display: flex; flex-direction: column;
}
.easy-top {
height: var(--menubar-h); flex: none; display: flex; align-items: center; gap: 14px;
padding: 0 16px; border-bottom: 1px solid var(--line); background: var(--ink-1);
}
.easy-project-name { color: var(--muted); font-size: 13px; }
.easy-top .easy-health-chip { margin-left: auto; }
a.brand { text-decoration: none; color: inherit; }
a.brand:hover .brand-sub { color: var(--amber-2); }
.easy-preview {
flex: 1; min-height: 0; display: flex; align-items: center; justify-content: center;
background: var(--ink-0); position: relative; padding: 24px;
}
.easy-preview-inner {
width: 100%; height: 100%; display: flex; align-items: center; justify-content: center;
}
.easy-preview-inner img, .easy-preview-inner video {
max-width: 100%; max-height: 100%; border-radius: var(--radius); box-shadow: var(--shadow);
background: #000;
}
.easy-preview-empty {
color: var(--faint); font-size: 14px; font-family: var(--font-display);
}
.easy-preview-error { color: var(--danger); max-width: 60ch; text-align: center; white-space: pre-wrap; }
.easy-progress {
position: absolute; left: 24px; right: 24px; bottom: 24px;
background: var(--ink-1); border: 1px solid var(--line); border-radius: var(--radius);
padding: 10px 14px; box-shadow: var(--shadow);
}
.easy-progress-bar {
height: 6px; border-radius: 4px; background: linear-gradient(90deg, var(--amber), var(--amber-2));
width: 0%; transition: width .2s ease; margin-bottom: 6px;
}
.easy-progress-label { font-size: 12px; color: var(--muted); font-family: var(--font-mono); }
.easy-bar {
flex: none; display: flex; align-items: flex-end; gap: 12px;
padding: 14px 16px; border-top: 1px solid var(--line); background: var(--ink-1);
}
.easy-upload-btn { flex: none; align-self: stretch; padding: 0 16px; }
.easy-prompt {
flex: 1; resize: none; background: var(--ink-2); border: 1px solid var(--line);
border-radius: var(--radius); color: var(--text); font: 14px/1.4 var(--font-ui);
padding: 10px 12px;
}
.easy-prompt:focus { outline: none; border-color: var(--amber); }
.easy-actions { flex: none; display: flex; flex-direction: column; align-items: stretch; gap: 6px; }
.easy-generate-btn { padding: 10px 26px; font-size: 14px; }
.easy-advanced-btn { align-self: center; }
+104
View File
@@ -0,0 +1,104 @@
// window.FunPackMaintenance shim — ports the five actions menubar.js exposes
// for the Settings window's built-in "Refinement & Taste" / "Updates & ComfyUI"
// sections (settings_window.js), without pulling in the other ~300 lines of
// menubar.js (menu rendering, keyboard shortcuts, timeline-specific state).
// Same API calls, same confirm/alert flows as the Editor.
(function () {
const API = window.MovieEditorAPI;
function projectRefinementKey() {
return window.Store?.get().project?.refinement_key || "";
}
const refinementKeyImportInput = (function () {
const inp = document.createElement("input");
inp.type = "file"; inp.accept = ".json,application/json"; inp.style.display = "none";
inp.onchange = async () => {
const file = inp.files?.[0];
inp.value = "";
if (!file) return;
try {
const data = JSON.parse(await file.text());
let res;
try {
res = await API.importRefinementKey(data);
} catch (e) {
if (!e.exists) throw e;
if (!confirm(`A refinement key named "${e.key}" already exists. Overwrite it?`)) return;
res = await API.importRefinementKey(data, { overwrite: true });
}
alert(`Imported refinement key "${res.key}".`);
} catch (e) {
alert("Refinement key import failed: " + (e.message || e));
}
};
document.body.appendChild(inp);
return inp;
})();
async function exportRefinementKey() {
let payload;
try { payload = await API.refinementKeys(); }
catch (e) { alert("Could not list refinement keys: " + (e.message || e)); return; }
const names = (payload.keys || []).filter((k) => k && k !== "-None-");
if (!names.length) { alert("No refinement keys on disk yet."); return; }
const cur = projectRefinementKey();
if (!window.SlotPicker) return;
window.SlotPicker.open({
title: "Export refinement key",
options: names.map((k) => ({ value: k, label: k, hint: k === cur ? "project session" : "" })),
onPick: async (key) => {
try { await API.exportRefinementKeyFile(key); }
catch (e) { alert("Refinement key export failed: " + (e.message || e)); }
},
});
}
async function deleteRefinementKey() {
let payload;
try { payload = await API.refinementKeys(); }
catch (e) { alert("Could not list refinement keys: " + (e.message || e)); return; }
const names = (payload.keys || []).filter((k) => k && k !== "-None-");
if (!names.length) { alert("No refinement keys on disk yet."); return; }
const cur = projectRefinementKey();
if (!window.SlotPicker) return;
window.SlotPicker.open({
title: "Delete refinement key",
options: names.map((k) => ({ value: k, label: k, hint: k === cur ? "project session" : "" })),
onPick: async (key) => {
if (!confirm(`Delete refinement key "${key}"?\n\nThis removes its learned state AND all sidecars (value function, blessed attention/K-V banks, creativity latent, velocity memory). This cannot be undone.`)) {
return;
}
try { await API.deleteRefinementKey(key); alert(`Deleted "${key}".`); }
catch (e) { alert("Delete failed: " + (e.message || e)); }
},
});
}
async function clearGlobalTaste() {
let info;
try { info = await API.absoluteStoreInfo(); }
catch (e) { alert("Could not read the global-taste store: " + (e.message || e)); return; }
if (!info.exists) { alert("The Absolute global-taste store is already empty."); return; }
if (!confirm(`Clear the Absolute global-taste store?\n\nIt has pooled ${info.total_iterations} rated generation(s) (${info.liked_count} liked / ${info.bad_count} disliked directions) across all prompts. It learns from every rated run and is applied only in absolute/both steer mode. This cannot be undone.`)) {
return;
}
try { await API.clearAbsoluteStore(); alert("Cleared the Absolute global-taste store."); }
catch (e) { alert("Clear global taste failed: " + (e.message || e)); }
}
async function restartComfy() {
if (!confirm("Restart ComfyUI now?\n\nThe server will be down for ~10-40s and any running generation will be lost. This page reloads automatically when it's back.")) return;
const msg = window.FunPackRestart?.showOverlay?.("Restarting ComfyUI…\nThis page will reload when it's back.");
try { await API.restart(); } catch (_) { /* connection drops as it execv's - expected */ }
window.FunPackRestart?.waitForReload?.(msg, Date.now());
}
window.FunPackMaintenance = {
exportRefinementKey,
importRefinementKeyFile: () => refinementKeyImportInput.click(),
deleteRefinementKey,
clearGlobalTaste,
restartComfy,
};
})();
+93
View File
@@ -0,0 +1,93 @@
// Easy Gen's own slimmed "Generation" Settings section — modeled on the
// field(label, control, hint) row pattern in movie_editor/frontend/engine_settings.js,
// but limited to plain Project-level fields (no scene-chain/continuity/overlay
// knobs, which only make sense with multiple scenes in the full Editor).
(function () {
const { el } = window.dom;
function field(labelText, control, hint) {
const row = el("div", "sw-row eng-field");
const main = el("div", "sw-row-main");
main.append(el("div", "sw-row-title", labelText));
if (hint) main.append(el("div", "sw-row-hint", hint));
row.append(main, control);
return row;
}
function numberInput(value, onChange, opts = {}) {
const inp = el("input", "sw-input");
inp.type = "number";
if (opts.min != null) inp.min = opts.min;
if (opts.step != null) inp.step = opts.step;
inp.value = value;
inp.style.width = "90px";
inp.onchange = () => onChange(Number(inp.value));
return inp;
}
function textInput(value, onChange, placeholder) {
const inp = el("input", "sw-input");
inp.type = "text";
inp.value = value || "";
if (placeholder) inp.placeholder = placeholder;
inp.style.width = "220px";
inp.onchange = () => onChange(inp.value);
return inp;
}
function mount(body) {
const S = window.Store;
const wrap = el("div", "sw-stack");
body.append(wrap);
function render() {
body.innerHTML = "";
body.append(wrap);
wrap.innerHTML = "";
const st = S.get();
const p = st.project;
if (!p) {
wrap.append(el("div", "sw-hint", "Open or create a project first."));
return;
}
const commit = async (mut) => { mut(p); await S.save(); };
wrap.append(el("div", "sw-rows-label", "Output"));
const out = el("div", "sw-rows");
out.append(field("Width", numberInput(p.width, (v) => commit((pr) => pr.width = v), { min: 64, step: 16 })));
out.append(field("Height", numberInput(p.height, (v) => commit((pr) => pr.height = v), { min: 64, step: 16 })));
out.append(field("Frames", numberInput(p.num_frames_per_scene, (v) => commit((pr) => pr.num_frames_per_scene = v), { min: 9, step: 8 }),
"Length of the generated clip, in frames."));
out.append(field("Frame rate", numberInput(p.frame_rate, (v) => commit((pr) => pr.frame_rate = v), { min: 1 })));
wrap.append(out);
wrap.append(el("div", "sw-rows-label", "Prompt"));
const pr = el("div", "sw-rows");
pr.append(field("Negative prompt", textInput(p.negative_prompt, (v) => commit((proj) => proj.negative_prompt = v)),
"Passed to the negative conditioning for every run."));
wrap.append(pr);
wrap.append(el("div", "sw-rows-label", "Seed & learning"));
const sd = el("div", "sw-rows");
sd.append(field("Seed", numberInput(p.seed, (v) => commit((proj) => proj.seed = v), { min: 0 }),
"Fixed seed for this project's runs."));
sd.append(field("Refinement key", textInput(p.refinement_key, (v) => commit((proj) => proj.refinement_key = v || "default")),
"Named session FunPack learns from your ratings under. \"default\" = the keyless store. "
+ "Manage keys under Refinement & Taste."));
wrap.append(sd);
}
const unsub = S.subscribe(render);
render();
return () => unsub();
}
window.SettingsWindow.register({
id: "generation", group: "Generation", order: 1, title: "Generation",
subtitle: "Output size, length, seed, and refinement key for this project.",
keywords: "width height frames frame rate seed negative prompt refinement key generation",
iconBg: "linear-gradient(180deg,#ffb64d,#e07f1f)",
icon: '<svg viewBox="0 0 16 16" width="13" height="13"><path d="M9.2 1.3 3 9h4.1l-1 5.7L12.9 7H8.5l.7-5.7z" fill="#fff"/></svg>',
mount,
});
})();
+109
View File
@@ -0,0 +1,109 @@
// Minimal state container for Easy Gen — NOT the Editor's store.js (that's a
// 4000+ line timeline/composer/overlay engine tightly coupled to the full NLE
// DOM). Easy Gen only ever holds one project with exactly one scene, so this
// is a small pub-sub wrapper around the same /funpack/movie/api/* endpoints.
//
// Exposed as window.Store (same global name the Editor uses) purely so
// reused sections — models.js, and the built-in "Refinement & Taste" /
// "Updates & ComfyUI" sections baked into settings_window.js — work
// unmodified: they all read window.Store.get().project/.health and call
// window.Store.subscribe(fn) without needing to know which app they're in.
(function () {
const API = window.MovieEditorAPI;
const state = {
project: null,
health: null,
resetSessionArmed: false,
};
const listeners = new Set();
function get() { return state; }
function notify() { listeners.forEach((fn) => { try { fn(state); } catch (_) {} }); }
function subscribe(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
}
function set(patch) {
Object.assign(state, patch);
notify();
}
async function refreshHealth() {
try { set({ health: await API.health() }); } catch (_) { set({ health: { ok: false } }); }
}
function emptyScene() {
return { text: "", source: { type: "empty" } };
}
async function listProjects() {
return (await API.listProjects()).projects || [];
}
async function createProject(name) {
const p = await API.createProject(name || "Untitled");
p.scenes = [emptyScene()];
const saved = await API.saveProject(p.id, p);
set({ project: saved });
return saved;
}
async function loadProject(id) {
const p = await API.getProject(id);
if (!p.scenes || !p.scenes.length) p.scenes = [emptyScene()];
set({ project: p });
return p;
}
async function deleteProject(id) {
await API.deleteProject(id);
if (state.project && state.project.id === id) set({ project: null });
}
// Local-only edit — call save() to persist. Keeps the prompt box from
// round-tripping to the server on every keystroke.
function setPromptText(text) {
if (!state.project) return;
state.project.scenes[0].text = text;
notify();
}
// kind: "image" | "video" | null (null clears the upload → pure t2v).
function setSceneMedia(mediaId, kind) {
if (!state.project) return;
state.project.scenes[0].source = mediaId
? { type: kind === "video" ? "v2v" : "image", media_ref: mediaId }
: { type: "empty" };
notify();
}
async function save() {
if (!state.project) return;
const saved = await API.saveProject(state.project.id, state.project);
set({ project: saved });
return saved;
}
function resetStudioSession() {
set({ resetSessionArmed: !state.resetSessionArmed });
}
// Consumed by the Generate flow: reset_session applies to the NEXT run only.
function takeResetSessionFlag() {
const armed = state.resetSessionArmed;
if (armed) set({ resetSessionArmed: false });
return armed;
}
window.Store = {
get, subscribe, set,
refreshHealth,
listProjects, createProject, loadProject, deleteProject,
setPromptText, setSceneMedia, save,
resetStudioSession, takeResetSessionFlag,
};
})();
+91
View File
@@ -0,0 +1,91 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>FunPack · Easy Gen</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,500;12..96,700;12..96,800&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="styles.css" />
<link rel="stylesheet" href="easy.css" />
<script>
(function () {
// A single failed shared script (e.g. dom.js) cascades into near-identical
// errors from every script that depends on it — keep the first few DISTINCT
// messages instead of overwriting, so the root cause (usually the first one)
// stays visible instead of being clobbered by the last script in load order.
var seen = [];
function show(msg) {
if (seen.indexOf(msg) !== -1) return;
seen.push(msg);
if (seen.length > 5) return;
var b = document.getElementById("boot-error");
if (!b) {
b = document.createElement("div"); b.id = "boot-error";
b.style.cssText = "position:fixed;z-index:99999;left:0;right:0;top:0;padding:10px 34px 10px 14px;" +
"background:#3a1320;color:#ffb3c1;font:12px/1.5 monospace;border-bottom:1px solid #e0566b;white-space:pre-wrap;max-height:60vh;overflow:auto";
var x = document.createElement("button");
x.textContent = "✕"; x.title = "Dismiss";
x.style.cssText = "position:absolute;top:6px;right:8px;background:transparent;border:1px solid #e0566b;" +
"color:#ffb3c1;border-radius:6px;cursor:pointer;font:12px monospace;padding:1px 7px;line-height:1.4";
x.onclick = function () { b.remove(); };
b.appendChild(x);
(document.body || document.documentElement).appendChild(b);
}
var t = b.querySelector(".boot-error-text");
if (!t) { t = document.createElement("span"); t.className = "boot-error-text"; b.insertBefore(t, b.firstChild); }
t.textContent = "Easy Gen error(s) — " + seen.join("\n---\n")
+ "\n(restart ComfyUI + hard-reload Cmd/Ctrl+Shift+R; if it persists, send me this text)";
}
window.addEventListener("error", function (e) {
if (e && e.target && e.target !== window && e.target.nodeName) return;
show((e.message || "load error") + (e.filename ? " @ " + e.filename.split("/").pop() + ":" + e.lineno : ""));
}, true);
window.addEventListener("unhandledrejection", function (e) {
show("promise: " + ((e.reason && (e.reason.message || e.reason)) || "rejected"));
});
})();
</script>
</head>
<body class="easy-body">
<div class="easy-app" id="easy-app">
<header class="easy-top">
<a class="brand" href="/funpack/" title="FunPack Hub"><span class="brand-mark">◉</span> FunPack <span class="brand-sub">Easy Gen</span></a>
<div class="easy-project-name" id="easy-project-name">No project</div>
<span class="health-chip" id="easy-health-chip"></span>
<button class="btn ghost tiny" id="easy-projects-btn" type="button">Projects…</button>
</header>
<main class="easy-preview" id="easy-preview"></main>
<footer class="easy-bar">
<button class="btn ghost easy-upload-btn" id="easy-upload-btn" type="button"
title="Upload an image or video to guide generation">⬆ Upload</button>
<textarea class="easy-prompt" id="easy-prompt" rows="2"
placeholder="Describe what you want to generate…"></textarea>
<div class="easy-actions">
<button class="btn primary easy-generate-btn" id="easy-generate-btn" type="button">Generate</button>
<button class="btn ghost tiny easy-advanced-btn" id="easy-advanced-btn" type="button">Advanced settings</button>
</div>
</footer>
<input type="file" id="easy-upload-input" accept="image/*,video/*" hidden />
</div>
<script>window.FunPackAppName = "Easy Gen";</script>
<script src="api.js"></script>
<script src="dom.js"></script>
<script src="settings_window.js"></script>
<script src="git_actions.js"></script>
<script src="restart_ui.js"></script>
<script src="slot_picker.js"></script>
<script src="pipeline_caps.js"></script>
<script src="models.js"></script>
<script src="easy_store.js"></script>
<script src="easy_maintenance.js"></script>
<script src="easy_settings.js"></script>
<script src="project_menu.js"></script>
<script src="preview.js"></script>
<script src="app.js"></script>
</body>
</html>
+107
View File
@@ -0,0 +1,107 @@
// Big preview pane: plain <video>/<img>, not the Editor's timeline-oriented
// player.js. Polls /api/progress (step count) while a run is in flight and
// /api/projects/{id}/status/{prompt_id} for the final result.
(function () {
const { el, clear } = window.dom;
const API = window.MovieEditorAPI;
function create(root) {
const inner = el("div", "easy-preview-inner");
const empty = el("div", "easy-preview-empty", "No generation yet");
inner.append(empty);
const progressWrap = el("div", "easy-progress");
progressWrap.hidden = true;
const bar = el("div", "easy-progress-bar");
const label = el("div", "easy-progress-label");
progressWrap.append(bar, label);
root.append(inner, progressWrap);
let pollTimer = null;
let progressTimer = null;
function stopPolling() {
if (pollTimer) clearTimeout(pollTimer);
if (progressTimer) clearInterval(progressTimer);
pollTimer = progressTimer = null;
}
function showMedia(m, projectId) {
clear(inner);
const url = API.resultUrl(projectId, m);
if (m.kind === "images") {
const img = el("img");
img.src = url;
inner.append(img);
} else {
const video = el("video");
video.src = url;
video.controls = true;
video.autoplay = true;
video.loop = true;
inner.append(video);
}
}
function showEmpty(msg) {
clear(inner);
inner.append(el("div", "easy-preview-empty", msg || "No generation yet"));
}
function showError(msg) {
clear(inner);
inner.append(el("div", "easy-preview-empty easy-preview-error", msg));
}
async function watch(projectId, promptId, { onDone } = {}) {
stopPolling();
progressWrap.hidden = false;
label.textContent = "Starting…";
bar.style.width = "0%";
progressTimer = setInterval(async () => {
try {
const pr = await API.progress();
if (pr.max > 0) {
const pct = Math.min(100, Math.round((pr.value / pr.max) * 100));
bar.style.width = pct + "%";
label.textContent = `Sampling… ${pr.value}/${pr.max}`;
}
} catch (_) { /* transient */ }
}, 700);
const poll = async () => {
let res;
try {
res = await API.status(projectId, promptId);
} catch (e) {
stopPolling();
progressWrap.hidden = true;
showError("Status check failed: " + (e.message || e));
if (onDone) onDone(false);
return;
}
if (res.state === "completed") {
stopPolling();
progressWrap.hidden = true;
const media = res.media && res.media.length ? res.media[res.media.length - 1] : null;
if (media) showMedia(media, projectId); else showEmpty("Generation finished but produced no media.");
if (onDone) onDone(true, res);
return;
}
if (res.state === "error") {
stopPolling();
progressWrap.hidden = true;
showError(res.error || "Generation failed.");
if (onDone) onDone(false, res);
return;
}
pollTimer = setTimeout(poll, 1200);
};
poll();
}
return { showMedia, showEmpty, showError, watch, stopPolling };
}
window.EasyPreview = { create };
})();
+104
View File
@@ -0,0 +1,104 @@
// "FunPack Easy Gen" project picker — same /funpack/movie/api/projects* calls
// as the Editor's welcome.js / menubar.js "Open recent" menu, reimplemented as
// a small standalone modal (welcome.js/menubar.js are wired into the full
// Editor chrome and not reusable in isolation).
(function () {
const { el, clear } = window.dom;
const API = window.MovieEditorAPI;
const S = window.Store;
let overlay = null;
const loadFileInput = (function () {
const inp = document.createElement("input");
inp.type = "file";
inp.accept = ".json,.funpack_project.json,application/json";
inp.style.display = "none";
inp.onchange = async () => {
const file = inp.files?.[0];
inp.value = "";
if (!file) return;
try {
const data = JSON.parse(await file.text());
const p = await API.importProject(data);
S.set({ project: p });
close();
} catch (e) {
alert("Could not load project file: " + (e.message || e));
}
};
document.body.appendChild(inp);
return inp;
})();
function close() {
if (overlay) { overlay.remove(); overlay = null; }
}
async function open({ dismissable } = {}) {
close();
overlay = el("div", "modal-overlay");
if (!dismissable) overlay.style.pointerEvents = "auto";
const box = el("div", "modal modal-wide");
const head = el("div", "modal-head");
head.append(el("div", "modal-title", "FunPack Easy Gen"));
if (dismissable) {
const x = el("button", "btn ghost tiny", "✕");
x.onclick = close;
head.append(el("div", "modal-head-right"), x);
}
box.append(head);
const content = el("div", "modal-content");
box.append(content);
const actions = el("div", "welcome-actions");
const mkBtn = (label, cls, hint, fn) => {
const b = el("button", "welcome-btn" + (cls ? " " + cls : ""));
b.type = "button";
b.append(el("span", "welcome-btn-label", label));
if (hint) b.append(el("span", "welcome-btn-hint", hint));
b.onclick = fn;
return b;
};
actions.append(
mkBtn("New Generation", "primary", "Start with a blank prompt", async () => {
await S.createProject("Untitled");
close();
}),
mkBtn("Load Project File…", "", "Import a .funpack_project.json", () => loadFileInput.click()),
);
content.append(actions);
content.append(el("div", "sw-rows-label", "Recent"));
const recentWrap = el("div", "sw-rows");
content.append(recentWrap);
try {
const list = await S.listProjects();
clear(recentWrap);
if (!list.length) {
recentWrap.append(el("div", "sw-hint", "No projects yet."));
}
list.slice(0, 12).forEach((p) => {
const row = el("div", "sw-row");
const main = el("div", "sw-row-main");
main.append(el("div", "sw-row-title", p.name));
main.append(el("div", "sw-row-hint", `${p.scene_count ?? 1} scene(s)`));
row.append(main);
const openBtn = el("button", "btn ghost tiny", "Open");
openBtn.type = "button";
openBtn.onclick = async () => { await S.loadProject(p.id); close(); };
row.append(openBtn);
recentWrap.append(row);
});
} catch (e) {
clear(recentWrap);
recentWrap.append(el("div", "sw-hint", "Could not load project list: " + (e.message || e)));
}
overlay.append(box);
if (dismissable) overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
document.body.append(overlay);
}
window.ProjectMenu = { open, close };
})();
+74
View File
@@ -0,0 +1,74 @@
"""FunPack Easy Gen — simplified single-scene generation UI.
UI: GET /funpack/easy/ (static assets under /funpack/easy/<file>)
No new backend API on purpose: this frontend talks to the exact same
/funpack/movie/api/* endpoints the Movie Editor uses (same Project files,
same on-disk storage), so a project created here opens unchanged in the
full Editor and vice versa. The only job of this module is serving Easy
Gen's own frontend files, falling back to the Editor's frontend directory
for shared scripts/styles (api.js excluded — see easy_gen/frontend/api.js)
so those stay a single source of truth instead of being copy-pasted.
"""
from __future__ import annotations
import mimetypes
from pathlib import Path
from typing import Optional
try:
from aiohttp import web
from server import PromptServer
except Exception: # pragma: no cover - only available inside ComfyUI
web = None
PromptServer = None
from ..movie_editor.backend import config as movie_config
UI_PREFIX = "/funpack/easy"
FRONTEND_DIR = Path(__file__).resolve().parent / "frontend"
def _resolve(tail: str) -> Optional[Path]:
rel = (tail or "index.html").lstrip("/")
for root in (FRONTEND_DIR, movie_config.FRONTEND_DIR):
root = root.resolve()
target = (root / rel).resolve()
if root not in target.parents and target != root:
continue # path traversal guard
if target.is_dir():
target = target / "index.html"
if target.is_file():
return target
return None
def _serve_static(tail: str) -> "web.Response":
target = _resolve(tail)
if target is None:
raise web.HTTPNotFound()
ctype = mimetypes.guess_type(str(target))[0] or "application/octet-stream"
if target.suffix == ".js":
ctype = "text/javascript" # be explicit; some platforms guess text/plain
return web.Response(
body=target.read_bytes(), content_type=ctype,
headers={"Cache-Control": "no-store, max-age=0"},
)
if web is not None and PromptServer is not None:
routes = PromptServer.instance.routes
@routes.get(UI_PREFIX)
async def _root_redirect(_req):
raise web.HTTPFound(UI_PREFIX + "/")
@routes.get(UI_PREFIX + "/")
async def _index(_req):
return _serve_static("index.html")
@routes.get(UI_PREFIX + "/{tail:.*}")
async def _static(req):
return _serve_static(req.match_info["tail"])
print(f"[FunPack] Easy Gen available at {movie_config.comfy_display_url()}{UI_PREFIX}/")
+253
View File
@@ -0,0 +1,253 @@
"""FunPack Hub — a small landing page at /funpack/ linking to the two FunPack
UIs (Movie Editor "Cutting Room" and "Easy Gen"), plus an About block (version/
branch/commit) and an Update/Restart action. Talks to the Movie Editor's
existing /funpack/movie/api/{health,git/status,git/update,restart} endpoints —
no new backend API, same git_update.py logic the Editor's own "Updates &
ComfyUI" settings section uses. Kept as a single self-contained inline page
(no shared JS files) since its surface is a handful of fetch calls, much
smaller than pulling in api.js/dom.js/git_actions.js/restart_ui.js would need.
"""
from __future__ import annotations
try:
from aiohttp import web
from server import PromptServer
except Exception: # pragma: no cover - only available inside ComfyUI
web = None
PromptServer = None
from .movie_editor.backend import config as movie_config
HUB_PREFIX = "/funpack"
_PAGE = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>FunPack</title>
<style>
:root {
--ink-0: #0c0b09; --ink-1: #131210; --ink-2: #1a1814; --line: #2a261e;
--text: #ece7db; --muted: #918a7a; --faint: #645e51;
--amber: #f3a93c; --amber-2: #ffc36b; --teal: #57d0c6; --danger: #e8607a; --good: #79c479;
--radius: 12px; --shadow: 0 10px 30px -12px rgba(0,0,0,.7);
}
* { box-sizing: border-box; }
html, body {
margin: 0; min-height: 100%; background: var(--ink-0); color: var(--text);
font-family: "IBM Plex Sans", system-ui, sans-serif;
display: flex; align-items: center; justify-content: center; padding: 32px 16px;
}
.hub { text-align: center; max-width: 640px; width: 100%; }
.brand { font-weight: 800; font-size: 28px; letter-spacing: .2px; margin-bottom: 6px; }
.brand-mark { color: var(--amber); margin-right: 8px; }
.lead { color: var(--muted); margin: 0 0 20px; font-size: 14px; }
.about {
display: inline-flex; align-items: center; gap: 14px; flex-wrap: wrap; justify-content: center;
background: var(--ink-1); border: 1px solid var(--line); border-radius: var(--radius);
padding: 10px 16px; margin-bottom: 28px; font-size: 12.5px; color: var(--muted);
font-family: "IBM Plex Mono", ui-monospace, monospace;
}
.about .led { width: 7px; height: 7px; border-radius: 50%; background: var(--faint); display: inline-block; margin-right: 5px; }
.about .led.ok { background: var(--good); box-shadow: 0 0 8px var(--good); }
.about .led.bad { background: var(--danger); box-shadow: 0 0 8px var(--danger); }
.about b { color: var(--text); font-weight: 600; }
.about .sep { color: var(--line); }
.update-row { margin-bottom: 28px; display: flex; align-items: center; justify-content: center; gap: 10px; flex-wrap: wrap; }
.btn {
background: var(--ink-2); border: 1px solid var(--line); border-radius: 7px; padding: 7px 14px;
cursor: pointer; font-size: 13px; color: var(--text); transition: border-color .12s, background .12s;
}
.btn:hover:not(:disabled) { border-color: var(--amber); }
.btn:disabled { opacity: .45; cursor: default; }
.btn.primary { background: linear-gradient(#f3a93c, #e0972a); color: #1a1206; border-color: #f3a93c; font-weight: 700; }
.btn.primary:hover:not(:disabled) { filter: brightness(1.08); }
.btn.ghost { background: transparent; color: var(--muted); }
.btn.ghost:hover:not(:disabled) { color: var(--amber-2); background: var(--ink-3); }
.update-hint { color: var(--faint); font-size: 12px; }
.cards { display: flex; gap: 18px; flex-wrap: wrap; justify-content: center; }
a.card {
display: block; width: 260px; padding: 22px 20px; text-align: left;
background: var(--ink-1); border: 1px solid var(--line); border-radius: var(--radius);
text-decoration: none; color: inherit; box-shadow: var(--shadow);
transition: border-color .12s, transform .12s;
}
a.card:hover { border-color: var(--amber); transform: translateY(-2px); }
.card-title { font-weight: 700; font-size: 17px; margin-bottom: 6px; }
.card-title.easy { color: var(--amber-2); }
.card-title.editor { color: var(--teal); }
.card-sub { color: var(--faint); font-size: 12.5px; line-height: 1.5; }
.restart-overlay {
position: fixed; inset: 0; z-index: 500; background: rgba(6,5,4,.75); backdrop-filter: blur(3px);
display: flex; align-items: center; justify-content: center;
}
.restart-card {
background: #16140f; border: 1px solid var(--line); border-radius: 14px; padding: 26px 30px;
box-shadow: var(--shadow); text-align: center; max-width: 360px;
}
.restart-spin {
width: 26px; height: 26px; margin: 0 auto 14px; border-radius: 50%;
border: 3px solid var(--line); border-top-color: var(--amber); animation: spin .8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.restart-msg { color: var(--muted); font-size: 13px; white-space: pre-wrap; }
</style>
</head>
<body>
<div class="hub">
<div class="brand"><span class="brand-mark">◉</span>FunPack</div>
<p class="lead">Pick a UI.</p>
<div class="about" id="about">Loading…</div>
<div class="update-row" id="update-row"></div>
<div class="cards">
<a class="card" href="/funpack/easy/">
<div class="card-title easy">Easy Gen</div>
<div class="card-sub">One prompt, one Generate button, one big preview. Advanced settings tucked away.</div>
</a>
<a class="card" href="/funpack/movie/">
<div class="card-title editor">Cutting Room</div>
<div class="card-sub">The full multi-scene NLE — timeline, composer, overlays, audio.</div>
</a>
</div>
</div>
<script>
(function () {
const API = (p) => "/funpack/movie/api" + p;
const aboutEl = document.getElementById("about");
const updateEl = document.getElementById("update-row");
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((payload && payload.detail) || res.statusText || "Request failed");
}
return res.status === 204 ? null : res.json();
}
function led(ok) { return `<span class="led ${ok ? "ok" : "bad"}"></span>`; }
function renderAbout(health, git) {
const parts = [];
parts.push(`${led(!!(health && health.ok))}${health && health.ok ? "online" : "offline"}`);
if (git && git.ok) {
parts.push(`<b>v${git.version || "?"}</b>`);
parts.push(`${git.branch}@${git.commit}${git.dirty ? " (local changes)" : ""}`);
} else {
parts.push(git && git.version ? `<b>v${git.version}</b>` : "");
parts.push("git unavailable");
}
aboutEl.innerHTML = parts.filter(Boolean).join(' <span class="sep">·</span> ');
}
function renderUpdateRow(git) {
updateEl.innerHTML = "";
if (!git || !git.ok) return;
const updateBtn = document.createElement("button");
updateBtn.className = "btn primary";
updateBtn.disabled = !!git.dirty;
updateBtn.textContent = git.behind > 0 ? `${git.behind}↓ Update` : "⬇⟳ Update";
updateBtn.onclick = doUpdate;
updateEl.appendChild(updateBtn);
const restartBtn = document.createElement("button");
restartBtn.className = "btn ghost";
restartBtn.textContent = "⟳ Restart ComfyUI";
restartBtn.onclick = doRestart;
updateEl.appendChild(restartBtn);
const hint = document.createElement("span");
hint.className = "update-hint";
hint.textContent = git.dirty
? "Local changes — commit or stash first"
: (git.behind > 0 ? `${git.behind} commit(s) behind origin/${git.branch}` : `origin/${git.branch} up to date`);
updateEl.appendChild(hint);
}
function showOverlay(message) {
document.querySelector(".restart-overlay")?.remove();
const ov = document.createElement("div"); ov.className = "restart-overlay";
const card = document.createElement("div"); card.className = "restart-card";
const spin = document.createElement("div"); spin.className = "restart-spin";
const msg = document.createElement("div"); msg.className = "restart-msg"; msg.textContent = message;
card.append(spin, msg); ov.append(card); document.body.append(ov);
return msg;
}
function waitForReload(msgEl, startMs) {
const start = startMs || Date.now();
const tick = async () => {
try {
const h = await j("GET", API("/health"));
if (h && h.ok) { location.reload(); return; }
} catch (_) { /* still down */ }
if (Date.now() - start > 90000) {
msgEl.textContent = "Still waiting on ComfyUI…\\nIt may need a manual restart - check the console.";
}
setTimeout(tick, 2000);
};
setTimeout(tick, 3500);
}
async function doUpdate() {
let git;
try { git = await j("GET", API("/git/status")); } catch (e) { alert("Could not read git status: " + e.message); return; }
if (!git.ok) { alert(git.detail || "Git unavailable for this install."); return; }
if (git.dirty) { alert("Local changes detected in the FunPack folder.\\nCommit or stash them before updating."); return; }
const behind = git.behind > 0 ? `\\n\\n${git.behind} commit(s) available on origin/${git.branch}.` : "";
if (!confirm(`Pull latest "${git.branch}" from origin and restart ComfyUI?\\n\\nAny running generation will be lost.${behind}`)) return;
const msg = showOverlay(`Pulling origin/${git.branch}…\\nComfyUI will restart when the pull finishes.`);
try {
const res = await j("POST", API("/git/update"), {});
msg.textContent = res.updated ? `Updated ${res.before} → ${res.after}.\\nRestarting ComfyUI…` : "Already up to date.\\nRestarting ComfyUI…";
} catch (e) {
document.querySelector(".restart-overlay")?.remove();
alert("Update failed: " + e.message);
return;
}
waitForReload(msg, Date.now());
}
async function doRestart() {
if (!confirm("Restart ComfyUI now?\\n\\nThe server will be down for ~10-40s and any running generation will be lost. This page reloads automatically when it's back.")) return;
const msg = showOverlay("Restarting ComfyUI…\\nThis page will reload when it's back.");
try { await j("POST", API("/restart"), {}); } catch (_) { /* connection drops as it execv's - expected */ }
waitForReload(msg, Date.now());
}
async function boot() {
let health = null, git = null;
try { health = await j("GET", API("/health")); } catch (_) {}
try { git = await j("GET", API("/git/status")); } catch (_) {}
renderAbout(health, git);
renderUpdateRow(git);
}
boot();
})();
</script>
</body>
</html>
"""
if web is not None and PromptServer is not None:
routes = PromptServer.instance.routes
@routes.get(HUB_PREFIX)
async def _hub_redirect(_req):
raise web.HTTPFound(HUB_PREFIX + "/")
@routes.get(HUB_PREFIX + "/")
async def _hub_index(_req):
return web.Response(body=_PAGE, content_type="text/html", headers={"Cache-Control": "no-store, max-age=0"})
print(f"[FunPack] Hub available at {movie_config.comfy_display_url()}{HUB_PREFIX}/")
+24
View File
@@ -53,6 +53,30 @@ def comfy_base_url() -> str:
return f"http://127.0.0.1:{port}" # always loopback for self-calls
def comfy_display_url() -> str:
"""Best-effort http://host:port for STARTUP LOG PRINTS (full clickable UI
URLs) — not for internal calls, see comfy_base_url() above. Routes are
registered while custom nodes load, before PromptServer binds a socket, so
the port/host here come from comfy.cli_args (already parsed at process
start), not the live server instance.
"""
override = os.environ.get("FUNPACK_COMFY_URL")
if override:
return override.rstrip("/")
host, port = None, None
try:
from comfy.cli_args import args
host = getattr(args, "listen", None)
port = getattr(args, "port", None)
except Exception:
pass
host = (host or "127.0.0.1").split(",")[0].strip()
if host in ("0.0.0.0", "::", ""):
host = "127.0.0.1" # not directly browsable; show the loopback equivalent
port = int(port or 8188)
return f"http://{host}:{port}"
def ensure_dirs() -> None:
PROJECTS_DIR.mkdir(parents=True, exist_ok=True)
MEDIA_DIR.mkdir(parents=True, exist_ok=True)
+4 -1
View File
@@ -197,7 +197,10 @@
const git = G ? G.get() : null;
wrap.append(el("div", "sw-about-mark", "◉"));
wrap.append(el("div", "sw-about-name", "FunPack"));
wrap.append(el("div", "sw-about-sub", "Cutting Room"));
// window.FunPackAppName lets a different frontend sharing this file (e.g. Easy
// Gen) relabel the app name without forking the whole section; Editor leaves it
// unset and keeps "Cutting Room".
wrap.append(el("div", "sw-about-sub", window.FunPackAppName || "Cutting Room"));
const facts = el("div", "sw-about-facts");
const fact = (k, v) => {
+1 -1
View File
@@ -2417,4 +2417,4 @@ if web is not None and PromptServer is not None:
async def _static(req):
return _serve_static(req.match_info["tail"])
print(f"[FunPack] Movie Editor available at {UI_PREFIX}/")
print(f"[FunPack] Movie Editor available at {config.comfy_display_url()}{UI_PREFIX}/")