// 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 = "/fun/api/projects"; /** * 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, keepalive) { const res = await fetch(path, { method, keepalive, 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, keepalive) => json("PUT", `${BASE}/${encodeURIComponent(project.id)}`, project, keepalive); /** * 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. */ const TYPING = 1200; // ms after the last keystroke that still counts as typing const KEPT = ["editor_settings", "models"]; export function createProject({ onChange, onError, onOpen, keepConsistent, beforeSwitch = async () => {} } = {}) { 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 lastKey = 0; if (typeof document !== "undefined") document.addEventListener("keydown", () => { lastKey = Date.now(); }, true); 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; let fresh = false; // the open project was made just now (it takes the live pipeline as its own) // `keepConsistent(project)` restates what must follow from an edit (audio lanes follow their clips) before anyone is told. const changed = () => { if (project && keepConsistent) keepConsistent(project); 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; const kept = KEPT.map((k) => project[k]); // preferences and the chosen pipeline are not edits: stepping back never takes them with it project = was.project; KEPT.forEach((k, i) => { if (kept[i] !== undefined) project[k] = kept[i]; }); 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); const every = Number(target && target.editor_settings && target.editor_settings.autosave_sec); const tick = () => { // Never save under someone's fingers: wait until they have stopped typing. const wait = TYPING - (Date.now() - lastKey); if (wait > 0) timer = setTimeout(tick, wait); else flush(target); }; timer = setTimeout(tick, (every > 0 ? every : 3) * 1000); } async function flush(target = project, leaving = false) { if (timer) { clearTimeout(timer); timer = null; } if (!target || !dirty) return saving; // nothing new to write, but one on its way is waited for // 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 saving; } // another flush wrote it meanwhile: wait for that write dirty = false; const body = { ...target }; saving = put(body, leaving).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 fresh() { return fresh; }, 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_; fresh = !found.length; 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 beforeSwitch(); await flush(); // an edit still landing elsewhere (the pipeline) reaches THIS project first 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; fresh = false; selected = (project.scenes || [])[0]?.id ?? null; forget(); // a project just opened has no past opened(); return project; }, async newProject(name) { const mine = ++generation; await beforeSwitch(); 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; fresh = true; 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 beforeSwitch(); await flush(); const imported = await json("POST", `${BASE}/import`, data); if (mine !== generation) { remove(imported.id).catch(() => {}); // superseded -- see newProject() return project; } project = imported; fresh = false; recent = [{ id: project.id, name: project.name }, ...recent]; selected = (project.scenes || [])[0]?.id ?? null; // a project file carries no media: the pictures it named are not in this bin const carried = Object.keys(project.fixed_references || {}).length > 0 || (project.scenes || []).some((sc) => (sc.references || []).length); project.fixed_references = {}; for (const sc of project.scenes || []) sc.references = []; forget(); opened(); // the cleared state is what the server keeps too (not through the edit timer: a pending edit of the old project must stay pending) if (carried) put({ ...imported }).then((saved) => { if (saved) imported.updated_at = saved.updated_at; }).catch((err) => { if (onError) onError(err); }); 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(); }, /** One undo step for a compound change: `fn(project)` mutates the open project in place * (see edits.js). Returns what fn returned; a falsy answer means nothing changed, and nothing is kept. */ edit(fn) { if (!project) return false; const before = JSON.stringify(project); remember(); let result; try { result = fn(project); } catch (err) { // a half-made edit must not stay in memory to be saved by the next one Object.keys(project).forEach((k) => delete project[k]); Object.assign(project, JSON.parse(before)); past.pop(); throw err; } if (!result || JSON.stringify(project) === before) { past.pop(); return result; } if (!sceneAt(selected)) selected = ((project.scenes || [])[0] || {}).id || null; // the selected scene was removed scheduleSave(); changed(); return result; }, /** Any project-level field. `quiet` is for what the app maintains by itself (the pipeline's copy): * saved, but not an edit a person made, so not an undo step. */ setField(key, value, { quiet = false } = {}) { if (!project) return; if (!quiet) remember(); project[key] = value; scheduleSave(); if (!quiet) 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: project.fixed_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(); }, /** Editor preferences that travel with the project (a new rental has an empty browser). */ pref(key, fallback) { const s = project && project.editor_settings; return s && key in s ? s[key] : fallback; }, setPref(key, value) { if (!project || this.pref(key) === value) return; (project.editor_settings ||= {})[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); } }, /** `edit(fn)` on a project that may not be the open one: a run finishes for the project it was started in, * whatever is open by then. The open project takes the normal edit; another is read, changed and saved. */ async editFor(projectId, fn) { if (project && project.id === projectId) return this.edit(fn); const doc = await read(projectId); if (!fn(doc)) return false; await put(doc); return true; }, /** 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, /** The page is going away (reload, close): write what the autosave timer has not yet, in a request that outlives the page. */ leave() { if (dirty && project) flush(project, true); }, get unsaved() { return dirty; }, }; }