From bd8ad114fca366adfdff2d60cbf2a0489e9c50b5 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Sat, 3 Oct 2026 19:01:47 +0300 Subject: [PATCH] feat: app/ui project core (ported as is) and the Assets project list; features get named services; fixes a test that leaked a pending save into the next --- app/shell/tests/projects.test.js | 7 +- app/ui/boot.js | 17 +- app/ui/catalogue/demos.js | 4 + app/ui/composer/elements/gallery.css | 10 + app/ui/composer/elements/gallery.js | 12 + app/ui/features/placeholders/index.js | 4 - app/ui/features/projects/index.js | 27 ++ app/ui/modules.js | 1 + app/ui/shell/features.js | 11 +- app/ui/shell/project.js | 463 +++++++++++++++++++ app/ui/shell/tests/project.test.js | 611 ++++++++++++++++++++++++++ 11 files changed, 1152 insertions(+), 15 deletions(-) create mode 100644 app/ui/features/projects/index.js create mode 100644 app/ui/shell/project.js create mode 100644 app/ui/shell/tests/project.test.js diff --git a/app/shell/tests/projects.test.js b/app/shell/tests/projects.test.js index 4926fb9b..2ae77b46 100644 --- a/app/shell/tests/projects.test.js +++ b/app/shell/tests/projects.test.js @@ -318,9 +318,10 @@ test("an edit made WHILE an import is in flight is still saved, against the old 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"); + // Only this edit's own PUT: an earlier test's still-pending debounce can fire into this fetch too. + const mine = sent.filter((body) => body.name === "Typed while importing"); + assert.equal(mine.length, 1, "the edit made during the import never reached the server"); + assert.equal(mine[0].id, "abcdef012345", "saved against the OLD project, not the one now open"); }); test("two imports racing land on whichever was clicked last, not whichever answered last", async () => { diff --git a/app/ui/boot.js b/app/ui/boot.js index c20b18fa..7a94fdbe 100644 --- a/app/ui/boot.js +++ b/app/ui/boot.js @@ -1,12 +1,23 @@ -// Entry point: build the frame, load the features. Nothing here names a feature. +// Entry point: build the frame, start the core, load the features. Nothing here names a feature. import { build } from "./shell/frame.js"; import { loadFeatures } from "./shell/features.js"; import { createSettings } from "./shell/settings.js"; +import { createProject } from "./shell/project.js"; import { hostFor } from "./shell/mounts.js"; import paths from "./modules.js"; +// What the core offers a feature, by name. `on(fn)` hears every change; fn gets "open" when a +// different project was opened, "change" for an edit. +const heard = new Set(); +const say = (what) => heard.forEach((fn) => { try { fn(what); } catch (err) { console.error(err); } }); +const app = { + project: createProject({ onChange: () => say("change"), onOpen: () => say("open"), onError: (err) => console.warn(err) }), + on: (fn) => { heard.add(fn); return () => heard.delete(fn); }, +}; + const frame = build(document.getElementById("app")); const settings = createSettings({ menubar: hostFor("menubar.menus") }); -const result = await loadFeatures(paths); +const result = await loadFeatures(paths, { app }); settings.addButton(); -window.FunPackUI = { frame, settings, ...result }; +app.project.start().catch((err) => console.warn("could not open a project:", err.message)); +window.FunPackUI = { frame, settings, app, ...result }; diff --git a/app/ui/catalogue/demos.js b/app/ui/catalogue/demos.js index 632b3657..f2d616d7 100644 --- a/app/ui/catalogue/demos.js +++ b/app/ui/catalogue/demos.js @@ -322,6 +322,10 @@ export const DEMOS = { { id: "2", label: "anchor_frame.png", hint: "image" }, { id: "3", label: "a_very_long_generated_filename_00042_.png", hint: "image" }, ] }, + "gallery.rows": { selection: ["1"], items: [ + { id: "1", label: "export-size-test", hint: "3▦" }, + { id: "2", label: "Race Test 2", hint: "1▦" }, + ] }, "gallery.cards": { value: "image", items: [ { id: "blank", icon: "▦", label: "Empty project", hint: "Nothing on the timeline" }, { id: "image", icon: "◐", label: "From an image", hint: "Adds it as the first anchor" }, diff --git a/app/ui/composer/elements/gallery.css b/app/ui/composer/elements/gallery.css index 5d14a2ef..d781adf2 100644 --- a/app/ui/composer/elements/gallery.css +++ b/app/ui/composer/elements/gallery.css @@ -144,3 +144,13 @@ font-size: var(--fs-8); line-height: var(--lh-8); color: var(--faint); font-variant-numeric: tabular-nums; } + +.cx-pick-rows { display: flex; flex-direction: column; gap: var(--sp-1); } +.cx-pick-row { + display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-3) var(--sp-2); text-align: left; + border: var(--bw) solid transparent; border-radius: var(--ctl-r); background: none; color: var(--text); cursor: pointer; +} +.cx-pick-row:hover { background: var(--elev-2); } +.cx-pick-row.cx-on { background: var(--accent-soft); border-color: color-mix(in srgb, var(--accent) 27%, transparent); } +.cx-pick-name { flex: 1; min-inline-size: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.cx-pick-hint { font-size: var(--fs-6); color: var(--faint); font-variant-numeric: tabular-nums; } diff --git a/app/ui/composer/elements/gallery.js b/app/ui/composer/elements/gallery.js index 6b1b4f6f..0074db41 100644 --- a/app/ui/composer/elements/gallery.js +++ b/app/ui/composer/elements/gallery.js @@ -154,6 +154,18 @@ define("gallery", "list", (props = {}) => { }; }); +/** Rows of a name and what it is, nothing to look at: a list of projects. */ +define("gallery", "rows", (props = {}) => { + const body = el("div", { cls: "cx-pick-rows", attrs: { role: "listbox" } }); + const api = collection(props, body, (item, on) => el("button", { + cls: ["cx-pick-row", on ? "cx-on" : null, "cx-focusable"], + attrs: option(on), + children: [el("span", { cls: "cx-pick-name", text: item.label }), + item.hint ? el("span", { cls: "cx-pick-hint", text: item.hint }) : null].filter(Boolean), + })); + return { node: body, get value() { return api.value; }, setValue: api.setValue, setItems: api.setItems, destroy: () => body.remove() }; +}); + /** Large clickable cards: the wizard's "what are you making?" choices. */ define("gallery", "cards", ({ items = [], value, onActivate } = {}) => { let current = value; diff --git a/app/ui/features/placeholders/index.js b/app/ui/features/placeholders/index.js index e63e623b..2191dae3 100644 --- a/app/ui/features/placeholders/index.js +++ b/app/ui/features/placeholders/index.js @@ -11,10 +11,6 @@ const items = { "menubar.menus": () => ["File", "Edit", "View", "Help"].map(menu), "menubar.right": () => [c.chip.neutral({ label: "saved" }), c.chip.good({ label: "ComfyUI live", dot: true })], - "assets.actions": () => [], - assets: () => [c.toolbar.default({ items: [c.text.sm({ text: "Projects" })], trailing: [btn("+ New")] }), - c.hint.default({ text: "Your projects appear here." })], - preview: () => [ c.viewer.media({ empty: "No render yet. Use Generate in the timeline header." }), c.toolbar.default({ items: [btn("⏹"), btn("▶"), c.text.sm({ text: "00:00:00:00" }), btn("📌 Save frame")] }), diff --git a/app/ui/features/projects/index.js b/app/ui/features/projects/index.js new file mode 100644 index 00000000..a4760c17 --- /dev/null +++ b/app/ui/features/projects/index.js @@ -0,0 +1,27 @@ +// The Assets zone's project list: every project, the open one marked, "New" to start another. +import { composer as c } from "../../composer/composer.js"; +import { list } from "../../shell/project.js"; + +export default { + id: "projects", + mount: "assets", + needs: ["project"], + setup({ host, app }) { + const p = app.project; + const rows = c.gallery.rows({ items: [], onActivate: (item) => p.open(item.id) }); + const news = c.button.sm({ label: "+ New", onClick: async () => { + const name = await c.modal.prompt({ title: "New project", label: "Name", value: "Untitled" }).result; + if (name) await p.newProject(name.trim() || "Untitled"); + } }); + host.append(c.toolbar.default({ items: [c.text.sm({ text: "Projects" })], trailing: [news] }).node, rows.node); + + let known = []; + const draw = () => { + rows.setItems(known.map((r) => ({ id: r.id, label: r.name, hint: r.scene_count == null ? "" : `${r.scene_count}▦` }))); + if (p.project) rows.setValue([p.project.id]); + }; + const refresh = () => list().then((all) => { known = all; draw(); }).catch(() => draw()); + refresh(); + return app.on((what) => { if (what === "open") refresh(); else draw(); }); + }, +}; diff --git a/app/ui/modules.js b/app/ui/modules.js index 4f11fc55..e8eb1cb2 100644 --- a/app/ui/modules.js +++ b/app/ui/modules.js @@ -1,6 +1,7 @@ // The one list of UI features. Adding a feature is a folder under ./features and a line here. export default [ "./features/log/index.js", + "./features/projects/index.js", "./features/placeholders/index.js", "./features/temp/index.js", ].map((path) => new URL(path, import.meta.url).href); diff --git a/app/ui/shell/features.js b/app/ui/shell/features.js index 791525bc..d0c23304 100644 --- a/app/ui/shell/features.js +++ b/app/ui/shell/features.js @@ -1,31 +1,32 @@ // Loads UI features. A feature file's default export is one hub, or several: -// { id, mount, needs?, setup({ host }) -> teardown? } +// { id, mount, needs?, setup({ host, app }) -> teardown? } // `mount` names a region (see mounts.js). A feature that cannot load, names a region nobody offers, // needs a service the app lacks, or throws in setup is hidden -- nothing of it reaches the screen. import { hostFor, offered } from "./mounts.js"; -function mountOne(hub, has) { +function mountOne(hub, has, app) { const host = hostFor(hub.mount); if (!host) throw new Error(`no region offers "${hub.mount}" (offered: ${offered().join(", ")})`); const lacking = (hub.needs || []).filter((name) => !has(name)); if (lacking.length) throw new Error(`needs ${lacking.join(", ")}`); const before = [...host.childNodes]; try { - return { id: hub.id, teardown: hub.setup({ host }) }; + return { id: hub.id, teardown: hub.setup({ host, app }) }; } catch (err) { // whatever it put on the page goes with it [...host.childNodes].filter((n) => !before.includes(n)).forEach((n) => n.remove()); throw err; } } -export async function loadFeatures(paths, { load = (path) => import(path), has = () => true } = {}) { +// `app` is what the core offers features (project, ...). A feature names what it needs; a missing one hides it. +export async function loadFeatures(paths, { load = (path) => import(path), app = {}, has = (name) => name in app } = {}) { const mounted = [], hidden = []; const hide = (id, err) => { hidden.push({ id, why: err.message }); console.warn(`[FunPack] ${id} hidden: ${err.message}`); }; for (const path of paths) { let hubs; try { hubs = [].concat((await load(path)).default); } catch (err) { hide(path, err); continue; } for (const hub of hubs) { - try { mounted.push(mountOne(hub, has)); } catch (err) { hide(hub?.id || path, err); } + try { mounted.push(mountOne(hub, has, app)); } catch (err) { hide(hub?.id || path, err); } } } return { mounted, hidden }; diff --git a/app/ui/shell/project.js b/app/ui/shell/project.js new file mode 100644 index 00000000..1e220870 --- /dev/null +++ b/app/ui/shell/project.js @@ -0,0 +1,463 @@ +// 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, onOpen } = {}) { + let project = null; + let selected = null; + // Every project this browser knows about, for the File menu. Read once at + // startup and kept in step here rather than re-fetched each time the menu + // opens: a listing is not a thing that changes behind the user's back. + let recent = []; + // Undo, the cheap way: a project is plain JSON and a few kilobytes, so a + // snapshot before each edit costs nothing worth measuring and needs no + // per-action inverse. Bounded, or a long session keeps every version of a + // project it has ever had. + // ponytail: whole-project snapshots, capped at 40. Per-field diffs only if a + // project ever gets big enough for the copy to show up in a profile. + const LIMIT = 40; + let past = []; + let future = []; + // The selection travels with the snapshot. It is view state and not saved, + // but undoing a removal and landing on a different scene than the one that + // came back is not what anyone means by "take that back". + const copy = () => ({ project: JSON.parse(JSON.stringify(project)), selected }); + 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(); }; + + /** Before an edit. Anything that changes what is SAVED calls this; selecting + * a scene does not, because a view is not an edit. */ + function remember() { + if (!project) return; + past.push(copy()); + if (past.length > LIMIT) past.shift(); + // A new edit ends the redo line. Keeping it would let a user redo their way + // into a version that never followed from what they are looking at. + future = []; + } + + function step(from, to) { + 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 + // where it had been removed. Fall back rather than leaving a selection that + // names nothing, which reads as "no scene" in every panel that draws it. + if (!(project.scenes || []).some((sc) => sc.id === selected)) { + selected = (project.scenes || [])[0]?.id ?? null; + } + scheduleSave(); + opened(); // the project was REPLACED, not edited + return true; + } + // A DIFFERENT project is open now -- which is not the same event as a field + // changing, and the things that follow a project are not the things that + // follow an edit. Fired by every route in: start, open, new. + const opened = () => { changed(); if (onOpen) onOpen(project); }; + 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(target), SAVE_AFTER); + } + + async function flush(target = project) { + if (timer) { clearTimeout(timer); timer = null; } + 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 = { ...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 (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); + }).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. */ + 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; + 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 + opened(); + return project; + }, + + async open(id) { + const mine = ++generation; + await flush(); + 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(); + return project; + }, + + async newProject(name) { + const mine = ++generation; + await flush(); + 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 + opened(); + 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; + changed(); // selection is not saved: it is a view + }, + + /** What it is called. The listing in the File menu reads this too, so both + * move together rather than the menu going stale until a reload. */ + rename(name) { + const clean = String(name || "").trim(); + if (!project || !clean || project.name === clean) return; + remember(); + project.name = clean; + const listed = recent.find((p) => p.id === project.id); + if (listed) listed.name = clean; + scheduleSave(); + changed(); + }, + + /** What the whole project generates at, whichever scene is current. */ + get video() { return (project && project.video) || {}; }, + + setVideo(key, value) { + if (!project) return; + project.video = project.video || {}; + if (project.video[key] === value) return; + remember(); + project.video[key] = value; + scheduleSave(); + // The timeline's clip widths and its ruler are computed from the project's + // length. This said "nothing on screen draws from this" and was true when + // it was written -- the timeline started reading it an hour later, and a + // comment is not a thing the next change has to keep true. + changed(); + }, + + /** The negative prompt, whichever scene is current -- a project-level + * choice, the same as video, not a per-scene one. */ + get negative() { return (project && project.negative) || ""; }, + + setNegative(value) { + if (!project) return; + if (project.negative === value) return; + remember(); + project.negative = value; + scheduleSave(); + changed(); + }, + + /** Text prepended to every scene's prompt at generation (core/prompt_build.py) + * -- a project-level choice, the same as negative. */ + get anchor() { return (project && project.anchor) || ""; }, + + setAnchor(value) { + if (!project) return; + if (project.anchor === value) return; + remember(); + project.anchor = value; + scheduleSave(); + changed(); + }, + + /** Text appended to every scene's prompt, when postfixEnabled is true. */ + get postfix() { return (project && project.postfix) || ""; }, + + setPostfix(value) { + if (!project) return; + if (project.postfix === value) return; + remember(); + project.postfix = value; + scheduleSave(); + changed(); + }, + + get postfixEnabled() { return project ? project.postfix_enabled !== false : true; }, + + setPostfixEnabled(value) { + if (!project) return; + if (project.postfix_enabled === value) return; + remember(); + project.postfix_enabled = value; + scheduleSave(); + changed(); + }, + + /** $name -> text, substituted into anchor/scene/postfix at generation. + * A list, not a dict: order is what the user set, and the whole list is + * replaced at once -- one row is typed at a time, but no row can be + * addressed by name alone while it is still being renamed. */ + get variables() { return (project && project.variables) || []; }, + + setVariables(list) { + if (!project) return; + remember(); + project.variables = list; + scheduleSave(); + changed(); + }, + + /** The text of one scene. The prompt box on the main window edits this. */ + setText(id, text) { this.setScene(id, "text", text); }, + + 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. + remember(); + const at = project.scenes.findIndex((s) => s.id === selected); + const scene = { + id: newId(), text: "", result: null, length: null, rating: null, + // Matching core's Scene dataclass shape exactly: the Inspector reads + // scene.references unconditionally (a list to map over, never a + // maybe-undefined field), and a scene born here has to look the same + // as one that came back from the server. + source_image: null, references: [], + }; + 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; + remember(); + 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; + remember(); + const [scene] = project.scenes.splice(at, 1); + project.scenes.splice(to, 0, scene); + scheduleSave(); + changed(); + }, + + /** One field of one scene. Everything a scene holds but its id goes through + * here, so there is one place that saves and one that says it changed. */ + setScene(id, key, value) { + const scene = sceneAt(id); + if (!scene || scene[key] === value) return; + remember(); + scene[key] = value; + scheduleSave(); + changed(); + }, + + /** Attach what a run produced to the scene it was started from. */ + setResult(id, result) { this.setScene(id, "result", result); }, + + /** The media library id sizing this scene's generation, or null for none. */ + setSourceImage(id, mediaId) { this.setScene(id, "source_image", mediaId); }, + + /** Media library ids, in order -- this scene's own reference images. */ + setReferences(id, mediaIds) { this.setScene(id, "references", mediaIds); }, + + /** + * The same, but for a run that may have finished after the user switched + * to a DIFFERENT project. A run takes minutes and nothing stops someone + * from opening another project while one is in flight -- `setResult` + * alone would look the scene id up in whatever is open NOW, find nothing, + * and drop the result with no trace: the GPU time spent, the image gone. + */ + async setResultFor(projectId, sceneId, result) { + if (project && project.id === projectId) { + this.setResult(sceneId, result); + return; + } + try { + const doc = await read(projectId); + const scene = (doc.scenes || []).find((s) => s.id === sceneId); + if (!scene) return; // the scene itself is gone -- nothing to attach this to + scene.result = result; + await put(doc); + } catch (err) { + if (onError) onError(err); + } + }, + + /** Step back, or forward. True when something moved. */ + undo() { return step(past, future); }, + redo() { return step(future, past); }, + get canUndo() { return Boolean(project) && past.length > 0; }, + get canRedo() { return Boolean(project) && future.length > 0; }, + + flush, + get unsaved() { return dirty; }, + }; +} diff --git a/app/ui/shell/tests/project.test.js b/app/ui/shell/tests/project.test.js new file mode 100644 index 00000000..20e4a0d0 --- /dev/null +++ b/app/ui/shell/tests/project.test.js @@ -0,0 +1,611 @@ +// What the project generates at. +// +// The scene text has its own path (boot wires the prompt box to it); this is +// the other half -- settings that belong to the project rather than to any one +// scene, which is what makes "regenerate" mean "again, at the same size". + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { setupDom, teardownDom } from "../../composer/tests/_dom.js"; + +let createProject; +test.before(async () => { + setupDom(); + ({ createProject } = await import("../project.js")); +}); +test.after(() => teardownDom()); + +/** A server that remembers the last body it was PUT. */ +function server({ video = {}, negative = "" } = {}) { + const sent = []; + const project = { id: "abcdef012345", name: "Untitled", scenes: [], video, negative, updated_at: 1 }; + globalThis.fetch = async (path, opts = {}) => { + const body = opts.body ? JSON.parse(opts.body) : null; + if (opts.method === "PUT") sent.push(body); + return { + ok: true, status: 200, + json: async () => (path.endsWith("/projects") && !opts.method + ? { projects: [{ id: project.id }] } + : (opts.method === "PUT" ? { ...body, updated_at: 2 } : project)), + }; + }; + return { sent, project }; +} + +test("a setting is kept on the project and saved with it", async () => { + const { sent } = server(); + const p = createProject({}); + await p.start(); + + p.setVideo("width", 832); + p.setVideo("length", 97); + await p.flush(); + + assert.deepEqual(p.video, { width: 832, length: 97 }); + assert.deepEqual(sent.at(-1).video, { width: 832, length: 97 }); +}); + +test("setting a value to what it already is saves nothing", async () => { + // Every commit of a number box calls this, including the ones that changed + // nothing -- a blur is a commit. Each would be a PUT of the whole project. + const { sent } = server({ video: { width: 512 } }); + const p = createProject({}); + await p.start(); + + p.setVideo("width", 512); + await p.flush(); + assert.equal(sent.length, 0, "an unchanged value was saved anyway"); + + p.setVideo("width", 832); + await p.flush(); + assert.equal(sent.length, 1); +}); + +test("the settings a project was opened with are what it reports", async () => { + server({ video: { width: 832, height: 480 } }); + const p = createProject({}); + await p.start(); + assert.deepEqual(p.video, { width: 832, height: 480 }); +}); + +test("with no project open there is nothing to set and nothing breaks", () => { + const p = createProject({}); + p.setVideo("width", 832); + assert.deepEqual(p.video, {}); +}); + +test("the negative prompt does not follow a switch to another project", async () => { + // The bug this guards: negative prompt used to have no per-project sync at + // all (unlike video), so it silently carried over from whichever project + // was open last -- typed for one project, sent as another's. This is the + // same store shape as "a run's result still lands on its scene..." above, + // for the same reason: a single store, switched with open(), not two. + const store = { + a: { id: "aaaaaaaaaaaa", name: "A", scenes: [{ id: "scene-a", text: "" }], negative: "", updated_at: 1 }, + b: { id: "bbbbbbbbbbbb", name: "B", scenes: [{ id: "scene-b", text: "" }], negative: "", updated_at: 1 }, + }; + globalThis.fetch = async (path, opts = {}) => { + if (path === "/funpack/api/projects") { + return { ok: true, status: 200, json: async () => ({ projects: [{ id: store.a.id }, { id: store.b.id }] }) }; + } + const id = decodeURIComponent(String(path).split("/").pop()); + const key = id === store.a.id ? "a" : "b"; + if (opts.method === "PUT") { + const body = JSON.parse(opts.body); + store[key] = body; + return { ok: true, status: 200, json: async () => ({ ...body, updated_at: (store[key].updated_at || 1) + 1 }) }; + } + return { ok: true, status: 200, json: async () => store[key] }; + }; + + const p = createProject({}); + await p.start(); // opens project A + p.setNegative("blurry, low quality"); + await p.flush(); + + await p.open(store.b.id); + assert.equal(p.negative, "", "project B opened holding project A's negative prompt"); +}); + +test("setting the negative prompt to what it already is saves nothing", async () => { + const { sent } = server({ negative: "blurry" }); + const p = createProject({}); + await p.start(); + + p.setNegative("blurry"); + await p.flush(); + assert.equal(sent.length, 0, "an unchanged value was saved anyway"); + + p.setNegative("smudged"); + await p.flush(); + assert.equal(sent.length, 1); +}); + +test("a scene added on the client has the same shape as one the server sent", async () => { + // core's Scene dataclass always has source_image/references -- the + // Inspector reads scene.references unconditionally (a list to map over, + // never a maybe-undefined field). A scene born here that skipped them + // crashed the very next thing that tried to draw it. + server(); + const p = createProject({}); + await p.start(); + p.addScene(); + const scene = p.selected; + assert.equal(scene.source_image, null); + assert.deepEqual(scene.references, []); +}); + +test("a run's result lands on the scene it was started for, not the current one", async () => { + // A run takes minutes and the user goes on clicking. Reading "the selected + // scene" when it finishes attaches the picture to whatever they wandered to. + const { sent } = server(); + const p = createProject({}); + await p.start(); + p.addScene(); + p.addScene(); + const [first, second] = p.scenes.map((s) => s.id); + + const startedFor = first; // what boot captures at Generate + p.select(second); // the user moves on + p.setResult(startedFor, "/view?filename=a.png"); + + assert.equal(p.scenes[0].result, "/view?filename=a.png"); + assert.equal(p.scenes[1].result, null); + await p.flush(); + assert.equal(sent.at(-1).scenes[0].result, "/view?filename=a.png"); +}); + +test("a run's result still lands on its scene after the user opened a DIFFERENT project", async () => { + // A run takes minutes; nothing stops the user opening another project while + // one is in flight. `setResult` alone looks the scene id up in whatever is + // open NOW -- finds nothing in the other project, and drops the result with + // no trace. `setResultFor` is what boot.js calls instead once it knows which + // project the run belongs to. + const store = { + a: { id: "aaaaaaaaaaaa", name: "A", scenes: [{ id: "scene-a", text: "", result: null }], updated_at: 1 }, + b: { id: "bbbbbbbbbbbb", name: "B", scenes: [{ id: "scene-b", text: "", result: null }], updated_at: 1 }, + }; + const puts = []; + globalThis.fetch = async (path, opts = {}) => { + if (path === "/funpack/api/projects") { + return { ok: true, status: 200, json: async () => ({ projects: [{ id: store.a.id }, { id: store.b.id }] }) }; + } + const id = decodeURIComponent(String(path).split("/").pop()); + const key = id === store.a.id ? "a" : "b"; + if (opts.method === "PUT") { + const body = JSON.parse(opts.body); + store[key] = body; + puts.push({ key, body }); + return { ok: true, status: 200, json: async () => ({ ...body, updated_at: (store[key].updated_at || 1) + 1 }) }; + } + return { ok: true, status: 200, json: async () => store[key] }; + }; + + const p = createProject({}); + await p.start(); // opens whatever `list()` names first -- project A + const startedInProject = p.project.id; + const startedForScene = p.selected.id; + assert.equal(startedInProject, store.a.id); + + await p.open(store.b.id); // the user wanders off to a different project + assert.equal(p.project.id, store.b.id); + + await p.setResultFor(startedInProject, startedForScene, "/view?filename=a.png"); + + // The project on screen (B) must be untouched. + assert.equal(p.scenes[0].result, null); + // Project A, not open, got the result written straight to its saved copy. + const saved = puts.find((x) => x.key === "a"); + assert.ok(saved, "project A was never saved"); + assert.equal(saved.body.scenes[0].result, "/view?filename=a.png"); +}); + +test("a result for a project that is open again goes through the normal live path", async () => { + // The user switched away and back before the run finished -- the project is + // open again by the time the result lands, so it should update on screen + // immediately rather than only reaching the server. + server(); + const p = createProject({}); + await p.start(); + p.addScene(); + const id = p.project.id; + const sceneId = p.selectedId; + + 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 + + // Only this edit's own PUT: an earlier test's still-pending debounce can fire into this fetch too. + const mine = sent.filter((body) => body.name === "Typed while importing"); + assert.equal(mine.length, 1, "the edit made during the import never reached the server"); + assert.equal(mine[0].id, "abcdef012345", "saved against the OLD project, not the one now open"); +}); + +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 () => { + // The listing and the open project are the same name seen twice; a menu that + // kept the old one until a reload is a menu that lies about what is open. + const { sent } = server(); + const p = createProject({}); + await p.start(); + + p.rename("Rooftops"); + assert.equal(p.project.name, "Rooftops"); + assert.deepEqual(p.recent.map((r) => r.name), ["Rooftops"]); + + await p.flush(); + assert.equal(sent.at(-1).name, "Rooftops"); +}); + +test("a rename that says nothing changes nothing", async () => { + const { sent } = server(); + const p = createProject({}); + await p.start(); + + p.rename(" "); + p.rename("Untitled"); + await p.flush(); + assert.equal(sent.length, 0); +}); + +test("changing a project setting tells whoever draws from it", async () => { + // The timeline's clip widths and its ruler are computed from the project's + // length. A setting that changes without saying so leaves them showing the + // proportions of the value before it. + server(); + const drew = []; + const p = createProject({ onChange: () => drew.push(1) }); + await p.start(); + const before = drew.length; + + p.setVideo("length", 97); + assert.ok(drew.length > before, "nothing was told the project changed"); +}); + +// --- undo --------------------------------------------------------------- + +test("an edit can be taken back, and put back again", async () => { + server(); + const p = createProject({}); + await p.start(); + p.addScene(); + const id = p.selectedId; + + p.setText(id, "a cat"); + p.setText(id, "a cat on a roof"); + assert.equal(p.selected.text, "a cat on a roof"); + + assert.equal(p.undo(), true); + assert.equal(p.selected.text, "a cat"); + assert.equal(p.undo(), true); + assert.equal(p.selected.text, ""); + + assert.equal(p.redo(), true); + assert.equal(p.selected.text, "a cat"); +}); + +test("undo puts back a scene that was removed, and the selection with it", async () => { + server(); + const p = createProject({}); + await p.start(); + p.addScene(); + p.addScene(); + const [first, second] = p.scenes.map((s) => s.id); + p.select(second); + + p.removeScene(second); + assert.equal(p.scenes.length, 1); + + p.undo(); + assert.equal(p.scenes.length, 2); + assert.ok(p.scenes.some((s) => s.id === second), "the scene came back as a different one"); + assert.equal(p.selectedId, second, "the selection did not follow the scene back"); + void first; +}); + +test("a selection that no longer names anything falls back rather than emptying", async () => { + // Undo can remove the scene that is selected. A selection naming nothing + // reads as "no scene" in every panel that draws from it. + server(); + const p = createProject({}); + await p.start(); + p.addScene(); + p.addScene(); + const added = p.selectedId; + + p.undo(); // takes the second scene away again + assert.ok(p.selectedId, "the selection was left naming nothing"); + assert.notEqual(p.selectedId, added); + assert.ok(p.scenes.some((s) => s.id === p.selectedId)); +}); + +test("a new edit ends the redo line", async () => { + // Otherwise a redo walks into a version that never followed from what is on + // screen. + server(); + const p = createProject({}); + await p.start(); + p.addScene(); + const id = p.selectedId; + + p.setText(id, "one"); + p.undo(); + assert.equal(p.canRedo, true); + + p.setText(id, "two"); + assert.equal(p.canRedo, false); + assert.equal(p.redo(), false); +}); + +test("undo with nothing behind it does nothing, and says so", async () => { + server(); + const p = createProject({}); + await p.start(); + assert.equal(p.canUndo, false); + assert.equal(p.undo(), false); + assert.equal(p.redo(), false); +}); + +test("a project just opened has no past to step into", async () => { + // Undoing across a project switch would replace the open project with a + // version of a DIFFERENT one. + server(); + const p = createProject({}); + await p.start(); + p.addScene(); + assert.equal(p.canUndo, true); + + await p.newProject("Another"); + assert.equal(p.canUndo, false, "undo reached back past the project switch"); +}); + +test("taking an edit back saves it, and tells whoever draws it", async () => { + const { sent } = server(); + const drew = []; + const opens = []; + const p = createProject({ onChange: () => drew.push(1), onOpen: () => opens.push(1) }); + await p.start(); + p.addScene(); + p.setText(p.selectedId, "a cat"); + await p.flush(); + + const before = drew.length; + p.undo(); + assert.ok(drew.length > before, "nothing was told the project moved"); + assert.ok(opens.length > 1, "the controls that follow a project were not put back"); + + await p.flush(); + assert.equal(sent.at(-1).scenes.at(-1).text, "", "the version saved was not the one on screen"); +});