First off the port list. Every edit in v5 was final -- v4 has had undo since store.js, which is why it never showed up in a file-by-file inventory: there is no undo.js to find. A project is plain JSON and a few kilobytes, so undo is a snapshot before each edit rather than an inverse per action. Capped at 40. The SELECTION travels with the snapshot: undoing a removal and landing on a different scene than the one that came back is not what anyone means by "take that back". Two things it deliberately does not do. A new edit ends the redo line, or a redo walks into a version that never followed from what is on screen. And a project just opened has no past -- otherwise undo reaches back across a project switch and replaces the open project with a version of a different one. Edit menu: undo, redo, add and delete scene, move clip either way. Built on each press so Undo is offered only when there is something behind it; a menu that always offers it teaches people it does nothing. ⌘Z and ⇧⌘Z do the same, except inside a text box, where the browser's own undo is the right one -- taking it over means one keystroke un-typing a paragraph instead of a word. View gains Reset layout, for the person who closed both panels and cannot remember what brings them back.
300 lines
13 KiB
JavaScript
300 lines
13 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 } from "./shell/run.js";
|
||
import { clientId, connect, queuedFor, finishedFor } from "./shell/client.js";
|
||
import { wire } from "./shell/session.js";
|
||
import { check, load, describe, search } from "./shell/pipeline.js";
|
||
import { open as openPipeline } from "./shell/pipeline_window.js";
|
||
import { createPrompts } from "./shell/prompt.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";
|
||
|
||
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(); },
|
||
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 = () => {
|
||
for (const { input, control } of (prompts ? prompts.controlsAt("project.video") : [])) {
|
||
if (project.video[input] !== undefined) control.setValue(project.video[input]);
|
||
}
|
||
};
|
||
|
||
// 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 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.
|
||
let ranFor = null;
|
||
|
||
// Before the page, because the properties column is composed with it in.
|
||
inspector = createInspector({ project, onRename: (name) => project.rename(name) });
|
||
|
||
const page = build(root, {
|
||
inspector,
|
||
onGenerate: () => { ranFor = project.selectedId; session.generate(); },
|
||
onCancel: () => run.cancel(),
|
||
onConstructor: () => page.constructor.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,
|
||
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 === "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 !== (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.
|
||
},
|
||
onPipeline: () => openPipeline({
|
||
load, describe, check, search,
|
||
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(); });
|
||
}
|
||
},
|
||
}),
|
||
});
|
||
const session = wire({ run, page, check, id, queuedFor, finishedFor,
|
||
slots: () => slots, values: allValues,
|
||
inputs: () => (prompts ? prompts.overrides() : {}) });
|
||
|
||
// 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;
|
||
project.setResult(ranFor, viewUrl(state.images[state.images.length - 1]));
|
||
ranFor = 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) => {
|
||
// Two 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;
|
||
}
|
||
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, project, wheel,
|
||
prompts: () => (prompts ? prompts.overrides() : {}),
|
||
mounted: mounted.map((m) => m.id),
|
||
};
|
||
}
|
||
|
||
start();
|