From 74cbd3c3bb875b1596c6dccfddbaec0bdaf9387e Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Sun, 4 Oct 2026 02:05:39 +0300 Subject: [PATCH] fix(ui): prefs survive undo, Chat/Variables follow the open project, generate hooks replace a named enhancer, undo no longer re-upscales, bulb settles, media delete asks and names stay current Co-Authored-By: Claude Sonnet 5.5 --- app/ui/boot.js | 2 +- app/ui/features/composer/chat.js | 41 ++++++++++++++++--------- app/ui/features/composer/chat.test.js | 17 ++++++++++ app/ui/features/composer/index.js | 7 +++-- app/ui/features/composer/storytools.js | 10 +++--- app/ui/features/generate/index.js | 5 +-- app/ui/features/generate/inputs.js | 17 +++++----- app/ui/features/generate/inputs.test.js | 13 +++----- app/ui/features/ideas/index.js | 5 +-- app/ui/features/inspector/index.js | 4 +-- app/ui/features/inspector/mediapick.js | 13 ++++---- app/ui/features/media/index.js | 5 ++- app/ui/features/settings/editor.js | 1 + app/ui/features/upscale/index.js | 6 ++-- app/ui/shell/bin.js | 10 ++++++ app/ui/shell/project.js | 2 ++ app/ui/shell/tests/project.test.js | 13 ++++++++ 17 files changed, 115 insertions(+), 56 deletions(-) create mode 100644 app/ui/features/composer/chat.test.js create mode 100644 app/ui/shell/bin.js diff --git a/app/ui/boot.js b/app/ui/boot.js index fd866077..72a7cfc6 100644 --- a/app/ui/boot.js +++ b/app/ui/boot.js @@ -29,7 +29,7 @@ linkPipeline({ project, pipeline, onOpen: (fn) => on((what) => { if (what === "o const frame = build(document.getElementById("app")); const selection = createSelection({ project }); selection.on(() => say("select")); -const app = { editorSettings: [], project, pipeline, generate, api, on, say, title: frame.setZoneTitle, selection, playhead: createPlayhead(), theme: window.ComposerTheme, mode: createMode(), panel: frame.togglePanel, closePanels: frame.closePanels, dock: createDock(), maintenance: createMaintenance({ api, flush: async () => { await project.flush(); if (project.unsaved) throw new Error("Your latest edits could not be saved, so nothing was changed. Try again in a moment."); } }), keys: createKeys(), resetLayout: () => { frame.resetLayout(); app.dock.reset(); } }; +const app = { editorSettings: [], inputHooks: [], project, pipeline, generate, api, on, say, title: frame.setZoneTitle, selection, playhead: createPlayhead(), theme: window.ComposerTheme, mode: createMode(), panel: frame.togglePanel, closePanels: frame.closePanels, dock: createDock(), maintenance: createMaintenance({ api, flush: async () => { await project.flush(); if (project.unsaved) throw new Error("Your latest edits could not be saved, so nothing was changed. Try again in a moment."); } }), keys: createKeys(), resetLayout: () => { frame.resetLayout(); app.dock.reset(); } }; const settings = createSettings({ menubar: hostFor("menubar.menus") }); const result = await loadFeatures(paths, { app }); settings.addButton(); diff --git a/app/ui/features/composer/chat.js b/app/ui/features/composer/chat.js index 9d1d7d09..d628a8e6 100644 --- a/app/ui/features/composer/chat.js +++ b/app/ui/features/composer/chat.js @@ -1,11 +1,21 @@ // Chat: comment on the enhancer's last rewrite. The next Generate rewrites again with every comment still applying; Reset starts over. -// The comments are the project's own (editor_settings.enhance_chat) and reach the enhancer node with the run. +// The comments are the project's own (editor_settings.enhance_chat); a generate hook hands them to the enhancer node for the prompt they were about. import { composer as c } from "../../composer/composer.js"; import { genUnitId, isGenerative, unitRoot } from "../../shell/scenes.js"; +const NODE = "FunPackEnhancePrompt"; + +/** Generate hook: the comments about THIS typed prompt go to the enhancer slot; the others are said to be left out. */ +export function chatHook({ project, scene, slots }) { + const chat = (project.editor_settings || {}).enhance_chat, slot = (slots || []).find((s) => s.node === NODE); + if (!slot || !Array.isArray(chat)) return null; + const typed = (scene.text || "").trim(), mine = chat.filter((r) => r && (r.original === undefined || r.original === typed)), left = chat.length - mine.length; + return { inputs: mine.length ? { [slot.id]: { chat: JSON.stringify(mine) } } : {}, notes: left ? [`${left} Chat comment(s) are about a different prompt and were left out of this run.`] : [] }; +} + export function chat(app, own) { const p = app.project, page = c.region.stack({ gap: "sm" }); - let last = null, alive = true; + let last = null, alive = true, key = "", draft = ""; own(() => { alive = false; }); const rounds = () => { const r = p.pref("enhance_chat", []); return Array.isArray(r) ? r : []; }; const typed = () => { // the prompt a comment is about: the picked scene's own text, as typed @@ -14,31 +24,32 @@ export function chat(app, own) { return root ? (root.text || "").trim() : undefined; }; const first = (r) => Object.values(r.rewrites || {})[0]; - let draft = ""; - function draw() { + function draw(force) { if (!alive) return; + const list = rounds(), ok = last && last.ok, next = JSON.stringify([p.project && p.project.id, list, last && last.after]); + if (!force && next === key) return; + key = next; if (!p.project) return page.set([c.hint.default({ text: "Open a project first." })]); - const list = rounds(), ok = last && last.ok; const bubbles = [...list.flatMap((r) => [first(r) ? c.text.sm({ text: `Model: ${first(r)}` }) : null, c.text.sm({ text: `You: ${r.comment}` })]), ...(ok ? [c.hint.default({ text: `Last rewrite of: ${String(last.before || "").slice(0, 80)}` }), c.text.sm({ text: `Model: ${last.after}` })] : []), ...(!list.length && !ok ? [c.hint.default({ text: "Generate once with the enhancer on, then comment here." })] : [])].filter(Boolean); const box = c.textarea.md({ label: "Comment", rows: 2, value: draft, placeholder: "e.g. warmer light, no rain", onInput: (v) => { draft = v; } }); const send = () => { - const comment = draft.trim(); + const comment = draft.trim(), now = rounds(); if (!comment) return; - const prev = list.length ? first(list[list.length - 1]) : null; - const fresh = ok && last.after !== prev; // a rewrite nobody has commented on yet - const original = typed(); - const round = { rewrites: fresh ? { whole: last.after } : {}, comment, ...(original === undefined ? {} : { original }) }; + const prev = now.length ? first(now[now.length - 1]) : null, here = typed(); + const fresh = ok && last.after !== prev && app.enhanceTyped !== undefined && app.enhanceTyped === here; // a rewrite of THIS prompt that nobody has commented on yet draft = ""; - p.setPref("enhance_chat", [...list, round]); - draw(); + p.setPref("enhance_chat", [...now, { rewrites: fresh ? { whole: last.after } : {}, comment, ...(here === undefined ? {} : { original: here }) }]); + draw(true); }; page.set([c.label.section({ text: "Chat" }), c.hint.default({ text: "Comment on the last rewrite. The next Generate rewrites again with every comment still applying. Reset starts over." }), c.region.stack({ gap: "xs", children: bubbles }), box, - c.toolbar.default({ items: [c.button.sm({ label: "Send", tone: "primary", onClick: send })], trailing: [c.button.sm({ label: "Reset", tone: "ghost", disabled: !list.length, onClick: () => { p.setPref("enhance_chat", []); draw(); } })] })]); + c.toolbar.default({ items: [c.button.sm({ label: "Send", tone: "primary", onClick: send })], trailing: [c.button.sm({ label: "Reset", tone: "ghost", disabled: !list.length, onClick: () => { p.setPref("enhance_chat", []); draw(true); } })] })]); } - draw(); - app.api.enhancerRuns().then((r) => { last = (r.runs || []).slice(-1)[0] || null; draw(); }).catch(() => {}); + const look = () => app.api.enhancerRuns().then((r) => { last = (r.runs || []).slice(-1)[0] || null; draw(); }).catch(() => {}); + own(app.on((what) => { if (what === "open") look(); else draw(); })); + draw(true); + look(); return page; } diff --git a/app/ui/features/composer/chat.test.js b/app/ui/features/composer/chat.test.js new file mode 100644 index 00000000..c884bdcc --- /dev/null +++ b/app/ui/features/composer/chat.test.js @@ -0,0 +1,17 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { chatHook } from "./chat.js"; + +const slots = [{ id: "e", node: "FunPackEnhancePrompt" }, { id: "x", node: "Other" }]; +const project = { editor_settings: { enhance_chat: [{ comment: "warmer", original: "a cat" }, { comment: "old", original: "a dog" }, { comment: "any" }] } }; + +test("comments reach the enhancer only when they are about the prompt being run, and the rest are said", () => { + const r = chatHook({ project, scene: { text: " a cat " }, slots }); + assert.deepEqual(JSON.parse(r.inputs.e.chat).map((x) => x.comment), ["warmer", "any"]); + assert.equal(r.notes.length, 1); +}); + +test("no enhancer in the pipeline, or no comments: nothing is sent", () => { + assert.equal(chatHook({ project, scene: { text: "a cat" }, slots: [slots[1]] }), null); + assert.equal(chatHook({ project: {}, scene: { text: "a cat" }, slots }), null); +}); diff --git a/app/ui/features/composer/index.js b/app/ui/features/composer/index.js index 94330ba8..7a3422dd 100644 --- a/app/ui/features/composer/index.js +++ b/app/ui/features/composer/index.js @@ -4,7 +4,7 @@ import { applyStory, clash, joinStory } from "./story.js"; import { shortcuts } from "./shortcuts.js"; import { cuts } from "./cuts.js"; import { enhance } from "./enhance.js"; -import { chat } from "./chat.js"; +import { chat, chatHook } from "./chat.js"; import { pickShortcut, templatesBar, variablesPanel } from "./storytools.js"; const TABS = [{ value: "story", label: "Story" }, { value: "shortcuts", label: "Shortcuts" }, { value: "cuts", label: "Cuts" }, { value: "enhance", label: "Enhance" }, { value: "chat", label: "Chat" }]; @@ -43,7 +43,7 @@ const story = (app, own) => { } })] }), box, warn, c.hint.default({ text: `Scenes are cut at the word “${marker}”. Edits apply to the scenes as you type; the anchor is its own field.` }), - c.collapsible.default({ label: "+ Variables", body: variablesPanel(app) }), + c.collapsible.default({ label: "+ Variables", body: variablesPanel(app, own) }), ] }); }; @@ -56,6 +56,7 @@ export default { needs: ["project", "api", "pipeline"], setup({ host, app }) { let win = null, owned = []; + app.inputHooks.push(chatHook); const cleanup = () => { owned.forEach((f) => f()); owned = []; }; const open = () => { if (win) return; @@ -66,6 +67,6 @@ export default { onClose: () => { cleanup(); win = null; } }); }; host.append(c.button.sm({ label: "◆ Composer", tone: "neutral", onClick: open }).node); - return cleanup; + return () => { cleanup(); app.inputHooks.splice(app.inputHooks.indexOf(chatHook) >>> 0, 1); }; }, }; diff --git a/app/ui/features/composer/storytools.js b/app/ui/features/composer/storytools.js index 2655ed84..9a45c41c 100644 --- a/app/ui/features/composer/storytools.js +++ b/app/ui/features/composer/storytools.js @@ -53,7 +53,7 @@ export function templatesBar(app, own) { bar.set([c.toolbar.default({ items: [c.select.sm({ label: "Templates", disabled: !open, value: known ? active : "", options: [{ value: "", label: "Templates…" }, { value: NONE, label: "— None (clear all prompts) —" }, ...tpls().map((t) => ({ value: t.name, label: t.name }))], - onChange: (v) => { if (v === NONE) clear(); else if (v) apply(v); } })], + onChange: async (v) => { if (v === NONE) await clear(); else if (v) apply(v); key = ""; draw(); } })], trailing: [c.button.sm({ label: known ? "Update…" : "Save", tone: "ghost", disabled: !open, title: known ? `Overwrite “${active}” or save under a new name` : "Save the Story and variables as a template", onClick: save }), ...(known ? [c.button.sm({ label: "✎", tone: "ghost", title: `Rename “${active}”`, onClick: () => rename(active) }), c.button.sm({ label: "✕", tone: "ghost", title: `Delete “${active}”`, onClick: () => remove(active) })] : [])] })]); }; @@ -63,19 +63,21 @@ export function templatesBar(app, own) { } /** `$name` → text, filled in at generation. Rows commit on leaving the field; empty names are dropped on save. */ -export function variablesPanel(app) { +export function variablesPanel(app, own) { const p = app.project, page = c.region.stack({ gap: "xs" }); + let shown = ""; const rows = () => (p.project && p.project.variables) || []; const write = (list) => p.setField("variables", list.map((v) => ({ name: String(v.name || "").replace(/^\$+/, "").trim(), value: String(v.value || "") })).filter((v) => v.name || v.value)); - const draw = () => page.set([ + const draw = () => { shown = JSON.stringify([p.project && p.project.id, rows()]); page.set([ ...rows().map((v, i) => c.toolbar.default({ items: [c.input.sm({ label: "Name", value: v.name, placeholder: "name", onCommit: (x) => { const l = clone(rows()); l[i].name = x; write(l); } }), c.input.sm({ label: "Value", value: v.value, placeholder: "text", onCommit: (x) => { const l = clone(rows()); l[i].value = x; write(l); } })], trailing: [c.button.sm({ label: "✕", tone: "ghost", title: "Remove", onClick: () => { write(rows().filter((_, k) => k !== i)); draw(); } })] })), c.button.sm({ label: "+ Add variable", tone: "ghost", disabled: !p.project, onClick: () => { write([...rows(), { name: "name", value: "" }]); draw(); } }), c.hint.default({ text: "Write $name in a prompt; it becomes the variable's text when you generate." }), - ]); + ]); }; draw(); + own(app.on(() => { const typing = page.node.contains(document.activeElement) && /^(input|textarea)$/i.test(document.activeElement.tagName); if (!typing && shown !== JSON.stringify([p.project && p.project.id, rows()])) draw(); })); return page; } diff --git a/app/ui/features/generate/index.js b/app/ui/features/generate/index.js index ba452874..67b4198c 100644 --- a/app/ui/features/generate/index.js +++ b/app/ui/features/generate/index.js @@ -52,10 +52,11 @@ export default { if (!root || group.every((s) => s.excluded) || !isGenerative(root)) continue; // removed, all left out, or not made by the model // The unit is made once at the length of its clips together (each cut adds one shared frame). const frames = group.reduce((t, s) => t + effFrames(s, p.project), 0) - (group.length - 1); - const { inputs, unwired, noPrompt, chatLeftOut } = await buildInputs({ project: p.project, scene: root, slots: app.pipeline.slots(), expand, frames }); + const { inputs, unwired, noPrompt, notes } = await buildInputs({ project: p.project, scene: root, slots: app.pipeline.slots(), expand, frames, hooks: app.inputHooks }); + app.enhanceTyped = (root.text || "").trim(); // what a Chat comment made now would be about if (stopped) break; if (noPrompt && !made) tell("This pipeline has no prompt input, so the scene text is not sent."); - if (chatLeftOut) tell(`${chatLeftOut} Chat comment(s) are about a different prompt and were left out of this run.`); + notes.forEach(tell); if (unwired) tell(`${unwired} reference(s) did not fit this pipeline and are not used.`); const done = g.waitForTerminal(); // listening before the run starts, so a fast one is not missed said = false; diff --git a/app/ui/features/generate/inputs.js b/app/ui/features/generate/inputs.js index ccf948b6..005e6e7e 100644 --- a/app/ui/features/generate/inputs.js +++ b/app/ui/features/generate/inputs.js @@ -6,7 +6,7 @@ import { DRIVEN } from "../../shell/scenes.js"; const rolesAt = (slots, at) => (slots || []).flatMap((s) => (s.roles || []).filter((r) => r.at === at).map((role) => ({ slot: s, role }))); /** `frames`: how long this unit is (its clips together); a fresh `seed` per call so a re-roll is a different result. */ -export async function buildInputs({ project, scene, slots, expand, frames, seed = () => Math.floor(Math.random() * 2 ** 31) }) { +export async function buildInputs({ project, scene, slots, expand, frames, hooks = [], seed = () => Math.floor(Math.random() * 2 ** 31) }) { const raw = {}; const put = (slot, name, value) => { raw[slot.id] = { ...raw[slot.id], [name]: value }; }; @@ -27,13 +27,12 @@ export async function buildInputs({ project, scene, slots, expand, frames, seed .catch(() => null); // a failing prompt-craft feature must not block the run: send what was typed put(prompt.slot, prompt.role.input, body && typeof body.text === "string" ? body.text : text); } - // Chat comments on the enhancer's last rewrite ride with the project; only those about THIS typed prompt reach the run. - const enhancer = (slots || []).find((s) => s.node === "FunPackEnhancePrompt"), chat = (project.editor_settings || {}).enhance_chat; - let chatLeftOut = 0; - if (enhancer && Array.isArray(chat)) { - const typed = (scene.text || "").trim(), mine = chat.filter((r) => r && (r.original === undefined || r.original === typed)); - chatLeftOut = chat.length - mine.length; - if (mine.length) put(enhancer, "chat", JSON.stringify(mine)); + // Features that add inputs of their own (a Chat comment for the enhancer, say) say so through a hook: { inputs: {slot: {name: value}}, notes: [text] }. + const notes = []; + for (const hook of hooks) { + const out = hook({ project, scene, slots }) || {}; + for (const [id, fields] of Object.entries(out.inputs || {})) raw[id] = { ...raw[id], ...fields }; + notes.push(...(out.notes || [])); } - return { inputs: raw, unwired, noPrompt: !prompt, chatLeftOut }; + return { inputs: raw, unwired, noPrompt: !prompt, notes }; } diff --git a/app/ui/features/generate/inputs.test.js b/app/ui/features/generate/inputs.test.js index 88fc7667..8bd7b0cb 100644 --- a/app/ui/features/generate/inputs.test.js +++ b/app/ui/features/generate/inputs.test.js @@ -42,12 +42,9 @@ test("starting shots from a prompt sends no picture even when the scene has one" assert.equal(inputs.s.media_id, ""); }); -test("Chat comments reach the enhancer only when they are about the prompt being run", async () => { - const s = [...slots, { id: "e", node: "FunPackEnhancePrompt", roles: [] }]; - const chat = [{ comment: "warmer", original: "a cat" }, { comment: "old", original: "a dog" }, { comment: "any" }]; - const project = { editor_settings: { enhance_chat: chat } }; - const r = await buildInputs({ project, scene: { text: " a cat " }, slots: s, expand: async () => null }); - assert.deepEqual(JSON.parse(r.inputs.e.chat).map((x) => x.comment), ["warmer", "any"]); - assert.equal(r.chatLeftOut, 1); - assert.equal((await buildInputs({ project, scene: { text: "a cat" }, slots, expand: async () => null })).inputs.e, undefined); // no enhancer in the pipeline: nothing to send +test("a hook's inputs are merged and its notes come back", async () => { + const hook = ({ scene }) => ({ inputs: { k: { extra: scene.text } }, notes: ["said"] }); + const r = await buildInputs({ project: {}, scene: { text: "x" }, slots, hooks: [hook, () => null], expand: async () => null }); + assert.equal(r.inputs.k.extra, "x"); + assert.deepEqual(r.notes, ["said"]); }); diff --git a/app/ui/features/ideas/index.js b/app/ui/features/ideas/index.js index 8e4e7a24..78a92b17 100644 --- a/app/ui/features/ideas/index.js +++ b/app/ui/features/ideas/index.js @@ -54,7 +54,7 @@ export default { body.set([c.toolbar.default({ items: [c.label.section({ text: "Ideas" })], trailing: [c.button.sm({ label: "⟳", tone: "ghost", title: "Shuffle the samples", onClick: draw })] }), ...parts]); }; draw(); - pop = c.popover.anchored({ anchor: node, body, side: "bottom", align: "end", onClose: () => { pop = null; } }); + pop = c.popover.anchored({ anchor: node, body, side: "bottom", align: "end", onClose: () => { pop = null; settle(); } }); } function show(target) { @@ -74,7 +74,8 @@ export default { Object.assign(node.style, { left: `${Math.max(4, r.right - 40)}px`, top: `${Math.max(4, r.top - 30)}px` }); } const onFocus = (e) => { const t = e.target; if (t instanceof HTMLTextAreaElement && BOXES.has(t.getAttribute("aria-label"))) { clearTimeout(hold); show(t); } }; - const onBlur = (e) => { if (e.target === box) hold = setTimeout(() => { if (node && !node.contains(document.activeElement) && !pop) closeBulb(); }, 400); }; + const settle = () => { clearTimeout(hold); hold = setTimeout(() => { if (node && box !== document.activeElement && !node.contains(document.activeElement) && !pop) closeBulb(); }, 400); }; + const onBlur = (e) => { if (e.target === box || (node && node.contains(e.target))) settle(); }; const onMove = () => place(); document.addEventListener("focusin", onFocus); document.addEventListener("focusout", onBlur); window.addEventListener("scroll", onMove, true); window.addEventListener("resize", onMove); diff --git a/app/ui/features/inspector/index.js b/app/ui/features/inspector/index.js index fbc59546..495fdbf2 100644 --- a/app/ui/features/inspector/index.js +++ b/app/ui/features/inspector/index.js @@ -2,7 +2,7 @@ import { composer as c } from "../../composer/composer.js"; import { effFrames, effFps, genUnitId } from "../../shell/scenes.js"; import { sceneRows } from "./scene.js"; -import { names } from "./mediapick.js"; +import { bin } from "../../shell/bin.js"; import { projectRows } from "./project.js"; export default { @@ -25,7 +25,7 @@ export default { }; function draw(force) { // not on every keystroke: that would rebuild the box being typed in const sc = p.selected, open = p.project; - const next = `${tab}|${open && open.id}|${p.selectedId}|${tab === "project" && open ? [open.postfix_enabled !== false, open.generation_mode] : ""}|${tab === "scene" && sc && open ? [effFrames(sc, open), effFps(sc, open), p.scenes.indexOf(sc), sc.frames_mode, sc.fps_mode, p.scenes.length, sc.source_image, (sc.references || []).join(), names.size] : ""}`; + const next = `${tab}|${open && open.id}|${p.selectedId}|${tab === "project" && open ? [open.postfix_enabled !== false, open.generation_mode] : ""}|${tab === "scene" && sc && open ? [effFrames(sc, open), effFps(sc, open), p.scenes.indexOf(sc), sc.frames_mode, sc.fps_mode, p.scenes.length, sc.source_image, (sc.references || []).join(), bin.version] : ""}`; titled(); if (!force && next === key) return; key = next; diff --git a/app/ui/features/inspector/mediapick.js b/app/ui/features/inspector/mediapick.js index 45e7e5cc..bec89634 100644 --- a/app/ui/features/inspector/mediapick.js +++ b/app/ui/features/inspector/mediapick.js @@ -1,18 +1,19 @@ // The media bin as a choice, for fields that need one picture or several: a window listing what is in it. import { composer as c } from "../../composer/composer.js"; -export const names = new Map(); // id -> name, remembered from the last look at the bin +import { learn, names } from "../../shell/bin.js"; + let asked = false; export async function look(api) { - const bin = (await api.media()).media || []; - bin.forEach((m) => names.set(m.id, m.name)); - return bin; + const list = (await api.media()).media || []; + learn(list); + return list; } /** The names behind some ids; looks the bin up once if any are not known yet, then says so with `again`. */ export function namesOf(ids, api, again) { - if (!asked && ids.some((id) => !names.has(id))) { asked = true; look(api).then(again, () => {}); } + if (!asked && ids.some((id) => !names.has(id))) { asked = true; look(api).then(again, () => { asked = false; }); } return ids.map((id) => names.get(id) || "…"); } @@ -28,7 +29,7 @@ export async function pick(api, { title, kinds, multiple, current = [] }) { : c.checklist.default({ label: title, items, values: [], onChange: (v) => { chosen = v.filter((id) => !chosen.includes(id)).slice(-1); win.close("done"); } }); let done = false; const finish = (value) => { if (!done) { done = true; resolve(value); } }; - const win = c.modal.generic({ title, size: "sm", body, onClose: (why) => finish(why === "done" ? chosen : null) }); + const win = c.modal.generic({ title, size: "sm", body, closeOnOutside: !multiple, onClose: (why) => finish(why === "done" ? chosen : null) }); if (multiple) win.setFooter({ actions: [c.button.sm({ label: "Done", tone: "primary", onClick: () => win.close("done") })] }); }); } diff --git a/app/ui/features/media/index.js b/app/ui/features/media/index.js index f5fb0d76..83839333 100644 --- a/app/ui/features/media/index.js +++ b/app/ui/features/media/index.js @@ -1,5 +1,6 @@ // The media bin: files brought in (reference images, clips), kept apart from what runs produce. import { composer as c } from "../../composer/composer.js"; +import { learn } from "../../shell/bin.js"; const base = (id) => `/funpack/api/media/${encodeURIComponent(id)}`; @@ -37,6 +38,8 @@ export default { }, export: (it) => { const m = items.find((x) => x.id === it.id); Object.assign(document.createElement("a"), { href: `${base(it.id)}/file`, download: m ? m.name : it.id }).click(); }, delete: async (it) => { + const name = (items.find((m) => m.id === it.id) || {}).name || "this file"; + if (!(await c.modal.dialogue({ title: "Delete media", message: `Delete “${name}” from the bin for good? Scenes in other projects that use it will lose it too.`, tone: "danger", confirmLabel: "Delete" }).result)) return; try { await api.deleteMedia(it.id); p.edit((pr) => pr.scenes.reduce((hit, s) => { @@ -77,7 +80,7 @@ export default { g.setValue(sc && sc.source_image ? [sc.source_image] : []); } async function refresh() { - try { items = (await api.media()).media || []; } catch (err) { tell(err.message); } + try { items = (await api.media()).media || []; learn(items); app.say("bin"); } catch (err) { tell(err.message); } draw(true); } refresh(); diff --git a/app/ui/features/settings/editor.js b/app/ui/features/settings/editor.js index e3435908..219430d8 100644 --- a/app/ui/features/settings/editor.js +++ b/app/ui/features/settings/editor.js @@ -3,5 +3,6 @@ import { composer as c } from "../../composer/composer.js"; export const editor = (app) => function mount() { const parts = app.editorSettings.map((section) => section()); + if (parts.length && !app.project.project) parts.unshift(c.banner.info({ text: "Open a project first: these preferences are kept with the project, so nothing here can be changed without one." })); return parts.length ? c.region.stack({ gap: "lg", children: parts }) : c.emptyState.default({ icon: "☰", title: "Nothing to set", hint: "No editor feature has a preference to show." }); }; diff --git a/app/ui/features/upscale/index.js b/app/ui/features/upscale/index.js index 4efdf08f..b9305bbe 100644 --- a/app/ui/features/upscale/index.js +++ b/app/ui/features/upscale/index.js @@ -44,12 +44,12 @@ export default { } // "Always": a render that appears after the project was opened is upscaled. What was there on open is left alone. - let seen = null, openId = null; + let seen = null, obj = null; const watch = () => { const open = p.project; - if (!open) { seen = null; openId = null; return; } + if (!open) { seen = null; obj = null; return; } const now = new Map(Object.entries(open.scene_renders || {}).filter(([, r]) => r && r.media).map(([id, r]) => [id, keyOf(r.media)])); - if (open.id !== openId || !seen) { openId = open.id; seen = now; return; } + if (open !== obj || !seen) { obj = open; seen = now; return; } // another project, or undo/redo put an older copy back: not a new render if (p.pref("upscaleMode", "never") === "always") { const fresh = new Map([...now].filter(([id, k]) => seen.get(id) !== k)); for (const media of new Map([...fresh.keys()].map((id) => [fresh.get(id), open.scene_renders[id].media])).values()) run(media); diff --git a/app/ui/shell/bin.js b/app/ui/shell/bin.js new file mode 100644 index 00000000..de6abb9e --- /dev/null +++ b/app/ui/shell/bin.js @@ -0,0 +1,10 @@ +// What the media bin's files are called, as last seen by whoever looked: one shared memory so a rename shows everywhere. +export const names = new Map(); +export const bin = { version: 0 }; + +/** Remember a listing (replacing what was known). */ +export function learn(list) { + names.clear(); + list.forEach((m) => names.set(m.id, m.name)); + bin.version += 1; +} diff --git a/app/ui/shell/project.js b/app/ui/shell/project.js index 4d5e0573..15570d43 100644 --- a/app/ui/shell/project.js +++ b/app/ui/shell/project.js @@ -107,7 +107,9 @@ export function createProject({ onChange, onError, onOpen, keepConsistent } = {} // before this must not still be allowed to land on top of it once its // own fetch catches up. generation += 1; + const prefs = project.editor_settings; // preferences are not edits: stepping back never takes them with it project = was.project; + if (prefs !== undefined) project.editor_settings = prefs; 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 diff --git a/app/ui/shell/tests/project.test.js b/app/ui/shell/tests/project.test.js index 2a5652e0..d0564900 100644 --- a/app/ui/shell/tests/project.test.js +++ b/app/ui/shell/tests/project.test.js @@ -638,3 +638,16 @@ test("an edit for a project that is not open is read, changed and saved there", assert.equal(done, true); assert.equal(sent.at(-1).name, "other"); }); + +test("an undo takes edits back but never an editor preference", async () => { + server(); + const p = createProject({}); + await p.start(); + p.setVideo("width", 832); + p.setPref("upscaleMode", "always"); + p.undo(); + assert.equal(p.video.width, undefined, "the edit was not undone"); + assert.equal(p.pref("upscaleMode", "never"), "always"); + p.redo(); + assert.equal(p.pref("upscaleMode", "never"), "always"); +});