From 9ecdb7e07c26c4978c85189a55834334d7d23447 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:38:47 +0300 Subject: [PATCH] feat: one app at /funpack/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The editor moves from /funpack/movie/ to /funpack/, taking its API to /funpack/api/*. The frontend needed no change — api.js already derives its base from the page's own path. /funpack/movie/ and /funpack/easy/ redirect there; they are registered before the static catch-all, which would otherwise swallow them, and after the /funpack/refinement_keys and /funpack/batch routes, which still win by registration order. Deletes hub.py and the easy_gen package: the hub picked between two apps that are now one, and every Easy Gen frontend file was either a duplicate of an editor file or a second way to do something the editor already does. window.FunPackAppName went with them, so the three gates that read it — models.js, sampler_panel.js, onboarding.js — now read the mode instead, live rather than once at load. The wizard drops its second host table. --- README.md | 12 +- __init__.py | 12 +- docs/MovieEditor.md | 2 +- easy_gen/__init__.py | 1 - easy_gen/frontend/api.js | 319 --------------- easy_gen/frontend/app.js | 287 -------------- easy_gen/frontend/easy.css | 140 ------- easy_gen/frontend/easy_gallery.js | 165 -------- easy_gen/frontend/easy_maintenance.js | 104 ----- easy_gen/frontend/easy_settings.js | 108 ------ easy_gen/frontend/easy_shortcuts_settings.js | 156 -------- easy_gen/frontend/easy_store.js | 385 ------------------- easy_gen/frontend/easy_variables.js | 225 ----------- easy_gen/frontend/index.html | 116 ------ easy_gen/frontend/preview.js | 275 ------------- easy_gen/frontend/project_menu.js | 113 ------ easy_gen/server.py | 74 ---- hub.py | 326 ---------------- movie_editor/frontend/models.js | 8 +- movie_editor/frontend/onboarding.js | 46 +-- movie_editor/frontend/sampler_panel.js | 6 +- movie_editor/server.py | 18 +- 22 files changed, 43 insertions(+), 2855 deletions(-) delete mode 100644 easy_gen/__init__.py delete mode 100644 easy_gen/frontend/api.js delete mode 100644 easy_gen/frontend/app.js delete mode 100644 easy_gen/frontend/easy.css delete mode 100644 easy_gen/frontend/easy_gallery.js delete mode 100644 easy_gen/frontend/easy_maintenance.js delete mode 100644 easy_gen/frontend/easy_settings.js delete mode 100644 easy_gen/frontend/easy_shortcuts_settings.js delete mode 100644 easy_gen/frontend/easy_store.js delete mode 100644 easy_gen/frontend/easy_variables.js delete mode 100644 easy_gen/frontend/index.html delete mode 100644 easy_gen/frontend/preview.js delete mode 100644 easy_gen/frontend/project_menu.js delete mode 100644 easy_gen/server.py delete mode 100644 hub.py diff --git a/README.md b/README.md index c3e54e0f..cc9d1e16 100644 --- a/README.md +++ b/README.md @@ -21,14 +21,14 @@ Opening `/funpack/` inside ComfyUI lands you on the **Hub** - a small picker bet ### Two UIs, one engine -Both UIs drive the exact same generation engine (FunPack Studio + the LTXAV Scene Chain Sampler) and the exact same project files - a project opened in one opens unchanged in the other. Pick based on how much control you want in front of you: +One app at `/funpack/`, with a mode switch beside the wordmark: -- **Cutting Room** - the full multi-scene non-linear editor: a real timeline with per-scene prompts, ratings and audio lanes, a program monitor, a media bin, and every Engine/Studio knob a click away. This is where rating-driven learning (refinement keys, value guidance, taste steering) lives. -- **Easy Gen** - one prompt, one Generate button, one big preview. Built for quick single-scene (or simple multi-scene via prompt splits) generations without the full NLE around it - advanced settings are still there, just tucked behind an "Advanced settings" link, and rating-dependent controls are hidden entirely since there's no rating UI to feed them (Studio always runs in a plain Prompt-only mode there). +- **Editor** - the full multi-scene non-linear editor: a real timeline with per-scene prompts, ratings and audio lanes, a program monitor, a media bin, and every Engine/Studio knob a click away. This is where rating-driven learning (refinement keys, value guidance, taste steering) lives. +- **Simple** - media bin, preview and one prompt. The timeline, ratings and advanced settings are hidden, not disabled: switching back shows them exactly as they were. -| Cutting Room - timeline, preview monitor, media bin, and scene inspector | Easy Gen - one prompt, one Generate button, one big preview | +| Editor mode - timeline, preview monitor, media bin, and scene inspector | Simple mode - media bin, one prompt, one Generate button | | --- | --- | -| ![FunPack Cutting Room - timeline, preview monitor, media bin, and scene inspector](docs/images/cutting-room.png) | ![FunPack Easy Gen - single prompt box, Generate button, and preview pane](docs/images/easy-gen.png) | +| ![FunPack Editor mode - timeline, preview monitor, media bin, and scene inspector](docs/images/cutting-room.png) | ![FunPack Simple mode - single prompt box, Generate button, and preview pane](docs/images/easy-gen.png) | | The Composer - global prompt, shortcuts, split markers, `$variables` | Engine Settings - Studio + Chain Sampler knobs without touching a graph | | --- | --- | @@ -51,7 +51,7 @@ Each major version carries a codename, shown in Settings ▸ About. 3.x is ### Version 3.0 — Cutting Room and development direction -FunPack **3.0** introduces the **Cutting Room** — a dedicated montage editor at `/funpack/movie` inside ComfyUI. It is the primary surface for building multi-scene projects, previewing on a real timeline, and driving FunPack Studio + the LTXAV Scene Chain Sampler without wiring a graph by hand. +FunPack **3.0** introduces the **Cutting Room** — a dedicated montage editor at `/funpack/` inside ComfyUI. It is the primary surface for building multi-scene projects, previewing on a real timeline, and driving FunPack Studio + the LTXAV Scene Chain Sampler without wiring a graph by hand. Going forward, **new features and UX work target the Cutting Room frontend**. The classic ComfyUI node popups (Studio, Refiner V2, Scene Chain Sampler, and other pre-3.0 nodes) remain fully usable but will receive **bugfixes only** — no significant UI reworks unless a fix requires it. If you live in ComfyUI graphs, nothing breaks; if you want the full montage workflow, use the Cutting Room. diff --git a/__init__.py b/__init__.py index 3c55cb3b..ebe1a1d9 100644 --- a/__init__.py +++ b/__init__.py @@ -27,17 +27,9 @@ if __package__: except Exception as _e: print(f"[FunPack] batch_training routes unavailable: {_e}") try: - from . import hub # noqa: F401 registers /funpack/ (the UI picker) + from .movie_editor import server as _movie_editor_server # noqa: F401 registers /funpack/* routes 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}") + print(f"[FunPack] FunPack UI routes unavailable: {_e}") else: # Standalone tests may not have the full ComfyUI/CUDA runtime loaded. from conditioning import ( diff --git a/docs/MovieEditor.md b/docs/MovieEditor.md index 385ae1a6..743904ba 100644 --- a/docs/MovieEditor.md +++ b/docs/MovieEditor.md @@ -2,7 +2,7 @@ The **Cutting Room** is FunPack's browser-based montage editor. It runs inside ComfyUI at: -`/funpack/movie` +`/funpack/` Use it to build multi-scene projects, edit on a real NLE timeline, preview the full sequence, generate clips with FunPack Studio and the LTXAV Scene Chain Sampler, and export or stitch a final video — without hand-wiring a ComfyUI graph for every montage. diff --git a/easy_gen/__init__.py b/easy_gen/__init__.py deleted file mode 100644 index 57b35744..00000000 --- a/easy_gen/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import server # noqa: F401 registers /funpack/easy/* routes diff --git a/easy_gen/frontend/api.js b/easy_gen/frontend/api.js deleted file mode 100644 index 98a03763..00000000 --- a/easy_gen/frontend/api.js +++ /dev/null @@ -1,319 +0,0 @@ -// 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 deleted file mode 100644 index 992d37a6..00000000 --- a/easy_gen/frontend/app.js +++ /dev/null @@ -1,287 +0,0 @@ -// 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. Reloading during a run re-attaches to it via the -// Editor's /api/active flow — see reattachRunning() below. -(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 galleryBtn = document.getElementById("easy-gallery-btn"); - const generateBtn = document.getElementById("easy-generate-btn"); - const warnStrip = document.getElementById("easy-warn-strip"); - const advancedBtn = document.getElementById("easy-advanced-btn"); - const projectsBtn = document.getElementById("easy-projects-btn"); - const saveBtn = document.getElementById("easy-save-btn"); - const exportBtn = document.getElementById("easy-export-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.scheduleGlobalPromptApply(promptEl.value); - }); - // Self-contained: reads Store.getEditorSetting("autocomplete")/get().shortcuts - // and no-ops until a project + shortcut library are loaded. - window.ShortcutAutocomplete?.attach(promptEl); - - function removeUpload() { - S.setSceneMedia(null, null); - scheduleSave(); - } - preview.setOnClearUpload(removeUpload); - - // True whenever a run of ours is in flight — a fresh Generate OR one re-attached after a - // reload. render() runs on every store notify (health poll, keystroke), so without this - // it would re-enable Generate underneath a running generation. - let generating = false; - // Live elapsed on the button, counted from the press to the finished video. The tick - // writes textContent directly rather than going through render(): render() runs on every - // store notify and would fight a once-a-second re-entry for no benefit. - let genStart = 0; - let genTick = null; - function genLabel() { - const s = Math.max(0, Math.floor((Date.now() - genStart) / 1000)); - const pad = (v) => String(v).padStart(2, "0"); - return `Generating (${s < 60 ? `${pad(s)}s` : `${Math.floor(s / 60)}m ${pad(s % 60)}s`})`; - } - function setGenerating(on) { - generating = on; - generateBtn.disabled = on || !S.get().project; - if (genTick) { clearInterval(genTick); genTick = null; } - if (!on) { - genStart = 0; - generateBtn.textContent = "Generate"; - generateBtn.classList.remove("btn-timer"); - return; - } - // Re-entering setGenerating(true) while already running (re-attach after reload) must - // not restart the clock — the run it is timing did not restart. - if (!genStart) genStart = Date.now(); - generateBtn.classList.add("btn-timer"); - generateBtn.textContent = genLabel(); - genTick = setInterval(() => { generateBtn.textContent = genLabel(); }, 1000); - } - - let lastProjectId = null; - function render(st) { - const p = st.project; - nameEl.textContent = p ? p.name : "No project"; - generateBtn.disabled = generating || !p; - uploadBtn.disabled = !p; - galleryBtn.disabled = !p; - saveBtn.disabled = !p; - exportBtn.disabled = !p; - if (p && !promptFocused) { - const text = p.global_prompt || ""; - if (promptEl.value !== text) promptEl.value = text; - } - const source = p?.scenes[0]?.source; - const attached = source && source.type !== "empty" && source.media_ref; - uploadBtn.textContent = attached ? "⬆ Uploaded ✕" : "⬆ Upload"; - preview.setUpload(attached ? API.mediaUrl(source.media_ref) : null, source?.type === "v2v" ? "video" : "image"); - - // A setting that is switched on but cannot do anything belongs on the main screen, - // above Generate — not only inside the pane that switched it on. - const warnings = []; - const idIssue = window.PipelineCaps?.identityTransferIssue(st); - if (idIssue) warnings.push(idIssue.detail); - // On MiniMax H3 the Chain Sampler switches several LTX-only settings off itself. - // It reports that on the console once the run has started; this says it beforehand. - (window.PipelineCaps?.h3InertSettings(st) || []).forEach((i) => warnings.push(i.detail)); - warnStrip.textContent = warnings.join(" • "); - warnStrip.hidden = !warnings.length; - - const pid = p ? p.id : null; - if (pid !== lastProjectId) { - lastProjectId = pid; - // Not while a run is in flight: the generation is still going server-side, and - // tearing down its poll here would orphan it exactly like a reload used to. - if (!generating) { - 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) { - removeUpload(); - return; - } - uploadInput.click(); - }; - - uploadInput.onchange = async () => { - const file = uploadInput.files?.[0]; - uploadInput.value = ""; - if (!file) return; - uploadBtn.disabled = true; - uploadBtn.textContent = "Uploading…"; - try { - const res = await API.uploadMedia(file); - const media = res.media?.[0]; // POST /media wraps the result: { media: [ {id, ...} ] } - if (!media) throw new Error("Upload succeeded but returned no media entry."); - const kind = file.type.startsWith("video") ? "video" : "image"; - S.setSceneMedia(media.id, kind); - await S.save(); - S.loadMedia(); - } catch (e) { - alert("Upload failed: " + (e.message || e)); - } finally { - render(S.get()); - } - }; - - // Easy Gen has no rating UI, so Studio must never apply learned/rated steering here — - // forced at the graph level (not just hidden in Engine settings) so a project edited in - // the Cutting Room with these on still generates plainly when run from Easy Gen. - // "studio"/"sampler" are the built-in pipeline's fixed graph keys (see backend/builder.py). - const EASY_MODE_OVERRIDES = [ - { node: "studio", input: "mode", value: "Prompt only" }, - { node: "sampler", input: "embed_guidance", value: false }, - { node: "sampler", input: "score_slider", value: false }, - { node: "sampler", input: "output_guidance", value: false }, - { node: "sampler", input: "dynashift", value: false }, - { node: "sampler", input: "taste_nearest_prompt", value: false }, - ]; - - generateBtn.onclick = async () => { - const st = S.get(); - if (!st.project) return; - if (!(st.project.global_prompt || "").trim()) { - alert("Type a prompt first."); - return; - } - setGenerating(true); - try { - await S.save(); - const resetSession = S.takeResetSessionFlag(); - const res = await API.generate(st.project.id, null, null, resetSession, EASY_MODE_OVERRIDES); - preview.watch(st.project.id, res.prompt_id, { - onDone: () => setGenerating(false), - }); - } catch (e) { - preview.showError("Generate failed: " + (e.message || e)); - setGenerating(false); - } - }; - - advancedBtn.onclick = () => window.SettingsWindow.open("generation"); - projectsBtn.onclick = () => window.ProjectMenu.open({ dismissable: !!S.get().project }); - galleryBtn.onclick = () => window.EasyGallery.open(); - - // "Save" persists into FunPack's own project store on THIS machine — the same mechanism - // as the Editor's autosave. It doesn't help moving a project off a rental GPU box. This - // downloads the actual .funpack_project.json file (re-importable via "Load Project - // File…" on another machine), same as the Editor's File ▸ "Save Project File…". - exportBtn.onclick = async () => { - const st = S.get(); - if (!st.project) return; - try { await S.save(); } catch (_) { /* export the last-saved version anyway */ } - const a = document.createElement("a"); - a.href = API.downloadProjectUrl(st.project.id); - a.download = ""; - document.body.appendChild(a); a.click(); document.body.removeChild(a); - }; - - let saveResetTimer = null; - saveBtn.onclick = async () => { - if (saveResetTimer) clearTimeout(saveResetTimer); - saveBtn.disabled = true; - saveBtn.textContent = "Saving…"; - try { - await S.save(); - saveBtn.textContent = "Saved ✓"; - } catch (e) { - saveBtn.textContent = "Save failed"; - alert("Save failed: " + (e.message || e)); - } finally { - saveResetTimer = setTimeout(() => { - saveBtn.textContent = "Save"; - saveBtn.disabled = !S.get().project; - }, 1200); - } - }; - - // Re-attach to a generation that was already running when the page (re)loaded. Easy Gen - // queues through the Editor's own /generate route, so its prompts are already stamped - // with the editor client_id + run identity and come back from /api/active — nothing was - // missing server-side, Easy Gen simply never asked. Same shape as the Cutting Room's - // store.js resumeRunningGeneration, minus the per-scene recording it has no UI for. - async function reattachRunning() { - let act = null; - try { act = await API.active(); } catch (_) { return; } - let last = null; - // Loop: a montage queued from the Cutting Room can have further runs behind the one - // that happens to be executing now, and Generate should stay blocked until they drain. - while (act && act.running && act.prompt_id && act.prompt_id !== last) { - last = act.prompt_id; - setGenerating(true); - const st = S.get(); - const queued = act.pending > 0 ? ` · ${act.pending} more queued` : ""; - if (st.project && act.pid && st.project.id === act.pid) { - // Ours, and the project it targets is loaded — full re-attach, result included. - await new Promise((resolve) => { - preview.watch(act.pid, act.prompt_id, { - onDone: () => resolve(), - note: `Reconnected after reload${queued}`, - }); - }); - } else { - // Started against a different project (typically from the Cutting Room). Do NOT - // switch projects out from under the user to claim it — just watch the queue so - // Generate stays blocked and Interrupt is offered while the GPU is busy. - await preview.monitor( - act.prompt_id, `A generation is running elsewhere${queued}`); - } - try { act = await API.active(); } catch (_) { act = null; } - } - setGenerating(false); - } - - async function boot() { - S.refreshHealth(); - S.loadLibraries(); - S.loadMedia(); - 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); - let loaded = false; - if (lastId) { - try { await S.loadProject(lastId); loaded = true; } catch (_) { /* fall through to picker */ } - } - // First run (or a wizard interrupted by an install restart) gets the setup flow; - // everyone else gets the project picker they already know. - if (!loaded) window.Onboarding?.maybeOpen(); - // After the project is in place, so a run targeting it re-attaches fully (result and - // all) instead of falling back to the queue-watch path. Not awaited — it runs for as - // long as the generation does. - reattachRunning(); - } - - boot(); -})(); diff --git a/easy_gen/frontend/easy.css b/easy_gen/frontend/easy.css deleted file mode 100644 index f09a7d30..00000000 --- a/easy_gen/frontend/easy.css +++ /dev/null @@ -1,140 +0,0 @@ -/* 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(--accent-hi); } - -.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: var(--black); -} -.easy-preview-empty { - color: var(--faint); font-size: 13px; font-family: var(--font-display); -} -.easy-preview-error { color: var(--danger); max-width: 60ch; text-align: center; white-space: pre-wrap; } - -.easy-preview-close { - position: absolute; top: 34px; right: 34px; width: 28px; height: 28px; padding: 0; - border-radius: 50%; background: color-mix(in srgb, var(--ink-0) 72%, transparent); border: 1px solid var(--line); - color: var(--text); font-size: 13px; cursor: pointer; display: flex; align-items: center; justify-content: center; - backdrop-filter: blur(2px); transition: border-color .12s, color .12s; -} -.easy-preview-close[hidden] { display: none; } /* class display:flex above would otherwise beat [hidden] */ -.easy-preview-close:hover { border-color: var(--danger); color: var(--danger); } -.easy-preview-badge { - position: absolute; top: 34px; left: 34px; padding: 4px 10px; border-radius: 999px; - background: color-mix(in srgb, var(--ink-0) 72%, transparent); border: 1px solid var(--line); color: var(--muted); - font-size: 11px; letter-spacing: .3px; backdrop-filter: blur(2px); -} - -.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(--accent), var(--accent-hi)); - width: 0%; transition: width .2s ease; margin-bottom: 6px; -} -.easy-progress-row { display: flex; align-items: center; gap: 10px; } -.easy-progress-label { flex: 1; min-width: 0; font-size: 12px; color: var(--muted); font-family: var(--font-mono); - overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.easy-progress-stop:disabled { opacity: .6; cursor: default; } - -/* Full-width notice between the preview and the Generate row: a setting that is on - but cannot do anything should be readable without opening any panel. */ -.easy-warn-strip { - flex: none; padding: 9px 16px; font-size: 12px; line-height: 1.45; - color: var(--accent); border-top: 1px solid color-mix(in srgb, var(--accent) 45%, var(--line)); - background: color-mix(in srgb, var(--accent) 9%, transparent); -} -.easy-warn-strip[hidden] { display: none; } - -.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, .easy-gallery-btn, .easy-vars-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: 13px/1.4 var(--font-ui); - padding: 10px 12px; -} -.easy-prompt:focus { outline: none; border-color: var(--accent); } -.easy-actions { flex: none; display: flex; flex-direction: column; align-items: stretch; gap: 6px; } -.easy-generate-btn { padding: 10px 26px; font-size: 13px; } -.easy-advanced-btn { align-self: center; } - -/* Prompt $name variables — own modal window, opened from the header button. */ -.easy-vars-hint { color: var(--muted); font-size: 12px; line-height: 1.5; margin-bottom: 8px; } -.easy-vars-hint.easy-var-warn { color: var(--accent); } -.easy-vars-empty { color: var(--faint); font-size: 12px; } -.easy-var-list { display: flex; flex-direction: column; gap: 6px; align-items: flex-start; } -.easy-var-row { display: flex; gap: 6px; align-items: flex-start; width: 100%; } -.easy-var-row > .easy-var-dollar, .easy-var-row > .easy-var-eq, .easy-var-row > .btn { margin-top: 6px; } -.easy-var-dollar { color: var(--accent); font-family: var(--font-mono); opacity: .8; } -.easy-var-eq { color: var(--muted); } -.easy-var-in { - background: var(--ink-2); border: 1px solid var(--line); border-radius: var(--radius); - color: var(--text); font: 13px/1.4 var(--font-ui); padding: 6px 9px; min-width: 0; -} -.easy-var-in:focus { outline: none; border-color: var(--accent); } -.easy-var-name { flex: 0 0 22%; font-family: var(--font-mono); } -/* Value is an auto-growing textarea (JS sets the height) — no manual resize handle, no scrollbar. */ -.easy-var-val { flex: 1; resize: none; overflow: hidden; } -.easy-var-row .btn { flex: 0 0 auto; } -#easy-vars-btn.easy-vars-warn { color: var(--accent); border-color: var(--accent); } - -.es-io-controls { display: flex; align-items: center; gap: 8px; } -.es-io-mode { background: var(--ink-2); border: 1px solid var(--line); color: var(--text); - border-radius: 6px; font-size: 12px; padding: 4px 6px; } - -.gal-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 12px; } -.gal-cell { cursor: pointer; border: 1px solid var(--line); border-radius: 10px; overflow: hidden; - background: var(--ink-2); transition: border-color .15s; } -.gal-cell:hover { border-color: var(--accent); } -.gal-thumb { position: relative; aspect-ratio: 1 / 1; background: var(--ink-0); display: flex; align-items: center; justify-content: center; } -.gal-thumb img, .gal-thumb video { width: 100%; height: 100%; object-fit: cover; } -.gal-name { padding: 6px 8px; font-size: 11px; color: var(--muted); white-space: nowrap; - overflow: hidden; text-overflow: ellipsis; } - -.gal-pin-btn { position: absolute; top: 4px; right: 4px; z-index: 2; width: 22px; height: 22px; - border-radius: 6px; background: color-mix(in srgb, var(--ink-0) 72%, transparent); border: 1px solid var(--line); - filter: grayscale(1); font-size: 12px; cursor: pointer; display: flex; align-items: center; - justify-content: center; padding: 0; } -.gal-pin-btn:hover { filter: none; } -.gal-pin-btn.active { filter: none; background: var(--accent-soft); border-color: var(--accent); } -.gal-pin-badge { position: absolute; top: 4px; left: 4px; z-index: 2; font-size: 10px; - background: color-mix(in srgb, var(--ink-0) 72%, transparent); border-radius: 4px; padding: 1px 4px; line-height: 1.4; } -/* Reference mark — sits under the pin so both are reachable on the same tile. */ -.gal-ref-btn { position: absolute; top: 30px; right: 4px; z-index: 2; width: 22px; height: 22px; - border-radius: 6px; background: color-mix(in srgb, var(--ink-0) 72%, transparent); border: 1px solid var(--line); - color: var(--muted); font-size: 11px; font-weight: 700; cursor: pointer; display: flex; - align-items: center; justify-content: center; padding: 0; } -.gal-ref-btn:hover { color: var(--accent); border-color: var(--accent); } -.gal-ref-btn.active { background: var(--accent); border-color: var(--accent); color: var(--ink-0); } -.gal-ref-badge { position: absolute; bottom: 4px; left: 4px; z-index: 2; font-size: 10px; - font-weight: 700; color: var(--ink-0); background: var(--accent); border-radius: 4px; - padding: 1px 5px; line-height: 1.4; } diff --git a/easy_gen/frontend/easy_gallery.js b/easy_gen/frontend/easy_gallery.js deleted file mode 100644 index 98a840e1..00000000 --- a/easy_gen/frontend/easy_gallery.js +++ /dev/null @@ -1,165 +0,0 @@ -// "Gallery" picker: choose an already-uploaded media-bin file as the i2v anchor, -// without re-uploading it. Standalone modal (same modal-overlay/.modal shell as -// project_menu.js) since Easy Gen has no Media Bin panel of its own. -(function () { - const { el, clear } = window.dom; - const API = window.MovieEditorAPI; - const S = window.Store; - let overlay = null; - - function close() { - if (overlay) { overlay.remove(); overlay = null; } - } - - // Continuity pin, right on the gallery card — same setting as Engine settings ▸ - // Continuity ▸ Identity pin, just quicker to reach (mirrors mediabrowser.js's card - // pin button in the Editor). Images only, chain-sampler pipelines only. - const pinEls = []; // { id, btn, badge } for every image cell in the currently-open grid - - function refreshPins() { - const cs = (S.get().project || {}).continuity_settings || {}; - pinEls.forEach(({ id, btn, badge }) => { - const isPin = cs.identity_pin_ref === id; - btn.classList.toggle("active", isPin); - btn.title = isPin - ? "Continuity pin — click to unpin" - : "Pin as identity guide for every scene (continuity pin)" - + (cs.auto_enabled === false ? " — also turns Auto continuity back on" : ""); - badge.hidden = !isPin; - }); - } - - function togglePin(m) { - const cs = (S.get().project || {}).continuity_settings || {}; - const isPin = cs.identity_pin_ref === m.id; - const patch = { ...cs, identity_pin_ref: isPin ? null : m.id }; - if (!isPin && patch.auto_enabled === false) patch.auto_enabled = true; - S.patchProject({ continuity_settings: patch }); - refreshPins(); - } - - // Reference marks — the same project list the Editor's Media Bin writes. Any number of - // items can carry one and their ORDER is the numbering shown, so they can be told apart - // when wired to node inputs in Models & Pipeline. - const refEls = []; // { id, btn, badge } per cell in the open grid - let clearBtn = null; - - function refs() { - return (S.get().project || {}).references || []; - } - - function refreshRefs() { - const list = refs(); - refEls.forEach(({ id, btn, badge }) => { - const idx = list.indexOf(id); - btn.classList.toggle("active", idx >= 0); - btn.title = idx >= 0 - ? `Reference R${idx + 1} — click to unmark` - : "Mark as a reference — wireable into node inputs in Models & Pipeline"; - badge.textContent = idx >= 0 ? `R${idx + 1}` : ""; - badge.hidden = idx < 0; - }); - if (clearBtn) { - clearBtn.hidden = !list.length; - clearBtn.textContent = `Clear references (${list.length})`; - } - } - - function toggleRef(m) { - const list = refs(); - const isRef = list.includes(m.id); - S.patchProject({ references: isRef ? list.filter((r) => r !== m.id) : [...list, m.id] }); - refreshRefs(); - } - - function cellFor(m) { - const cell = el("div", "gal-cell"); - cell.title = m.name || m.id; - const thumb = el("div", "gal-thumb"); - const url = API.mediaUrl(m.id); - if (m.kind === "video") { - const v = el("video"); - v.src = url; v.muted = true; v.preload = "metadata"; - thumb.append(v); - } else { - const img = el("img"); - img.src = url; img.loading = "lazy"; - thumb.append(img); - } - if (m.kind === "image" && window.PipelineCaps?.usesChainSampler(S.get())) { - const btn = el("button", "gal-pin-btn", "📌"); - btn.type = "button"; - btn.onclick = (e) => { e.stopPropagation(); togglePin(m); }; - thumb.append(btn); - const badge = el("span", "gal-pin-badge", "📌"); - badge.hidden = true; - thumb.append(badge); - pinEls.push({ id: m.id, btn, badge }); - } - const refBtn = el("button", "gal-ref-btn", "R"); - refBtn.type = "button"; - refBtn.onclick = (e) => { e.stopPropagation(); toggleRef(m); }; - thumb.append(refBtn); - const refBadge = el("span", "gal-ref-badge"); - refBadge.hidden = true; - thumb.append(refBadge); - refEls.push({ id: m.id, btn: refBtn, badge: refBadge }); - cell.append(thumb); - cell.append(el("div", "gal-name", m.name || m.id)); - cell.onclick = async () => { - S.setSceneMedia(m.id, m.kind === "video" ? "video" : "image"); - await S.save(); - close(); - }; - return cell; - } - - async function open() { - if (!S.get().project) return; - close(); - pinEls.length = 0; - refEls.length = 0; - overlay = el("div", "modal-overlay"); - const box = el("div", "modal modal-wide"); - const head = el("div", "modal-head"); - head.append(el("div", "modal-title", "Gallery — choose an anchor")); - clearBtn = el("button", "btn ghost tiny", "Clear references (0)"); - clearBtn.type = "button"; - clearBtn.title = "Remove the R mark from every item"; - clearBtn.hidden = true; - clearBtn.onclick = () => { S.patchProject({ references: [] }); refreshRefs(); }; - const x = el("button", "btn ghost tiny", "✕"); - x.onclick = close; - head.append(el("div", "modal-head-right"), clearBtn, x); - box.append(head); - - const content = el("div", "modal-content gal-grid"); - content.append(el("div", "sw-hint", "Loading…")); - box.append(content); - - overlay.append(box); - overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); }); - document.addEventListener("keydown", function onKey(e) { - if (e.key === "Escape") { close(); document.removeEventListener("keydown", onKey); } - }); - document.body.append(overlay); - - try { - const r = await API.listMedia(); - const items = (r.media || []).filter((m) => m.kind === "image" || m.kind === "video"); - clear(content); - if (!items.length) { - content.append(el("div", "sw-hint", "No uploaded media yet — use Upload first.")); - return; - } - items.forEach((m) => content.append(cellFor(m))); - refreshPins(); - refreshRefs(); - } catch (e) { - clear(content); - content.append(el("div", "sw-hint", "Could not load media: " + (e.message || e))); - } - } - - window.EasyGallery = { open, close }; -})(); diff --git a/easy_gen/frontend/easy_maintenance.js b/easy_gen/frontend/easy_maintenance.js deleted file mode 100644 index 697547a0..00000000 --- a/easy_gen/frontend/easy_maintenance.js +++ /dev/null @@ -1,104 +0,0 @@ -// 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 deleted file mode 100644 index f1b07070..00000000 --- a/easy_gen/frontend/easy_settings.js +++ /dev/null @@ -1,108 +0,0 @@ -// 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 }))); - // Frame geometry is per model family: LTX generates on an 8k+1 grid at the project's - // fps, MiniMax H3 on a 17k+5 grid at a fixed 24 fps. Off-grid lengths fail the run - // outright on H3, so there the input snaps; on LTX the builder rounds up by itself, so - // the typed number is kept and only the arrows follow the grid. - const grid = window.PipelineCaps?.frameGrid ? window.PipelineCaps.frameGrid(st) - : { step: 8, base: 1, fps: null, label: "8k+1" }; - const spec = window.PipelineCaps?.frameInputSpec - ? window.PipelineCaps.frameInputSpec(st) : { step: grid.step, min: grid.base, snap: false }; - const snapTo = (v) => (window.PipelineCaps?.snapFramesIfRequired - ? window.PipelineCaps.snapFramesIfRequired(v, st) - : Math.max(1, Math.round(Number(v) || 0))); - out.append(field("Frames", - numberInput(p.num_frames_per_scene, (v) => commit((pr) => pr.num_frames_per_scene = snapTo(v)), - { min: spec.min, step: spec.step }), - spec.snap - ? "Length of the generated clip, in frames — MiniMax H3 only generates on its " - + grid.label + " grid, so this snaps to it and the arrows move " + spec.step + " at a time." - : "Length of the generated clip, in frames. The arrows walk this model's " + grid.label - + " grid; an off-grid number is rounded up to it when the graph is built.")); - out.append(field("Frame rate", numberInput(p.frame_rate, (v) => commit((pr) => pr.frame_rate = v), { min: 1 }), - grid.fps ? "MiniMax H3 always generates at " + grid.fps + " fps; the render is muxed at that rate." : null)); - 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-hint", - "Studio/Chain Sampler/continuity controls, including Seed, live under Engine ▸ Timing " - + "& Seed (empty = a new random seed every Generate). Studio always runs in Prompt-only " - + "mode here — no refinement key, since Easy Gen has no rating UI to train one. For the " - + "full learned refiner, use the Cutting Room or the ComfyUI node graph.")); - } - - const unsub = S.subscribe(render); - render(); - return () => unsub(); - } - - window.SettingsWindow.register({ - id: "generation", group: "Generation", order: 1, title: "Generation", - subtitle: "Output size, length, and negative prompt for this project.", - keywords: "width height frames frame rate negative prompt generation seed engine", - iconBg: "linear-gradient(180deg,#ffb64d,#e07f1f)", - icon: '', - mount, - }); -})(); diff --git a/easy_gen/frontend/easy_shortcuts_settings.js b/easy_gen/frontend/easy_shortcuts_settings.js deleted file mode 100644 index b6837aab..00000000 --- a/easy_gen/frontend/easy_shortcuts_settings.js +++ /dev/null @@ -1,156 +0,0 @@ -// Easy Gen's "Shortcuts" settings section: prompt autocomplete + anchor toggles -// (same per-browser settings/localStorage key as the Editor's editor_settings.js, -// via Store.getEditorSetting/setEditorSetting) plus the shortcut revolver -// (server-side, shared with the Editor and with generation regardless of which -// UI queues the run). Deliberately NOT the whole of editor_settings.js — this -// skips "Shortcut ideas" (not wanted here) and the "Anchor as guide" custom-node -// bypass section (Easy Gen never creates anchor_guide scenes, so it would be -// dead UI). revolverSection() below is intentionally a copy of -// editor_settings.js's — it's small and purely API-driven (no Store deps beyond -// dom/API), so duplicating it here keeps Easy Gen's settings free of the -// Editor-only sections above without forcing a shared-module refactor. -(function () { - const { el } = window.dom; - const S = window.Store; - const API = window.MovieEditorAPI; - - function toggleRow(label, hint, key) { - const row = el("div", "es-row"); - const lbl = el("label", "chk es-toggle"); - const cb = el("input"); cb.type = "checkbox"; cb.checked = !!S.getEditorSetting(key); - cb.onchange = () => S.setEditorSetting(key, cb.checked); - lbl.append(cb, el("span", null, label)); - row.append(lbl); - if (hint) row.append(el("div", "es-hint", hint)); - return row; - } - - function revolverSection() { - const wrap = el("div", "es-section"); - wrap.append(el("div", "es-section-title", "Shortcut revolver")); - wrap.append(el("div", "es-hint", - "For shortcuts with several replacements: remember which replacement fired and don't reuse it " - + "until the WHOLE set has fired. Off = seeded random pick (may repeat). Saved with your shortcut " - + "library — applies to every browser and to generation.")); - - const main = el("label", "chk es-toggle"); - const mainCb = el("input"); mainCb.type = "checkbox"; mainCb.disabled = true; - main.append(mainCb, el("span", null, "Shortcut revolver")); - wrap.append(main); - - const sub = el("div", "es-subfields"); - const rand = el("label", "chk es-toggle"); - const randCb = el("input"); randCb.type = "checkbox"; randCb.disabled = true; - rand.append(randCb, el("span", null, "Random")); - sub.append(rand); - sub.append(el("div", "es-hint", - "Unchecked: replacements fire in order — first, second, … last, then the cycle restarts. " - + "Checked: any order, but still no repeats until the set is exhausted. Changing either " - + "option restarts all cycles.")); - wrap.append(sub); - - const apply = (s) => { - mainCb.checked = !!(s && s.enabled); - randCb.checked = !!(s && s.random); - mainCb.disabled = randCb.disabled = false; - sub.style.display = mainCb.checked ? "" : "none"; - }; - - const save = () => { - mainCb.disabled = randCb.disabled = true; - API.setRevolverSettings({ enabled: mainCb.checked, random: randCb.checked }) - .then(apply) - .catch(() => { API.revolverSettings().then(apply).catch(() => {}); }); - }; - mainCb.onchange = save; - randCb.onchange = save; - - sub.style.display = "none"; - API.revolverSettings().then(apply).catch(() => { - wrap.append(el("div", "es-hint", "Could not load revolver settings.")); - }); - return wrap; - } - - // Import/export the shortcut and transition ("prompt split") JSON libraries — one - // shared FunPack library, same files the ComfyUI node graph and the Movie Editor - // read/write. Easy Gen had no UI for this at all, so a fresh install or a browser - // with an empty library had no way to load a shortcuts/transitions file. - function ioRow(label, hint, onImport, exportUrl) { - const row = el("div", "es-row"); - row.append(el("div", "es-toggle", label)); - if (hint) row.append(el("div", "es-hint", hint)); - - const modeSel = el("select", "es-io-mode"); - const optMerge = el("option", null, "Merge"); optMerge.value = "merge"; - const optReplace = el("option", null, "Replace all"); optReplace.value = "replace"; - modeSel.append(optMerge, optReplace); - - const fileInput = el("input"); - fileInput.type = "file"; fileInput.accept = ".json,application/json"; fileInput.style.display = "none"; - fileInput.onchange = async () => { - const file = fileInput.files?.[0]; - fileInput.value = ""; - if (!file) return; - const mode = modeSel.value; - if (mode === "replace" - && !confirm(`This replaces the ENTIRE ${label.toLowerCase()} library with the imported file. Continue?`)) return; - try { - const n = await onImport(file, mode); - alert(`Imported ${n} ${label.toLowerCase()}.`); - } catch (e) { alert("Import failed: " + (e.message || e)); } - }; - - const importBtn = el("button", "btn ghost tiny", "Import…"); - importBtn.type = "button"; - importBtn.onclick = () => fileInput.click(); - - const exportBtn = el("button", "btn ghost tiny", "Export"); - exportBtn.type = "button"; - exportBtn.onclick = () => window.open(exportUrl(), "_blank"); - - const controls = el("div", "es-io-controls"); - controls.append(modeSel, importBtn, exportBtn, fileInput); - row.append(controls); - return row; - } - - function librarySection() { - const wrap = el("div", "es-section"); - wrap.append(el("div", "es-section-title", "Shortcut & transition libraries")); - wrap.append(el("div", "es-hint", - "Load a shortcuts/transitions JSON file exported from the ComfyUI node graph or the Movie " - + "Editor, or export what's currently loaded. Merge overlays onto the existing library " - + "(same trigger name overwrites); Replace all wipes it first.")); - wrap.append(ioRow("Shortcuts", "Prompt-expansion triggers.", - (file, mode) => S.importShortcuts(file, mode), () => API.exportShortcutsUrl())); - wrap.append(ioRow("Transitions", "Split/cut trigger words used to break the global prompt into scenes.", - (file, mode) => S.importTransitions(file, mode), () => API.exportTransitionsUrl())); - return wrap; - } - - function mount(body) { - const content = el("div", "es-content"); - content.append(toggleRow( - "Prompt autocomplete", - "Suggest matching shortcuts while you type. Shows the trigger, its prompt, and category.", - "autocomplete")); - content.append(toggleRow( - "Use anchor", - "Text before the first split trigger (\"cut\", etc.) is a shared anchor prepended to every " - + "scene. Turn off to make that leading text the first scene instead.", - "anchorEnabled")); - content.append(revolverSection()); - content.append(librarySection()); - body.append(content); - } - - window.SettingsWindow.register({ - id: "shortcuts", group: "Generation", order: 2, title: "Shortcuts", - subtitle: "Autocomplete, anchor text, the shortcut revolver, and library import/export — shared with the Editor.", - keywords: "autocomplete anchor shortcuts split cut revolver random replacements cycle import export json library transitions", - iconBg: "linear-gradient(180deg,#6aa9ff,#3b6fd9)", - icon: '', - mount, - }); -})(); diff --git a/easy_gen/frontend/easy_store.js b/easy_gen/frontend/easy_store.js deleted file mode 100644 index a4a3e6cf..00000000 --- a/easy_gen/frontend/easy_store.js +++ /dev/null @@ -1,385 +0,0 @@ -// 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). This is a small pub-sub wrapper around the same /funpack/movie/api/* -// endpoints, but it DOES support the same one-prompt-splits-into-N-scenes -// flow as the Editor's Composer — the user only ever sees one textarea, the -// resulting scenes are never surfaced in any per-scene UI, and Generate always -// runs the whole project as one chain (no scene_ids filter), so N scenes come -// back as one stitched video with no manual cut/trim ability, by design. -// -// Exposed as window.Store (same global name the Editor uses) purely so -// reused sections — models.js, autocomplete.js, and the built-in "Refinement -// & Taste" / "Updates & ComfyUI" sections baked into settings_window.js — -// work unmodified: they all read window.Store.get().project/.shortcuts/etc. -// 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, - shortcuts: [], // prompt shortcut library (for autocomplete) - transitions: [], // transition trigger library (for split-marker inference) - mediaBin: [], // uploaded assets [{id,name,kind,...}] — feeds engine_settings.js's MediaPicker - }; - 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 } }); } - } - - // ── per-browser editor settings ────────────────────────────────────────── - // Same localStorage key/shape as movie_editor/frontend/store.js so toggling - // "Prompt autocomplete" or "Use anchor" is shared between the Editor and - // Easy Gen on the same browser (matches the Editor's own cross-entry-point - // behavior — these are per-browser prompt-editing prefs, not project data). - const EDITOR_SETTINGS_KEY = "funpack_editor_settings"; - const EDITOR_SETTINGS_DEFAULTS = { autocomplete: true, anchorEnabled: true }; - function _loadEditorSettings() { - try { - const raw = JSON.parse(localStorage.getItem(EDITOR_SETTINGS_KEY) || "{}"); - return { ...EDITOR_SETTINGS_DEFAULTS, ...(raw && typeof raw === "object" ? raw : {}) }; - } catch (_) { return { ...EDITOR_SETTINGS_DEFAULTS }; } - } - let _editorSettings = _loadEditorSettings(); - function getEditorSetting(key) { return _editorSettings[key]; } - function setEditorSetting(key, val) { - if (_editorSettings[key] === val) return; - _editorSettings = { ..._editorSettings, [key]: val }; - try { localStorage.setItem(EDITOR_SETTINGS_KEY, JSON.stringify(_editorSettings)); } catch (_) {} - notify(); - if (key === "anchorEnabled" && state.project && (state.project.global_prompt || "").trim()) { - applyGlobalPrompt(state.project.global_prompt); - } - } - - async function loadLibraries() { - try { const r = await API.shortcuts(); set({ shortcuts: r.shortcuts || [] }); } catch (_) {} - try { const r = await API.transitions(); set({ transitions: r.transitions || r || [] }); } catch (_) {} - } - - async function loadMedia() { - try { set({ mediaBin: (await API.listMedia()).media || [] }); } catch (_) { set({ mediaBin: [] }); } - } - - // Import/export the same shortcut/transition JSON libraries the ComfyUI node graph - // and the Movie Editor read and write (one shared FunPack library, no Easy Gen copy). - // mode: "merge" (overlay onto the existing library) | "replace" (wipe first). - async function importShortcuts(file, mode) { - const data = JSON.parse(await file.text()); - const r = await API.importShortcuts(data, mode); - set({ shortcuts: r.shortcuts || state.shortcuts }); - return r.imported; - } - async function importTransitions(file, mode) { - const data = JSON.parse(await file.text()); - const r = await API.importTransitions(data, mode); - set({ transitions: r.transitions || state.transitions }); - return r.imported; - } - - 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; - } - - // Fallback for projects saved before the global-prompt split existed, or - // edited scene-by-scene in the Editor without ever using its Composer: - // reconstruct a verbatim prompt from anchor + scene texts + transition - // markers so the textarea shows something sensible instead of going blank. - function buildPromptFallback(p) { - const parts = []; - const anchor = (p.anchor || "").trim(); - if (anchor) parts.push(anchor); - (p.scenes || []).forEach((s, i) => { - if (i > 0) parts.push((p.scenes[i - 1].transition_to_next || "cut").trim()); - const t = (s.text || "").trim(); - if (t) parts.push(t); - }); - return parts.filter(Boolean).join(" "); - } - - async function loadProject(id) { - const p = await API.getProject(id); - if (!p.scenes || !p.scenes.length) p.scenes = [emptyScene()]; - if (!(p.global_prompt || "").trim()) p.global_prompt = buildPromptFallback(p); - set({ project: p }); - return p; - } - - async function deleteProject(id) { - await API.deleteProject(id); - if (state.project && state.project.id === id) set({ project: null }); - } - - // ── global prompt → N scenes (mirrors the Editor's Composer Apply, minus - // the timeline/ghost/history machinery Easy Gen has no use for) ────────── - - // Ported from movie_editor/frontend/store.js's _normalizeGlobalPromptParse: - // folds the parser's "anchor" back into Scene 1 when the anchor setting is off. - function _normalizeGlobalPromptParse(trimmed, v) { - v = v || {}; - const text = String(trimmed || "").trim(); - const anchor = String(v.anchor || "").trim(); - const scenes = Array.isArray(v.scenes) ? v.scenes : []; - const hasSceneText = scenes.some((s) => String(s.text || "").trim()); - - if (!hasSceneText && text) return { anchor: "", scenes: [{ text }] }; - if (!hasSceneText && anchor) return { anchor: "", scenes: [{ text: anchor }] }; - if (!_editorSettings.anchorEnabled && anchor) { - return { anchor: "", scenes: [{ text: anchor }, ...scenes] }; - } - return { anchor: v.anchor || "", scenes }; - } - - // Ported from movie_editor/frontend/store.js's _inferSplitMarkersFromPrompt: - // finds which transition trigger (if any) sits in the raw gap text between - // two consecutive verbatim scene chunks, so the chain sampler gets the same - // seam behavior ("cut", etc.) the Editor would have produced. - function _inferSplitMarkers(scenes, fullPrompt) { - if (!fullPrompt || !scenes.length) return; - const markers = (state.transitions || []).filter((m) => m.enabled !== false && (m.trigger || m.name)); - let pos = 0; - for (let i = 0; i < scenes.length; i++) { - const t = scenes[i].text || ""; - if (!t) continue; - const idx = fullPrompt.indexOf(t, pos); - if (idx < 0) continue; - const endOfScene = idx + t.length; - if (i < scenes.length - 1) { - const nextT = scenes[i + 1].text || ""; - const nextIdx = nextT ? fullPrompt.indexOf(nextT, endOfScene) : endOfScene; - const gapEnd = nextIdx >= 0 ? nextIdx : fullPrompt.length; - const gap = fullPrompt.slice(endOfScene, gapEnd); - for (const m of markers) { - const trig = m.trigger || m.name; - if (trig && gap.includes(trig)) { scenes[i].transition_to_next = trig; break; } - } - } - pos = endOfScene; - } - } - - // Sequence guard: if the user keeps typing while a parse is in flight, an - // older (now-stale) response must not overwrite scenes built from newer text. - let _applySeq = 0; - - // Splits `text` into project.anchor + project.scenes via the same backend - // parser the Editor's Composer uses (POST /projects/{id}/parse, lossless — - // no shortcut expansion, no scene UI). Scene 0 keeps whatever upload/guide - // it already had; new scenes 1..N-1 default to "carry" (continue the chain), - // matching how the Editor treats anchorless continuation scenes. - async function applyGlobalPrompt(text) { - if (!state.project) return false; - const trimmed = String(text || "").trim(); - state.project.global_prompt = trimmed; - if (!trimmed) { - const prevSource = state.project.scenes[0]?.source || { type: "empty" }; - state.project.scenes = [{ text: "", source: prevSource }]; - state.project.anchor = ""; - notify(); - return true; - } - const seq = ++_applySeq; - let res; - try { res = await API.parsePrompt(state.project.id, trimmed); } - catch (e) { console.warn("Prompt parse failed:", e); return false; } - if (seq !== _applySeq) return false; // superseded by a newer keystroke - if (!state.project || state.project.global_prompt !== trimmed) return false; // project changed underneath us - const v = _normalizeGlobalPromptParse(trimmed, res.parsed_verbatim || res.parsed_raw || res.parsed || {}); - if (!(v.scenes || []).length) return false; - const prevSource = state.project.scenes[0]?.source || { type: "empty" }; - state.project.anchor = v.anchor || ""; - state.project.scenes = v.scenes.map((s, i) => ({ - text: String(s.text || "").trim(), - transition_to_next: "", - source: i === 0 ? prevSource : { type: "carry" }, - })); - _inferSplitMarkers(state.project.scenes, trimmed); - notify(); - return true; - } - - let _applyTimer = null; - function scheduleGlobalPromptApply(text) { - if (_applyTimer) clearTimeout(_applyTimer); - _applyTimer = setTimeout(() => { applyGlobalPrompt(text).then(() => save().catch(() => {})); }, 500); - } - - // 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(); - } - - // Two overlapping save() calls (e.g. a debounced auto-save from an earlier - // edit still in flight when a later action calls save() directly) can land - // on the server out of order — whichever PUT is written to disk last wins, - // even if it was the older/now-stale one, silently reverting a newer edit. - // Serializing through one chain means a save only starts once the previous - // one has FULLY completed, and reads state.project live at that point, so - // it always picks up whatever changed while it waited. - let saveQueue = Promise.resolve(); - function save() { - const run = saveQueue.then(async () => { - if (!state.project) return null; - // Models & Pipeline (models.js) saves bypass/expose/widget edits straight to the - // server via its own PUT /projects/{id}/models call — it never touches state.project - // here. Without refetching first, this save would send project.models as it was when - // the project was last loaded, silently reverting whatever was just changed there - // (same class of bug the Editor's store.js fixed by syncing state.models into - // state.project.models before every autosave commit). - try { state.project.models = await API.getModels(state.project.id); } catch (_) {} - const saved = await API.saveProject(state.project.id, state.project); - set({ project: saved }); - return saved; - }); - saveQueue = run.catch(() => {}); // keep the queue alive even if this save failed - return run; - } - - // ── Engine settings (Studio / Chain Sampler / continuity) ────────────────── - // Minimal re-implementation of movie_editor/frontend/store.js's project-patch - // helpers, just enough for the shared engine_settings.js (loaded via the same - // frontend-fallback mechanism as autocomplete.js/suggestions.js) to run - // unmodified against Easy Gen's project. No undo/history, no global-prompt - // resync — Easy Gen's scenes come from the split parser, not manual editing. - let _engineSaveTimer = null; - function _scheduleEngineSave(ms) { - if (_engineSaveTimer) clearTimeout(_engineSaveTimer); - _engineSaveTimer = setTimeout(() => { _engineSaveTimer = null; save().catch(() => {}); }, ms); - } - - function patchProject(patch) { - if (!state.project) return; - Object.assign(state.project, patch); - notify(); - save().catch(() => {}); - } - function patchProjectQuiet(patch) { - if (!state.project) return; - Object.assign(state.project, patch); - notify(); - _scheduleEngineSave(600); - } - - // ── prompt variables ($name) ─────────────────────────────────────────────── - // Project-scoped find/replace, stored in the same project.variables field the Editor - // uses; the generate endpoint already forwards it to the builder, and Studio resolves - // it dead-last (after shortcut expansion + the scene split) inside refine_v2. - function projectVariables() { return (state.project && state.project.variables) || []; } - function setProjectVariables(list) { - if (!state.project) return; - // A leading $ is stripped so "$vid" and "vid" declare the same variable. Fully-empty - // rows are dropped on persist (the UI keeps showing them while they're being filled in), - // so an accidental "Add variable" click never sticks to the project. - patchProjectQuiet({ variables: (Array.isArray(list) ? list : []).map((v) => ({ - name: String((v && v.name) || "").replace(/^\$+/, "").trim(), - value: String((v && v.value != null) ? v.value : ""), - })).filter((v) => v.name || v.value) }); - } - - function setConditioningSlot(slotId) { patchProject({ conditioning_slot: slotId || "funpack" }); } - function setSamplerSlot(slotId) { patchProject({ sampler_slot: slotId || "funpack" }); } - function setSamplerInput(name, value) { - if (!state.project) return; - patchProjectQuiet({ sampler_inputs: { ...(state.project.sampler_inputs || {}), [name]: value } }); - } - function setSamplerInputNow(name, value) { - if (!state.project) return; - patchProject({ sampler_inputs: { ...(state.project.sampler_inputs || {}), [name]: value } }); - } - function unsetSamplerInput(name) { - if (!state.project) return; - const prev = { ...(state.project.sampler_inputs || {}) }; - delete prev[name]; - patchProjectQuiet({ sampler_inputs: prev }); - } - function setStudioInput(name, value) { - if (!state.project) return; - patchProjectQuiet({ studio_inputs: { ...(state.project.studio_inputs || {}), [name]: value } }); - } - function setStudioInputNow(name, value) { - if (!state.project) return; - patchProject({ studio_inputs: { ...(state.project.studio_inputs || {}), [name]: value } }); - } - - const ENGINE_PRESETS = { - draft: { label: "Fast draft", studio_inputs: { "refiner.split_by_transitions": false }, sampler_inputs: { steps: 20 } }, - quality: { label: "Quality", studio_inputs: { "refiner.split_by_transitions": true }, sampler_inputs: { steps: 40 } }, - continuity: { - label: "Continuity-heavy", - guide_settings: { stack_enabled: true, auto_guides: true, identity_pins: true }, - continuity_settings: { enabled: true }, - sampler_inputs: { steps: 36 }, - }, - }; - function applyEnginePreset(key) { - const preset = ENGINE_PRESETS[key]; - if (!preset || !state.project) return; - const patch = {}; - if (preset.studio_inputs) patch.studio_inputs = { ...(state.project.studio_inputs || {}), ...preset.studio_inputs }; - if (preset.sampler_inputs) patch.sampler_inputs = { ...(state.project.sampler_inputs || {}), ...preset.sampler_inputs }; - if (preset.guide_settings) patch.guide_settings = { ...(state.project.guide_settings || {}), ...preset.guide_settings }; - if (preset.continuity_settings) patch.continuity_settings = { ...(state.project.continuity_settings || {}), ...preset.continuity_settings }; - patchProject(patch); - } - - 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, - getEditorSetting, setEditorSetting, - loadLibraries, importShortcuts, importTransitions, - loadMedia, - listProjects, createProject, loadProject, deleteProject, - applyGlobalPrompt, scheduleGlobalPromptApply, - setSceneMedia, save, - patchProject, patchProjectQuiet, - projectVariables, setProjectVariables, - setConditioningSlot, setSamplerSlot, - setSamplerInput, setSamplerInputNow, unsetSamplerInput, - setStudioInput, setStudioInputNow, - ENGINE_PRESETS, applyEnginePreset, - resetStudioSession, takeResetSessionFlag, - }; -})(); diff --git a/easy_gen/frontend/easy_variables.js b/easy_gen/frontend/easy_variables.js deleted file mode 100644 index 5f8d9d75..00000000 --- a/easy_gen/frontend/easy_variables.js +++ /dev/null @@ -1,225 +0,0 @@ -// Easy Gen's prompt `$name` variables — quick replacements for full phrases, -// e.g. $vid = "High quality, high fidelity realistic video, motion blur, cinematic fog". -// -// Opens as its own modal off the header's "$ Variables" button (same modal-overlay/.modal -// shell as easy_gallery.js / project_menu.js) — deliberately NOT docked into the main -// screen, which stays prompt + preview only. -// -// Storage is project.variables ([{name, value}]) — the SAME project field the Editor's -// Composer writes and the same one movie_editor/server.py already hands to the builder on -// every generate ("variables": list(target.variables or [])), so nothing backend-side is -// new: templates.resolve_variables substitutes them inside refine_v2 DEAD LAST, after -// shortcut expansion and after the transition split. That order is what makes them work -// INSIDE shortcuts (a shortcut whose replacement contains $vid still resolves) while a -// value containing a comma or a trigger word can never move a scene cut. -// -// Undefined names stay literal ($foo passes through as text) and a self-referencing chain -// is left literal rather than looping — the hint line warns about both before you burn a -// generation on it. -(function () { - const { el, clear } = window.dom; - const S = window.Store; - - const toggleBtn = document.getElementById("easy-vars-btn"); - const promptEl = document.getElementById("easy-prompt"); - if (!toggleBtn) return; - - let overlay = null; - let listEl = null; - let hintEl = null; - - function vars() { - return (S.projectVariables() || []).map((v) => ({ - name: String((v && v.name) || ""), - value: String((v && v.value != null) ? v.value : ""), - })); - } - - // Cycle detection over the declared variables — mirrors the safe-degrade guard in - // templates.resolve_variables, but reports the loop instead of silently leaving it literal. - function detectCycles() { - const map = {}; - (S.projectVariables() || []).forEach((v) => { - const k = String((v && v.name) || "").replace(/^\$+/, "").trim(); - if (k) map[k] = String((v && v.value) || ""); - }); - const refs = (n) => (String(map[n] || "").match(/\$([A-Za-z_][A-Za-z0-9_]*)/g) || []) - .map((m) => m.slice(1)).filter((r) => r in map); - const color = {}, path = [], cycles = []; - const dfs = (n) => { - color[n] = 1; path.push(n); - for (const r of refs(n)) { - if (color[r] === 1) cycles.push(path.slice(path.indexOf(r)).concat(r)); - else if (!color[r]) dfs(r); - } - path.pop(); color[n] = 2; - }; - Object.keys(map).forEach((n) => { if (!color[n]) dfs(n); }); - return cycles; - } - - // A cycle wins over the undeclared list. Only the visible prompt is scanned — a $var used - // solely inside a shortcut's replacement text is legitimate and stays silent. - function warning() { - const cycles = detectCycles(); - if (cycles.length) { - return { - bad: true, - msg: "⚠ Variable loop: " + cycles[0].map((n) => "$" + n).join(" → ") - + " — references itself. Rework it; it won't expand.", - }; - } - const declared = new Set((S.projectVariables() || []) - .map((v) => String((v && v.name) || "").replace(/^\$+/, "").trim()).filter(Boolean)); - const txt = promptEl ? promptEl.value : ""; - const used = new Set((txt.match(/\$([A-Za-z_][A-Za-z0-9_]*)/g) || []).map((m) => m.slice(1))); - const undeclared = [...used].filter((n) => !declared.has(n)); - if (undeclared.length) { - return { - bad: false, - msg: "Referencing " + undeclared.map((n) => "$" + n).join(", ") - + " — never declared, passed through as plain text.", - }; - } - return { bad: false, msg: "" }; - } - - // Header button: enabled only with a project, carries the count, and goes amber on a loop - // so a broken variable is visible without opening the window. - function updateButton() { - const st = S.get(); - toggleBtn.disabled = !st.project; - const n = (S.projectVariables() || []).filter((v) => String((v && v.name) || "").trim()).length; - toggleBtn.textContent = "$ Variables" + (n ? ` (${n})` : ""); - toggleBtn.classList.toggle("easy-vars-warn", !!warning().bad); - } - - function updateHint() { - if (!hintEl) return; - const w = warning(); - hintEl.textContent = w.msg; - hintEl.classList.toggle("easy-var-warn", !!w.bad); - hintEl.style.display = w.msg ? "" : "none"; - updateButton(); - } - - // Grow a value textarea to fit its text (measured after a reset to "auto", or it can only - // ever get taller). Deferred once on mount because scrollHeight reads 0 before layout. - function autoGrow(ta) { - const fit = () => { - ta.style.height = "auto"; - // scrollHeight covers content + padding; under border-box the borders must be added - // back or every line is clipped by ~2px and the textarea scrolls anyway. - const cs = getComputedStyle(ta); - const border = cs.boxSizing === "border-box" - ? parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth) - : -(parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom)); - ta.style.height = (ta.scrollHeight + (border || 0)) + "px"; - }; - fit(); - if (!ta.dataset.grown) { ta.dataset.grown = "1"; setTimeout(fit, 0); } - } - - // Working copy for the open window. It is the source of truth for what's on screen — - // a half-typed row (blank name AND value) is dropped by the store on persist, so - // re-reading the project after every edit would delete the row being filled in. - let rows = []; - - function renderList() { - if (!listEl) return; - clear(listEl); - // Every keystroke commits to the project (debounced save); only add/remove re-renders, - // so an input never loses focus mid-word. - const commit = () => { S.setProjectVariables(rows); updateHint(); }; - - rows.forEach((v, i) => { - const row = el("div", "easy-var-row"); - row.append(el("span", "easy-var-dollar", "$")); - const nm = el("input", "easy-var-in easy-var-name"); - nm.value = v.name; nm.placeholder = "name"; - nm.oninput = () => { v.name = nm.value.replace(/^\$+/, ""); commit(); }; - row.append(nm); - row.append(el("span", "easy-var-eq", "=")); - // Values are usually long phrases, so this is a textarea that grows with its content — - // a one-line input makes editing "High quality, high fidelity realistic video, …" a - // horizontal-scrolling guessing game. - const vv = el("textarea", "easy-var-in easy-var-val"); - vv.rows = 1; - vv.value = v.value; vv.placeholder = "value (may reference $other)"; - vv.oninput = () => { v.value = vv.value; autoGrow(vv); commit(); }; - row.append(vv); - autoGrow(vv); - const rm = el("button", "btn ghost tiny", "✕"); - rm.type = "button"; - rm.title = "Remove variable"; - rm.onclick = () => { rows.splice(i, 1); S.setProjectVariables(rows); renderList(); updateHint(); }; - row.append(rm); - listEl.append(row); - }); - if (!rows.length) listEl.append(el("div", "easy-vars-empty", "No variables yet.")); - - const add = el("button", "btn ghost tiny easy-var-add", "+ Add variable"); - add.type = "button"; - add.onclick = () => { rows.push({ name: "", value: "" }); S.setProjectVariables(rows); renderList(); updateHint(); }; - listEl.append(add); - } - - function close() { - if (overlay) { overlay.remove(); overlay = null; } - listEl = null; hintEl = null; - updateButton(); - } - - function open() { - if (!S.get().project) return; - close(); - overlay = el("div", "modal-overlay"); - const box = el("div", "modal modal-wide"); - - const head = el("div", "modal-head"); - head.append(el("div", "modal-title", "Variables")); - 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 easy-vars"); - content.append(el("div", "easy-vars-hint", - "Shorthand for full phrases. Type $name in the prompt (or inside a shortcut's replacement) " - + "and it's swapped for the text below at generation — after shortcuts expand and after the " - + "prompt splits into scenes, so a value can never move a scene cut.")); - hintEl = el("div", "easy-vars-hint easy-var-hint"); - content.append(hintEl); - listEl = el("div", "easy-var-list"); - content.append(listEl); - box.append(content); - - overlay.append(box); - overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); }); - document.addEventListener("keydown", function onKey(e) { - if (e.key === "Escape") { close(); document.removeEventListener("keydown", onKey); } - }); - document.body.append(overlay); - - rows = vars(); - renderList(); - updateHint(); - } - - toggleBtn.onclick = () => (overlay ? close() : open()); - - // A project switch replaces the whole variable set — rebuild the open window's rows. - let renderedProjectId = null; - S.subscribe((st) => { - const pid = st.project ? st.project.id : null; - if (pid !== renderedProjectId) { - renderedProjectId = pid; - if (overlay) { rows = vars(); renderList(); updateHint(); return; } - } - updateButton(); - }); - if (promptEl) promptEl.addEventListener("input", () => { if (overlay) updateHint(); else updateButton(); }); - - updateButton(); - window.EasyVariables = { open, close }; -})(); diff --git a/easy_gen/frontend/index.html b/easy_gen/frontend/index.html deleted file mode 100644 index 4d561a6f..00000000 --- a/easy_gen/frontend/index.html +++ /dev/null @@ -1,116 +0,0 @@ - - - - - - FunPack · Easy Gen - - - - - - - - - - -
-
- ◉ FunPack Easy Gen -
No project
- - -
- -
- - - -
- - - - -
- - -
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/easy_gen/frontend/preview.js b/easy_gen/frontend/preview.js deleted file mode 100644 index 00b462b0..00000000 --- a/easy_gen/frontend/preview.js +++ /dev/null @@ -1,275 +0,0 @@ -// Big preview pane: plain