Files
olivv-cs-ComfyUI-FunPack/app/shell/bin.js
T
digital-garbage b85c79a6a2 app, core: save/load a result, save/load a project, generate every scene
Player: Save to bin (whatever the viewer shows, if it has a real file
identity) and Save frame (canvas-capture the video on screen, upload
through ComfyUI's own /upload/image, no new route needed). Both add
without navigating the viewer -- an upload is a real round trip, and
the result is added to the bin, not shown as a stale finished pick.

Save/Load Project File: a real download route with Content-Disposition,
and an import route that never trusts an id in the file -- an import
always lands as its own project, never overwrites one already here.

Generate All: every scene in order, waiting for each to finish before
starting the next -- not a montage (v5 has no render/stitch stage),
N separate generations each landing on its own scene.

Both project-switching (open/newProject/importProject) and the earlier
run-reattach path shared a bug class this session kept finding: state
captured at one moment, applied at a later async moment with no check
that nothing had moved on in between. Closed it properly this time --
a generation counter every switch bumps and checks, including start()
(the page's own handlers are live before it resolves) and undo/redo
(also "a different project is open now"), and scheduleSave() captures
the object an edit actually touched instead of re-reading a variable
that can point somewhere else by the time its debounce timer fires.

Four adversarial rounds against this range, each fixed before landing.
2026-09-06 22:53:34 +03:00

171 lines
6.2 KiB
JavaScript

// The media bin: everything this session has produced, and the one place a
// result is chosen from.
//
// Until this existed a second run replaced the first on screen with no way
// back -- the picture you were comparing against was simply gone, and the only
// record of it was a file on the server nobody in the app could name. So the
// bin accumulates across runs while `run.state.images` deliberately does not:
// the run's state is one generation, and the bin is the session.
//
// Three views, because the same twenty results are read three ways: a grid to
// compare them, a list to read filenames, icons to see many at once. The view
// is the only thing here that is remembered between visits, and it is a
// convenience -- a browser refusing storage still gets a working bin.
import { composer } from "../composer/composer.js";
import { viewUrl } from "./run.js";
const LS_KEY = "funpack.bin.view";
export const VIEWS = [
{ value: "grid", label: "▦", title: "Grid" },
{ value: "list", label: "☰", title: "List" },
{ value: "icons", label: "⣿", title: "Icons" },
];
const NAMES = new Set(VIEWS.map((v) => v.value));
const VIDEO = /\.(mp4|webm|mov|mkv|avi)$/i;
/** Video and image results arrive the same way and cannot be shown the same way. */
export const kindOf = (file) => (VIDEO.test((file && file.filename) || "") ? "video" : "image");
/**
* A file's identity. ComfyUI answers with the same three fields every time, and
* the same file can arrive twice -- a reload adopting a finished run replays
* its outputs -- so an entry is keyed by the file rather than by arrival.
*/
export const keyOf = (file = {}) =>
`${file.type || "output"}|${file.subfolder || ""}|${file.filename || ""}`;
function remember(view) {
try { window.localStorage.setItem(LS_KEY, view); } catch { /* private mode */ }
}
export function recallView(fallback = "grid") {
try {
const raw = window.localStorage.getItem(LS_KEY);
return NAMES.has(raw) ? raw : fallback;
} catch {
return fallback;
}
}
/**
* onOpen is given the whole entry plus the URL to fetch it from, so the caller
* decides what "open" means -- today it is the viewer, later it will also be
* dropping one onto a timeline.
*/
export function createBin({ onOpen, view = recallView(), persist = true,
empty = "Results appear here as you generate." } = {}) {
let mode = NAMES.has(view) ? view : "grid";
let items = []; // newest first
let selected = null;
const seen = new Set();
const host = composer.region.stack({ gap: "sm", label: "Media bin", fill: true });
const control = composer.buttonGroup.md({
label: "Bin view", value: mode, items: VIEWS,
onChange: (next) => setView(next),
});
let shown = null; // the gallery for the current view
// No <video> thumbnails, ever. v4 put live <video> elements in the bin and
// Chrome's six-connections-per-origin pool wedged the whole API behind them
// -- the app stopped answering while the bin loaded. A glyph and a badge say
// what it is; the viewer is where a video plays.
const cellOf = (item) => ({
id: item.id,
label: item.label,
thumb: item.kind === "image" ? item.url : null,
icon: item.kind === "video" ? "▶" : "▦",
// Only the odd one out is labelled. A bin of images with "image" written
// across every thumbnail says nothing and costs a corner of each picture.
badge: item.kind === "video" ? "video" : null,
hint: item.kind,
});
function build() {
const props = {
items: items.map(cellOf),
selection: selected ? [selected] : [],
empty,
onActivate: (cell) => open(cell.id),
};
if (mode === "list") return composer.gallery.list(props);
if (mode === "icons") return composer.gallery.icons(props);
return composer.gallery.adaptive({ id: "bin", ...props });
}
function render() {
const next = build();
host.set([next]);
if (shown && shown !== next && shown.destroy) shown.destroy();
shown = next;
}
render();
function setView(next) {
if (!NAMES.has(next) || next === mode) return mode;
mode = next;
if (persist) remember(mode);
control.setValue(mode);
render(); // items and selection survive
return mode;
}
/** Show one entry. Selection is the user's until a run produces something. */
function open(id) {
const item = items.find((i) => i.id === id);
if (!item) return null;
selected = id;
if (shown) shown.setValue([id]);
if (onOpen) onOpen(item);
return item;
}
/**
* Take in whatever a run has produced so far.
*
* Called on every state change, which means it is called far more often than
* anything new arrives -- so it must be silent when there is nothing new. It
* was not, once: redrawing on each message walked the selection back to the
* newest result the moment the user clicked an older one.
*
* `open: false` is for a save action, not a run: something ADDED to the bin
* a while after it was asked for (an upload's own round trip) must not then
* yank the viewer back to it -- the user may have moved on to something else
* in the meantime, and a save was never a request to look at what got saved.
*/
function absorb(files = [], { open: shouldOpen = true } = {}) {
const added = [];
for (const file of files || []) {
const id = keyOf(file);
if (seen.has(id)) continue;
seen.add(id);
const item = { id, file, kind: kindOf(file), label: file.filename || "(unnamed)",
url: viewUrl(file) };
items = [item, ...items];
added.push(item);
}
if (!added.length) return added;
if (shown) shown.setItems(items.map(cellOf));
// The newest of this batch, which is now at the head. A finished run showing
// its own result is the whole point; an older selection is not preserved
// through it, because nobody generates in order to keep looking at the last
// one.
if (shouldOpen) open(added[added.length - 1].id);
return added;
}
return {
host, control,
absorb, open, setView,
get view() { return mode; },
get items() { return [...items]; },
get selected() { return selected; },
};
}