feat: one app at /funpack/

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.
This commit is contained in:
digital-garbage
2026-08-13 14:38:47 +03:00
parent 5ca34a9b1d
commit 9ecdb7e07c
22 changed files with 43 additions and 2855 deletions
+6 -6
View File
@@ -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.
+2 -10
View File
@@ -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 (
+1 -1
View File
@@ -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.
-1
View File
@@ -1 +0,0 @@
from . import server # noqa: F401 registers /funpack/easy/* routes
-319
View File
@@ -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 <key>.json orphans those, and they keep steering future runs
// across restarts — the backend sweep is what makes "key deleted" mean it.
async deleteRefinementKey(key) {
const res = await funpackFetch(
"POST", `${FUNPACK}/refinement_keys/delete?key=${encodeURIComponent(key)}&_=${Date.now()}`,
);
if (!res.ok) {
let payload = null;
try { payload = await res.json(); } catch (_) {}
throw new Error(readApiError(res, payload));
}
return res.json();
},
// The keyless Absolute "global taste" store — learns from every rated generation
// across all prompts, invisible in the key list, applied only in absolute/both
// steer mode, and otherwise only wiped by Session Reset.
async absoluteStoreInfo() {
const res = await funpackFetch("GET", `${FUNPACK}/refinement_keys/absolute?_=${Date.now()}`);
return res.json();
},
async clearAbsoluteStore() {
const res = await funpackFetch("POST", `${FUNPACK}/refinement_keys/clear_absolute?_=${Date.now()}`);
if (!res.ok) {
let payload = null;
try { payload = await res.json(); } catch (_) {}
throw new Error(readApiError(res, payload));
}
return res.json();
},
};
window.MovieEditorAPI = ClientAPI;
})();
-287
View File
@@ -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();
})();
-140
View File
@@ -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; }
-165
View File
@@ -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 };
})();
-104
View File
@@ -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,
};
})();
-108
View File
@@ -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: '<svg viewBox="0 0 16 16" width="13" height="13"><path d="M9.2 1.3 3 9h4.1l-1 5.7L12.9 7H8.5l.7-5.7z" fill="#fff"/></svg>',
mount,
});
})();
@@ -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: '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="#fff" stroke-width="1.5" stroke-linecap="round"><path d="M2 4.5h12M2 8h12M2 11.5h12"/><circle cx="6" cy="4.5" r="1.7" fill="#fff" stroke="none"/><circle cx="11" cy="8" r="1.7" fill="#fff" stroke="none"/><circle cx="5" cy="11.5" r="1.7" fill="#fff" stroke="none"/></svg>',
mount,
});
})();
-385
View File
@@ -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,
};
})();
-225
View File
@@ -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 };
})();
-116
View File
@@ -1,116 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>FunPack · Easy Gen</title>
<!-- Before the stylesheet on purpose: data-theme has to be on <html> for the
first paint, or a light-theme user gets a dark flash on every load. -->
<script src="theme.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Inter+Tight:wght@600;700;800&family=Roboto+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="styles.css" />
<link rel="stylesheet" href="easy.css" />
<script>
(function () {
// A single failed shared script (e.g. dom.js) cascades into near-identical
// errors from every script that depends on it — keep the first few DISTINCT
// messages instead of overwriting, so the root cause (usually the first one)
// stays visible instead of being clobbered by the last script in load order.
var seen = [];
function show(msg) {
if (seen.indexOf(msg) !== -1) return;
seen.push(msg);
if (seen.length > 5) return;
var b = document.getElementById("boot-error");
if (!b) {
b = document.createElement("div"); b.id = "boot-error";
b.style.cssText = "position:fixed;z-index:99999;left:0;right:0;top:0;padding:10px 34px 10px 14px;" +
"background:#3a1320;color:#ffb3c1;font:12px/1.5 monospace;border-bottom:1px solid #e0566b;white-space:pre-wrap;max-height:60vh;overflow:auto";
var x = document.createElement("button");
x.textContent = "✕"; x.title = "Dismiss";
x.style.cssText = "position:absolute;top:6px;right:8px;background:transparent;border:1px solid #e0566b;" +
"color:#ffb3c1;border-radius:6px;cursor:pointer;font:12px monospace;padding:1px 7px;line-height:1.4";
x.onclick = function () { b.remove(); };
b.appendChild(x);
(document.body || document.documentElement).appendChild(b);
}
var t = b.querySelector(".boot-error-text");
if (!t) { t = document.createElement("span"); t.className = "boot-error-text"; b.insertBefore(t, b.firstChild); }
t.textContent = "Easy Gen error(s) — " + seen.join("\n---\n")
+ "\n(restart ComfyUI + hard-reload Cmd/Ctrl+Shift+R; if it persists, send me this text)";
}
window.addEventListener("error", function (e) {
if (e && e.target && e.target !== window && e.target.nodeName) return;
show((e.message || "load error") + (e.filename ? " @ " + e.filename.split("/").pop() + ":" + e.lineno : ""));
}, true);
window.addEventListener("unhandledrejection", function (e) {
show("promise: " + ((e.reason && (e.reason.message || e.reason)) || "rejected"));
});
})();
</script>
</head>
<body class="easy-body">
<div class="easy-app" id="easy-app">
<header class="easy-top">
<a class="brand" href="/funpack/" title="FunPack Hub"><span class="brand-mark">◉</span> FunPack <span class="brand-sub">Easy Gen</span></a>
<div class="easy-project-name" id="easy-project-name">No project</div>
<span class="health-chip" id="easy-health-chip"></span>
<div class="menubar-right">
<button class="btn ghost tiny" id="easy-save-btn" type="button">Save</button>
<button class="btn ghost tiny" id="easy-export-btn" type="button"
title="Download this project as a .funpack_project.json file — e.g. to move it off a rental machine">Export…</button>
<button class="btn ghost tiny" id="easy-projects-btn" type="button">Projects…</button>
</div>
</header>
<main class="easy-preview" id="easy-preview"></main>
<div class="easy-warn-strip" id="easy-warn-strip" hidden></div>
<footer class="easy-bar">
<button class="btn ghost easy-upload-btn" id="easy-upload-btn" type="button"
title="Upload an image or video to guide generation">⬆ Upload</button>
<button class="btn ghost easy-gallery-btn" id="easy-gallery-btn" type="button"
title="Choose from previously uploaded media">🖼 Gallery</button>
<button class="btn ghost easy-vars-btn" id="easy-vars-btn" type="button"
title="Prompt $name variables — shorthand for full phrases, substituted at generation">$ Variables</button>
<textarea class="easy-prompt" id="easy-prompt" rows="2"
placeholder="Describe what you want to generate…"></textarea>
<div class="easy-actions">
<button class="btn primary easy-generate-btn" id="easy-generate-btn" type="button">Generate</button>
<button class="btn ghost tiny easy-advanced-btn" id="easy-advanced-btn" type="button">Advanced settings</button>
</div>
</footer>
<input type="file" id="easy-upload-input" accept="image/*,video/*" hidden />
</div>
<script>window.FunPackAppName = "Easy Gen";</script>
<script src="api.js"></script>
<script src="dom.js"></script>
<script src="settings_window.js"></script>
<script src="appearance_settings.js"></script>
<script src="git_actions.js"></script>
<script src="restart_ui.js"></script>
<script src="slot_picker.js"></script>
<script src="pipeline_caps.js"></script>
<script src="models.js"></script>
<script src="easy_store.js"></script>
<script src="easy_maintenance.js"></script>
<script src="easy_settings.js"></script>
<script src="media_picker.js"></script>
<script src="sampler_panel.js"></script>
<script src="engine_settings.js"></script>
<script src="easy_shortcuts_settings.js"></script>
<script src="autocomplete.js"></script>
<script src="project_menu.js"></script>
<script src="pipeline_setup.js"></script>
<script src="onboarding.js"></script>
<script src="easy_gallery.js"></script>
<script src="easy_variables.js"></script>
<script src="log.js"></script>
<script src="preview.js"></script>
<script src="app.js"></script>
</body>
</html>
-275
View File
@@ -1,275 +0,0 @@
// Big preview pane: plain <video>/<img>, not the Editor's timeline-oriented
// player.js. Polls /api/progress (step count) while a run is in flight and
// /api/projects/{id}/status/{prompt_id} for the final result.
//
// Display priority: generated result > uploaded source > empty message. The
// corner ✕ closes whichever of those two media layers is currently showing —
// closing a generated result reveals the uploaded source underneath (if any),
// closing the uploaded source clears the upload itself (via onClearUpload).
(function () {
const { el, clear } = window.dom;
const API = window.MovieEditorAPI;
function create(root) {
const inner = el("div", "easy-preview-inner");
const closeBtn = el("button", "easy-preview-close", "✕");
closeBtn.type = "button";
closeBtn.title = "Close";
closeBtn.hidden = true;
const badge = el("div", "easy-preview-badge", "Uploaded source");
badge.hidden = true;
const progressWrap = el("div", "easy-progress");
progressWrap.hidden = true;
const bar = el("div", "easy-progress-bar");
const label = el("div", "easy-progress-label");
// Same "■ Interrupt" affordance as the Cutting Room's player readout, and it lives in the
// progress panel so it only exists while there's something to stop.
const stopBtn = el("button", "gen-interrupt easy-progress-stop", "■ Interrupt");
stopBtn.type = "button";
stopBtn.title = "Stop the current generation";
const progressRow = el("div", "easy-progress-row");
progressRow.append(label, stopBtn);
progressWrap.append(bar, progressRow);
root.append(inner, closeBtn, badge, progressWrap);
let pollTimer = null;
let progressTimer = null;
// generated/upload hold the last-drawn payload so the ✕ handler knows what
// layer it's dismissing; emptyMsg is what to fall back to once both are gone.
let generated = null; // { media, projectId } | null
let upload = null; // { url, kind } | null
let errorMsg = null;
let emptyMsg = "No generation yet";
let onClearUpload = null;
function stopPolling() {
if (pollTimer) clearTimeout(pollTimer);
if (progressTimer) clearInterval(progressTimer);
pollTimer = progressTimer = null;
}
function mediaEl(url, kind, opts = {}) {
if (kind === "video") {
const video = el("video");
video.src = url;
video.controls = true;
// Not the `autoplay` attribute: that makes the browser call .play() internally, and
// if this element gets replaced/removed before the fetch starts playing (e.g. the
// user immediately picks a different Gallery item), that internal promise rejects
// with "fetching process...aborted by the user agent" — surfacing as an unhandled
// rejection banner, since we never had a handle on it to catch. Call .play()
// ourselves so we own the promise.
if (opts.autoplay) {
video.loop = true;
video.play().catch(() => {});
}
return video;
}
const img = el("img");
img.src = url;
return img;
}
// The store notifies on every health poll / prompt keystroke, and each of those calls
// setUpload()/render() again — without this, draw() rebuilt the <video> from scratch
// every time (new element = autoplay/loop reapplied), silently resuming anything the
// user had paused. Skip the rebuild when the content actually shown hasn't changed.
let _lastSig = null;
function _sig() {
if (errorMsg) return "error:" + errorMsg;
if (generated) return "generated:" + generated.projectId + ":" + generated.media.filename;
if (upload) return "upload:" + upload.url + ":" + upload.kind;
return "empty:" + emptyMsg;
}
function draw() {
const sig = _sig();
if (sig === _lastSig) return;
_lastSig = sig;
clear(inner);
badge.hidden = true;
if (errorMsg) {
closeBtn.hidden = true;
inner.append(el("div", "easy-preview-empty easy-preview-error", errorMsg));
} else if (generated) {
closeBtn.hidden = false;
const url = API.resultUrl(generated.projectId, generated.media);
inner.append(mediaEl(url, generated.media.kind === "images" ? "image" : "video", { autoplay: true }));
} else if (upload) {
closeBtn.hidden = false;
badge.hidden = false;
inner.append(mediaEl(upload.url, upload.kind));
} else {
closeBtn.hidden = true;
inner.append(el("div", "easy-preview-empty", emptyMsg));
}
}
closeBtn.onclick = () => {
if (generated) {
generated = null;
draw();
} else if (upload) {
upload = null;
draw();
if (onClearUpload) onClearUpload();
}
};
function setUpload(url, kind) {
upload = url ? { url, kind } : null;
draw();
}
function setGenerated(media, projectId) {
generated = media ? { media, projectId } : null;
draw();
}
function showEmpty(msg) {
errorMsg = null;
generated = null;
emptyMsg = msg || "No generation yet";
draw();
}
function showError(msg) {
errorMsg = msg;
draw();
}
// Wire the Interrupt button for whatever run is being followed. Returns a getter for
// the "the user asked for this" flag, since an interrupted job is indistinguishable
// from a failed one at the API level and both callers below need to tell them apart.
function _armStopButton(onFlag) {
stopBtn.disabled = false;
stopBtn.textContent = "■ Interrupt";
stopBtn.onclick = async () => {
onFlag(true);
stopBtn.disabled = true;
stopBtn.textContent = "Interrupting…";
label.textContent = "Interrupting…";
try { await API.interrupt(); }
catch (e) {
// The run may well still be going — say so instead of leaving a dead button.
onFlag(false);
stopBtn.disabled = false;
stopBtn.textContent = "■ Interrupt";
label.textContent = "Could not interrupt: " + (e.message || e);
}
};
}
// Follow a run we cannot map to the loaded project — one queued from the Cutting Room,
// or from Easy Gen under a different project before a reload. There is no history to
// poll for a result here, so this just watches ComfyUI's queue via /api/active: enough
// to keep Generate blocked and offer Interrupt while the GPU is busy.
function monitor(promptId, note) {
return new Promise((resolve) => {
stopPolling();
errorMsg = null;
progressWrap.hidden = false;
label.textContent = note || "A generation is running…";
bar.style.width = "0%";
let interrupted = false;
_armStopButton((v) => { interrupted = v; });
const tick = async () => {
let act = null;
try { act = await API.active(); }
catch (_) { pollTimer = setTimeout(tick, 2000); return; } // transient — keep watching
if (!act || !act.running || act.prompt_id !== promptId) {
stopPolling();
progressWrap.hidden = true;
resolve();
return;
}
if (!interrupted) {
try {
const pr = await API.progress();
if (pr.max > 0) {
bar.style.width = Math.min(100, Math.round((pr.value / pr.max) * 100)) + "%";
label.textContent = `${note || "A generation is running"} · ${pr.value}/${pr.max}`;
}
} catch (_) { /* transient */ }
}
pollTimer = setTimeout(tick, 2000);
};
tick();
});
}
async function watch(projectId, promptId, { onDone, note } = {}) {
stopPolling();
errorMsg = null;
progressWrap.hidden = false;
label.textContent = note || "Starting…";
bar.style.width = "0%";
// An interrupted job lands in ComfyUI's history as status_str "error" with no media —
// indistinguishable from a real failure at the API level. Remember that the user asked
// for it so the poll below reports "stopped", not "Generation failed".
let interrupted = false;
_armStopButton((v) => { interrupted = v; });
progressTimer = setInterval(async () => {
if (interrupted) return; // don't paint "Sampling…" over "Interrupting…"
try {
const pr = await API.progress();
if (pr.max > 0) {
const pct = Math.min(100, Math.round((pr.value / pr.max) * 100));
bar.style.width = pct + "%";
label.textContent = `Sampling… ${pr.value}/${pr.max}`;
}
} catch (_) { /* transient */ }
}, 700);
const poll = async () => {
let res;
try {
res = await API.status(projectId, promptId);
} catch (e) {
stopPolling();
progressWrap.hidden = true;
showError("Status check failed: " + (e.message || e));
if (onDone) onDone(false);
return;
}
if (res.state === "completed") {
stopPolling();
progressWrap.hidden = true;
const media = res.media && res.media.length ? res.media[res.media.length - 1] : null;
// A run stopped mid-sampling can still have written partial media — show it if so,
// it's the whole point of interrupting a long run you can already tell is wrong.
if (media) setGenerated(media, projectId);
else if (interrupted) showEmpty("Generation stopped.");
else showEmpty("Generation finished but produced no media.");
if (onDone) onDone(!interrupted, res);
return;
}
if (res.state === "error") {
stopPolling();
progressWrap.hidden = true;
if (interrupted) showEmpty("Generation stopped.");
else showError(res.error || "Generation failed.");
if (onDone) onDone(false, res);
return;
}
pollTimer = setTimeout(poll, 1200);
};
poll();
}
return {
setUpload,
setGenerated,
setOnClearUpload(fn) { onClearUpload = fn; },
showEmpty,
showError,
watch,
monitor,
stopPolling,
};
}
window.EasyPreview = { create };
})();
-113
View File
@@ -1,113 +0,0 @@
// "FunPack Easy Gen" project picker — same /funpack/movie/api/projects* calls
// as the Editor's welcome.js / menubar.js "Open recent" menu, reimplemented as
// a small standalone modal (welcome.js/menubar.js are wired into the full
// Editor chrome and not reusable in isolation).
(function () {
const { el, clear } = window.dom;
const API = window.MovieEditorAPI;
const S = window.Store;
let overlay = null;
const loadFileInput = (function () {
const inp = document.createElement("input");
inp.type = "file";
inp.accept = ".json,.funpack_project.json,application/json";
inp.style.display = "none";
inp.onchange = async () => {
const file = inp.files?.[0];
inp.value = "";
if (!file) return;
try {
const data = JSON.parse(await file.text());
const p = await API.importProject(data);
S.set({ project: p });
close();
} catch (e) {
alert("Could not load project file: " + (e.message || e));
}
};
document.body.appendChild(inp);
return inp;
})();
function close() {
if (overlay) { overlay.remove(); overlay = null; }
}
async function open({ dismissable } = {}) {
close();
overlay = el("div", "modal-overlay");
if (!dismissable) overlay.style.pointerEvents = "auto";
const box = el("div", "modal modal-wide");
const head = el("div", "modal-head");
head.append(el("div", "modal-title", "FunPack Easy Gen"));
if (dismissable) {
const x = el("button", "btn ghost tiny", "✕");
x.onclick = close;
head.append(el("div", "modal-head-right"), x);
}
box.append(head);
const content = el("div", "modal-content");
box.append(content);
const actions = el("div", "welcome-actions");
const mkBtn = (label, cls, hint, fn) => {
const b = el("button", "welcome-btn" + (cls ? " " + cls : ""));
b.type = "button";
b.append(el("span", "welcome-btn-label", label));
if (hint) b.append(el("span", "welcome-btn-hint", hint));
b.onclick = fn;
return b;
};
actions.append(
mkBtn("New Generation", "primary", "Start with a blank prompt", async () => {
await S.createProject("Untitled");
close();
}),
mkBtn("Load Project File…", "", "Import a .funpack_project.json", () => loadFileInput.click()),
// The Editor puts this in File; Easy Gen has no menubar, and this menu is the
// equivalent front door.
mkBtn("Setup Wizard…", "", "Theme, model family, prerequisites, models",
() => { close(); window.Onboarding?.reopen?.(); }),
);
content.append(actions);
// Same three maintenance actions as the Editor's splash — an Easy Gen user on a
// rental had no way to switch branch or restart without opening the other UI.
content.append(el("div", "sw-rows-label", "Maintenance"));
content.append(window.FunPackGit.maintenanceRow());
content.append(el("div", "sw-rows-label", "Recent"));
const recentWrap = el("div", "sw-rows");
content.append(recentWrap);
try {
const list = await S.listProjects();
clear(recentWrap);
if (!list.length) {
recentWrap.append(el("div", "sw-hint", "No projects yet."));
}
list.slice(0, 12).forEach((p) => {
const row = el("div", "sw-row");
const main = el("div", "sw-row-main");
main.append(el("div", "sw-row-title", p.name));
main.append(el("div", "sw-row-hint", `${p.scene_count ?? 1} scene(s)`));
row.append(main);
const openBtn = el("button", "btn ghost tiny", "Open");
openBtn.type = "button";
openBtn.onclick = async () => { await S.loadProject(p.id); close(); };
row.append(openBtn);
recentWrap.append(row);
});
} catch (e) {
clear(recentWrap);
recentWrap.append(el("div", "sw-hint", "Could not load project list: " + (e.message || e)));
}
overlay.append(box);
if (dismissable) overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
document.body.append(overlay);
}
window.ProjectMenu = { open, close };
})();
-74
View File
@@ -1,74 +0,0 @@
"""FunPack Easy Gen — simplified single-scene generation UI.
UI: GET /funpack/easy/ (static assets under /funpack/easy/<file>)
No new backend API on purpose: this frontend talks to the exact same
/funpack/movie/api/* endpoints the Movie Editor uses (same Project files,
same on-disk storage), so a project created here opens unchanged in the
full Editor and vice versa. The only job of this module is serving Easy
Gen's own frontend files, falling back to the Editor's frontend directory
for shared scripts/styles (api.js excluded — see easy_gen/frontend/api.js)
so those stay a single source of truth instead of being copy-pasted.
"""
from __future__ import annotations
import mimetypes
from pathlib import Path
from typing import Optional
try:
from aiohttp import web
from server import PromptServer
except Exception: # pragma: no cover - only available inside ComfyUI
web = None
PromptServer = None
from ..movie_editor.backend import config as movie_config
UI_PREFIX = "/funpack/easy"
FRONTEND_DIR = Path(__file__).resolve().parent / "frontend"
def _resolve(tail: str) -> Optional[Path]:
rel = (tail or "index.html").lstrip("/")
for root in (FRONTEND_DIR, movie_config.FRONTEND_DIR):
root = root.resolve()
target = (root / rel).resolve()
if root not in target.parents and target != root:
continue # path traversal guard
if target.is_dir():
target = target / "index.html"
if target.is_file():
return target
return None
def _serve_static(tail: str) -> "web.Response":
target = _resolve(tail)
if target is None:
raise web.HTTPNotFound()
ctype = mimetypes.guess_type(str(target))[0] or "application/octet-stream"
if target.suffix == ".js":
ctype = "text/javascript" # be explicit; some platforms guess text/plain
return web.Response(
body=target.read_bytes(), content_type=ctype,
headers={"Cache-Control": "no-store, max-age=0"},
)
if web is not None and PromptServer is not None:
routes = PromptServer.instance.routes
@routes.get(UI_PREFIX)
async def _root_redirect(_req):
raise web.HTTPFound(UI_PREFIX + "/")
@routes.get(UI_PREFIX + "/")
async def _index(_req):
return _serve_static("index.html")
@routes.get(UI_PREFIX + "/{tail:.*}")
async def _static(req):
return _serve_static(req.match_info["tail"])
print(f"[FunPack] Easy Gen available at {movie_config.comfy_display_url()}{UI_PREFIX}/")
-326
View File
@@ -1,326 +0,0 @@
"""FunPack Hub — a small landing page at /funpack/ linking to the two FunPack
UIs (Movie Editor "Cutting Room" and "Easy Gen"), plus an About block (version/
branch/commit) and an Update/Restart action. Talks to the Movie Editor's
existing /funpack/movie/api/{health,git/status,git/update,restart} endpoints —
no new backend API, same git_update.py logic the Editor's own "Updates &
ComfyUI" settings section uses. Kept as a single self-contained inline page
(no shared JS files) since its surface is a handful of fetch calls, much
smaller than pulling in api.js/dom.js/git_actions.js/restart_ui.js would need.
"""
from __future__ import annotations
try:
from aiohttp import web
from server import PromptServer
except Exception: # pragma: no cover - only available inside ComfyUI
web = None
PromptServer = None
from .movie_editor.backend import config as movie_config
HUB_PREFIX = "/funpack"
_PAGE = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>FunPack</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Roboto+Mono:wght@400;500&display=swap" rel="stylesheet" />
<script>
(function () {
var v; try { v = localStorage.getItem("funpack_theme"); } catch (e) {}
if (["dark", "light", "auto"].indexOf(v) < 0) v = "dark";
var res = v === "auto"
? (window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark")
: v;
document.documentElement.setAttribute("data-theme", res);
})();
</script>
<style>
/* Same token vocabulary and the same funpack_theme key as the two apps, so the
door you come in through is not the one page that ignores your choice. */
:root, :root[data-theme="dark"] {
color-scheme: dark;
--ink-0: #0c0b09; --ink-1: #131210; --ink-2: #1a1814; --ink-3: #221f19; --line: #2a261e;
--text: #ece7db; --muted: #918a7a; --faint: #645e51;
--accent: #f3a93c; --accent-hi: #ffc36b; --accent-lo: #e0972a; --on-accent: #1a1206;
--teal: #57d0c6; --danger: #e8607a; --good: #79c479;
--surface-modal: #16140f; --backdrop: rgba(6,5,4,.75); --shadow-ink: rgba(0,0,0,.7);
}
:root[data-theme="light"] {
color-scheme: light;
--ink-0: #f5f7fa; --ink-1: #ffffff; --ink-2: #eef1f6; --ink-3: #e1e7ef; --line: #d3dae3;
--text: #16202c; --muted: #59677a; --faint: #8d9aab;
--accent: #2f7fd4; --accent-hi: #5aa4ec; --accent-lo: #2668b0; --on-accent: #ffffff;
--teal: #12857c; --danger: #c62a48; --good: #2f8f4e;
--surface-modal: #ffffff; --backdrop: rgba(24,34,48,.34); --shadow-ink: rgba(24,34,48,.30);
}
:root { --radius: 12px; --shadow: 0 10px 30px -12px var(--shadow-ink); }
* { box-sizing: border-box; }
html, body {
margin: 0; min-height: 100%; background: var(--ink-0); color: var(--text);
font-family: "Inter", system-ui, -apple-system, "Helvetica Neue", Arial, sans-serif;
display: flex; align-items: center; justify-content: center; padding: 32px 16px;
}
.hub { text-align: center; max-width: 640px; width: 100%; }
.brand { font-weight: 800; font-size: 28px; letter-spacing: .2px; margin-bottom: 6px; }
.brand-mark { color: var(--accent); margin-right: 8px; }
.lead { color: var(--muted); margin: 0 0 20px; font-size: 14px; }
.about {
display: inline-flex; align-items: center; gap: 14px; flex-wrap: wrap; justify-content: center;
background: var(--ink-1); border: 1px solid var(--line); border-radius: var(--radius);
padding: 10px 16px; margin-bottom: 28px; font-size: 12.5px; color: var(--muted);
font-family: "Roboto Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
}
.about .led { width: 7px; height: 7px; border-radius: 50%; background: var(--faint); display: inline-block; margin-right: 5px; }
.about .led.ok { background: var(--good); box-shadow: 0 0 8px var(--good); }
.about .led.bad { background: var(--danger); box-shadow: 0 0 8px var(--danger); }
.about b { color: var(--text); font-weight: 600; }
.about .sep { color: var(--line); }
.update-row { margin-bottom: 28px; display: flex; align-items: center; justify-content: center; gap: 10px; flex-wrap: wrap; }
.btn {
background: var(--ink-2); border: 1px solid var(--line); border-radius: 7px; padding: 7px 14px;
cursor: pointer; font-size: 13px; color: var(--text); transition: border-color .12s, background .12s;
}
.btn:hover:not(:disabled) { border-color: var(--accent); }
.btn:disabled { opacity: .45; cursor: default; }
.btn.primary { background: linear-gradient(var(--accent), var(--accent-lo)); color: var(--on-accent); border-color: var(--accent); font-weight: 700; }
.btn.primary:hover:not(:disabled) { filter: brightness(1.08); }
.btn.ghost { background: transparent; color: var(--muted); }
.btn.ghost:hover:not(:disabled) { color: var(--accent-hi); background: var(--ink-3); }
.update-hint { color: var(--faint); font-size: 12px; }
.branch-select {
background: var(--ink-2); border: 1px solid var(--line); border-radius: 7px; padding: 7px 10px;
font-size: 13px; color: var(--text); font-family: "Roboto Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
cursor: pointer; max-width: 220px;
}
.branch-select:hover:not(:disabled) { border-color: var(--accent); }
.branch-select:disabled { opacity: .45; cursor: default; }
.cards { display: flex; gap: 18px; flex-wrap: wrap; justify-content: center; }
a.card {
display: block; width: 260px; padding: 22px 20px; text-align: left;
background: var(--ink-1); border: 1px solid var(--line); border-radius: var(--radius);
text-decoration: none; color: inherit; box-shadow: var(--shadow);
transition: border-color .12s, transform .12s;
}
a.card:hover { border-color: var(--accent); transform: translateY(-2px); }
.card-title { font-weight: 700; font-size: 17px; margin-bottom: 6px; }
.card-title.easy { color: var(--accent-hi); }
.card-title.editor { color: var(--teal); }
.card-sub { color: var(--faint); font-size: 12.5px; line-height: 1.5; }
.restart-overlay {
position: fixed; inset: 0; z-index: 500; background: var(--backdrop); backdrop-filter: blur(3px);
display: flex; align-items: center; justify-content: center;
}
.restart-card {
background: var(--surface-modal); border: 1px solid var(--line); border-radius: 14px; padding: 26px 30px;
box-shadow: var(--shadow); text-align: center; max-width: 360px;
}
.restart-spin {
width: 26px; height: 26px; margin: 0 auto 14px; border-radius: 50%;
border: 3px solid var(--line); border-top-color: var(--accent); animation: spin .8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.restart-msg { color: var(--muted); font-size: 13px; white-space: pre-wrap; }
</style>
</head>
<body>
<div class="hub">
<div class="brand"><span class="brand-mark">◉</span>FunPack</div>
<p class="lead">Pick a UI.</p>
<div class="about" id="about">Loading…</div>
<div class="update-row" id="update-row"></div>
<div class="cards">
<a class="card" href="/funpack/easy/">
<div class="card-title easy">Easy Gen</div>
<div class="card-sub">One prompt, one Generate button, one big preview. Advanced settings tucked away.</div>
</a>
<a class="card" href="/funpack/movie/">
<div class="card-title editor">Cutting Room</div>
<div class="card-sub">The full multi-scene NLE — timeline, composer, overlays, audio.</div>
</a>
</div>
</div>
<script>
(function () {
const API = (p) => "/funpack/movie/api" + p;
const aboutEl = document.getElementById("about");
const updateEl = document.getElementById("update-row");
async function j(method, url, body) {
const opts = { method, headers: {} };
if (body !== undefined) { opts.headers["Content-Type"] = "application/json"; opts.body = JSON.stringify(body); }
const res = await fetch(url, opts);
if (!res.ok) {
let payload = null; try { payload = await res.json(); } catch (_) {}
throw new Error((payload && payload.detail) || res.statusText || "Request failed");
}
return res.status === 204 ? null : res.json();
}
function led(ok) { return `<span class="led ${ok ? "ok" : "bad"}"></span>`; }
function renderAbout(health, git) {
const parts = [];
parts.push(`${led(!!(health && health.ok))}${health && health.ok ? "online" : "offline"}`);
if (git && git.ok) {
parts.push(`<b>v${git.version || "?"}</b>`);
parts.push(`${git.branch}@${git.commit}${git.dirty ? " (local changes)" : ""}`);
} else {
parts.push(git && git.version ? `<b>v${git.version}</b>` : "");
parts.push("git unavailable");
}
aboutEl.innerHTML = parts.filter(Boolean).join(' <span class="sep">·</span> ');
}
function renderUpdateRow(git) {
updateEl.innerHTML = "";
if (!git || !git.ok) return;
const updateBtn = document.createElement("button");
updateBtn.className = "btn primary";
updateBtn.disabled = !!git.dirty;
updateBtn.textContent = git.behind > 0 ? `${git.behind}↓ Update` : "⬇⟳ Update";
updateBtn.onclick = doUpdate;
updateEl.appendChild(updateBtn);
// A <select> rather than a button: /git/status already carries the branch list,
// so the choice and the current branch fit in one control instead of two.
const branches = git.branches || [];
if (branches.length) {
const sel = document.createElement("select");
sel.className = "branch-select";
sel.title = "Switch the FunPack branch and restart ComfyUI";
sel.disabled = !!git.dirty;
branches.forEach((b) => {
const o = document.createElement("option");
o.value = b; o.textContent = b === git.branch ? b + " (current)" : b;
if (b === git.branch) o.selected = true;
sel.appendChild(o);
});
sel.onchange = () => doSwitch(sel, git.branch);
updateEl.appendChild(sel);
}
const restartBtn = document.createElement("button");
restartBtn.className = "btn ghost";
restartBtn.textContent = "⟳ Restart ComfyUI";
restartBtn.onclick = doRestart;
updateEl.appendChild(restartBtn);
const hint = document.createElement("span");
hint.className = "update-hint";
hint.textContent = git.dirty
? "Local changes — commit or stash first"
: (git.behind > 0 ? `${git.behind} commit(s) behind origin/${git.branch}` : `origin/${git.branch} up to date`);
updateEl.appendChild(hint);
}
function showOverlay(message) {
document.querySelector(".restart-overlay")?.remove();
const ov = document.createElement("div"); ov.className = "restart-overlay";
const card = document.createElement("div"); card.className = "restart-card";
const spin = document.createElement("div"); spin.className = "restart-spin";
const msg = document.createElement("div"); msg.className = "restart-msg"; msg.textContent = message;
card.append(spin, msg); ov.append(card); document.body.append(ov);
return msg;
}
function waitForReload(msgEl, startMs) {
const start = startMs || Date.now();
const tick = async () => {
try {
const h = await j("GET", API("/health"));
if (h && h.ok) { location.reload(); return; }
} catch (_) { /* still down */ }
if (Date.now() - start > 90000) {
msgEl.textContent = "Still waiting on ComfyUI…\\nIt may need a manual restart - check the console.";
}
setTimeout(tick, 2000);
};
setTimeout(tick, 3500);
}
async function doUpdate() {
let git;
try { git = await j("GET", API("/git/status")); } catch (e) { alert("Could not read git status: " + e.message); return; }
if (!git.ok) { alert(git.detail || "Git unavailable for this install."); return; }
if (git.dirty) { alert("Local changes detected in the FunPack folder.\\nCommit or stash them before updating."); return; }
const behind = git.behind > 0 ? `\\n\\n${git.behind} commit(s) available on origin/${git.branch}.` : "";
if (!confirm(`Pull latest "${git.branch}" from origin and restart ComfyUI?\\n\\nAny running generation will be lost.${behind}`)) return;
const msg = showOverlay(`Pulling origin/${git.branch}…\\nComfyUI will restart when the pull finishes.`);
try {
const res = await j("POST", API("/git/update"), {});
msg.textContent = res.updated ? `Updated ${res.before} → ${res.after}.\\nRestarting ComfyUI…` : "Already up to date.\\nRestarting ComfyUI…";
} catch (e) {
document.querySelector(".restart-overlay")?.remove();
alert("Update failed: " + e.message);
return;
}
waitForReload(msg, Date.now());
}
async function doSwitch(sel, current) {
const branch = sel.value;
if (branch === current) return;
if (!confirm(`Switch to "${branch}", pull from origin, and restart ComfyUI?\n\nAny running generation will be lost.`)) {
sel.value = current;
return;
}
const msg = showOverlay(`Switching to ${branch}…\nComfyUI will restart when ready.`);
try {
const res = await j("POST", API("/git/checkout"), { branch });
msg.textContent = res.updated
? `Switched to ${branch} (${res.before} → ${res.after}).\nRestarting ComfyUI…`
: `On ${branch}, already up to date.\nRestarting ComfyUI…`;
} catch (e) {
document.querySelector(".restart-overlay")?.remove();
sel.value = current;
alert("Branch switch failed: " + e.message);
return;
}
waitForReload(msg, Date.now());
}
async function doRestart() {
if (!confirm("Restart ComfyUI now?\\n\\nThe server will be down for ~10-40s and any running generation will be lost. This page reloads automatically when it's back.")) return;
const msg = showOverlay("Restarting ComfyUI…\\nThis page will reload when it's back.");
try { await j("POST", API("/restart"), {}); } catch (_) { /* connection drops as it execv's - expected */ }
waitForReload(msg, Date.now());
}
async function boot() {
let health = null, git = null;
try { health = await j("GET", API("/health")); } catch (_) {}
try { git = await j("GET", API("/git/status")); } catch (_) {}
renderAbout(health, git);
renderUpdateRow(git);
}
boot();
})();
</script>
</body>
</html>
"""
if web is not None and PromptServer is not None:
routes = PromptServer.instance.routes
@routes.get(HUB_PREFIX)
async def _hub_redirect(_req):
raise web.HTTPFound(HUB_PREFIX + "/")
@routes.get(HUB_PREFIX + "/")
async def _hub_index(_req):
return web.Response(body=_PAGE, content_type="text/html", headers={"Cache-Control": "no-store, max-age=0"})
print(f"[FunPack] Hub available at {movie_config.comfy_display_url()}{HUB_PREFIX}/")
+4 -4
View File
@@ -7,7 +7,7 @@
// Easy Gen has no per-scene inspector / main editor window for an exposed widget or
// bypass toggle to actually surface in — the eye buttons below would just be inert
// clutter (or worse, look actionable and do nothing visible), so they're hidden there.
const EASY = !!window.FunPackAppName;
const EASY = () => !!window.FunPackMode?.isSimple();
let roles = []; // [{key,label,category}]
let ports = []; // pipeline connection points [{id,type,label}]
@@ -551,7 +551,7 @@
byp.title = "Skip this node's effect (pass its inputs straight through) without losing its configuration.";
byp.onclick = async (e) => { e.stopPropagation(); slot.bypassed = !slot.bypassed; await persist(); render(); };
acts.append(byp);
if (!EASY) acts.append(bypassEyeButton(slot));
if (!EASY()) acts.append(bypassEyeButton(slot));
const rm = el("button", "btn ghost tiny danger", "remove");
rm.onclick = async (e) => {
e.stopPropagation();
@@ -612,7 +612,7 @@
const l2 = linkOf(slot.id, spec.name); if (l2) applyLinkValue(l2, v); // keep group in sync
await persist();
});
if (!EASY || iw || lk || linkMode) f.classList.add("with-eye");
if (!EASY() || iw || lk || linkMode) f.classList.add("with-eye");
if (iw) {
// Wired from another node's output: the widget value is replaced by the
// connection at generation — lock the field and say where it comes from
@@ -637,7 +637,7 @@
render();
};
f.append(chk);
} else if (!EASY) {
} else if (!EASY()) {
f.append(eyeButton(slot, spec));
}
grid.append(f);
+13 -33
View File
@@ -1,4 +1,4 @@
// First-run wizard, shared by the Cutting Room and Easy Gen.
// Setup wizard and start screen.
//
// Shape is lifted from the macOS Setup Assistant: full screen, no window chrome, one
// vertically centred column per step, a glyph over a large headline over a short
@@ -6,9 +6,6 @@
// button is always the way forward. Back is a chevron in the top-left; there are no step
// dots, because a linear flow that tells you how far you have to go invites you to bail.
//
// The steps a host runs are declared in HOSTS below, not branched on inside each step —
// Easy Gen has no timeline, so prompt splits are not a thing it can offer, and that
// belongs in one table rather than scattered through the screens.
(function () {
const { el, clear } = window.dom;
const S = () => window.Store;
@@ -23,27 +20,14 @@
let ctx = null; // per-run state: project, family, deps, generation type
let panel = null; // { kind, cleanup } while a hosted pane is on screen
const isEasyGen = () => window.FunPackAppName === "Easy Gen";
const HOSTS = {
editor: {
product: "Cutting Room",
lead: "Multi-scene video on a real timeline.",
steps: ["theme", "project", "prereqs", "gentype", "models", "extras", "tour", "done"],
extras: ["links", "shortcuts", "splits"],
newProject: (name) => S().newProject(name),
},
easy: {
// No guided tour here: the tour drives a timeline, a Composer and an inspector that
// Easy Gen does not have. Its own screen is the whole product.
product: "Easy Gen",
lead: "One prompt, one button, one video.",
steps: ["theme", "project", "prereqs", "gentype", "models", "extras", "done"],
extras: ["links", "shortcuts"],
newProject: (name) => S().createProject(name),
},
// One app now — Simple and Editor are modes of it, and setup is the same either way.
const HOST = {
lead: "Multi-scene video on a real timeline.",
steps: ["theme", "project", "prereqs", "gentype", "models", "extras", "tour", "done"],
extras: ["links", "shortcuts", "splits"],
newProject: (name) => S().newProject(name),
};
const host = () => (isEasyGen() ? HOSTS.easy : HOSTS.editor);
const host = () => HOST;
// ── chrome ────────────────────────────────────────────────────────────────
@@ -127,11 +111,7 @@
shortcuts: {
title: "Prompt shortcuts",
sub: "Short triggers that expand into full phrases while you type.",
// The Editor keeps shortcuts in the Composer; Easy Gen registers a settings section
// for the same library. Whichever this build has.
mount: (body) => (window.Composer?.mountPane
? { cleanup: window.Composer.mountPane("shortcuts", body) }
: window.SettingsWindow.mountSection("shortcuts", body)),
mount: (body) => ({ cleanup: window.Composer.mountPane("shortcuts", body) }),
},
splits: {
title: "Prompt splits",
@@ -278,7 +258,7 @@
nameRow.append(el("label", "oo-label", "Project name"));
const input = el("input", "oo-input");
input.type = "text";
input.value = ctx.name || (isEasyGen() ? "Untitled" : "Untitled montage");
input.value = ctx.name || "Untitled montage";
input.placeholder = "Project name";
input.oninput = () => { ctx.name = input.value; };
nameRow.append(input);
@@ -526,8 +506,8 @@
box.append(head("done", "You're ready to generate",
ctx.wantTour
? "The guided tour starts as soon as you press the button."
: isEasyGen()
? "Write a prompt at the bottom and press Generate."
: window.FunPackMode?.isSimple()
? "Write a prompt, then press Generate."
: "Write in the Composer, then press Generate in the timeline header."));
box.append(actions({ primary: ctx.wantTour ? "Start the tour" : "Let's go", onPrimary: finish }));
return box;
@@ -598,7 +578,7 @@
// ── actions ───────────────────────────────────────────────────────────────
async function createProject() {
const name = (ctx.name || "").trim() || (isEasyGen() ? "Untitled" : "Untitled montage");
const name = (ctx.name || "").trim() || "Untitled montage";
const bar = root.querySelector(".oo-actions");
// An abandoned run left a project behind: adopt it rather than making a second one.
// Failing to load it (deleted since) just falls through to creating a fresh project.
+3 -3
View File
@@ -6,9 +6,9 @@
(function () {
const { el } = window.dom;
// Easy Gen has no rating UI — velocity bias / rescue are no-ops without a rated
// Simple mode has no rating UI — velocity bias / rescue are no-ops without a rated
// velocity bank, so they're hidden there (see engine_settings.js for the same gate).
const EASY = !!window.FunPackAppName;
const EASY = () => !!window.FunPackMode?.isSimple();
const SAMPLER_TYPES = ["Hybrid Euler 2S", "Distilled Flow", "KSampler"];
const VELOCITY_BIAS_MODES = ["off", "capture", "apply", "capture_and_apply"];
@@ -169,7 +169,7 @@
// ── velocity-bias / rescue block (shared by Hybrid and Distilled) ──
function renderVelocityBlock(c, dk, sub, save, saveNow) {
if (EASY) {
if (EASY()) {
hint(c, "Velocity bias / rescue mode hidden — both are no-ops without a rated velocity bank, "
+ "which Easy Gen has no UI for. Use the Cutting Room or ComfyUI graph instead.");
return;
+14 -4
View File
@@ -1,7 +1,7 @@
"""Movie Editor routes, served by ComfyUI's own aiohttp server.
UI: GET /funpack/movie/ (and static assets under /funpack/movie/<file>)
API: /funpack/movie/api/*
UI: GET /funpack/ (and static assets under /funpack/<file>)
API: /funpack/api/*
Registered on PromptServer like the other /funpack/* routes (see templates.py,
batch_training.py). Pure logic lives in backend/ (timeline, projects, workflow);
@@ -36,7 +36,7 @@ from .backend.timeline import (
is_video_clip,
)
UI_PREFIX = "/funpack/movie"
UI_PREFIX = "/funpack"
def _restart_comfy() -> None:
@@ -2552,6 +2552,16 @@ if web is not None and PromptServer is not None:
nodes.save_models(glob)
return web.json_response(body)
# --- UI: legacy entry points ---
# /funpack/movie/ and /funpack/easy/ were the Cutting Room and Easy Gen when they were
# two apps. Both are the same app now, so old bookmarks land on it rather than 404.
# Registered BEFORE the catch-all below, which would otherwise swallow them.
for _old in ("/movie", "/easy"):
for _path in (UI_PREFIX + _old, UI_PREFIX + _old + "/"):
@routes.get(_path)
async def _legacy_ui(_req):
raise web.HTTPFound(UI_PREFIX + "/")
# --- UI: static frontend (must be registered AFTER api routes) ---
@routes.get(UI_PREFIX)
async def _root_redirect(_req):
@@ -2565,4 +2575,4 @@ if web is not None and PromptServer is not None:
async def _static(req):
return _serve_static(req.match_info["tail"])
print(f"[FunPack] Movie Editor available at {config.comfy_display_url()}{UI_PREFIX}/")
print(f"[FunPack] FunPack available at {config.comfy_display_url()}{UI_PREFIX}/")