Files
olivv-cs-ComfyUI-FunPack/app/shell/layout.js
T
digital-garbage dae136b04c core, app: where a file went when it did not land in the bin
Fifth off the port list. ComfyUI writes previews and other transient outputs to
a temp directory and wipes it on restart, so everything in there is findable
for exactly as long as the server has been up -- which is the window in which
anyone asks where something went.

Read-only on purpose. This is a place to FIND a file, not to manage one;
deleting temp files is ComfyUI's job and it does it on restart.

Rows rather than tiles, which is the opposite of the bin and for the opposite
reason: a row shows the whole name, the size and when it was written, and the
grid draws none of those. Only media is listed -- a temp directory fills with
whatever any node felt like writing, and a browser listing 400 .pt files is not
a media bin. No <video> thumbnails, which wedged this app's API once already.

Three empty states, because they are three different facts: nothing was written
yet, the directory was wiped, and ComfyUI is not here to ask. An empty list
reads as the first one whichever is true.

One test taught me something about this environment again: opening a file puts
it in the Preview, the dev server has no /view, so the picture 404s and the kit
replaces it with its fallback -- correctly, and leaving nothing in the DOM to
assert on. What the viewer FETCHES is the evidence, so the test reads the
request rather than the picture.
2026-09-06 14:22:41 +03:00

227 lines
11 KiB
JavaScript

