diff --git a/.gitignore b/.gitignore index 7761d9c8..be0a04d8 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/app/boot.js b/app/boot.js index 4d38d95e..93c875ae 100644 --- a/app/boot.js +++ b/app/boot.js @@ -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), }; diff --git a/app/composer/elements/button.css b/app/composer/elements/button.css index e2ec1e6a..79a628f7 100644 --- a/app/composer/elements/button.css +++ b/app/composer/elements/button.css @@ -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' */ } diff --git a/app/composer/elements/button.js b/app/composer/elements/button.js index ff4d47c2..ee0914e9 100644 --- a/app/composer/elements/button.js +++ b/app/composer/elements/button.js @@ -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(), diff --git a/app/composer/elements/gallery.js b/app/composer/elements/gallery.js index 1052e6bf..980631a6 100644 --- a/app/composer/elements/gallery.js +++ b/app/composer/elements/gallery.js @@ -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(), + }; }); /** diff --git a/app/composer/elements/layout.js b/app/composer/elements/layout.js index 3314da2c..ee0ccd4a 100644 --- a/app/composer/elements/layout.js +++ b/app/composer/elements/layout.js @@ -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(); diff --git a/app/shell/constructor.js b/app/shell/constructor.js index 104dfd36..e737c5a8 100644 --- a/app/shell/constructor.js +++ b/app/shell/constructor.js @@ -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_; } diff --git a/app/shell/layout.js b/app/shell/layout.js index f48f1acc..e45266ed 100644 --- a/app/shell/layout.js +++ b/app/shell/layout.js @@ -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 }; } diff --git a/app/shell/projects.js b/app/shell/projects.js new file mode 100644 index 00000000..51d6971f --- /dev/null +++ b/app/shell/projects.js @@ -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; }, + }; +} diff --git a/app/shell/prompt.js b/app/shell/prompt.js index 1a5356e9..9d9f1b0c 100644 --- a/app/shell/prompt.js +++ b/app/shell/prompt.js @@ -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([]); }, }; } diff --git a/app/shell/timeline.js b/app/shell/timeline.js new file mode 100644 index 00000000..f3279425 --- /dev/null +++ b/app/shell/timeline.js @@ -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(); + }, + }; +} diff --git a/core/config.py b/core/config.py index 119b9c95..7c579d82 100644 --- a/core/config.py +++ b/core/config.py @@ -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" diff --git a/core/projects.py b/core/projects.py new file mode 100644 index 00000000..c88530e6 --- /dev/null +++ b/core/projects.py @@ -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 diff --git a/core/routes.py b/core/routes.py index 7af7eb18..6bb7b8b0 100644 --- a/core/routes.py +++ b/core/routes.py @@ -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 diff --git a/core/tests/test_projects.py b/core/tests/test_projects.py new file mode 100644 index 00000000..6597ac01 --- /dev/null +++ b/core/tests/test_projects.py @@ -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