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.
This commit is contained in:
digital-garbage
2026-09-06 22:53:34 +03:00
parent 7ec055c7e8
commit b85c79a6a2
19 changed files with 1125 additions and 32 deletions
+95 -8
View File
@@ -9,9 +9,9 @@ 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 { createRun, viewUrl, DONE, FAILED, CANCELLED } from "./shell/run.js";
import { clientId, connect, queuedFor, finishedFor } from "./shell/client.js";
import { wire } from "./shell/session.js";
import { wire, waitForTerminal } 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";
@@ -113,13 +113,85 @@ async function start() {
// Before the page, because the properties column is composed with it in.
inspector = createInspector({ project, onRename: (name) => project.rename(name) });
// "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() {
const scenes = [...project.scenes];
if (!scenes.length) 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 mid-batch by whoever is not at the keyboard right
// now is not a scene left to generate.
if (!project.scenes.some((s) => s.id === scene.id)) 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(),
});
const page = build(root, {
inspector,
onGenerate: () => {
ranFor = project.selectedId;
ranForProject = project.project ? project.project.id : null;
session.generate();
},
onGenerate: generateCurrentScene,
generateAll: generateAllBtn,
onCancel: () => run.cancel(),
onConstructor: () => page.constructor.open(),
// The window asks whether a run is in flight, because the restart that
@@ -129,7 +201,8 @@ async function start() {
onLog: () => openLog(),
// 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.
onTemp: () => openTemp({ onOpen: (item) => page.viewer.setSource(item.url, item.kind) }),
onTemp: () => openTemp({ onOpen: (item) => page.viewer.setSource(
item.url, item.kind, item.file ? { ...item.file, type: "temp" } : null) }),
// What the Edit menu offers, and what the keyboard reaches. One list, so a
// menu item and its shortcut cannot drift apart.
edits: {
@@ -156,6 +229,19 @@ async function start() {
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 {
@@ -331,6 +417,7 @@ async function start() {
window.FunPack = {
manifest, values: allValues, failed, hidden, run, bin: page.bin, project, wheel,
viewer: page.viewer,
prompts: () => (prompts ? prompts.overrides() : {}),
mounted: mounted.map((m) => m.id),
};
+32 -2
View File
@@ -304,9 +304,15 @@ define("ruler", "default", ({ marks = [], total = 1, label } = {}) => {
define("viewer", "media", ({ src, kind = "image", empty = "Nothing yet", onError } = {}) => {
const stage = el("div", { cls: "cx-viewer-stage" });
let current = null;
let currentKind = kind;
let currentFile = null;
let mediaEl = null;
function show(next, nextKind = kind) {
function show(next, nextKind = kind, file = null) {
current = next || null;
currentKind = nextKind;
currentFile = current ? file : null;
mediaEl = null;
stage.replaceChildren();
if (!current) {
stage.append(el("p", { cls: "cx-list-empty", text: empty }));
@@ -315,10 +321,16 @@ define("viewer", "media", ({ src, kind = "image", empty = "Nothing yet", onError
const media = nextKind === "video"
? el("video", { cls: "cx-viewer-media", attrs: { src: current, controls: "", loop: "", playsinline: "" } })
: el("img", { cls: "cx-viewer-media", attrs: { src: current, alt: "" } });
mediaEl = media;
// A failed load is reported rather than left as a broken icon: the file is
// produced by a run, so it not arriving is a fault worth naming.
media.addEventListener("error", () => {
stage.replaceChildren(el("p", { cls: "cx-list-empty", text: "This result could not be loaded." }));
mediaEl = null;
// Nothing is actually showing any more -- `file` saying otherwise is
// what let "Save to bin" silently bin a dead reference from this exact
// state, the one place the panel itself says there is nothing here.
currentFile = null;
if (onError) onError(current);
});
stage.append(media);
@@ -329,8 +341,26 @@ define("viewer", "media", ({ src, kind = "image", empty = "Nothing yet", onError
return {
node,
get value() { return current; },
get kind() { return currentKind; },
// The file this came from -- {filename, subfolder, type} -- when the
// caller had one to give. Null for anything shown by URL alone, which is
// not enough to name a file back to the server with.
get file() { return currentFile; },
setValue: (next) => show(next, kind),
setSource: (next, nextKind) => show(next, nextKind || kind),
setSource: (next, nextKind, file) => show(next, nextKind || kind, file),
/**
* The video frame on screen right now, as a PNG blob -- null if nothing is
* playing or there is no frame yet to draw (a video that has not loaded
* its first frame has no dimensions to capture at).
*/
captureFrame() {
if (currentKind !== "video" || !mediaEl || !mediaEl.videoWidth) return Promise.resolve(null);
const canvas = document.createElement("canvas");
canvas.width = mediaEl.videoWidth;
canvas.height = mediaEl.videoHeight;
canvas.getContext("2d").drawImage(mediaEl, 0, 0);
return new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
},
destroy: () => node.remove(),
};
});
+7 -2
View File
@@ -132,8 +132,13 @@ export function createBin({ onOpen, view = recallView(), persist = true,
* 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 = []) {
function absorb(files = [], { open: shouldOpen = true } = {}) {
const added = [];
for (const file of files || []) {
const id = keyOf(file);
@@ -151,7 +156,7 @@ export function createBin({ onOpen, view = recallView(), persist = true,
// 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.
open(added[added.length - 1].id);
if (shouldOpen) open(added[added.length - 1].id);
return added;
}
+74 -3
View File
@@ -38,15 +38,81 @@ export function build(root, handlers = {}) {
// 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 bin = createBin({
onOpen: (item) => viewer.setSource(item.url, item.kind, item.file),
});
const assets = composer.panel.zone({
title: "Assets",
actions: [bin.control],
body: bin.host,
});
// ComfyUI's own route, not FunPack's -- ordinary image upload already exists
// and there is nothing about a captured frame that needs its own.
async function uploadFrame(blob) {
const form = new FormData();
form.append("image", blob, `frame-${Date.now()}.png`);
form.append("type", "input");
const res = await fetch("/upload/image", { method: "POST", body: form });
if (!res.ok) throw new Error(`the upload was refused (${res.status})`);
const body = await res.json();
return { filename: body.name, subfolder: body.subfolder || "", type: body.type || "input" };
}
// `transport.say` is the Timeline's own line, overwritten by the next
// progress message a run in flight sends -- often inside a second, which
// silently erased a save's own confirmation or error whenever anything was
// generating. This one is the Preview zone's, and nothing else writes to it.
const previewStatus = composer.text.sm({ text: "" });
let previewStatusTimer = null;
function sayPreview(text, ms = 3000) {
previewStatus.setText(text);
if (previewStatusTimer) clearTimeout(previewStatusTimer);
previewStatusTimer = setTimeout(() => previewStatus.setText(""), ms);
// Not a reason to keep a process alive: under a test runner, a page built
// and abandoned mid-test (a new one built for the next test, this one's
// DOM never explicitly torn down) left a timer that fired minutes later,
// reaching for a `document` the test harness had already removed.
if (previewStatusTimer && typeof previewStatusTimer.unref === "function") previewStatusTimer.unref();
}
// What's shown here came from somewhere already server-side (a run's own
// result, or an already-open bin/temp item) OR is a video the viewer can
// still draw a frame from -- either way, "keep this" should not mean a
// download and a re-upload by hand.
const saveToBin = composer.iconButton.sm({
icon: "➕", label: "Save to bin",
onClick: () => {
if (!viewer.file) { sayPreview("Nothing here has a file to save."); return; }
// Already showing, so nothing needs to move -- but a save is a save,
// never a navigation, even when it happens to name what's on screen.
const added = bin.absorb([viewer.file], { open: false });
sayPreview(added.length ? "Saved to the bin." : "Already in the bin.");
},
});
const saveFrame = composer.iconButton.sm({
icon: "⛶", label: "Save this frame",
onClick: async () => {
const blob = await viewer.captureFrame();
if (!blob) { sayPreview("Play a video first -- there is no frame to save."); return; }
try {
// Not `open: true`: the upload is a real round trip, and by the time
// it lands the viewer may be showing something else entirely -- a
// save must not yank it back to what it saved.
const added = bin.absorb([await uploadFrame(blob)], { open: false });
sayPreview(added.length ? "Frame saved to the bin." : "Already in the bin.");
} catch (err) {
sayPreview(`Could not save the frame: ${err.message}`);
}
},
});
// 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 });
// looked at is not something you act on -- except now it is, the two
// actions above, which only ever touch what is ALREADY on screen.
const preview = composer.panel.zone({
title: "Preview", body: viewer, flush: true,
actions: [saveToBin, saveFrame], status: [previewStatus],
});
// The timeline: what the project IS, and where a run is started from.
//
@@ -101,6 +167,11 @@ export function build(root, handlers = {}) {
title: "Timeline",
actions: [
...transport.actions,
// A ready-made widget, not a callback: the loop it runs needs the run,
// the session and the project all at once, and none of those exist yet
// at the point build() is called -- boot.js is the one place all three
// do, so boot.js builds the button too.
...(handlers.generateAll ? [handlers.generateAll] : []),
regionToggle("Assets", () => Boolean(ws && ws.isOpen("left")),
() => ws && ws.toggle("left"), { overlaid: true }),
regionToggle("Properties", () => Boolean(ws && ws.isOpen("right")),
+5
View File
@@ -76,6 +76,11 @@ export function createMenubar({ workspace, onPipeline, onProject, projects = ()
const file = menu("File", () => [
{ id: "new", label: "New project…" },
{ separator: true },
// Moving a project between machines -- a git checkout does not carry
// ~/Documents/ComfyUI/user/default/funpack_projects with it.
{ id: "save-file", label: "Save Project File…", hint: "⬇" },
{ id: "load-file", label: "Load Project File…" },
...(projects().length ? [{ separator: true }] : []),
// A tick rather than leaving the current one out: which project is open is
// a fact about the app, and a menu that hides it makes you press one to find out.
+88 -8
View File
@@ -76,6 +76,12 @@ export function createProject({ onChange, onError, onOpen } = {}) {
let timer = null;
let saving = null; // the PUT in flight, so a queued one waits for it
let dirty = false;
// Bumped by every open/newProject/importProject, BEFORE their own await --
// so a switch that starts and then loses a race (two imports, or an import
// overlapping an open()) can tell, once its fetch finally answers, that a
// NEWER switch already won and abandon its own result instead of clobbering
// whatever the user is actually looking at now.
let generation = 0;
const changed = () => { if (onChange) onChange(); };
@@ -94,6 +100,12 @@ export function createProject({ onChange, onError, onOpen } = {}) {
if (!project || !from.length) return false;
to.push(copy());
const was = from.pop();
// Same reason open/newProject/importProject/start bump it: an undo/redo
// is also "a different project is open now" (the comment on opened() two
// lines below already says so), and an in-flight switch that started
// before this must not still be allowed to land on top of it once its
// own fetch catches up.
generation += 1;
project = was.project;
selected = was.selected;
// The scene that was selected may be gone -- stepping FORWARD into a state
@@ -113,24 +125,39 @@ export function createProject({ onChange, onError, onOpen } = {}) {
const forget = () => { past = []; future = []; };
function scheduleSave() {
// The object THIS edit actually touched, captured now -- not read back
// later from `project`, which can point somewhere else entirely by the
// time this timer fires (a project switch started and finished in
// between). Without this, an edit made in the gap between a switch
// starting and completing was silently lost: mutated on the right object,
// then saved -- once the timer finally fired -- against whatever project
// had since replaced it.
//
// ponytail: `dirty`/`timer`/`saving` stay single, shared flags rather than
// one per target. A second edit landing on a DIFFERENT project before this
// one's timer fires still cancels it (real, narrower race: an edit during
// a switch's gap, then another edit to the new project, both inside one
// debounce window). Upgrade to a per-target queue if that ever shows up
// outside a deliberately adversarial test.
const target = project;
dirty = true;
if (timer) clearTimeout(timer);
timer = setTimeout(flush, SAVE_AFTER);
timer = setTimeout(() => flush(target), SAVE_AFTER);
}
async function flush() {
async function flush(target = project) {
if (timer) { clearTimeout(timer); timer = null; }
if (!project || !dirty) return;
if (!target || !dirty) return;
// One PUT at a time. Two overlapping writes of a whole project can land in
// either order, and the loser is a version of the project the user has
// already moved past.
if (saving) { await saving.catch(() => {}); if (!dirty) return; }
dirty = false;
const body = { ...project };
const body = { ...target };
saving = put(body).then((saved) => {
// Only the fields the server owns are taken back: replacing the whole
// project would overwrite whatever was typed while the PUT was in flight.
if (project && saved && project.id === saved.id) project.updated_at = saved.updated_at;
if (target && saved && target.id === saved.id) target.updated_at = saved.updated_at;
}).catch((err) => {
dirty = true; // it is still unsaved, so say so
if (onError) onError(err);
@@ -150,9 +177,19 @@ export function createProject({ onChange, onError, onOpen } = {}) {
get recent() { return recent; },
async start() {
// Guarded like open/newProject/importProject, and for the same reason:
// the page's own handlers (the File menu, the hidden file input) are
// wired up by build() well before this is awaited, and both of start()'s
// own network calls are real gaps a user's own click can finish inside
// of. Losing this race used to mean start() caught up later and
// silently reverted a switch the user had already made, with no error.
const mine = ++generation;
const found = await list();
if (mine !== generation) return project;
recent = found;
project = found.length ? await read(found[0].id) : await create("Untitled");
const opened_ = found.length ? await read(found[0].id) : await create("Untitled");
if (mine !== generation) return project;
project = opened_;
if (!found.length) recent = [{ id: project.id, name: project.name }];
selected = (project.scenes || [])[0]?.id ?? null;
forget(); // a project just opened has no past
@@ -161,8 +198,14 @@ export function createProject({ onChange, onError, onOpen } = {}) {
},
async open(id) {
const mine = ++generation;
await flush();
project = await read(id);
const found = await read(id);
// A newer switch already won this race (another open/new/import started
// after this one and has already landed) -- applying this one now would
// silently pull the user back to a project they already left.
if (mine !== generation) return project;
project = found;
selected = (project.scenes || [])[0]?.id ?? null;
forget(); // a project just opened has no past
opened();
@@ -170,8 +213,18 @@ export function createProject({ onChange, onError, onOpen } = {}) {
},
async newProject(name) {
const mine = ++generation;
await flush();
project = await create(name);
const made = await create(name);
if (mine !== generation) {
// Superseded by a newer switch -- this one already created a real
// project on the server that nothing will ever show. Best-effort:
// a failed cleanup leaves an orphan, which is the pre-existing
// (worse) behaviour, not a new one.
remove(made.id).catch(() => {});
return project;
}
project = made;
recent = [{ id: project.id, name: project.name }, ...recent];
selected = (project.scenes || [])[0]?.id ?? null;
forget(); // a project just opened has no past
@@ -179,6 +232,33 @@ export function createProject({ onChange, onError, onOpen } = {}) {
return project;
},
/** Where "Save Project File" downloads FROM, for the current project. */
downloadUrl() { return project ? `${BASE}/${encodeURIComponent(project.id)}/download` : null; },
/**
* A project file from disk, as its own new project -- moving a project
* between machines, not restoring one in place. The file's own id is
* never kept (the server strips it too, but a project already open in
* THIS browser with that id must not be what a stale double-check relies
* on): imported always means "another one", even from the very checkout
* it came from.
*/
async importProject(data) {
const mine = ++generation;
await flush();
const imported = await json("POST", `${BASE}/import`, data);
if (mine !== generation) {
remove(imported.id).catch(() => {}); // superseded -- see newProject()
return project;
}
project = imported;
recent = [{ id: project.id, name: project.name }, ...recent];
selected = (project.scenes || [])[0]?.id ?? null;
forget();
opened();
return project;
},
select(id) {
if (!sceneAt(id) || id === selected) return;
selected = id;
+35
View File
@@ -8,6 +8,41 @@
// live during a round trip, and a socket listening before anyone knew which run
// was ours.
import { DONE, FAILED, CANCELLED } from "./run.js";
const TERMINAL = new Set([DONE, FAILED, CANCELLED]);
/**
* Resolves with the phase a run ends at -- done, failed, or cancelled.
*
* Subscribe FIRST, before whatever starts the run: subscribing after risks
* missing the very transition being waited for. subscribe() delivers the
* CURRENT state immediately, which is not a new transition and is not what
* this is waiting for -- if a page reload adopts a run that already finished,
* that first delivery would resolve this before the caller's own "start"
* step has even run. Only a state delivered AFTER this call counts.
*
* The returned promise carries its own `.cancel()`: subscribing before the
* caller knows whether a run will actually start is the whole point (see
* above), but a start attempt that gets refused before it ever reaches
* run.start() -- an incomplete pipeline, a queue that says no -- never
* transitions the run at all, so nothing would ever resolve this and the
* subscription would sit on `run` forever. Call `.cancel()` when the attempt
* this was waiting on turns out never to have started.
*/
export function waitForTerminal(run) {
let unsubscribe = () => {};
const promise = new Promise((resolve) => {
let first = true;
unsubscribe = run.subscribe((state) => {
if (first) { first = false; return; }
if (TERMINAL.has(state.phase)) { unsubscribe(); resolve(state.phase); }
});
});
promise.cancel = () => unsubscribe();
return promise;
}
/**
* onGenerate: ask what to queue, then queue it, once at a time.
*
+24
View File
@@ -99,6 +99,30 @@ test("the newest of a batch is the one shown", () => {
assert.deepEqual(b.items.map((i) => i.label), ["three.png", "two.png", "one.png"]);
});
test("open: false adds a file to the bin without moving what the viewer shows", () => {
// A save action's own round trip (an upload) can finish well after the user
// has moved on to looking at something else -- absorb()'s normal auto-open
// exists for a RUN's own result appearing, not for that. `open: false` is
// what a save uses instead.
const b = bin();
b.absorb([file("already-open.png")]); // the normal path: this IS opened
assert.equal(b.opened.length, 1);
b.absorb([file("saved-later.png")], { open: false });
assert.equal(b.items.length, 2, "the file was not added to the bin at all");
assert.equal(b.opened.length, 1, "opened a second time despite open: false");
});
test("open: false still dedupes -- a file already in the bin is not added twice", () => {
const b = bin();
b.absorb([file("a.png")]);
const added = b.absorb([file("a.png")], { open: false });
assert.equal(added.length, 0);
assert.equal(b.items.length, 1);
});
test("a video is known as one, and never becomes a <video> in the bin", () => {
// v4 put live <video> elements in the bin and Chrome's six-per-origin
// connection pool wedged the whole API behind them: the app stopped answering
+120
View File
@@ -56,6 +56,126 @@ test("picking an older result in the bin changes what the viewer shows", () => {
assert.match(p.viewer.node.querySelector("img").getAttribute("src"), /first\.png/);
});
test("what a run produced can be saved to the bin from wherever it is shown", () => {
// Opened from the temp browser, or anywhere else that hands the viewer a
// real file identity -- "keep this" should not mean a download and a
// re-upload by hand.
const p = page();
p.viewer.setSource("/view?filename=stray.png&subfolder=&type=temp", "image",
{ filename: "stray.png", subfolder: "", type: "temp" });
const saveToBin = p.preview.node.querySelector('button[aria-label="Save to bin"]');
assert.ok(saveToBin, "no Save to bin action in the Preview head");
saveToBin.click();
assert.equal(p.bin.items.length, 1);
assert.equal(p.bin.items[0].file.filename, "stray.png");
});
test("saving something already in the bin says so instead of duplicating it", () => {
const p = page();
p.bin.absorb([file("a.png")]); // also becomes what the viewer shows
const saveToBin = p.preview.node.querySelector('button[aria-label="Save to bin"]');
saveToBin.click();
assert.equal(p.bin.items.length, 1, "the same file was saved twice");
});
test("saving with nothing shown does not throw, and says why", () => {
const p = page();
const saveToBin = p.preview.node.querySelector('button[aria-label="Save to bin"]');
assert.doesNotThrow(() => saveToBin.click());
assert.equal(p.bin.items.length, 0);
});
test("saving a frame with no video playing says so rather than uploading nothing", async () => {
// jsdom has no real video decoder, so a genuine capture can only be proven
// in a real browser (see the Playwright spec) -- this is the guard that
// keeps a silent no-op from reaching the upload at all.
const p = page();
const saveFrame = p.preview.node.querySelector('button[aria-label="Save this frame"]');
assert.ok(saveFrame, "no Save frame action in the Preview head");
let asked = false;
globalThis.fetch = async () => { asked = true; return { ok: true, json: async () => ({}) }; };
saveFrame.click();
await new Promise((r) => setTimeout(r, 0)); // let the click's own async handler settle
assert.equal(asked, false, "uploaded with no frame to upload");
assert.equal(p.bin.items.length, 0);
});
test("a frame that dedupes to something already in the bin says so, not that it saved", async () => {
// saveToBin already checked bin.absorb()'s own dedup result; saveFrame did
// not, so a second capture that happened to upload to the same identity
// (two rapid clicks, a server that overwrites rather than renames on
// collision) claimed success while adding nothing.
const p = page();
p.viewer.captureFrame = async () => new Blob(["x"], { type: "image/png" });
globalThis.fetch = async () => ({
ok: true, json: async () => ({ name: "captured.png", subfolder: "", type: "input" }),
});
const saveFrame = p.preview.node.querySelector('button[aria-label="Save this frame"]');
saveFrame.click();
await new Promise((r) => setTimeout(r, 0));
assert.equal(p.bin.items.length, 1);
assert.match(p.preview.node.querySelector(".cx-panel-status").textContent, /Frame saved/);
saveFrame.click(); // resolves to the exact same identity again
await new Promise((r) => setTimeout(r, 0));
assert.equal(p.bin.items.length, 1, "the same capture was added twice");
assert.match(p.preview.node.querySelector(".cx-panel-status").textContent, /Already in the bin/);
});
test("a frame upload that resolves after the user moved on does not yank the viewer back", async () => {
// The upload is a real round trip -- by the time it lands the user may
// already be looking at something else. A save must add to the bin without
// moving what is on screen, whenever it finishes.
const p = page();
p.bin.absorb([file("first.png")]); // the viewer shows this
// jsdom cannot decode video; stand in for a real captured frame (the real
// capture itself is proven in the Playwright spec, against a real video).
p.viewer.captureFrame = async () => new Blob(["x"], { type: "image/png" });
let resolveUpload;
globalThis.fetch = () => new Promise((resolve) => { resolveUpload = resolve; });
p.preview.node.querySelector('button[aria-label="Save this frame"]').click();
// Let captureFrame()'s own await, then the call into fetch(), actually
// happen -- the upload is now in flight, waiting on resolveUpload.
await Promise.resolve(); await Promise.resolve();
assert.equal(typeof resolveUpload, "function", "the upload was never started");
// The user moves on before the upload resolves.
p.bin.absorb([file("second.png")]);
assert.match(p.viewer.node.querySelector("img").getAttribute("src"), /second\.png/);
resolveUpload({ ok: true, json: async () => ({ name: "captured.png", subfolder: "", type: "input" }) });
await Promise.resolve(); await Promise.resolve(); await Promise.resolve();
assert.equal(p.bin.items.length, 3, "the captured frame was never saved");
assert.match(p.viewer.node.querySelector("img").getAttribute("src"), /second\.png/,
"a save that finished late pulled the viewer back to what it saved");
});
test("a result that failed to load is not something Save to bin can still save", () => {
// The viewer's own error state says "nothing is showing here" -- `file`
// disagreeing with that let a dead reference be saved as if it were real.
const p = page();
p.bin.absorb([file("gone.png")]);
p.viewer.node.querySelector("img").dispatchEvent(new window.Event("error"));
assert.match(p.viewer.node.textContent, /could not be loaded/);
assert.equal(p.viewer.file, null, "the viewer still claims a file is showing after it failed to load");
const saveToBin = p.preview.node.querySelector('button[aria-label="Save to bin"]');
saveToBin.click();
assert.equal(p.bin.items.length, 1, "the failed file was saved again, over the guard");
});
test("a control sits in the head of the zone it acts on", () => {
// The arrangement that makes this an editor rather than a dashboard: the bin's
// view control is in the Assets head, and Generate is in the head of the zone
+237
View File
@@ -153,6 +153,243 @@ test("a result for a project that is open again goes through the normal live pat
await p.setResultFor(id, sceneId, "/view?filename=b.png");
assert.equal(p.scenes[0].result, "/view?filename=b.png");
await p.flush(); // setResultFor's same-project branch schedules a save like
// any other edit -- left pending, its real debounce timer
// outlives this test and can fire mid a LATER one.
});
test("downloadUrl names the current project, and nothing when there is none", async () => {
const p = createProject({});
assert.equal(p.downloadUrl(), null, "a download link before anything is open");
server(); // default project id abcdef012345
await p.start();
assert.equal(p.downloadUrl(), "/funpack/api/projects/abcdef012345/download");
});
test("importing a project file opens whatever the server hands back, as its own project", async () => {
// The server strips the id (a file imported twice, or on the machine it
// came from, must not overwrite anything already here) -- this only has to
// send what the file held and open what comes back.
const posted = [];
globalThis.fetch = async (path, opts = {}) => {
if (String(path).endsWith("/projects/import")) {
posted.push(JSON.parse(opts.body));
return { ok: true, status: 200, json: async () => ({
id: "newid000001", name: "Imported", scenes: [{ id: "s1", text: "", result: null }],
video: {}, updated_at: 5,
}) };
}
return { ok: true, status: 200, json: async () => ({ projects: [] }) };
};
const p = createProject({});
const result = await p.importProject({ id: "someoldid0001", name: "Imported", scenes: [] });
assert.equal(p.project.id, "newid000001");
assert.equal(p.project.name, "Imported");
assert.equal(p.selectedId, "s1", "an imported project with scenes has nothing selected");
assert.deepEqual(p.recent.map((r) => r.id), ["newid000001"]);
assert.equal(posted[0].id, "someoldid0001", "the client's job is to send the file, not clean it");
assert.equal(result.id, "newid000001");
assert.equal(p.canUndo, false, "an import just opened has no past to step into");
});
test("a project open when an import happens is not what flush() saves afterward", async () => {
// flush() is called before the import switches `project` -- if it ran
// AFTER, whatever debounced edit was pending on the OLD project would be
// saved against the NEW one's id instead.
const { sent } = server();
const p = createProject({});
await p.start();
p.rename("About to leave"); // scheduled, not yet sent
globalThis.fetch = async (path, opts = {}) => {
if (String(path).endsWith("/projects/import")) {
return { ok: true, status: 200, json: async () => ({
id: "newid000002", name: "Imported", scenes: [], video: {}, updated_at: 5 }) };
}
const body = opts.body ? JSON.parse(opts.body) : null;
if (opts.method === "PUT") sent.push(body);
return { ok: true, status: 200, json: async () => (body ? { ...body, updated_at: 2 } : {}) };
};
await p.importProject({ name: "Imported", scenes: [] });
assert.equal(sent.length, 1, "the pending rename was never saved, or was saved against the wrong project");
assert.equal(sent[0].id, "abcdef012345", "flushed against the OLD project, which is what flush() is for");
assert.equal(p.project.id, "newid000002");
});
test("an edit made WHILE an import is in flight is still saved, against the old project", async () => {
// The gap this closes: importProject()'s OWN initial flush() finds nothing
// dirty (the edit hasn't happened yet), then the edit lands DURING the
// import's network round trip, on the project still open at that moment.
// scheduleSave() has to remember which object that was -- the debounce
// timer fires only after the import has already switched `project` out
// from under it.
const { sent } = server();
const p = createProject({});
await p.start(); // project abcdef012345
let resolveImport;
globalThis.fetch = async (path, opts = {}) => {
if (String(path).endsWith("/projects/import")) {
return new Promise((resolve) => {
resolveImport = () => resolve({ ok: true, status: 200, json: async () => (
{ id: "newid000003", name: "Imported", scenes: [], video: {}, updated_at: 5 }) });
});
}
const body = opts.body ? JSON.parse(opts.body) : null;
if (opts.method === "PUT") sent.push(body);
return { ok: true, status: 200, json: async () => (body ? { ...body, updated_at: 2 } : {}) };
};
const importing = p.importProject({ name: "Imported", scenes: [] });
await Promise.resolve(); await Promise.resolve(); await Promise.resolve();
assert.equal(p.project.id, "abcdef012345", "the import switched before the edit -- this test proves nothing");
p.rename("Typed while importing"); // scheduled against the still-current OLD project
resolveImport();
await importing;
assert.equal(p.project.id, "newid000003", "the import did not complete");
await new Promise((r) => setTimeout(r, 700)); // past the real debounce window
assert.equal(sent.length, 1, "the edit made during the import never reached the server");
assert.equal(sent[0].id, "abcdef012345", "saved against the OLD project, not the one now open");
assert.equal(sent[0].name, "Typed while importing");
});
test("two imports racing land on whichever was clicked last, not whichever answered last", async () => {
const p = createProject({});
await p.start();
const pending = {};
globalThis.fetch = async (path, opts = {}) => {
if (String(path).endsWith("/projects/import")) {
const body = JSON.parse(opts.body);
return new Promise((resolve) => {
pending[body.name] = () => resolve({ ok: true, status: 200, json: async () => (
{ id: `id-${body.name}`, name: body.name, scenes: [], video: {}, updated_at: 5 }) });
});
}
return { ok: true, status: 200, json: async () => ({ projects: [] }) };
};
const first = p.importProject({ name: "A", scenes: [] }); // clicked first
await Promise.resolve();
const second = p.importProject({ name: "B", scenes: [] }); // clicked second, while A is still in flight
await Promise.resolve();
// A answers LAST, after B -- a real, ordinary network race, not a
// contrived one.
pending.B();
await second;
assert.equal(p.project.name, "B");
pending.A();
await first;
assert.equal(p.project.name, "B", "the earlier, superseded import overwrote the one clicked last");
assert.equal(p.project.id, "id-B");
});
test("a superseded import deletes its own orphaned project instead of leaving it behind", async () => {
const deleted = [];
const pending = {};
globalThis.fetch = async (path, opts = {}) => {
if (String(path).endsWith("/projects/import")) {
const body = JSON.parse(opts.body);
return new Promise((resolve) => {
pending[body.name] = () => resolve({ ok: true, status: 200, json: async () => (
{ id: `id-${body.name}`, name: body.name, scenes: [], video: {}, updated_at: 5 }) });
});
}
if (opts.method === "DELETE") { deleted.push(path); return { ok: true, status: 200, json: async () => ({}) }; }
return { ok: true, status: 200, json: async () => ({ projects: [] }) };
};
const p = createProject({});
await p.start();
const first = p.importProject({ name: "A", scenes: [] });
await Promise.resolve();
const second = p.importProject({ name: "B", scenes: [] });
await Promise.resolve();
pending.B(); await second;
pending.A(); await first;
assert.deepEqual(deleted, ["/funpack/api/projects/id-A"], "the superseded project was never cleaned up");
});
test("start() losing a race to a switch the user already made does not revert it", async () => {
// build() wires the File menu (and so onProject -> newProject/open/import)
// before start() is ever awaited -- start()'s own network calls are real
// gaps a user's click can finish inside of.
let resolveList;
globalThis.fetch = async (path, opts = {}) => {
if (path === "/funpack/api/projects" && opts.method === "GET") {
return new Promise((resolve) => { resolveList = () => resolve(
{ ok: true, status: 200, json: async () => ({ projects: [] }) }); });
}
if (opts.method === "POST" && path.endsWith("/projects")) {
const body = JSON.parse(opts.body);
return { ok: true, status: 200, json: async () => (
{ id: "usercreated0", name: body.name, scenes: [{ id: "s1" }], video: {}, updated_at: 2 }) };
}
return { ok: true, status: 200, json: async () => ({}) };
};
const p = createProject({});
const starting = p.start(); // in flight -- list() has not answered yet
await Promise.resolve();
await p.newProject("MyNewThing"); // the user's own action completes first
assert.equal(p.project.name, "MyNewThing");
resolveList(); // start()'s list() finally answers
await starting;
assert.equal(p.project.name, "MyNewThing", "start() caught up and silently reverted the switch");
});
test("an undo during an in-flight open supersedes it too, not just another switch", async () => {
const store = {
a: { id: "aaaaaaaaaaaa", name: "A", scenes: [{ id: "s1", text: "", result: null }], updated_at: 1 },
b: { id: "bbbbbbbbbbbb", name: "B", scenes: [{ id: "s1", text: "", result: null }], updated_at: 1 },
};
let resolveOpenB;
globalThis.fetch = async (path, opts = {}) => {
if (path === "/funpack/api/projects" && opts.method === "GET") {
return { ok: true, status: 200, json: async () => ({ projects: [{ id: store.a.id }] }) };
}
if (String(path).endsWith(store.b.id) && opts.method === "GET") {
return new Promise((resolve) => { resolveOpenB = () => resolve(
{ ok: true, status: 200, json: async () => store.b }); });
}
if (opts.method === "PUT") return { ok: true, status: 200, json: async () => JSON.parse(opts.body) };
return { ok: true, status: 200, json: async () => store.a };
};
const p = createProject({});
await p.start(); // project A
p.setText(p.selectedId, "edited"); // undo history now has the original
const opening = p.open(store.b.id); // in flight
await new Promise((r) => setTimeout(r, 0)); // past open()'s own flush() first
p.undo(); // restores A's original text, DURING the open
assert.equal(p.project.id, store.a.id);
assert.equal(p.scenes[0].text, "");
resolveOpenB();
await opening;
// The open lost the race it was already in when the undo happened -- it
// must not land on top of the undo a moment later.
assert.equal(p.project.id, store.a.id, "the in-flight open overwrote the undo");
});
test("renaming a project moves the name in the File menu too", async () => {
+51 -2
View File
@@ -9,11 +9,11 @@ import assert from "node:assert/strict";
import { setupDom, teardownDom } from "../../composer/tests/_dom.js";
let createGenerator, reattach, wire, createTransport;
let createGenerator, reattach, wire, waitForTerminal, createTransport;
test.before(async () => {
setupDom();
await import("../../composer/composer.js");
({ createGenerator, reattach, wire } = await import("../session.js"));
({ createGenerator, reattach, wire, waitForTerminal } = await import("../session.js"));
({ createTransport } = await import("../transport.js"));
});
test.after(() => teardownDom());
@@ -515,3 +515,52 @@ test("what is typed on the main window is sent with the run", async () => {
assert.deepEqual(asked[0].inputs, { positive: { text: "a cat on a roof" } });
});
// --- waitForTerminal ---------------------------------------------------------
test("waitForTerminal resolves with the phase a run actually ends at", async () => {
const run = fakeRun();
const waiting = waitForTerminal(run);
run.state = { ...run.state, phase: "running" };
run.announce();
run.state = { ...run.state, phase: "done" };
run.announce();
assert.equal(await waiting, "done");
});
test("the snapshot delivered at subscribe time is not mistaken for a transition", async () => {
// A page reload adopting a run that already finished delivers ITS finished
// state the moment subscribe() is called -- if that counted, this would
// resolve before the caller's own "start" step had even run.
const run = fakeRun("done");
let resolved = false;
waitForTerminal(run).then(() => { resolved = true; });
await Promise.resolve(); await Promise.resolve();
assert.equal(resolved, false, "resolved from the snapshot, not a real transition");
run.announce(); // the same "done" state, delivered again -- now a real one
await Promise.resolve(); await Promise.resolve();
assert.equal(resolved, true);
});
test("cancel() unsubscribes a waiter whose run never actually started", () => {
// A generation refused before it ever reaches run.start() (an incomplete
// pipeline, a queue that says no) never transitions the run -- nothing
// would ever resolve this promise, and the subscription would sit on the
// run forever without a way out.
const run = fakeRun();
assert.equal(run.subscribers.size, 0);
const waiting = waitForTerminal(run);
assert.equal(run.subscribers.size, 1);
waiting.cancel();
assert.equal(run.subscribers.size, 0, "cancel() did not unsubscribe");
});
test("cancel() after the promise already resolved is harmless", async () => {
const run = fakeRun();
const waiting = waitForTerminal(run);
run.state = { ...run.state, phase: "done" };
run.announce();
assert.equal(await waiting, "done");
assert.doesNotThrow(() => waiting.cancel());
});
+6 -2
View File
@@ -102,9 +102,13 @@ test("a run is started from the head of the zone it fills, and reported there",
await expect(page.locator(".cx-action-bar")).toHaveCount(0);
const head = page.locator(".cx-panel", { hasText: "Timeline" }).first().locator(".cx-panel-head");
await expect(head.getByRole("button", { name: "Generate" })).toBeVisible();
// Exact: "Generate All" also matches an inexact "Generate" search now.
await expect(head.getByRole("button", { name: "Generate", exact: true })).toBeVisible();
const [bar] = await boxes(page, ".cx-panel-status");
// Scoped to the zone with the Generate button: the Preview zone has its own
// .cx-panel-status now too (a save confirmation), and an unscoped selector
// stopped naming a unique element the day that arrived.
const [bar] = await boxes(page, ".cx-panel:has(.cx-btn-primary) .cx-panel-status");
const [generate] = await boxes(page, ".cx-panel-head .cx-btn-primary");
expect(bar.x, "what the run is doing is not at the far end of its own head")
.toBeGreaterThan(generate.r);
+65
View File
@@ -0,0 +1,65 @@
// "Generate All": every scene in order, waiting for each before starting the
// next. The dev server has no /prompt, so every attempt is refused before it
// ever reaches the queue -- which is exactly the path this proves does not
// hang or leak: the loop must still walk every scene and finish cleanly.
import { test, expect } from "@playwright/test";
test.describe.configure({ mode: "serial" });
async function app(page) {
await page.goto("/funpack/");
await page.waitForFunction(() => window.FunPack !== undefined);
}
test("Generate All walks every scene and lands the selection on one of them", async ({ page }) => {
await app(page);
const ids = await page.evaluate(() => {
const { project } = window.FunPack;
while (project.scenes.length < 3) project.addScene();
return project.scenes.map((s) => s.id);
});
await page.getByRole("button", { name: "Generate All" }).click();
// The batch actually finishes -- the button comes back, rather than
// staying disabled on a hung loop (every scene here is refused before it
// reaches the queue, since the dev server has no /prompt).
await expect.poll(() => page.getByRole("button", { name: "Generate All" }).isEnabled(),
{ timeout: 15000 }).toBe(true);
const finalSelected = await page.evaluate(() => window.FunPack.project.selectedId);
expect(ids).toContain(finalSelected);
});
test("Generate All says how many scenes did not generate, and re-enables both buttons", async ({ page }) => {
await app(page);
await page.evaluate(() => {
const { project } = window.FunPack;
while (project.scenes.length < 2) project.addScene();
});
await page.getByRole("button", { name: "Generate All" }).click();
await expect.poll(() =>
page.locator(".cx-panel-status", { hasText: /did not generate/i }).count(), { timeout: 15000 },
).toBeGreaterThan(0);
await expect(page.getByRole("button", { name: "Generate All" })).toBeEnabled();
await expect(page.getByRole("button", { name: "Generate", exact: true })).toBeEnabled();
});
test("a second click while a batch is already running does not start an overlapping one", async ({ page }) => {
await app(page);
await page.evaluate(() => {
const { project } = window.FunPack;
while (project.scenes.length < 3) project.addScene();
});
const btn = page.getByRole("button", { name: "Generate All" });
await btn.click();
await expect(btn).toBeDisabled();
await btn.click({ force: true }); // a disabled button ignores this; proves it, not a real click path
await expect.poll(() => btn.isEnabled(), { timeout: 15000 }).toBe(true);
});
+62
View File
@@ -0,0 +1,62 @@
// Saving a video frame, where it needs a real <video> element and a real
// canvas -- jsdom has neither. The dev server has no ComfyUI behind it (no
// /upload/image, same as no /view elsewhere), so the upload itself is
// intercepted; what is under test is that a real browser can actually draw
// the frame on screen into a real PNG blob and post it.
import { test, expect } from "@playwright/test";
// A two-frame, 32x32 red mp4 -- ffmpeg's own smallest sane output, small
// enough to inline. Real enough for Chromium to decode and report a real
// videoWidth/videoHeight, which is the one thing that matters here.
const TINY_MP4 = "data:video/mp4;base64,AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAMybW9vdgAAAGxtdmhkAAAAAAAAAAAAAAAAAAAD6AAAA+gAAQAAAQAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAlx0cmFrAAAAXHRraGQAAAADAAAAAAAAAAAAAAABAAAAAAAAA+gAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAACAAAAAgAAAAAAAkZWR0cwAAABxlbHN0AAAAAAAAAAEAAAPoAAAAAAABAAAAAAHUbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAABAAAAAQABVxAAAAAAALWhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAABf21pbmYAAAAUdm1oZAAAAAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAT9zdGJsAAAAv3N0c2QAAAAAAAAAAQAAAK9hdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAACAAIABIAAAASAAAAAAAAAABFUxhdmM2Mi4yOC4xMDEgbGlieDI2NAAAAAAAAAAAAAAAGP//AAAANWF2Y0MBZAAK/+EAGGdkAAqs2UlsBEAAAAMAQAAAAwEDxIllgAEABmjr48siwP34+AAAAAAQcGFzcAAAAAEAAAABAAAAFGJ0cnQAAAAAAAAW6AAAAAAAAAAYc3R0cwAAAAAAAAABAAAAAgAAIAAAAAAUc3RzcwAAAAAAAAABAAAAAQAAABxzdHNjAAAAAAAAAAEAAAABAAAAAgAAAAEAAAAcc3RzegAAAAAAAAAAAAAAAgAAAtAAAAANAAAAFHN0Y28AAAAAAAAAAQAAA2IAAABidWR0YQAAAFptZXRhAAAAAAAAACFoZGxyAAAAAAAAAABtZGlyYXBwbAAAAAAAAAAAAAAAAC1pbHN0AAAAJal0b28AAAAdZGF0YQAAAAEAAAAATGF2ZjYyLjEyLjEwMQAAAAhmcmVlAAAC5W1kYXQAAAKtBgX//6ncRem95tlIt5Ys2CDZI+7veDI2NCAtIGNvcmUgMTY1IHIzMjIyIGIzNTYwNWEgLSBILjI2NC9NUEVHLTQgQVZDIGNvZGVjIC0gQ29weWxlZnQgMjAwMy0yMDI1IC0gaHR0cDovL3d3dy52aWRlb2xhbi5vcmcveDI2NC5odG1sIC0gb3B0aW9uczogY2FiYWM9MSByZWY9MyBkZWJsb2NrPTE6MDowIGFuYWx5c2U9MHgzOjB4MTEzIG1lPWhleCBzdWJtZT03IHBzeT0xIHBzeV9yZD0xLjAwOjAuMDAgbWl4ZWRfcmVmPTEgbWVfcmFuZ2U9MTYgY2hyb21hX21lPTEgdHJlbGxpcz0xIDh4OGRjdD0xIGNxbT0wIGRlYWR6b25lPTIxLDExIGZhc3RfcHNraXA9MSBjaHJvbWFfcXBfb2Zmc2V0PS0yIHRocmVhZHM9MSBsb29rYWhlYWRfdGhyZWFkcz0xIHNsaWNlZF90aHJlYWRzPTAgbnI9MCBkZWNpbWF0ZT0xIGludGVybGFjZWQ9MCBibHVyYXlfY29tcGF0PTAgY29uc3RyYWluZWRfaW50cmE9MCBiZnJhbWVzPTMgYl9weXJhbWlkPTIgYl9hZGFwdD0xIGJfYmlhcz0wIGRpcmVjdD0xIHdlaWdodGI9MSBvcGVuX2dvcD0wIHdlaWdodHA9MiBrZXlpbnQ9MjUwIGtleWludF9taW49MiBzY2VuZWN1dD00MCBpbnRyYV9yZWZyZXNoPTAgcmNfbG9va2FoZWFkPTQwIHJjPWNyZiBtYnRyZWU9MSBjcmY9MjMuMCBxY29tcD0wLjYwIHFwbWluPTAgcXBtYXg9NjkgcXBzdGVwPTQgaXBfcmF0aW89MS40MCBhcT0xOjEuMDAAgAAAABtliIQAFP/+7Np+BTY91+fFNDe1lLW3K4+Aln0AAAAJQZohbEEv/rXA";
async function app(page) {
await page.goto("/funpack/");
await page.waitForFunction(() => window.FunPack !== undefined);
}
test("a real frame from a real video is captured and posted as an upload", async ({ page }) => {
let posted = null;
await page.route("**/upload/image", (route) => {
posted = route.request().postDataBuffer();
return route.fulfill({ json: { name: "captured.png", subfolder: "", type: "input" } });
});
await app(page);
await page.evaluate((src) => {
window.FunPack.viewer.setSource(src, "video", { filename: "clip.mp4", subfolder: "", type: "output" });
}, TINY_MP4);
// A real decode, not a stub: nothing here is faked once the <video> exists.
await expect.poll(() => page.evaluate(() =>
document.querySelector(".cx-viewer-media")?.videoWidth || 0)).toBeGreaterThan(0);
await page.getByRole("button", { name: "Save this frame" }).click();
await expect.poll(() => posted !== null).toBe(true);
expect(posted.length, "no image data reached the upload").toBeGreaterThan(200);
await expect.poll(() => page.evaluate(() => window.FunPack.bin.items.length)).toBe(1);
await expect.poll(() => page.evaluate(() => window.FunPack.bin.items[0].file.filename))
.toBe("captured.png");
});
test("saving a frame from a still image does nothing -- there is no frame to draw", async ({ page }) => {
// The dev server has no /view, so this 404s and the viewer shows its own
// "could not be loaded" state rather than an <img> -- irrelevant here: what
// matters is that NOTHING playable is on screen, image or otherwise.
await app(page);
await page.evaluate(() => {
window.FunPack.bin.absorb([{ filename: "a.png", subfolder: "", type: "output" }]);
});
let posted = false;
await page.route("**/upload/image", (route) => { posted = true; return route.fulfill({ json: {} }); });
await page.getByRole("button", { name: "Save this frame" }).click();
await page.waitForTimeout(200); // long enough for a wrongly-fired upload to have started
expect(posted, "uploaded something with no video on screen").toBe(false);
expect(await page.evaluate(() => window.FunPack.bin.items.length)).toBe(1); // still just the image
});
+86
View File
@@ -0,0 +1,86 @@
// Moving a project between machines: Save Project File downloads the real
// server route, Load Project File reads a real file back in. jsdom covers the
// store's own downloadUrl()/importProject() logic against a fake fetch; what
// only a browser can prove is that the menu items actually trigger a real
// download and a real file picker, against the real project routes.
import { test, expect } from "@playwright/test";
import { readFileSync } from "node:fs";
test.describe.configure({ mode: "serial" });
async function app(page) {
await page.goto("/funpack/");
await page.waitForFunction(() => window.FunPack !== undefined);
}
test("Save Project File downloads the current project as real JSON", async ({ page }) => {
await app(page);
const name = `Downloadable ${Date.now()}`;
await page.evaluate((n) => window.FunPack.project.newProject(n), name);
await page.getByRole("button", { name: "File" }).click();
const [download] = await Promise.all([
page.waitForEvent("download"),
page.getByRole("menuitem", { name: "Save Project File…" }).click(),
]);
expect(download.suggestedFilename()).toMatch(/\.funpack_project\.json$/);
const body = JSON.parse(readFileSync(await download.path(), "utf-8"));
expect(body.name).toBe(name);
expect(Array.isArray(body.scenes)).toBe(true);
});
test("Load Project File opens the file as a new project, not the one it came from", async ({ page }) => {
await app(page);
const original = await page.evaluate(async (n) => {
await window.FunPack.project.newProject(n);
window.FunPack.project.setVideo("width", 777);
await window.FunPack.project.flush();
return window.FunPack.project.project.id;
}, `Original ${Date.now()}`);
const fileContent = await page.evaluate(async () => {
const r = await fetch(window.FunPack.project.downloadUrl());
return r.text();
});
await page.getByRole("button", { name: "File" }).click();
const [chooser] = await Promise.all([
page.waitForEvent("filechooser"),
page.getByRole("menuitem", { name: "Load Project File…" }).click(),
]);
await chooser.setFiles({
name: "project.funpack_project.json",
mimeType: "application/json",
buffer: Buffer.from(fileContent),
});
await expect.poll(() => page.evaluate(() => window.FunPack.project.project.video.width)).toBe(777);
const imported = await page.evaluate(() => window.FunPack.project.project.id);
expect(imported).not.toBe(original);
// The original is untouched by the import -- a shared id would have
// overwritten it instead of landing as a project of its own.
const stillThere = await page.evaluate((id) => fetch(`/funpack/api/projects/${id}`).then((r) => r.status), original);
expect(stillThere).toBe(200);
});
test("a file that is not a project is refused, not silently opened", async ({ page }) => {
await app(page);
const before = await page.evaluate(() => window.FunPack.project.project.id);
await page.getByRole("button", { name: "File" }).click();
const [chooser] = await Promise.all([
page.waitForEvent("filechooser"),
page.getByRole("menuitem", { name: "Load Project File…" }).click(),
]);
await chooser.setFiles({
name: "not-a-project.json", mimeType: "application/json",
buffer: Buffer.from(JSON.stringify({ hello: "world" })),
});
await expect(page.locator(".cx-panel-status", { hasText: /could not load/i })).toBeVisible();
expect(await page.evaluate(() => window.FunPack.project.project.id)).toBe(before);
});
+4 -3
View File
@@ -110,11 +110,12 @@ test("a project can be made and switched to from the File menu", async ({ page }
const made = await page.evaluate(() => window.FunPack.project.project.id);
// And away again. By position, not by name: every other project in the store
// may well be called Untitled.
// may well be called Untitled. Three fixed items ahead of the project list --
// New project, Save Project File, Load Project File -- then the newest first.
await page.getByRole("button", { name: "File" }).click();
const items = page.getByRole("menuitem");
await expect(items.nth(1)).toHaveText(new RegExp(name)); // the one just made, ticked
await items.nth(2).click();
await expect(items.nth(3)).toHaveText(new RegExp(name)); // the one just made, ticked
await items.nth(4).click();
await expect.poll(() => page.evaluate(() => window.FunPack.project.project.id)).not.toBe(made);
});
+8 -2
View File
@@ -118,8 +118,14 @@ test("the transport reports a refusal beside Generate", async ({ page }) => {
// The dev server has no ComfyUI behind it, so the pipeline genuinely cannot
// run -- and that is the case worth seeing: the reason appears where the run
// is started, not in a console nobody has open.
await page.locator('.cx-panel-head button:has-text("Generate")').click();
const bar = page.locator(".cx-panel-status").first();
// The real Generate button specifically: "Generate All" also matches a
// plain text search for "Generate", and .cx-btn-primary is what actually
// distinguishes the one this test means.
await page.locator(".cx-panel-head button.cx-btn-primary").click();
// Scoped to the Timeline zone specifically: the Preview zone has its own
// .cx-panel-status now too (a save confirmation), and .first() stopped
// being safe to assume once there was more than one on the page.
const bar = page.locator(".cx-zone:has(button.cx-btn-primary) .cx-panel-status");
await expect(bar).not.toHaveText(/^Ready/);
// Whatever the reason is, it names the slot it is about. This used to read
// "there is no node called X installed", because the dev server had no node
+35
View File
@@ -584,6 +584,41 @@ def register(routes, prefix=None):
return web.json_response({"problems": ["no such project"]}, status=404)
return web.json_response({"deleted": True})
@routes.post(P + "/api/projects/import")
async def _projects_import(req):
body = await _body(req)
if body is None or "scenes" not in body:
return web.json_response(
{"problems": ["that does not look like a project file"]}, status=400)
# Never the id in the file: a project imported twice, or imported on
# the machine it came from, must land as its OWN project -- an id that
# happens to match one already here would overwrite it on save.
body = {**body, "id": None}
# from_dict() itself is lenient by design -- everything it cannot make
# sense of degrades to a safe default rather than raising, matching
# every other project load. Only save()'s own disk I/O can actually
# fail here, and that is not the file's fault: calling it "invalid"
# sends someone to inspect their JSON when the real problem is on this
# end (disk full, permissions).
try:
saved = projects.save(projects.Project.from_dict(body))
except Exception as exc: # noqa: BLE001
log.broke("project import", exc, doing="saving the imported project")
return web.json_response(
{"problems": [f"could not save the imported project: {exc}"]}, status=500)
return web.json_response(saved.to_dict())
@routes.get(P + "/api/projects/{pid}/download")
async def _projects_download(req):
found = projects.get(req.match_info["pid"])
if found is None:
return web.json_response({"problems": ["no such project"]}, status=404)
safe = "".join(c if c.isalnum() or c in "._- " else "_" for c in found.name).strip()[:64]
return web.json_response(
found.to_dict(),
headers={"Content-Disposition":
f'attachment; filename="{safe or found.id}.funpack_project.json"'})
@routes.get(P + "/api/log/funpack")
async def _log(req):
level = req.query.get("level") or None
+91
View File
@@ -231,6 +231,97 @@ def test_a_path_shaped_id_is_refused_not_served(server):
assert status in (404, 400), pid
# --- moving a project between machines ----------------------------------------
def test_a_downloaded_project_imports_back_as_a_new_one(server):
"""The round trip the feature exists for: save it, move the file
somewhere else, load it there."""
_, made = _request(server, "POST", "/funpack/api/projects", {"name": "Portable"})
made["scenes"].append({"text": "a second scene"})
_request(server, "PUT", f"/funpack/api/projects/{made['id']}", made)
status, downloaded = _request(server, "GET", f"/funpack/api/projects/{made['id']}/download")
assert status == 200
assert downloaded["name"] == "Portable"
assert len(downloaded["scenes"]) == 2
status, imported = _request(server, "POST", "/funpack/api/projects/import", downloaded)
assert status == 200
assert imported["name"] == "Portable"
assert [s["text"] for s in imported["scenes"]] == ["", "a second scene"]
status, listed = _request(server, "GET", "/funpack/api/projects")
assert sorted(p["id"] for p in listed["projects"]) == sorted([made["id"], imported["id"]]), \
"the import did not land as a project of its own"
def test_importing_a_project_never_reuses_its_old_id(server):
"""Importing the SAME file twice -- or importing it back on the machine it
came from -- must not silently overwrite whatever already has that id."""
_, made = _request(server, "POST", "/funpack/api/projects", {"name": "Original"})
status, downloaded = _request(server, "GET", f"/funpack/api/projects/{made['id']}/download")
assert downloaded["id"] == made["id"]
status, imported = _request(server, "POST", "/funpack/api/projects/import", downloaded)
assert status == 200
assert imported["id"] != made["id"]
# The original is untouched -- an id collision would have overwritten it.
_, original = _request(server, "GET", f"/funpack/api/projects/{made['id']}")
assert original["name"] == "Original"
def test_an_id_offered_in_the_body_does_not_win_either(server):
"""Not just the downloaded shape -- ANY id in an imported body is a
liability, including a made-up one that could collide with something
imported later."""
status, imported = _request(server, "POST", "/funpack/api/projects/import",
{"id": "aaaaaaaaaaaa", "name": "Sneaky", "scenes": [{"text": ""}]})
assert status == 200
assert imported["id"] != "aaaaaaaaaaaa"
def test_something_that_does_not_look_like_a_project_is_refused(server):
for body in ({"name": "not a project"}, {}):
status, resp = _request(server, "POST", "/funpack/api/projects/import", body)
assert status == 400, body
assert "problems" in resp
def test_a_scenes_field_of_the_wrong_shape_degrades_to_empty_rather_than_erroring(server):
"""`scenes` present but not a list is the same "never trust a field" rule
every other project load already follows (Project.from_dict), not a
special case this route invents -- an import is not the one place a
malformed file gets to crash instead of degrading."""
status, imported = _request(server, "POST", "/funpack/api/projects/import",
{"name": "Odd", "scenes": "not a list"})
assert status == 200, imported
assert imported["scenes"] == []
def test_downloading_a_project_that_does_not_exist_is_a_404(server):
status, _ = _request(server, "GET", "/funpack/api/projects/0123456789ab/download")
assert status == 404
def test_a_disk_failure_during_import_is_not_blamed_on_the_file(server, monkeypatch):
"""from_dict() is lenient by design -- it cannot actually raise on a
well-formed body. The only thing that can fail here is save()'s own I/O,
which is not the file's fault and should not be reported as though it
were."""
from core import projects
def broken_save(_project):
raise OSError("No space left on device")
monkeypatch.setattr(projects, "save", broken_save)
status, resp = _request(server, "POST", "/funpack/api/projects/import",
{"name": "Fine", "scenes": [{"text": ""}]})
assert status == 500, resp
assert "No space left on device" in resp["problems"][0]
assert "invalid" not in resp["problems"][0].lower()
# --- what the project is generated at ----------------------------------------
def test_video_settings_survive_a_round_trip(store):