GGUF: port v4's gguf_support.py (backend selection, content-based .gguf detection, dequantize fallback) and wire it into both the diffusion-model and CLIP loaders. Pipeline editor: reopening the Models-and-pipeline settings window was silently discarding the whole edited pipeline (a loaded preset, every loader file, every sampler setting) because GET /api/pipeline is deliberately stateless server-side and boot.js never fed its own already-saved slots back into a freshly remounted editor -- found live while walking through the MiniMax-H3 setup flow. Fixed by re-validating the saved slots through check() on reopen instead of always replaying the raw stateless default. Also fixes, found via the extensive_testing adversarial loop over both of the above: a stale file-listing cache in the pipeline window (no way to see a newly-added model file without a full page reload), and a chain of races/leaks in its own refresh/commit error-banner handling that surfaced across five review rounds -- a rejection from a superseded request overwriting a fresher success, a failure banner never clearing on success, banners bleeding across groups on navigation, and a commit()'s refusal landing on the wrong view after the user backs out before it answers. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
618 lines
30 KiB
JavaScript
618 lines
30 KiB
JavaScript
// Entry point.
|
||
//
|
||
// The whole of what the app does at startup: build the regions, ask the server
|
||
// what exists, and mount whatever announced itself. Nothing here names a module.
|
||
|
||
import { build } from "./shell/layout.js";
|
||
import { fetchManifest } from "./shell/manifest.js";
|
||
import { mountAll } from "./shell/panels.js";
|
||
import { settle } from "./shell/mounts.js";
|
||
import { all as allValues } from "./shell/values.js";
|
||
import { composer } from "./composer/composer.js";
|
||
import { createRun, viewUrl, DONE, FAILED, CANCELLED } from "./shell/run.js";
|
||
import { clientId, connect, queuedFor, finishedFor } from "./shell/client.js";
|
||
import { wire, waitForTerminal } from "./shell/session.js";
|
||
import { check, load, describe, search, presets } from "./shell/pipeline.js";
|
||
import { mount as mountPipeline } from "./shell/pipeline_window.js";
|
||
import { createPrompts } from "./shell/prompt.js";
|
||
import { attach as attachAutocomplete } from "./shell/autocomplete.js";
|
||
import { createProject } from "./shell/projects.js";
|
||
import { createTimeline } from "./shell/timeline.js";
|
||
import { createInspector } from "./shell/inspector.js";
|
||
import { createWheel } from "./shell/wheel.js";
|
||
import { offerAction } from "./shell/actions.js";
|
||
import { open as openWizard } from "./shell/wizard.js";
|
||
import { mount as mountUpdates, status as gitStatus } from "./shell/updates.js";
|
||
import { mount as mountPacks } from "./shell/packs.js";
|
||
import { mount as mountLog } from "./shell/logwindow.js";
|
||
import { mount as mountTemp } from "./shell/tempfiles.js";
|
||
import { mount as mountShortcuts } from "./shell/shortcuts.js";
|
||
import { createSettingsWindow } from "./shell/settings_window.js";
|
||
import { wireReferences } from "./shell/reference_wiring.js";
|
||
|
||
const root = document.querySelector("#app");
|
||
|
||
async function start() {
|
||
const id = clientId();
|
||
const run = createRun({ clientId: id, connect });
|
||
|
||
// The pipeline the user has edited, if they have. Held here and passed in
|
||
// rather than kept inside a module: the app has one store of live values
|
||
// already, and a second one hiding in a transport file is a second place to
|
||
// look when a run turns out to have used something other than what is on
|
||
// screen. Null until the window says otherwise, which means "the server's
|
||
// defaults" -- so a fresh page generates without the window ever opening.
|
||
let slots = null;
|
||
|
||
// The inputs the pipeline puts on the main window -- the prompt, today. Null
|
||
// until the pipeline has been read, because until then nobody knows which
|
||
// inputs those are.
|
||
let prompts = null;
|
||
|
||
// The project, and the strip that draws it. Both exist before the manifest is
|
||
// read so a failure to reach the server leaves an empty timeline rather than
|
||
// an undefined one.
|
||
let timeline = null;
|
||
let inspector = null;
|
||
const project = createProject({
|
||
onChange: () => {
|
||
if (timeline) timeline.draw();
|
||
if (inspector) inspector.draw();
|
||
},
|
||
// A different project is open. Everything that follows the project rather
|
||
// than one of its fields is put back here -- by every route in, including
|
||
// the ones that are not the File menu.
|
||
onOpen: () => { showScene(project.selected); syncVideo(); syncNegative(); },
|
||
onError: (err) => page.transport.say(`The project could not be saved: ${err.message}`),
|
||
});
|
||
|
||
// A group with nothing in it is a heading over nothing. The pipeline decides
|
||
// which inputs belong on the app's surface, so whether this group exists at
|
||
// all is only known once its controls have been built -- and again whenever
|
||
// the pipeline changes underneath.
|
||
const showGroups = () => {
|
||
const empty = !prompts || !prompts.controlsAt("project.video").length;
|
||
page.constructor.video.node.toggleAttribute("hidden", empty);
|
||
};
|
||
|
||
/**
|
||
* Put the open project's settings into the controls that produce them.
|
||
*
|
||
* Every time EITHER side moves: a different project is opened, or the pipeline
|
||
* changed and the controls were rebuilt from its defaults. Seeding once at
|
||
* startup left the boxes -- and so `overrides()`, and so the run -- holding the
|
||
* numbers of whatever project was open first. The store's own getter reported
|
||
* the new project correctly, so the only place the stale value showed was a
|
||
* window nobody had a reason to reopen.
|
||
*/
|
||
const syncVideo = () => {
|
||
// A project that never touched an input is not "leave it alone" -- it is
|
||
// "this input has no opinion here", which means the pipeline's own
|
||
// default. Without the fallback, switching FROM a project that set width
|
||
// TO one that never did left the box (and so overrides(), and so the run)
|
||
// still showing the first project's number.
|
||
for (const { input, control } of (prompts ? prompts.controlsAt("project.video") : [])) {
|
||
control.setValue(project.video[input] !== undefined ? project.video[input] : control.default);
|
||
}
|
||
};
|
||
|
||
// Same reason and same shape as syncVideo: a project that never touched
|
||
// this is the pipeline's own default, not "whatever the last project had
|
||
// typed here" -- without this, switching projects left the box (and so
|
||
// overrides(), and so the run) still holding the FIRST project's negative
|
||
// prompt.
|
||
const syncNegative = () => {
|
||
const box = prompts && prompts.at("project.negative");
|
||
if (box) box.setValue(project.negative || box.default);
|
||
};
|
||
|
||
// A scene's text and the prompt box are the same value seen twice. The box is
|
||
// the editor; the scene is where it lives.
|
||
const showScene = (scene) => {
|
||
const box = prompts && prompts.at("generation.prompt");
|
||
if (box) box.setValue(scene ? scene.text : "");
|
||
// The box's own DOM node, so autocomplete has an actual field to attach
|
||
// to. `attach()` is a no-op on a node it has already seen -- the guard is
|
||
// needed because showScene() runs on every selection change, and box.node
|
||
// only actually CHANGES after a pipeline edit rebuilds the control.
|
||
if (box && box.node) attachAutocomplete(box.node);
|
||
// The properties column is about ONE scene, and says which.
|
||
const at = scene ? project.scenes.findIndex((s) => s.id === scene.id) + 1 : 0;
|
||
if (page && page.properties) page.properties.setTitle(at ? `Scene · ${at}` : "Scene");
|
||
};
|
||
|
||
// Which scene a run was started for, read at the moment it starts. A run
|
||
// takes minutes and the user goes on clicking, so "the selected scene" at the
|
||
// end is not the one that was generated. The project id travels with it --
|
||
// nothing stops the user opening a DIFFERENT project while this one is in
|
||
// flight, and the result still belongs to the project it was started in.
|
||
let ranFor = null;
|
||
let ranForProject = null;
|
||
|
||
// A scene waiting for the NEXT item picked in the Media bin -- there is one
|
||
// bin and one onPick, shared by every scene, so "which field asked" has to
|
||
// live somewhere between the click that asked and the click that answers.
|
||
let pendingPick = null; // { sceneId, kind: "source" | "reference" }
|
||
const requestPick = (sceneId, kind) => {
|
||
pendingPick = { sceneId, kind };
|
||
composer.toast.info({ text: "Click an image in the Media tab (Assets ▸ Media) to use it." });
|
||
};
|
||
// What the Media bin's own onActivate calls, for every item -- not only
|
||
// while something is pending. Nothing happens for a plain gallery click
|
||
// (there is nothing to consume the pick with yet -- see media.js's own
|
||
// docstring), and picking a non-image for either field is the world
|
||
// disagreeing with what was asked, said rather than silently ignored.
|
||
const handleMediaPick = (entry) => {
|
||
if (!pendingPick) return;
|
||
if (entry.kind !== "image") {
|
||
composer.toast.warn({ text: `${entry.name} is a ${entry.kind}, not an image.` });
|
||
return;
|
||
}
|
||
const { sceneId, kind } = pendingPick;
|
||
pendingPick = null;
|
||
if (kind === "source") { project.setSourceImage(sceneId, entry.id); return; }
|
||
const scene = project.scenes.find((s) => s.id === sceneId);
|
||
project.setReferences(sceneId, [...(scene ? scene.references : []), entry.id]);
|
||
};
|
||
|
||
// Before the page, because the properties column is composed with it in.
|
||
inspector = createInspector({
|
||
project, onRename: (name) => project.rename(name),
|
||
onPickSourceImage: (sceneId) => requestPick(sceneId, "source"),
|
||
onPickReference: (sceneId) => requestPick(sceneId, "reference"),
|
||
});
|
||
|
||
// "Load Project File..." trigger. One input, kept and reused rather than
|
||
// built fresh per click -- a fresh one would need its `change` listener
|
||
// rewired every time, which is a listener leaked on every click but the last.
|
||
const loadProjectFile = document.createElement("input");
|
||
loadProjectFile.type = "file";
|
||
loadProjectFile.accept = "application/json,.json";
|
||
loadProjectFile.hidden = true;
|
||
document.body.appendChild(loadProjectFile);
|
||
loadProjectFile.addEventListener("change", async () => {
|
||
const file = loadProjectFile.files[0];
|
||
loadProjectFile.value = ""; // the same file picked twice must still change
|
||
if (!file) return;
|
||
try {
|
||
await project.importProject(JSON.parse(await file.text()));
|
||
} catch (err) {
|
||
page.transport.say(`Could not load that project file: ${err.message}`);
|
||
}
|
||
});
|
||
|
||
// What Generate does for whichever scene is selected right now -- shared
|
||
// with "Generate All" below, so the two never disagree about how a run gets
|
||
// attributed to its scene.
|
||
const generateCurrentScene = () => {
|
||
ranFor = project.selectedId;
|
||
ranForProject = project.project ? project.project.id : null;
|
||
return session.generate();
|
||
};
|
||
|
||
// Every scene, one at a time, waiting for each to actually finish before
|
||
// starting the next. Not a montage -- v5 has no render/stitch stage -- this
|
||
// is N separate generations, each landing on its own scene, the same as
|
||
// pressing Generate N times without having to sit at the keyboard for each.
|
||
let batchCancelled = false;
|
||
async function generateAll() {
|
||
// Excluded scenes are dropped from the count as well as the loop -- "3 of
|
||
// 5" while two of the five were never going to run reads as a wrong
|
||
// number, not as a batch that respected the exclude.
|
||
const scenes = project.scenes.filter((s) => !s.excluded);
|
||
if (!scenes.length) { page.transport.say("Every scene is excluded -- nothing to generate."); return; }
|
||
generateAllBtn.setDisabled(true);
|
||
batchCancelled = false;
|
||
page.transport.hold(`Generating scene 1 of ${scenes.length}…`);
|
||
const failed = [];
|
||
try {
|
||
for (let i = 0; i < scenes.length; i++) {
|
||
if (batchCancelled) break;
|
||
const scene = scenes[i];
|
||
// A scene removed -- or excluded -- mid-batch by whoever is not at
|
||
// the keyboard right now is not a scene left to generate.
|
||
const live = project.scenes.find((s) => s.id === scene.id);
|
||
if (!live || live.excluded) continue;
|
||
project.select(scene.id);
|
||
showScene(project.selected);
|
||
page.transport.say(`Generating scene ${i + 1} of ${scenes.length}…`);
|
||
const waiting = waitForTerminal(run);
|
||
const queued = await generateCurrentScene();
|
||
if (!queued) {
|
||
// Refused before it ever reached run.start() -- nothing will ever
|
||
// transition the run, so nothing will ever resolve `waiting` either.
|
||
waiting.cancel();
|
||
failed.push(scene);
|
||
continue;
|
||
}
|
||
const phase = await waiting;
|
||
if (phase === CANCELLED) { batchCancelled = true; break; }
|
||
if (phase === FAILED) failed.push(scene);
|
||
}
|
||
} finally {
|
||
generateAllBtn.setDisabled(false);
|
||
page.transport.release(run.state);
|
||
}
|
||
if (batchCancelled) page.transport.say("Generate All was cancelled.");
|
||
else if (failed.length) page.transport.say(`${failed.length} of ${scenes.length} scenes did not generate.`);
|
||
}
|
||
const generateAllBtn = composer.button.sm({
|
||
label: "Generate All", tone: "ghost",
|
||
onClick: () => generateAll(),
|
||
});
|
||
|
||
// One window for everything that is a PREFERENCE rather than an edit to the
|
||
// project on screen. Each section mounts the same content its standalone
|
||
// window would build -- pipeline_window.js etc. -- inside this one instead.
|
||
const settings = createSettingsWindow({
|
||
gitStatus,
|
||
// The machine ComfyUI runs on, for About -- injected the same as every
|
||
// other network call in this file, not imported into settings_window.js.
|
||
systemInfo: () => fetch("/funpack/api/system").then((r) => r.json()),
|
||
sections: [
|
||
{
|
||
id: "pipeline", title: "Models and pipeline", subtitle: "What the run is made of.",
|
||
keywords: "pipeline models nodes slots loaders", icon: "▦", tone: "accent",
|
||
mount: (ctx) => mountPipeline({
|
||
// load() always answers with the server's stateless default -- it
|
||
// holds no session, by design (core/routes.py's _pipeline()). The
|
||
// window itself only remembers what was saved for as long as it
|
||
// stays open; closing Settings and reopening this section made a
|
||
// fresh one every time, so a saved preset and every edit on top of
|
||
// it vanished the moment someone closed Settings to look at
|
||
// something else, with no error and no way to tell it had happened.
|
||
// Re-validating the already-saved `slots` through check() (rather
|
||
// than replaying `load()`'s raw answer) keeps `incomplete` and
|
||
// `queueable` correct, exactly as a fresh commit() would.
|
||
load: () => (slots ? check({ slots }) : load()),
|
||
describe, check, search, presets, ...ctx,
|
||
onApply: (next) => {
|
||
slots = next;
|
||
// The boxes on the main window are for inputs of THESE slots. A slot
|
||
// that was removed takes its box with it, and a value saved in the
|
||
// window is what its box now shows -- otherwise the two windows hold
|
||
// different text for one input and the run uses whichever was sent.
|
||
if (prompts) {
|
||
// sync() rebuilds every control from the PIPELINE's values, which is
|
||
// right for the prompt and wrong for the project's own settings: they
|
||
// outrank the pipeline's defaults and have to be put back.
|
||
Promise.resolve(prompts.sync(next)).then(() => { showGroups(); syncVideo(); syncNegative(); });
|
||
}
|
||
},
|
||
}),
|
||
},
|
||
{
|
||
id: "updates", title: "Updates", subtitle: "FunPack's own code, branch, and restart.",
|
||
keywords: "update git branch restart pull checkout", icon: "⟳", tone: "warn",
|
||
// The window asks whether a run is in flight, because the restart that
|
||
// follows an update would take it with it.
|
||
mount: (ctx) => mountUpdates({
|
||
...ctx, running: () => ["queued", "running"].includes(run.state.phase),
|
||
}),
|
||
},
|
||
{
|
||
id: "packs", title: "Node packs", subtitle: "Install, update, or remove a custom_nodes pack.",
|
||
keywords: "node packs custom_nodes install", icon: "▣", tone: "good",
|
||
mount: (ctx) => mountPacks(ctx),
|
||
},
|
||
{
|
||
id: "log", title: "ComfyUI log", subtitle: "The server's own log, without opening devtools.",
|
||
keywords: "log console errors output", icon: "▤", tone: "danger",
|
||
mount: (ctx) => mountLog(ctx),
|
||
},
|
||
{
|
||
id: "shortcuts", title: "Shortcuts", subtitle: "Trigger words that expand into a prompt at generation.",
|
||
keywords: "shortcuts triggers replacements autocomplete prompt", icon: "✱", tone: "accent",
|
||
mount: (ctx) => mountShortcuts(ctx),
|
||
},
|
||
{
|
||
id: "temp", title: "Temp files", subtitle: "Where a file went when it did not land in the bin.",
|
||
keywords: "temp files output directory", icon: "▥", tone: "neutral",
|
||
// Opening one puts it in the Preview, which is where somebody hunting
|
||
// for a file wants it -- the same place a result from the bin goes.
|
||
mount: (ctx) => mountTemp({
|
||
...ctx, onOpen: (item) => page.viewer.setSource(
|
||
item.url, item.kind, item.file ? { ...item.file, type: "temp" } : null),
|
||
}),
|
||
},
|
||
],
|
||
});
|
||
|
||
const page = build(root, {
|
||
inspector,
|
||
onMediaPick: handleMediaPick,
|
||
onGenerate: generateCurrentScene,
|
||
generateAll: generateAllBtn,
|
||
onCancel: () => run.cancel(),
|
||
onConstructor: () => page.constructor.open(),
|
||
onSettings: () => settings.open(),
|
||
// What the Edit menu offers, and what the keyboard reaches. One list, so a
|
||
// menu item and its shortcut cannot drift apart.
|
||
edits: {
|
||
canUndo: () => project.canUndo,
|
||
canRedo: () => project.canRedo,
|
||
excluded: () => Boolean(project.selected && project.selected.excluded),
|
||
run: (id) => {
|
||
if (id === "undo") project.undo();
|
||
else if (id === "redo") project.redo();
|
||
else if (id === "scene") { project.addScene(); showScene(project.selected); }
|
||
else if (id === "remove" && project.scenes.length > 1) {
|
||
project.removeScene(project.selectedId);
|
||
showScene(project.selected);
|
||
} else if (id === "exclude" && project.selected) {
|
||
project.setScene(project.selectedId, "excluded", !project.selected.excluded);
|
||
} else if (id === "earlier") project.move(project.selectedId, -1);
|
||
else if (id === "later") project.move(project.selectedId, 1);
|
||
},
|
||
},
|
||
projects: () => project.recent,
|
||
currentProject: () => (project.project ? project.project.id : null),
|
||
onProject: async (id) => {
|
||
if (id === "new") {
|
||
const asked = composer.modal.prompt({
|
||
title: "New project", label: "Name", value: "Untitled", confirmLabel: "Create",
|
||
});
|
||
const name = await asked.result;
|
||
if (name === null) return; // cancelled
|
||
await project.newProject(name);
|
||
} else if (id === "save-file") {
|
||
const url = project.downloadUrl();
|
||
if (!url) return;
|
||
// Navigation, not a fetch: the server's own Content-Disposition is
|
||
// what makes this a save-as instead of a page full of JSON.
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = "";
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
a.remove();
|
||
} else if (id === "load-file") {
|
||
loadProjectFile.click();
|
||
} else if (id !== (project.project || {}).id) {
|
||
await project.open(id);
|
||
} else {
|
||
return; // already open
|
||
}
|
||
// Nothing to put back here: the store says when a project was opened, and
|
||
// whatever follows it is wired to that.
|
||
},
|
||
});
|
||
// The slot a role names, whether or not this shell hosts a field for it --
|
||
// "assets.source_image"/"assets.reference_N" mount nothing on screen (see
|
||
// requestPick/handleMediaPick above), so prompts.js's own `fields` never
|
||
// carries them; this reads the pipeline's raw slots directly instead, the
|
||
// same generic "found by role name, never by this preset's own slot id"
|
||
// contract "generation.prompt" below also uses.
|
||
const slotForRole = (at) => (slots || []).find((s) => (s.roles || []).some((r) => r.at === at));
|
||
|
||
// What a run actually sends: the prompt box's typed text, expanded with the
|
||
// project's anchor/postfix/$variables/shortcuts (core/prompt_build.py) --
|
||
// scene.text itself stays exactly what was typed, so editing and
|
||
// regenerating always shows that, not an expanded copy nothing can trace
|
||
// back to it -- plus whatever media the selected scene has picked, for any
|
||
// pipeline that wired an "assets.*" role to ask for one.
|
||
async function queueInputs() {
|
||
const raw = prompts ? prompts.overrides() : {};
|
||
|
||
// The scene about to generate's own picked media -- set here rather than
|
||
// through prompts.js because these roles mount no field for a person to
|
||
// type into (a media id is picked in the Media bin, never typed).
|
||
const scene = project.selected;
|
||
if (scene) {
|
||
const source = slotForRole("assets.source_image");
|
||
if (source) raw[source.id] = { ...raw[source.id], media_id: scene.source_image || "" };
|
||
|
||
const { overrides, unwired } = wireReferences(
|
||
scene.references || [], slots || [], source ? [source.id] : []);
|
||
for (const [id, fields] of Object.entries(overrides)) {
|
||
raw[id] = { ...raw[id], ...fields };
|
||
}
|
||
// `unwired` covers two different causes -- the pipeline has fewer
|
||
// reference slots than the scene has references (H3's R2V caps at 4
|
||
// today), or a malformed preset points two references at the same
|
||
// slot/destination -- but from here they look the same, and saying
|
||
// "no slot" for the second would send someone hunting for a bigger
|
||
// pipeline instead of the real, preset-authoring cause. Word it as
|
||
// what is actually and always true of both: the reference did not
|
||
// make it into this run.
|
||
if (unwired > 0) {
|
||
composer.toast.warn({ text: unwired === 1
|
||
? "1 reference could not be wired into this pipeline and will not be used."
|
||
: `${unwired} references could not be wired into this pipeline and will not be used.` });
|
||
}
|
||
}
|
||
|
||
const field = prompts && prompts.fields.find((f) => f.at === "generation.prompt");
|
||
const text = field && (raw[field.slot] || {})[field.input];
|
||
if (!field || text == null) return raw;
|
||
try {
|
||
const res = await fetch("/funpack/api/prompt/expand", {
|
||
method: "POST", headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
text, anchor: project.anchor, postfix: project.postfix,
|
||
postfix_enabled: project.postfixEnabled, variables: project.variables,
|
||
// Without a seed, core/shortcuts.py's expand() falls back to
|
||
// hashing the literal text -- a fixed function of it, not a
|
||
// "picks at random each time" the Shortcuts editor's own hint
|
||
// promises. This app has no per-run sampler seed reachable from
|
||
// here to reuse (v4's version threaded its actual generation
|
||
// seed through the same fallback; nothing plays that role here
|
||
// yet), so a fresh one is drawn per queue instead -- real
|
||
// variety across separate Generate clicks, even on unedited text.
|
||
seed: Math.floor(Math.random() * 2 ** 31) || 1,
|
||
}),
|
||
});
|
||
if (res.ok) {
|
||
const body = await res.json();
|
||
raw[field.slot] = { ...raw[field.slot], [field.input]: body.text };
|
||
}
|
||
// A non-OK response leaves `raw` holding the literal typed text -- the
|
||
// run still queues, just without anchor/shortcuts/variables applied,
|
||
// rather than being blocked by a prompt-craft feature failing.
|
||
} catch { /* same fallback: the network call itself failed */ }
|
||
return raw;
|
||
}
|
||
|
||
const session = wire({ run, page, check, id, queuedFor, finishedFor,
|
||
slots: () => slots, values: allValues,
|
||
inputs: queueInputs,
|
||
// Read by run.start() at the moment IT queues -- not
|
||
// at Generate-click time, which is what onGenerate
|
||
// sets ranFor/ranForProject from.
|
||
extra: () => (ranForProject && ranFor
|
||
? { funpack_scene_id: ranFor, funpack_project_id: ranForProject } : null),
|
||
// The other way a run reaches this page: reattaching
|
||
// to one after a reload, where the click that started
|
||
// it happened on a page that is gone. Called BEFORE
|
||
// the run is adopted -- an already-finished run can go
|
||
// straight to DONE inside adopt() itself, and setting
|
||
// these from session.ready instead would run one tick
|
||
// too late, after that DONE already found nothing to
|
||
// attach the result to.
|
||
onAdopt: (sceneId, projectId) => { ranFor = sceneId; ranForProject = projectId; } });
|
||
|
||
// What a run produced, on the scene it was started from -- which is what puts
|
||
// it on the timeline and what makes it still there after a reload.
|
||
run.subscribe((state) => {
|
||
if (state.phase !== DONE || !ranFor || !state.images.length) return;
|
||
if (ranForProject) project.setResultFor(ranForProject, ranFor, viewUrl(state.images[state.images.length - 1]));
|
||
else project.setResult(ranFor, viewUrl(state.images[state.images.length - 1]));
|
||
ranFor = null;
|
||
ranForProject = null;
|
||
});
|
||
|
||
// Undo from the keyboard, which is where anyone will reach for it first.
|
||
//
|
||
// Not inside a field: a text box has its own undo stack and taking that over
|
||
// would mean one keystroke un-typing a whole paragraph instead of a word.
|
||
window.addEventListener("keydown", (event) => {
|
||
if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "z") return;
|
||
const on = event.target;
|
||
const typing = on && (on.tagName === "INPUT" || on.tagName === "TEXTAREA" || on.isContentEditable);
|
||
if (typing) return;
|
||
event.preventDefault();
|
||
if (event.shiftKey) project.redo();
|
||
else project.undo();
|
||
});
|
||
|
||
// Saved on the way out. A debounce that has not fired yet is work the user
|
||
// did and cannot see anywhere, and a reload is exactly when it is lost.
|
||
window.addEventListener("pagehide", () => {
|
||
// Blur first: a control commits on blur, so text still being typed is not
|
||
// in the project yet -- and this is exactly the moment it would be lost.
|
||
if (document.activeElement && document.activeElement.blur) document.activeElement.blur();
|
||
project.flush();
|
||
});
|
||
|
||
let manifest;
|
||
try {
|
||
manifest = await fetchManifest();
|
||
} catch (err) {
|
||
// The one failure the user must see: with no manifest there is no app, so
|
||
// silence here would be an empty window with no explanation.
|
||
root.replaceChildren(composer.emptyState.default({
|
||
icon: "▲",
|
||
title: "Could not reach FunPack",
|
||
hint: `${err.message}. Is ComfyUI running?`,
|
||
}).node);
|
||
return;
|
||
}
|
||
|
||
const { mounted, hidden } = await mountAll(manifest);
|
||
|
||
// After the modules, because a region has to exist before anything can be put
|
||
// in it, and a module may be sharing the region a role names.
|
||
try {
|
||
prompts = await createPrompts((await load()).slots, {
|
||
// Controls come from the node's own description: a combo gets the node's
|
||
// real choices, a number its real bounds.
|
||
describe,
|
||
// Typing in the box writes to the scene it belongs to. Without this the
|
||
// prompt is a value the run uses and the project never hears about, so a
|
||
// reload shows a timeline whose scenes are all empty.
|
||
onChange: (field) => {
|
||
// Three places take a value now, and which one is decided by where
|
||
// the pipeline asked for the control -- not by what it is called.
|
||
if (field && field.at === "project.video") {
|
||
project.setVideo(field.input, field.control.value);
|
||
return;
|
||
}
|
||
if (field && field.at === "project.negative") {
|
||
project.setNegative(field.control.value);
|
||
return;
|
||
}
|
||
const box = prompts && prompts.at("generation.prompt");
|
||
if (box && project.selectedId) project.setText(project.selectedId, box.value);
|
||
},
|
||
});
|
||
} catch (err) {
|
||
// Not fatal and not silent: the app still runs on the server's own
|
||
// defaults, and an empty prompt panel with no explanation is the failure
|
||
// this project keeps finding.
|
||
console.warn(`[FunPack] the pipeline could not be read, so nothing it puts on the main window is here: ${err.message}`);
|
||
}
|
||
|
||
// Everything that mounts has now had its turn, so a region still holding its
|
||
// stand-in is a region nothing wanted.
|
||
settle();
|
||
showGroups();
|
||
|
||
// What the app can be asked to do, offered by the part that owns each one.
|
||
// The wheel shows whatever is here; nothing has to tell it about a new one.
|
||
offerAction({ id: "generate", icon: "▶", label: "Generate", run: () => session.generate() });
|
||
offerAction({ id: "cancel", icon: "■", label: "Cancel", run: () => run.cancel() });
|
||
offerAction({ id: "constructor", icon: "✎", label: "Constructor",
|
||
run: () => page.constructor.open() });
|
||
offerAction({ id: "scene", icon: "+", label: "Add scene",
|
||
run: () => { project.addScene(); showScene(project.selected); } });
|
||
offerAction({ id: "pipeline", icon: "⚙", label: "Models",
|
||
run: () => page.menubar.settings.node.click() });
|
||
offerAction({ id: "assets", icon: "▤", label: "Assets",
|
||
run: () => page.workspace.toggle("left") });
|
||
const wheel = createWheel();
|
||
|
||
// After the prompt exists, so the first scene's text has somewhere to go.
|
||
try {
|
||
const fresh = await project.start();
|
||
// Nothing to come back to: offer the way in. Dismissing is a real answer --
|
||
// the project is already made, and it carries on with one empty scene.
|
||
if (!(fresh.scenes || []).length) {
|
||
openWizard({ onPick: (choice) => {
|
||
const wanted = choice === "scenes" ? 3 : 1;
|
||
for (let i = 0; i < wanted; i += 1) project.addScene();
|
||
showScene(project.selected);
|
||
} });
|
||
if (!project.scenes.length) project.addScene();
|
||
}
|
||
|
||
timeline = createTimeline({ project, onSelect: showScene });
|
||
page.timelineBody.set([page.transport.warning, timeline]);
|
||
|
||
showScene(project.selected);
|
||
} catch (err) {
|
||
console.warn(`[FunPack] no project: ${err.message}`);
|
||
page.transport.say(`The project could not be opened: ${err.message}`);
|
||
}
|
||
|
||
const failed = manifest.failed || [];
|
||
|
||
console.info(`[FunPack] ${mounted.length} module(s) mounted`,
|
||
hidden.length ? `· ${hidden.length} hidden` : "",
|
||
failed.length ? `· ${failed.length} failed to load` : "");
|
||
for (const { id, why } of hidden) console.warn(`[FunPack] ${id} is hidden: ${why}`);
|
||
// These never reached the manifest, so no panel could be missing "in a way
|
||
// the user notices" -- which is exactly why they have to be said. A module
|
||
// that failed to import looks identical to one nobody installed.
|
||
for (const { where, why } of failed) console.warn(`[FunPack] ${where} did not load: ${why}`);
|
||
|
||
window.FunPack = {
|
||
manifest, values: allValues, failed, hidden, run, bin: page.bin,
|
||
media: page.mediaLibrary, project, wheel,
|
||
viewer: page.viewer,
|
||
prompts: () => (prompts ? prompts.overrides() : {}),
|
||
mounted: mounted.map((m) => m.id),
|
||
};
|
||
}
|
||
|
||
start();
|