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:
@@ -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 (
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from . import server # noqa: F401 registers /funpack/easy/* routes
|
||||
@@ -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;
|
||||
})();
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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; }
|
||||
@@ -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,
|
||||
};
|
||||
})();
|
||||
@@ -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,
|
||||
});
|
||||
})();
|
||||
@@ -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,
|
||||
};
|
||||
})();
|
||||
@@ -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>
|
||||
@@ -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 };
|
||||
})();
|
||||
@@ -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 };
|
||||
})();
|
||||
@@ -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}/")
|
||||
@@ -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}/")
|
||||
@@ -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)
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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}/")
|
||||
|
||||
Reference in New Issue
Block a user