diff --git a/__init__.py b/__init__.py index c3741389..3c55cb3b 100644 --- a/__init__.py +++ b/__init__.py @@ -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 ( diff --git a/easy_gen/__init__.py b/easy_gen/__init__.py new file mode 100644 index 00000000..57b35744 --- /dev/null +++ b/easy_gen/__init__.py @@ -0,0 +1 @@ +from . import server # noqa: F401 registers /funpack/easy/* routes diff --git a/easy_gen/frontend/api.js b/easy_gen/frontend/api.js new file mode 100644 index 00000000..98a03763 --- /dev/null +++ b/easy_gen/frontend/api.js @@ -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 .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; +})(); diff --git a/easy_gen/frontend/app.js b/easy_gen/frontend/app.js new file mode 100644 index 00000000..af5f3aef --- /dev/null +++ b/easy_gen/frontend/app.js @@ -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(); +})(); diff --git a/easy_gen/frontend/easy.css b/easy_gen/frontend/easy.css new file mode 100644 index 00000000..da127435 --- /dev/null +++ b/easy_gen/frontend/easy.css @@ -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; } diff --git a/easy_gen/frontend/easy_maintenance.js b/easy_gen/frontend/easy_maintenance.js new file mode 100644 index 00000000..697547a0 --- /dev/null +++ b/easy_gen/frontend/easy_maintenance.js @@ -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, + }; +})(); diff --git a/easy_gen/frontend/easy_settings.js b/easy_gen/frontend/easy_settings.js new file mode 100644 index 00000000..102a5fc5 --- /dev/null +++ b/easy_gen/frontend/easy_settings.js @@ -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: '', + mount, + }); +})(); diff --git a/easy_gen/frontend/easy_store.js b/easy_gen/frontend/easy_store.js new file mode 100644 index 00000000..fa4cbee1 --- /dev/null +++ b/easy_gen/frontend/easy_store.js @@ -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, + }; +})(); diff --git a/easy_gen/frontend/index.html b/easy_gen/frontend/index.html new file mode 100644 index 00000000..064a677d --- /dev/null +++ b/easy_gen/frontend/index.html @@ -0,0 +1,91 @@ + + + + + + FunPack · Easy Gen + + + + + + + + +
+
+ ◉ FunPack Easy Gen +
No project
+ + +
+ +
+ +
+ + +
+ + +
+
+ +
+ + + + + + + + + + + + + + + + + + diff --git a/easy_gen/frontend/preview.js b/easy_gen/frontend/preview.js new file mode 100644 index 00000000..f38518e7 --- /dev/null +++ b/easy_gen/frontend/preview.js @@ -0,0 +1,107 @@ +// Big preview pane: plain