app, core: a project of scenes, and the timeline that draws it

The app could run a pipeline and had nowhere to keep what it was making. A
project is an ordered list of scenes, one JSON file each, and it is the only
part of the app that has to survive a reload.

A scene owns its prompt text and the result it produced, and nothing else. v4's
Scene grew three dozen fields; each belongs to the feature that needs it. Per
scene length and fps are deliberately absent — a regenerate uses the project's
values, so a scene cropped on the timeline comes back whole.

The prompt box edits the current scene rather than holding its own copy, so
what is on screen and what a run uses cannot disagree.

app: the region toggles are named buttons in the timeline bar

v4's arrangement. The kit's fallback is one glyph on a rail at each edge of the
window, which is right for a kit that cannot know what its panels are called and
wrong here, where they have names and a bar to put them in.

A pressed toggle was styled only inside a button group, so a standalone one
carried aria-pressed and looked identical either way.

gallery.strip is selectable like its siblings: a filmstrip is the one shape
whose whole job is saying which of these you are on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-09-03 12:03:19 +03:00
co-authored by Claude Opus 5
parent d3b0ba0fa9
commit 9dcd94c156
15 changed files with 910 additions and 32 deletions
+3
View File
@@ -28,3 +28,6 @@ node_modules/
# Playwright: browsers live in a shared cache, results and traces are per-run
app/test-results/
app/playwright-report/
# User projects — their content, not ours.
projects/
+44 -2
View File
@@ -15,6 +15,8 @@ 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";
const root = document.querySelector("#app");
@@ -35,6 +37,22 @@ async function start() {
// 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;
const project = createProject({
onChange: () => { if (timeline) timeline.draw(); },
onError: (err) => page.transport.say(`The project could not be saved: ${err.message}`),
});
// 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 : "");
};
const page = build(root, {
onGenerate: () => session.generate(),
onCancel: () => run.cancel(),
@@ -55,6 +73,10 @@ async function start() {
slots: () => slots, values: allValues,
inputs: () => (prompts ? prompts.overrides() : {}) });
// 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", () => { project.flush(); });
let manifest;
try {
manifest = await fetchManifest();
@@ -74,7 +96,15 @@ async function start() {
// 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 = createPrompts((await load()).slots);
prompts = createPrompts((await load()).slots, {
// 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: () => {
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
@@ -85,6 +115,18 @@ async function start() {
// Everything that mounts has now had its turn, so a region still holding its
// stand-in is a region nothing wanted.
settle();
// After the prompt exists, so the first scene's text has somewhere to go.
try {
await project.start();
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`,
@@ -97,7 +139,7 @@ async function start() {
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,
manifest, values: allValues, failed, hidden, run, bin: page.bin, project,
prompts: () => (prompts ? prompts.overrides() : {}),
mounted: mounted.map((m) => m.id),
};
+7 -1
View File
@@ -59,8 +59,14 @@
.cx-btn-group .cx-btn:first-child { border-radius: var(--ctl-r) 0 0 var(--ctl-r); margin-left: 0; }
.cx-btn-group .cx-btn:last-child { border-radius: 0 var(--ctl-r) var(--ctl-r) 0; }
.cx-btn-group .cx-btn:only-child { border-radius: var(--ctl-r); }
.cx-btn-group .cx-btn.cx-on {
/* A pressed toggle, grouped or not. Scoping this to .cx-btn-group meant a
standalone toggle -- the region buttons in the timeline bar -- carried
aria-pressed and looked identical either way: a control that reports its state
to a screen reader and not to the eye. */
.cx-btn.cx-on {
background: var(--accent-soft); border-color: var(--accent); color: var(--accent-hi);
}
.cx-btn-group .cx-btn.cx-on {
position: relative; z-index: 1; /* its border wins over its neighbours' */
}
+11 -2
View File
@@ -11,13 +11,18 @@ import { roving } from "../internals/focus.js";
const TONES = new Set(["neutral", "primary", "danger", "ghost"]);
function base(size, { label, onClick, tone = "neutral", icon, disabled = false, busy = false, type = "button", title } = {}) {
function base(size, { label, onClick, tone = "neutral", icon, disabled = false, busy = false, type = "button", title, pressed } = {}) {
if (!TONES.has(tone)) throw new RangeError(`Unknown button tone "${tone}". Known: ${[...TONES].join(", ")}.`);
const node = el("button", {
cls: ["cx-btn", `cx-btn-${size}`, `cx-btn-${tone}`, "cx-focusable"],
attrs: { type, disabled: disabled || busy, title, "aria-busy": busy || undefined },
attrs: { type, disabled: disabled || busy, title, "aria-busy": busy || undefined,
// A toggle says so, or a screen reader reads "Assets" the same
// whether the panel is open or shut. Absent unless the caller
// passes one: most buttons are not toggles and must not claim to be.
"aria-pressed": pressed === undefined ? undefined : String(Boolean(pressed)) },
});
if (pressed) node.classList.add("cx-on");
const spinner = el("span", { cls: "cx-btn-spin", attrs: { "aria-hidden": "true" } });
const glyph = icon ? el("span", { cls: "cx-btn-icon", text: icon, attrs: { "aria-hidden": "true" } }) : null;
@@ -47,6 +52,10 @@ function base(size, { label, onClick, tone = "neutral", icon, disabled = false,
else { node.removeAttribute("aria-busy"); spinner.remove(); }
},
setDisabled(value) { isDisabled = Boolean(value); sync(); },
setPressed(value) {
node.setAttribute("aria-pressed", String(Boolean(value)));
node.classList.toggle("cx-on", Boolean(value));
},
get disabled() { return isDisabled; },
get busy() { return isBusy; },
destroy: () => node.remove(),
+25 -10
View File
@@ -182,18 +182,33 @@ define("gallery", "cards", ({ items = [], value, onActivate } = {}) => {
return { node, get value() { return current; }, destroy: () => node.remove() };
});
/** A single scrolling row: filmstrips, segment strips, recent items. */
define("gallery", "strip", ({ items = [], onActivate, label } = {}) => {
const node = el("div", { cls: "cx-strip", attrs: { role: "list", "aria-label": label } });
for (const item of items) {
const cell = el("button", { cls: ["cx-strip-cell", "cx-focusable"],
attrs: { type: "button", role: "listitem", title: item.label } });
/**
* A single scrolling row: filmstrips, segment strips, recent items.
*
* Selectable like its siblings, and through the same `collection` -- a strip is
* the one shape whose whole job is "which of these am I on", and one that could
* only be clicked THROUGH left the caller drawing its own current-item marker
* over a row that did not have the concept.
*/
define("gallery", "strip", (props = {}) => {
const node = el("div", { cls: "cx-strip", attrs: { role: "listbox", "aria-label": props.label } });
const api = collection(props, node, (item, on) => {
const cell = el("button", { cls: ["cx-strip-cell", on ? "cx-on" : null, "cx-focusable"],
attrs: option(on, { title: item.label, "aria-label": item.label }) });
if (item.thumb) cell.append(el("img", { cls: "cx-cell-img", attrs: { src: item.thumb, alt: "", loading: "lazy" } }));
else cell.append(el("span", { cls: "cx-cell-glyph", text: item.icon || "▦", attrs: { "aria-hidden": "true" } }));
cell.addEventListener("click", () => { if (onActivate) onActivate(item); });
node.append(cell);
}
return { node, destroy: () => node.remove() };
if (item.badge) cell.append(el("span", { cls: "cx-cell-badge", text: item.badge }));
return cell;
});
return {
node,
get value() { return api.value; },
setValue: api.setValue,
setItems: api.setItems,
destroy: () => node.remove(),
};
});
/**
+9 -4
View File
@@ -324,7 +324,7 @@ define("frame", "app", ({ header, main, footer } = {}) => {
define("workspace", "docked", ({
id = "workspace", centre, left, right,
leftLabel = "Assets", rightLabel = "Properties",
leftOpen = true, rightOpen = true, onToggle,
leftOpen = true, rightOpen = true, onToggle, rails = true,
} = {}) => {
const node = el("div", { cls: "cx-workspace" });
@@ -375,8 +375,13 @@ define("workspace", "docked", ({
"aria-expanded": String(state[which]) },
on: { click: () => set(which, !state[which]) } });
const rail = el("div", { cls: ["cx-workspace-rail", `cx-workspace-rail-${which}`],
children: button });
// The rail is one glyph on the outer edge of the window. It is the fallback,
// not the design: where the app has somewhere to put a NAMED toggle, it
// passes rails:false and drives open/close through this handle instead --
// "▎" on an edge is a control whose meaning has to be discovered.
const rail = rails
? el("div", { cls: ["cx-workspace-rail", `cx-workspace-rail-${which}`], children: button })
: null;
panels[which] = panel;
toggles[which] = button;
@@ -423,7 +428,7 @@ define("workspace", "docked", ({
const l = side("left", leftLabel, left);
const r = side("right", rightLabel, right);
node.append(l.rail, l.panel, main, r.panel, r.rail);
node.append(...[l.rail, l.panel, main, r.panel, r.rail].filter(Boolean));
set("left", state.left, { remember: false });
set("right", state.right, { remember: false });
fit();
+3 -2
View File
@@ -17,7 +17,7 @@
import { composer } from "../composer/composer.js";
export function createConstructor({ title = "Constructor" } = {}) {
export function createConstructor({ title = "Constructor", onChange } = {}) {
const empty = composer.emptyState.default({
icon: "✎",
title: "Nothing to write yet",
@@ -40,8 +40,9 @@ export function createConstructor({ title = "Constructor" } = {}) {
body: host,
actions: [composer.button.md({ label: "Done", tone: "primary",
onClick: () => window_ && window_.close("done") })],
onClose: () => { window_ = null; },
onClose: () => { window_ = null; if (onChange) onChange(); },
});
if (onChange) onChange();
return window_;
}
+41 -7
View File
@@ -28,7 +28,9 @@ export function build(root, 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.
const constructor = createConstructor();
// 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." });
@@ -57,23 +59,49 @@ export function build(root, handlers = {}) {
// 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 = () => { for (const r of regionButtons) r.button.setPressed(r.isOn()); };
const regionToggle = (label, isOn, act) => {
const button = composer.button.sm({ label, pressed: isOn(), onClick: () => { act(); syncRegions(); } });
regionButtons.push({ button, isOn });
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,
composer.button.sm({
label: "Constructor",
onClick: () => { if (handlers.onConstructor) handlers.onConstructor(); },
regionToggle("Assets", () => Boolean(ws && ws.isOpen("left")),
() => ws && ws.toggle("left")),
regionToggle("Properties", () => Boolean(ws && ws.isOpen("right")),
() => ws && ws.toggle("right")),
regionToggle("Constructor", () => constructor.isOpen, () => {
if (constructor.isOpen) constructor.close();
else if (handlers.onConstructor) handlers.onConstructor();
}),
],
status: transport.status,
body: composer.region.stack({ gap: "sm", fill: true,
children: [transport.warning, timelineEmpty] }),
body: timelineBody,
});
// The stand-ins below are EMPTY STATES, not labels: they say a region is
@@ -127,7 +155,12 @@ export function build(root, handlers = {}) {
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
@@ -154,7 +187,8 @@ export function build(root, handlers = {}) {
offer(`generation.${point}`, generation.node, generationEmpty.node);
}
return { workspace, assets, bin, preview, viewer, timeline, generation, settings,
return { workspace, assets, bin, preview, viewer, timeline, timelineBody,
timelineEmpty, generation, settings, syncRegions,
properties, transport, constructor, menubar,
connection: menubar.connection, bar, page };
}
+188
View File
@@ -0,0 +1,188 @@
// The project: an ordered list of scenes, and the only thing in the app that
// has to survive a reload.
//
// Held here rather than in the timeline that draws it, for the reason the
// pipeline is held in boot: one store of live values. A timeline that owned the
// project would make "what is on screen" and "what a run uses" two questions
// with two answers.
//
// Saving is DEBOUNCED and last-write-wins per project. Typing a prompt is one
// edit per keystroke and each is a PUT of the whole project; without this the
// server answers them out of order and an older body lands last.
const BASE = "/funpack/api/projects";
const SAVE_AFTER = 600;
/**
* A scene id the server will keep.
*
* Twelve hex characters is what core/projects.py generates and the only shape it
* accepts back; anything else it replaces with one of its own. Minting a valid
* one here means a new scene is addressable the moment it appears, instead of
* being unselectable until a save round trip has answered.
*/
function newId() {
const bytes = new Uint8Array(6);
(globalThis.crypto || {}).getRandomValues?.(bytes);
return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
}
async function json(method, path, body) {
const res = await fetch(path, {
method,
headers: body === undefined ? {} : { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
if (!res.ok) {
let detail = "";
try { detail = ((await res.json()).problems || [])[0] || ""; } catch { /* not JSON */ }
throw new Error(detail || `HTTP ${res.status}`);
}
return res.status === 204 ? null : res.json();
}
export const list = () => json("GET", BASE).then((d) => d.projects || []);
export const create = (name) => json("POST", BASE, { name });
export const read = (id) => json("GET", `${BASE}/${encodeURIComponent(id)}`);
export const remove = (id) => json("DELETE", `${BASE}/${encodeURIComponent(id)}`);
const put = (project) => json("PUT", `${BASE}/${encodeURIComponent(project.id)}`, project);
/**
* createProject({ onChange, onError }) -> the live project.
*
* `onChange` fires for anything that alters what is on screen, including the
* selection. Nothing here draws; the timeline subscribes.
*/
export function createProject({ onChange, onError } = {}) {
let project = null;
let selected = null;
let timer = null;
let saving = null; // the PUT in flight, so a queued one waits for it
let dirty = false;
const changed = () => { if (onChange) onChange(); };
function scheduleSave() {
dirty = true;
if (timer) clearTimeout(timer);
timer = setTimeout(flush, SAVE_AFTER);
}
async function flush() {
if (timer) { clearTimeout(timer); timer = null; }
if (!project || !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 };
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;
}).catch((err) => {
dirty = true; // it is still unsaved, so say so
if (onError) onError(err);
}).finally(() => { saving = null; });
return saving;
}
const sceneAt = (id) => (project ? (project.scenes || []).find((s) => s.id === id) : null);
return {
get project() { return project; },
get scenes() { return project ? project.scenes || [] : []; },
get selectedId() { return selected; },
get selected() { return sceneAt(selected); },
/** The most recent project, or a new one when there are none. */
async start() {
const found = await list();
project = found.length ? await read(found[0].id) : await create("Untitled");
selected = (project.scenes || [])[0]?.id ?? null;
changed();
return project;
},
async open(id) {
await flush();
project = await read(id);
selected = (project.scenes || [])[0]?.id ?? null;
changed();
return project;
},
async newProject(name) {
await flush();
project = await create(name);
selected = (project.scenes || [])[0]?.id ?? null;
changed();
return project;
},
select(id) {
if (!sceneAt(id) || id === selected) return;
selected = id;
changed(); // selection is not saved: it is a view
},
/** The text of one scene. The prompt box on the main window edits this. */
setText(id, text) {
const scene = sceneAt(id);
if (!scene || scene.text === text) return;
scene.text = text;
scheduleSave();
changed();
},
addScene() {
if (!project) return null;
// A new scene lands AFTER the selected one, not at the end: adding partway
// through a timeline is how a scene gets inserted, and appending silently
// would put it somewhere the user was not looking.
const at = project.scenes.findIndex((s) => s.id === selected);
const scene = { id: newId(), text: "", result: null };
project.scenes.splice(at < 0 ? project.scenes.length : at + 1, 0, scene);
selected = scene.id; // you add a scene in order to fill it
scheduleSave();
changed();
return scene;
},
removeScene(id) {
if (!project || !sceneAt(id)) return;
const at = project.scenes.findIndex((s) => s.id === id);
project.scenes.splice(at, 1);
if (selected === id) {
const next = project.scenes[at] || project.scenes[at - 1];
selected = next ? next.id : null;
}
scheduleSave();
changed();
},
move(id, by) {
if (!project) return;
const at = project.scenes.findIndex((s) => s.id === id);
const to = at + by;
if (at < 0 || to < 0 || to >= project.scenes.length) return;
const [scene] = project.scenes.splice(at, 1);
project.scenes.splice(to, 0, scene);
scheduleSave();
changed();
},
/** Attach what a run produced to the scene it was started from. */
setResult(id, result) {
const scene = sceneAt(id);
if (!scene) return;
scene.result = result;
scheduleSave();
changed();
},
flush,
get unsaved() { return dirty; },
};
}
+14 -2
View File
@@ -58,7 +58,7 @@ export function createPrompts(slots = [], { onChange } = {}) {
control,
});
host.appendChild(field.node);
fields.push({ slot: slot.id, input: role.input, control, field });
fields.push({ slot: slot.id, input: role.input, at: role.at, control, field });
}
}
}
@@ -86,7 +86,19 @@ export function createPrompts(slots = [], { onChange } = {}) {
*/
sync(next) { build(next); },
get fields() { return fields.map((f) => ({ slot: f.slot, input: f.input })); },
get fields() { return fields.map((f) => ({ slot: f.slot, input: f.input, at: f.at })); },
/**
* The control mounted at a place, or null.
*
* What makes a scene's text editable: the timeline owns the text and this is
* the box it is typed in, so the two are bound rather than each holding
* their own copy of a prompt.
*/
at(point) {
const found = fields.find((f) => f.at === point);
return found ? found.control : null;
},
destroy() { build([]); },
};
}
+115
View File
@@ -0,0 +1,115 @@
// The timeline: the project's scenes, in the order they play.
//
// It draws and it dispatches. The project itself lives in projects.js, so this
// file holds no scene state of its own -- a timeline that kept its own copy is
// how "what is on screen" and "what a run uses" become two questions.
//
// One scene is always current, and the prompt box on the main window edits THAT
// scene. v4 had one prompt for the whole project and split it by markers in the
// text, which meant the text was both the content and the structure: editing a
// scene could re-cut every other one.
import { composer } from "../composer/composer.js";
const EMPTY = {
icon: "▭",
title: "Nothing on the timeline",
hint: "What you generate lands here, in the order it plays.",
};
/**
* createTimeline({ project, onSelect }) -> { node, draw, destroy }
*
* `project` is the store from projects.js. `onSelect` fires whenever the current
* scene changes, including when a removal moves it -- the prompt box follows it.
*/
export function createTimeline({ project, onSelect } = {}) {
const host = composer.region.stack({ gap: "sm", fill: true });
const empty = composer.emptyState.default(EMPTY);
let strip = null;
// Rebuilt on every draw, so a stale handler cannot act on a scene that has
// moved. The buttons that act on "the current scene" read it at click time
// rather than closing over it for the same reason.
const controls = composer.toolbar.default({
label: "Scenes",
items: [
composer.button.sm({ label: "Add scene", onClick: () => add() }),
composer.button.sm({ label: "Remove", onClick: () => remove() }),
composer.iconButton.sm({ icon: "◀", label: "Move earlier", onClick: () => move(-1) }),
composer.iconButton.sm({ icon: "▶", label: "Move later", onClick: () => move(1) }),
],
});
function add() {
project.addScene();
announce();
}
function remove() {
const id = project.selectedId;
// The last scene stays. A project with no scenes has nowhere to type, and
// the Add button would be the only thing on the timeline that did anything.
if (!id || project.scenes.length <= 1) return;
project.removeScene(id);
announce();
}
function move(by) {
if (project.selectedId) project.move(project.selectedId, by);
}
function announce() {
if (onSelect) onSelect(project.selected || null);
}
function items() {
return project.scenes.map((scene, i) => ({
id: scene.id,
// The number is what a scene is called before it has any text, and the
// text is what it is called after -- a strip of "Scene 1..8" is a strip
// nobody can read their own project off.
label: scene.text ? `${i + 1}. ${scene.text}` : `Scene ${i + 1}`,
badge: String(i + 1),
thumb: scene.result || null,
icon: "▦",
}));
}
function draw() {
const scenes = project.scenes;
if (!scenes.length) {
host.set([empty]);
strip = null;
return;
}
if (!strip) {
strip = composer.gallery.strip({
label: "Scenes",
items: items(),
selection: project.selectedId ? [project.selectedId] : [],
onActivate: (item) => { project.select(item.id); announce(); },
});
host.set([controls, strip]);
return;
}
// setItems then setValue: setValue redraws against whatever items are
// current, so seeding the selection first marks a row that is about to be
// replaced and the strip comes back with nothing on.
strip.setItems(items());
strip.setValue(project.selectedId ? [project.selectedId] : []);
}
draw();
return {
node: host.node,
draw,
destroy() {
if (strip) strip.destroy();
controls.destroy();
empty.destroy();
host.destroy();
},
};
}
+5
View File
@@ -14,3 +14,8 @@ UI_PREFIX = "/funpack"
# a module cannot ship a stylesheet even if someone writes one.
APP_EXTS = frozenset({".js", ".css", ".html", ".woff2", ".svg"})
MODULE_EXTS = frozenset({".js"})
# Where projects are kept. Inside the pack rather than ComfyUI's output tree: a
# project is FunPack's own state, and clearing outputs must not take a user's
# edits with it.
PROJECTS_DIR = ROOT / "projects"
+163
View File
@@ -0,0 +1,163 @@
"""Projects: an ordered list of scenes, one JSON file each.
A scene owns its prompt text and the result it last produced. Nothing else about
a scene lives here. v4's Scene accumulated three dozen fields over its life --
transitions, effects, audio gain, per-scene frames, guide sources -- and each of
those belongs to the feature that needs it, added when that feature lands, not to
the model every part of the app has to load.
Per-scene length and fps are deliberately absent: regeneration uses the PROJECT's
values, never a scene's own, so a scene cropped on the timeline and regenerated
comes back whole. A crop is a timeline decision; a regenerate is a new scene.
Synchronous, one file per project, stdlib json. Projects are small.
"""
from __future__ import annotations
import json
import re
import time
import uuid
from dataclasses import asdict, dataclass, field
from . import config
#: A generated id, never a user-supplied name, is what reaches the filesystem.
_ID = re.compile(r"\A[0-9a-f]{12}\Z")
MAX_NAME = 120
def _new_id() -> str:
return uuid.uuid4().hex[:12]
def is_id(value) -> bool:
"""True for something this module generated. Everything from a request is
checked against this before it is used to build a path."""
return isinstance(value, str) and bool(_ID.match(value))
def _clean_name(raw, fallback="Untitled") -> str:
name = (raw if isinstance(raw, str) else "").strip()
return name[:MAX_NAME] or fallback
@dataclass
class Scene:
id: str = field(default_factory=_new_id)
text: str = ""
#: The asset this scene last produced, so a reload shows the timeline the
#: user left rather than an empty one they have to regenerate.
result: str | None = None
@staticmethod
def from_dict(d) -> "Scene":
d = d if isinstance(d, dict) else {}
sid = d.get("id")
result = d.get("result")
return Scene(
id=sid if is_id(sid) else _new_id(),
text=d.get("text") if isinstance(d.get("text"), str) else "",
result=result if isinstance(result, str) else None,
)
@dataclass
class Project:
id: str = field(default_factory=_new_id)
name: str = "Untitled"
scenes: list[Scene] = field(default_factory=list)
updated_at: float = 0.0
@staticmethod
def from_dict(d) -> "Project":
d = d if isinstance(d, dict) else {}
pid = d.get("id")
raw = d.get("scenes")
return Project(
id=pid if is_id(pid) else _new_id(),
name=_clean_name(d.get("name")),
scenes=[Scene.from_dict(s) for s in (raw if isinstance(raw, list) else [])],
updated_at=float(d.get("updated_at") or 0.0),
)
def to_dict(self) -> dict:
return asdict(self)
def _dir():
config.PROJECTS_DIR.mkdir(parents=True, exist_ok=True)
return config.PROJECTS_DIR
def _path(project_id: str):
# is_id, not a sanitiser: a name that merely survives cleaning can still be
# "..", and a store that repairs a bad id quietly is one that writes
# somewhere nobody asked for.
if not is_id(project_id):
raise ValueError(f"not a project id: {project_id!r}")
return _dir() / f"{project_id}.json"
def listing() -> list[dict]:
"""Every project, newest first, as the little the picker needs."""
out = []
for p in _dir().glob("*.json"):
if not is_id(p.stem):
continue
try:
d = json.loads(p.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError, UnicodeDecodeError):
continue # an unreadable file is not a reason to have no list
if not isinstance(d, dict):
continue
out.append({
"id": p.stem,
"name": _clean_name(d.get("name"), p.stem),
"scenes": len(d.get("scenes") or []),
"updated_at": float(d.get("updated_at") or 0.0),
})
out.sort(key=lambda x: x["updated_at"], reverse=True)
return out
def get(project_id: str) -> Project | None:
try:
path = _path(project_id)
except ValueError:
return None
if not path.exists():
return None
try:
return Project.from_dict(json.loads(path.read_text(encoding="utf-8")))
except (json.JSONDecodeError, OSError, UnicodeDecodeError):
return None
def save(project: Project) -> Project:
project.updated_at = time.time()
path = _path(project.id)
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(project.to_dict(), indent=2), encoding="utf-8")
tmp.replace(path) # a concurrent read never sees a half-written project
return project
def create(name=None) -> Project:
"""A new project with one empty scene: a timeline with nothing on it cannot
be typed into, so a brand-new project would need an Add before it could be
used at all."""
return save(Project(name=_clean_name(name), scenes=[Scene()]))
def delete(project_id: str) -> bool:
try:
path = _path(project_id)
except ValueError:
return False
if not path.exists():
return False
path.unlink()
return True
+51 -2
View File
@@ -7,8 +7,8 @@ thin adapters over pure functions in `serve`.
import json
from . import (config, graph as graph_mod, log, registry as registry_mod,
serve as static, widgets)
from . import (config, graph as graph_mod, log, projects,
registry as registry_mod, serve as static, widgets)
from .contract import CONTRACT_VERSION
from .relations import order
from .traits import split
@@ -277,6 +277,55 @@ def register(routes, prefix=None):
return web.json_response(
widgets.search(req.query.get("q", ""), max(1, min(limit, 200))))
# ── projects ──────────────────────────────────────────────────────────
# What the timeline IS. A run produces one clip; a project is the ordered
# list of them the user is making, and the only part of the app that has to
# survive a reload.
async def _body(req):
"""The request's JSON object, or None. A body that is not an object is
not a project edit -- `[]` and `"x"` both reach `.get` otherwise."""
try:
data = await req.json()
except Exception: # noqa: BLE001 - a malformed body is the caller's
return None
return data if isinstance(data, dict) else None
@routes.get(P + "/api/projects")
async def _projects_list(_req):
return web.json_response({"projects": projects.listing()})
@routes.post(P + "/api/projects")
async def _projects_create(req):
body = await _body(req) or {}
return web.json_response(projects.create(body.get("name")).to_dict())
@routes.get(P + "/api/projects/{pid}")
async def _projects_get(req):
found = projects.get(req.match_info["pid"])
if found is None:
return web.json_response({"problems": ["no such project"]}, status=404)
return web.json_response(found.to_dict())
@routes.put(P + "/api/projects/{pid}")
async def _projects_save(req):
pid = req.match_info["pid"]
body = await _body(req)
if body is None:
return web.json_response({"problems": ["expected a project object"]}, status=400)
if projects.get(pid) is None:
return web.json_response({"problems": ["no such project"]}, status=404)
# The id comes from the URL, never the body: a PUT that names its own
# target is a PUT that can write over a different project.
return web.json_response(
projects.save(projects.Project.from_dict({**body, "id": pid})).to_dict())
@routes.delete(P + "/api/projects/{pid}")
async def _projects_delete(req):
if not projects.delete(req.match_info["pid"]):
return web.json_response({"problems": ["no such project"]}, status=404)
return web.json_response({"deleted": True})
@routes.get(P + "/api/log")
async def _log(req):
level = req.query.get("level") or None
+231
View File
@@ -0,0 +1,231 @@
"""The project store, and the same store over HTTP.
A project is the one thing in the app that has to survive a reload, so the tests
that matter here are about what happens to it when the input is not what the app
would have sent: a hand-edited file, a body that is not an object, an id that is
really a path.
"""
import json
import socket
import threading
import pytest
from core import config, projects
@pytest.fixture
def store(tmp_path, monkeypatch):
monkeypatch.setattr(config, "PROJECTS_DIR", tmp_path / "projects")
return tmp_path / "projects"
# ── the model ────────────────────────────────────────────────────────────
def test_a_new_project_can_be_typed_into(store):
"""One empty scene, not none: a timeline with nothing on it has nowhere to
put a prompt, so an empty new project would need an Add before it was usable."""
made = projects.create("First")
assert made.name == "First"
assert len(made.scenes) == 1
assert made.scenes[0].text == ""
def test_a_project_survives_a_round_trip(store):
made = projects.create("Round trip")
made.scenes.append(projects.Scene(text="a wolf on a ridge"))
projects.save(made)
read = projects.get(made.id)
assert [s.text for s in read.scenes] == ["", "a wolf on a ridge"]
assert read.name == "Round trip"
def test_scenes_keep_their_order(store):
made = projects.create("Ordered")
made.scenes = [projects.Scene(text=str(i)) for i in range(6)]
projects.save(made)
assert [s.text for s in projects.get(made.id).scenes] == [str(i) for i in range(6)]
def test_a_hand_edited_file_does_not_crash_the_app(store):
"""The store reads files a person can edit. Every one of these used to be an
attribute error on a dict that was a list, or a str that was None."""
made = projects.create("Edited")
path = store / f"{made.id}.json"
for body in ('{"scenes": "not a list"}', '{"scenes": [null, 3, "x"]}',
'{"name": 42}', '[]', '"just a string"', '{}'):
path.write_text(body, encoding="utf-8")
read = projects.get(made.id)
assert read is not None, body
assert isinstance(read.scenes, list)
assert isinstance(read.name, str)
def test_unreadable_json_is_not_a_project(store):
made = projects.create("Broken")
(store / f"{made.id}.json").write_text("{oh no", encoding="utf-8")
assert projects.get(made.id) is None
assert projects.listing() == [] # and it does not take the list down with it
def test_a_scene_without_an_id_gets_one(store):
"""Ids address a scene from the client. Two scenes sharing one, or a scene
with none, is a selection that lands on the wrong row."""
p = projects.Project.from_dict({"scenes": [{"text": "a"}, {"text": "b"}]})
ids = [s.id for s in p.scenes]
assert all(projects.is_id(i) for i in ids)
assert len(set(ids)) == 2
def test_an_id_from_a_request_cannot_be_a_path(store):
"""The filename is a generated id or the call fails. A sanitiser that
repairs "../../x" writes somewhere nobody asked for and says it saved."""
for bad in ("../escape", "a/b", "", "..", "x" * 64, None, 5, "ABCDEF123456"):
assert projects.is_id(bad) is False
assert projects.get(bad) is None
assert projects.delete(bad) is False
def test_the_listing_is_newest_first(store):
a = projects.create("A")
b = projects.create("B")
a.name = "A again"
projects.save(a) # touches updated_at
assert [p["id"] for p in projects.listing()] == [a.id, b.id]
assert [p["name"] for p in projects.listing()][0] == "A again"
def test_a_long_name_is_cut_not_refused(store):
made = projects.create("x" * 500)
assert len(made.name) == projects.MAX_NAME
def test_delete_removes_it(store):
made = projects.create("Doomed")
assert projects.delete(made.id) is True
assert projects.get(made.id) is None
assert projects.delete(made.id) is False
def test_a_save_is_atomic(store):
"""A reader must never see half a project. The temp file is what makes that
true, and a leftover .tmp in the directory would be read as one."""
made = projects.create("Atomic")
projects.save(made)
assert list(store.glob("*.tmp")) == []
assert len(projects.listing()) == 1
# ── over HTTP ────────────────────────────────────────────────────────────
pytest.importorskip("aiohttp")
@pytest.fixture
def server(store):
from aiohttp import web as aioweb
from core import routes
app = aioweb.Application()
table = aioweb.RouteTableDef()
routes.register(table, prefix="/funpack")
app.add_routes(table)
holder = {"ready": threading.Event()}
def run():
import asyncio
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
runner = aioweb.AppRunner(app)
loop.run_until_complete(runner.setup())
site = aioweb.TCPSite(runner, "127.0.0.1", 0)
loop.run_until_complete(site.start())
holder["port"] = site._server.sockets[0].getsockname()[1]
holder["loop"] = loop
holder["ready"].set()
loop.run_forever()
thread = threading.Thread(target=run, daemon=True)
thread.start()
assert holder["ready"].wait(20), "the test server did not start"
yield holder["port"]
holder["loop"].call_soon_threadsafe(holder["loop"].stop)
def _request(port, method, path, body=None):
payload = b"" if body is None else json.dumps(body).encode()
head = (f"{method} {path} HTTP/1.1\r\nHost: localhost\r\n"
f"Content-Type: application/json\r\n"
f"Content-Length: {len(payload)}\r\nConnection: close\r\n\r\n").encode()
with socket.create_connection(("127.0.0.1", port), timeout=30) as sock:
sock.sendall(head + payload)
chunks = []
while True:
got = sock.recv(65536)
if not got:
break
chunks.append(got)
header, _, rest = b"".join(chunks).partition(b"\r\n\r\n")
status = int(header.split()[1])
try:
return status, json.loads(rest.decode() or "{}")
except ValueError:
return status, {"raw": rest.decode(errors="replace")}
def test_create_list_read_save_delete(server):
status, made = _request(server, "POST", "/funpack/api/projects", {"name": "HTTP"})
assert status == 200 and made["name"] == "HTTP"
pid = made["id"]
status, listed = _request(server, "GET", "/funpack/api/projects")
assert status == 200 and [p["id"] for p in listed["projects"]] == [pid]
assert listed["projects"][0]["scenes"] == 1
made["scenes"].append({"text": "a second scene"})
status, saved = _request(server, "PUT", f"/funpack/api/projects/{pid}", made)
assert status == 200 and len(saved["scenes"]) == 2
status, read = _request(server, "GET", f"/funpack/api/projects/{pid}")
assert status == 200 and read["scenes"][1]["text"] == "a second scene"
assert _request(server, "DELETE", f"/funpack/api/projects/{pid}")[0] == 200
assert _request(server, "GET", f"/funpack/api/projects/{pid}")[0] == 404
def test_a_put_cannot_rename_its_own_target(server):
"""The id comes from the URL. A body that could name a different project is
a write over someone else's work, reported as a success."""
_, first = _request(server, "POST", "/funpack/api/projects", {"name": "First"})
_, second = _request(server, "POST", "/funpack/api/projects", {"name": "Second"})
_request(server, "PUT", f"/funpack/api/projects/{first['id']}",
{**first, "id": second["id"], "name": "Overwritten"})
_, untouched = _request(server, "GET", f"/funpack/api/projects/{second['id']}")
assert untouched["name"] == "Second"
_, target = _request(server, "GET", f"/funpack/api/projects/{first['id']}")
assert target["name"] == "Overwritten"
def test_a_body_that_is_not_an_object_is_refused(server):
_, made = _request(server, "POST", "/funpack/api/projects", {"name": "Shape"})
for body in ([], "text", 7):
status, _ = _request(server, "PUT", f"/funpack/api/projects/{made['id']}", body)
assert status == 400, body
def test_saving_something_that_is_not_there_is_a_404(server):
status, _ = _request(server, "PUT", "/funpack/api/projects/0123456789ab", {"name": "Ghost"})
assert status == 404
def test_a_path_shaped_id_is_refused_not_served(server):
for pid in ("..", "%2e%2e", "a/b"):
status, _ = _request(server, "GET", f"/funpack/api/projects/{pid}")
assert status in (404, 400), pid