Files
olivv-cs-ComfyUI-FunPack/app/shell/media.js
T
digital-garbageandClaude Sonnet 5 fd292259e7 core, app: media import -- a library separate from the run-output bin
Files the user brings IN (a reference image, an imported clip) now have a
real store: core/media.py, /api/media routes, and a "Media" tab beside the
bin's "Results" tab in the Assets zone. Upload, list, fetch, delete all
round-trip through the real server; nothing wires into a pipeline input yet,
since no model module or Studio conditioning takes a reference image today.

Two adversarial review rounds found and fixed three real issues along the
way: MAX_BYTES (512MB) was dead code because aiohttp's own client_max_size
capped every upload far lower first in both prod and dev, with a JSON-parse
error leaking to the user on rejection instead of a clean message; the
index.json read-modify-write had no guard against concurrent writers; and
the devserver's bare Application didn't match ComfyUI's own 100MB default,
which also broke an unrelated e2e assertion once the review polluted the
real, unisolated media/ directory testing the fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 16:58:18 +03:00

113 lines
4.4 KiB
JavaScript

// The media library: files the user brought IN -- a reference image, an
// imported clip -- kept apart from the bin, which is everything a RUN
// produced. Same reason projects and media are separate stores on the
// server: the two ids look alike and mean different things.
import { composer } from "../composer/composer.js";
export const mediaUrl = (item) => `/funpack/api/media/${item.id}/file`;
const cellOf = (item) => ({
id: item.id, label: item.name,
thumb: item.kind === "image" ? mediaUrl(item) : null,
icon: item.kind === "video" ? "▶" : item.kind === "audio" ? "♪" : "▦",
badge: item.kind !== "image" ? item.kind : null,
hint: item.kind,
});
/**
* onPick is given the whole entry when an item is activated, so the caller
* decides what that means -- today nothing consumes it, since no pipeline
* stage takes an image input yet; the hook exists so wiring one up later is
* not a rewrite of this file.
*/
export function createMediaLibrary({ onPick } = {}) {
let items = [];
const gallery = composer.gallery.adaptive({
id: "media", items: [],
empty: "Nothing imported yet. Drop a file above, or click it to browse.",
onActivate: (item) => {
gallery.setValue([item.id]);
const found = items.find((i) => i.id === item.id);
if (found && onPick) onPick(found);
},
// Right-click removes -- the bin has no delete at all today, but an
// import the user reconsiders should not need a trip to the filesystem.
onContext: (item) => remove(item.id),
});
const dropzone = composer.dropzone.default({
label: "Import media", hint: "Images, clips, audio -- or drop a file here",
accept: "image/*,video/*,audio/*",
onFiles: (files) => upload(files),
});
const host = composer.region.stack({ gap: "sm", fill: true, label: "Media library",
children: [dropzone, gallery] });
function setItems(next) {
items = next;
gallery.setItems(items.map(cellOf));
}
async function refresh() {
try {
const res = await fetch("/funpack/api/media");
const body = await res.json();
setItems(body.media || []);
} catch {
composer.toast.danger({ text: "Could not load the media library." });
}
}
async function upload(files) {
const form = new FormData();
for (const file of files) form.append("file", file, file.name);
let body;
try {
const res = await fetch("/funpack/api/media", { method: "POST", body: form });
if (!res.ok) {
// A refusal FunPack's own route made comes back as JSON with a
// reason. A 413 does not -- aiohttp refuses an oversized body before
// the route ever runs, as plain text, and res.json() on that would
// throw a parser error that read as the failure instead of the size
// limit it actually was.
if (res.status === 413) throw new Error("that file is too large for this server to accept");
const problem = await res.json().catch(() => null);
throw new Error((problem && problem.problems && problem.problems[0]) || `refused (${res.status})`);
}
body = await res.json();
} catch (err) {
composer.toast.danger({ text: `Could not import that: ${err.message}` });
return;
}
setItems([...(body.media || []), ...items]);
if (body.problems && body.problems.length) {
composer.toast.warn({ text: `Imported ${body.media.length}, skipped: ${body.problems[0]}` });
} else if (body.media.length) {
composer.toast.good({ text: body.media.length > 1
? `Imported ${body.media.length} files.` : `Imported ${body.media[0].name}.` });
}
}
async function remove(id) {
const found = items.find((i) => i.id === id);
setItems(items.filter((i) => i.id !== id));
try {
const res = await fetch(`/funpack/api/media/${id}`, { method: "DELETE" });
if (!res.ok) throw new Error(`refused (${res.status})`);
composer.toast.good({ text: found ? `Removed ${found.name}.` : "Removed." });
} catch {
composer.toast.danger({ text: "Could not remove that -- it may still be on the server." });
refresh();
}
}
// No fetch here: this is built once, up front, whether or not the tab
// hosting it is ever opened -- exactly like every Settings section that
// waits for its own mount() to be reached. The caller decides when
// "reach the library" actually happens by calling refresh().
return { host, refresh, get items() { return [...items]; } };
}