fix(ui): maintenance refuses when edits are unsaved and reports a blocked or absent restart; Models/Engine keep typing, show save errors, multiline text; export keeps its project name and picked clips

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-10-03 22:20:20 +03:00
co-authored by Claude Sonnet 5.5
parent d5efcc8b46
commit 175c1a7643
9 changed files with 35 additions and 14 deletions
+1 -1
View File
@@ -28,7 +28,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 = { 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: () => project.flush() }), keys: createKeys(), resetLayout: () => { frame.resetLayout(); app.dock.reset(); } };
const app = { 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 -1
View File
@@ -7,7 +7,7 @@ export function clipSpecs(p, only) { // `only`: a Set of scene ids to keep (th
const clips = [];
let missing = 0;
for (const seg of segments(p)) {
if (seg.kind !== "scene" || (seg.scene.excluded && !seg.scene.removed_from_plan)) continue; // removed from the plan still plays
if (seg.kind !== "scene" || (!only && seg.scene.excluded && !seg.scene.removed_from_plan)) continue; // removed from the plan still plays; a clip picked by hand is wanted even if left out of the full run
if (only && !only.has(seg.scene.id)) continue;
const sc = seg.scene, r = renders[sc.id], base = { scene_id: sc.id, dur: seg.dur, fps: effFps(sc, p), gap_after: Math.max(0, +sc.gap_after_sec || 0) };
if (isVideoClip(sc)) {
+4
View File
@@ -20,3 +20,7 @@ test("only the picked clips are kept, still in timeline order, and unpicked ones
assert.deepEqual(clips.map((c) => c.scene_id), ["a", "v"]);
assert.equal(missing, 0);
});
test("a clip picked by hand is exported even when it is excluded from the full run", () => {
assert.deepEqual(clipSpecs(p, new Set(["c"])).clips.map((c) => c.scene_id), ["c"]);
});
+4 -4
View File
@@ -31,15 +31,15 @@ export default {
try { await work(); } catch (err) { tell(err.message); } finally { busy = false; draw(); }
};
const exportIt = run(async () => {
const media = await join();
const name = p.project.name, media = await join(); // named for the project it was started in
if (!media) return;
const link = Object.assign(document.createElement("a"), { href: viewUrl(media), download: `${p.project.name}.mp4` });
const link = Object.assign(document.createElement("a"), { href: viewUrl(media), download: `${name}.mp4` });
link.click();
});
const toBin = run(async () => {
const media = await join();
const name = p.project.name, media = await join();
if (!media) return;
const { media: entry } = await call("POST", "/api/import-clip", { clip: media, name: `${p.project.name}.mp4` });
const { media: entry } = await call("POST", "/api/import-clip", { clip: media, name: `${name}.mp4` });
app.say("media");
c.toast.good({ text: `Saved to the media bin as ${entry.name}.` });
});
+3 -1
View File
@@ -27,6 +27,8 @@ export const engine = (app) => function mount() {
}
draw();
ps.ensureLoaded().then(draw);
const off = ps.subscribe(draw);
const typing = () => page.node.contains(document.activeElement) && /^(input|textarea)$/i.test(document.activeElement.tagName);
page.node.addEventListener("focusout", () => setTimeout(() => { if (!typing()) draw(); }));
const off = ps.subscribe(() => { if (!typing()) draw(); });
return { node: page.node, destroy: () => { off(); page.node.remove(); } };
};
+10 -3
View File
@@ -14,7 +14,7 @@ const widgetControl = (w, current, set) => {
return c.number.md({ label, value: current ?? w.default ?? 0, min: w.min, max: w.max, step: w.step ?? (w.type === "INT" ? 1 : undefined),
precision: w.type === "INT" ? 0 : undefined, onChange: set });
}
return c.input.md({ label, value: current ?? "", onCommit: set });
return w.multiline ? c.textarea.md({ label, value: current ?? "", rows: 4, onCommit: set }) : c.input.md({ label, value: current ?? "", onCommit: set });
};
export const models = (app) => function mount() {
@@ -30,7 +30,11 @@ export const models = (app) => function mount() {
try { Object.assign(specs, (await app.api.describeNodes(missing)).nodes || {}); } catch (err) { note = err.message; }
missing.forEach((n) => { if (!(n in specs)) specs[n] = null; });
}
const set = (slot, name) => async (value) => { await ps.save({ inputs: { [slot.id]: { [name]: value } } }); note = (ps.refused() || []).join(" "); draw(); };
const set = (slot, name) => async (value) => {
if (JSON.stringify((slot.inputs || {})[name]) === JSON.stringify(value)) return; // a blur that changed nothing saves nothing
await ps.save({ inputs: { [slot.id]: { [name]: value } } });
note = [...(ps.refused() || []), ...ps.saveNotes()].join(" "); draw();
};
const groups = [];
slots.forEach((s) => { const g = s.group || "Other"; (groups.find((x) => x[0] === g) || (groups.push([g, []]), groups[groups.length - 1]))[1].push(s); });
page.set([
@@ -45,8 +49,11 @@ export const models = (app) => function mount() {
})]),
].filter(Boolean));
}
// A redraw while a box has focus would eat what is being typed: it waits for the focus to leave.
const typing = () => page.node.contains(document.activeElement) && /^(input|textarea)$/i.test(document.activeElement.tagName);
page.node.addEventListener("focusout", () => setTimeout(() => { if (!typing()) draw(); }));
ps.ensureLoaded().then(draw);
off = ps.subscribe(() => draw());
off = ps.subscribe(() => { if (!typing()) draw(); });
const handle = { node: page.node, destroy: () => { off && off(); page.node.remove(); } };
return handle;
};
+3 -2
View File
@@ -26,8 +26,9 @@ export const modules = (app) => function mount() {
return c.toggle.default({ label: name(m), hint: ps.isOff(m.id) ? "Off for this project" : undefined, checked: !ps.isOff(m.id),
onChange: (on) => ps.setOff(m.id, !on).then(draw) });
};
page.set([c.hint.default({ text: "A module you turn off here is off for this project only, and disappears from Engine settings. One that failed while generating is off for every project until its code is repaired or you turn it back on." }),
...CATEGORIES.flatMap(([key, label]) => { const list = mine.filter((m) => (m.category || "") === key); return list.length ? [c.label.section({ text: label }), ...list.map(row)] : []; })]);
const notes = ps.saveNotes();
page.set([notes.length ? c.banner.warn({ text: notes.join(" ") }) : null, c.hint.default({ text: "A module you turn off here is off for this project only, and disappears from Engine settings. One that failed while generating is off for every project until its code is repaired or you turn it back on." }),
...CATEGORIES.flatMap(([key, label]) => { const list = mine.filter((m) => (m.category || "") === key); return list.length ? [c.label.section({ text: label }), ...list.map(row)] : []; })].filter(Boolean));
}
draw();
ps.ensureLoaded().then(() => ps.refreshControl()).then(draw);
+8 -2
View File
@@ -27,12 +27,18 @@ export function createMaintenance({ api, flush }) {
}
}
async function run(message, action, said) {
await flush();
try { await flush(); } catch (err) { return tell(err.message); }
const text = c.text.md({ text: message });
const card = c.modal.generic({ title: "Working", size: "sm", closeOnOutside: false, body: c.region.stack({ gap: "md", children: [c.progress.indeterminate({ label: "Working" }), text] }) });
card.setText = (t) => text.setText ? text.setText(t) : (text.node.textContent = t);
try {
const res = await action();
if (res && res.restarting === false) { // nothing restarted: say what is true, and stay on the page
card.close();
return c.modal.dialogue({ title: "Not restarted", message: `${res.blocked || (said ? said(res) : "Nothing changed.")} ${res.blocked ? "Restart ComfyUI yourself when the generation has finished." : ""}`.trim(), confirmLabel: "OK", cancelLabel: "Close" }).result;
}
const bad = res && res.requirements && res.requirements.ran && !res.requirements.ok;
if (bad) { await c.modal.dialogue({ title: "Dependencies failed", message: `${res.requirements.detail || ""}\n\nFunPack was updated, but installing its dependencies failed: it may not load until they are installed by hand. ComfyUI will restart now.`, confirmLabel: "Restart", cancelLabel: "Restart" }).result; }
card.setText(`${said ? said(res) : message}\nRestarting ComfyUI…`);
} catch (err) {
if (action.dropsConnection) { /* the server went down as it restarted: expected */ } else { card.close(); return tell(err.message); }
@@ -47,7 +53,7 @@ export function createMaintenance({ api, flush }) {
let gs; try { gs = await api.gitFull(); } catch { gs = null; }
if (!gs || !gs.ok) return { ok: false, update: "Git unavailable for this install", branch: "Git unavailable for this install", rollback: "Git unavailable for this install" };
return { ok: true,
update: gs.dirty ? "Local changes — commit first" : gs.behind > 0 ? `${gs.behind} commit(s) behind origin/${gs.branch}` : `origin/${gs.branch} up to date`,
update: gs.fetch_ok === false ? "Could not reach origin: update status unknown" : gs.dirty ? "Local changes — commit first" : gs.behind > 0 ? `${gs.behind} commit(s) behind origin/${gs.branch}` : `origin/${gs.branch} up to date`,
branch: `On ${gs.branch}${gs.dirty ? " · local changes — commit first" : " · pick another"}`,
rollbackOk: Boolean(gs.rollback_target),
rollback: gs.rollback_target ? `Undo last update — back to ${String(gs.rollback_target.commit).slice(0, 8)}` : "Nothing to undo yet" };
+1
View File
@@ -169,6 +169,7 @@ export function createProject({ onChange, onError, onOpen } = {}) {
return {
get project() { return project; },
get unsaved() { return dirty; },
get scenes() { return project ? project.scenes || [] : []; },
get selectedId() { return selected; },
get selected() { return sceneAt(selected); },