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