Files
olivv-cs-ComfyUI-FunPack/app/shell/projects.js
T
digital-garbage c338769431 app: the run used the project you left, not the one you opened
Found by the adversarial pass, reproduced twice.

Switching projects put the new project's numbers in the store and left the
OLD ones in the controls that produce what a run actually sends. The store's
own getter reported the new project correctly and the Inspector agreed, so
the only place the stale value showed was the Constructor -- a window nobody
has a reason to reopen after switching. Generate then ran at the size of the
project you left.

The attacker found it on the File-menu path. It is on three: opening from the
menu, opening programmatically, and applying a pipeline change, which rebuilds
every control from the PIPELINE's defaults and discards the project's. So the
fix is not in the menu handler -- the store now says when a DIFFERENT project
is open, which is not the same event as a field changing, and everything that
follows a project is wired to that instead of to the one route in that
happened to be reported.

Second finding, same pass: setVideo said "nothing on screen draws from this"
and did not report the change. True when written; the timeline started
computing its clip widths and its ruler from that value an hour later. A
comment is not a thing the next change has to keep true.

Also serial-mode for the project spec: two tests making and switching projects
share one real store on the dev server, and the config runs a file in
parallel. A race over a JSON directory, not a bug in the app.
2026-09-06 13:07:08 +03:00

228 lines
8.5 KiB
JavaScript

// 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 = [];
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(); };
// 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); };
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. */
get recent() { return recent; },
async start() {
const found = await list();
recent = found;
project = found.length ? await read(found[0].id) : await create("Untitled");
if (!found.length) recent = [{ id: project.id, name: project.name }];
selected = (project.scenes || [])[0]?.id ?? null;
opened();
return project;
},
async open(id) {
await flush();
project = await read(id);
selected = (project.scenes || [])[0]?.id ?? null;
opened();
return project;
},
async newProject(name) {
await flush();
project = await create(name);
recent = [{ id: project.id, name: project.name }, ...recent];
selected = (project.scenes || [])[0]?.id ?? null;
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;
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;
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 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.
const at = project.scenes.findIndex((s) => s.id === selected);
const scene = { id: newId(), text: "", result: null, length: null, rating: 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();
},
/** 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;
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); },
flush,
get unsaved() { return dirty; },
};
}