// The app's regions, and the mount points each one offers.
//
// A region declares what it can host. Core never learns these names, and no
// list of them exists anywhere else -- a module asks for one by name, and gets
// it or does not.
//
// The shape is the one v4 called Simple mode, built the other way round. v4's
// Simple mode was the Editor with about ten `display: none !important` rules
// over it, which is not a smaller app but the same app wearing a mask -- and it
// broke: a collapsed column was marked [hidden], and nothing can slide a
// display:none element into view, so a panel button did nothing at all and said
// nothing. So here a region that does not exist yet is ABSENT. The timeline,
// the picker wheel and the project wizard are not hidden below; they have not
// been built, and adding one is adding a region rather than removing a rule.
// The bin arrived exactly that way.
import { composer } from "../composer/composer.js";
import { offer } from "./mounts.js";
import { createTransport } from "./transport.js";
import { createBin } from "./bin.js";
import { createMenubar } from "./menubar.js";
import { createConstructor } from "./constructor.js";
export function build(root, handlers = {}) {
// The transport: Generate, Cancel, and what the run is doing. Built before the
// zones because its controls are handed to the zone that holds them.
const transport = createTransport(handlers);
// Built here rather than on first open: a mount point has to exist when
// modules mount, which is long before anyone opens a window.
// onChange, so the Constructor toggle is right when the window closes ITSELF --
// its own Done button, Escape, or a click on the backdrop.
const constructor = createConstructor({ onChange: () => syncRegions() });
// Centre: the result, and the timeline under it.
const viewer = composer.viewer.media({ empty: "The result of the last run appears here." });
// Left: what a project is made of, which for now is what it has produced. The
// bin owns the choice of what the viewer shows -- one place decides, so a
// click on an older result and a run finishing cannot both be right at once.
const bin = createBin({ onOpen: (item) => viewer.setSource(item.url, item.kind) });
const assets = composer.panel.zone({
title: "Assets",
actions: [bin.control],
body: bin.host,
});
// v4's preview head carries nothing but the name of the zone: what is being
// looked at is not something you act on.
const preview = composer.panel.zone({ title: "Preview", body: viewer, flush: true });
// The timeline: what the project IS, and where a run is started from.
//
// Generate lives here rather than in a bar across the bottom, because a bar
// that spans the window belongs to no region and acts on whatever happens to
// be in front -- which is a dashboard. v4 puts Generate in the timeline head,
// beside the thing it fills, and so does this.
//
// The warning is the first thing INSIDE the zone rather than in its head: it
// is a sentence, not a chip, and it has to sit under the button it is about
// -- an inert setting is said where the run is started, not where it was
// switched on.
// The zone's body is held rather than built inline: boot replaces the stand-in
// with the real timeline once there is a project, and layout stays ignorant of
// what a scene is. The warning stays put -- it is about the run, not the list.
const timelineEmpty = composer.emptyState.default({
icon: "▭",
title: "Nothing on the timeline",
hint: "What you generate lands here, in the order it plays.",
});
// The region toggles, v4's arrangement: named buttons, grouped, in the bar
// above the timeline. The kit's own fallback is a glyph on a rail at each
// outer edge of the window -- fine for a kit that cannot know what its panels
// are called, wrong here, where they have names and a bar to put them in. A
// control whose meaning has to be discovered is not a control.
//
// They are toggles and say so (aria-pressed), and the workspace stays the one
// source of truth for what is open: these ask it to toggle and are told back
// through onToggle, so a panel closed by a narrow window updates its button.
let ws = null;
const regionButtons = [];
const syncRegions = () => {
// Too narrow to dock means the panel OVERLAYS the centre -- and this button
// is in the centre, so the panel it opens covers it. A control that can be
// hidden by the thing it operates is not a control: it goes away, and the
// View menu in the bar, which nothing can cover, is the way in and out.
const covered = Boolean(ws && ws.narrow());
for (const r of regionButtons) {
r.button.setPressed(r.isOn());
if (r.overlaid) r.button.node.toggleAttribute("hidden", covered);
}
};
const regionToggle = (label, isOn, act, { overlaid = false } = {}) => {
const button = composer.button.sm({ label, pressed: isOn(), onClick: () => { act(); syncRegions(); } });
regionButtons.push({ button, isOn, overlaid });
return button;
};
const timelineBody = composer.region.stack({ gap: "sm", fill: true,
children: [transport.warning, timelineEmpty] });
const timeline = composer.panel.zone({
title: "Timeline",
actions: [
...transport.actions,
regionToggle("Assets", () => Boolean(ws && ws.isOpen("left")),
() => ws && ws.toggle("left"), { overlaid: true }),
regionToggle("Properties", () => Boolean(ws && ws.isOpen("right")),
() => ws && ws.toggle("right"), { overlaid: true }),
regionToggle("Constructor", () => constructor.isOpen, () => {
if (constructor.isOpen) constructor.close();
else if (handlers.onConstructor) handlers.onConstructor();
}),
],
status: transport.status,
body: timelineBody,
});
// The stand-ins below are EMPTY STATES, not labels: they say a region is
// empty, and settle() takes them down once it is not. A line reading "modules
// appear here" left above the modules that appeared is a region explaining
// itself to nobody.
// The result gets the room. Draggable, so a long timeline can take it back.
const centre = composer.splitPane.v({
size: 74,
label: "Preview and timeline",
panes: [preview, timeline],
});
// Right: everything about the run. Generation is open, settings folded away
// -- one is read constantly and the other rarely, and a fold says which is
// which without needing a second place to look. A settings WINDOW is a
// separate thing that has not been built; this is not a stand-in for it.
const generationEmpty = composer.emptyState.default({
icon: "◎",
title: "Nothing to set",
hint: "Modules with settings for a run appear here.",
});
const generation = composer.region.stack({ gap: "sm", label: "Generation",
children: [generationEmpty] });
const settingsEmpty = composer.emptyState.default({
icon: "⚙",
title: "Nothing to set",
hint: "Modules with settings that are not about one run appear here.",
});
// A host, not a panel: a bordered panel inside the collapsible's own bordered
// box is two frames drawn around one list of settings.
const settings = composer.region.stack({ gap: "sm", label: "Settings",
children: [settingsEmpty] });
// ONE zone, not two panels stacked. A region of the app is one area with one
// head on it; two heads in a column means two things, and there is only one
// thing here -- what the next run will do.
// The inspector goes ABOVE the module panels: what this scene and this project
// are is read more often than any switch, and a column is read downwards.
const propertyRows = composer.region.stack({
gap: "sm", label: "Properties",
children: [
// What this scene and this project ARE, above the switches: a column is
// read downwards, and this is the half that is read every time.
handlers.inspector,
generation,
composer.collapsible.default({ label: "Settings", body: settings }),
].filter(Boolean),
});
// Titled after what it is ABOUT, and it follows the timeline -- v4's right
// column reads "Scene · 1" and so does this. boot renames it on selection.
const properties = composer.panel.zone({ title: "Scene", body: propertyRows });
const workspace = composer.workspace.docked({
id: "main",
leftLabel: "Assets",
rightLabel: "Properties",
left: assets,
centre,
right: properties,
// Named toggles live in the timeline bar instead -- see regionToggle above.
rails: false,
onToggle: () => syncRegions(),
});
ws = workspace;
syncRegions();
// The menu bar: who this is, what is not a zone, and whether ComfyUI is
// still on the other end. A zone head holds what acts on THAT zone, so
// anything that acts on the app has nowhere else to live.
const menubar = createMenubar({
workspace,
onPipeline: handlers.onPipeline,
onUpdates: handlers.onUpdates,
onPacks: handlers.onPacks,
onLog: handlers.onLog,
onTemp: handlers.onTemp,
onProject: handlers.onProject,
edits: handlers.edits,
projects: handlers.projects,
current: handlers.currentProject,
});
const bar = menubar;
const page = composer.frame.app({ header: bar, main: workspace });
root.replaceChildren(page.node);
// The mount point IS the contract with modules; the region behind it can be
// rearranged freely as long as the name survives.
offer("assets.library", assets.body);
// The prompt is written in a WINDOW, not in a zone: it is the longest thing a
// person writes and the least often read, and a permanent third of the centre
// column is the room the timeline needs.
offer("generation.prompt", constructor.written.node, constructor.empty.node);
// What the project generates at. A pipeline with no such inputs offers no
// roles, nothing mounts, and the group is simply not there.
offer("project.video", constructor.video.node);
offer("settings.general", settings.node, settingsEmpty.node);
// One host, five names. Each carries the same stand-in, and settle() takes it
// down once anything at all has mounted into the panel they share.
for (const point of ["model", "latent", "sampling", "timing", "post"]) {
offer(`generation.${point}`, generation.node, generationEmpty.node);
}
return { workspace, assets, bin, preview, viewer, timeline, timelineBody,
timelineEmpty, generation, settings, syncRegions,
properties, transport, constructor, menubar,
connection: menubar.connection, bar, page };
}