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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
d3bf2896b3
commit
74cbd3c3bb
+1
-1
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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); };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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") })] });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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." });
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user