fix: a preset, import or wizard pipeline no longer regains the default's loaders on reopen; opening a project no longer rewrites it; Engine hides settings whose node is not in the pipeline; readiness knows Apple GPUs; a detected model with no traits is filtered

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-10-05 04:19:20 +03:00
co-authored by Claude Opus 5.5
parent 927d8d7358
commit 22df12a487
11 changed files with 86 additions and 15 deletions
+1 -1
View File
@@ -50,7 +50,7 @@ export function sceneRows(p, app) {
if (!sc) return [c.emptyState.default({ icon: "▭", title: "No scene", hint: "Add one on the timeline." })];
const cuts = p.scenes.filter((s) => genUnitId(s) === genUnitId(sc)).length > 1;
return [
c.banner.info({ text: "Generating with FunPack Studio + Chain Sampler", action: { label: "Engine settings →", onClick: () => app.openSettings && app.openSettings("engine") } }),
c.banner.info({ text: "Generated by this project's pipeline", action: { label: "Engine settings →", onClick: () => app.openSettings && app.openSettings("engine") } }),
...(sc.removed_from_plan ? [c.banner.info({ text: "Removed from plan — its generated clip stays on the timeline.", action: { label: "Restore to plan", onClick: () => p.edit((pr) => restoreToPlan(pr, sc.id)) } })] : []),
...(cuts ? [c.hint.default({ text: "This scene has editorial cuts — Generate regens the whole uncut scene." })] : []),
...(isSubclip(sc) ? [c.hint.default({ text: "This is a cut: its prompt and source belong to the first part." })] : [card(
+3 -1
View File
@@ -13,7 +13,9 @@ export const engine = (app) => function mount() {
if (ps.loading()) return page.set([c.hint.default({ text: "Loading…" })]);
if (ps.loadError()) return page.set([c.banner.warn({ text: `Could not load Engine settings: ${ps.loadError()}` })]);
if (ps.allOff()) return page.set([c.banner.info({ text: "All enhancements are disabled for this project, so there is nothing to set. Turn them back on in Settings ▸ Modules." })]);
const withSettings = ps.activeModules().filter((m) => Object.keys(m.settings || {}).length);
// A module whose settings only its own node reads does nothing in a pipeline without that node: hidden.
const inPipeline = (m) => !(m.nodes || []).length || !ps.slots() || m.nodes.some((n) => ps.slots().some((s) => s.node === n));
const withSettings = ps.activeModules().filter((m) => Object.keys(m.settings || {}).length && inPipeline(m));
const cats = CATEGORIES.filter(([k]) => withSettings.some((m) => (m.category || "") === k));
if (!cats.length) return page.set([c.emptyState.default({ icon: "⚡", title: "Nothing to set", hint: "No installed module exposes a setting for this pipeline." })]);
if (!cats.some(([k]) => k === category)) category = cats[0][0];
+1 -1
View File
@@ -21,7 +21,7 @@ export async function call(method, path, body) {
export const api = {
pipeline: () => call("GET", "/api/pipeline"),
editPipeline: (body) => call("POST", "/api/pipeline", body),
modules: (traits) => call("GET", `/api/modules${traits ? `?traits=${encodeURIComponent(traits)}` : ""}`),
modules: (traits) => call("GET", `/api/modules${traits != null ? `?traits=${encodeURIComponent(traits)}` : ""}`),
releaseModule: (id) => call("POST", "/api/control/release", { id }),
searchNodes: (q, limit = 40) => call("GET", `/api/nodes/search?q=${encodeURIComponent(q || "")}&limit=${limit}`),
pipelinePresets: () => call("GET", "/api/pipeline/presets"),
+10 -6
View File
@@ -3,18 +3,23 @@
export function linkPipeline({ project, pipeline, onOpen, say = () => {} }) {
let adopted = true; // false while the open project's pipeline is not the live one: its copy is not rewritten
let retries = 0;
// Only a real change is written: opening a project must not rewrite its file.
const store = (base, slots) => {
const next = { ...base, slots: JSON.parse(JSON.stringify(slots || [])), removed: pipeline.removedIds(), unwired: pipeline.unwiredMap() };
if (JSON.stringify(next) !== JSON.stringify(project.project.models || {})) project.setField("models", next, { quiet: true });
};
async function adopt() {
const saved = project.project && project.project.models || {};
let ok = false;
let ok = false, timer;
try {
// Bounded: a hung request must not hold the project behind it.
ok = await Promise.race([pipeline.adopt(saved.slots || [], saved.removed, saved.unwired), new Promise((r) => setTimeout(() => r(false), 20000))]);
} catch { ok = false; }
ok = await Promise.race([pipeline.adopt(saved.slots || [], saved.removed, saved.unwired), new Promise((r) => { timer = setTimeout(() => r(false), 20000); })]);
} catch { ok = false; } finally { clearTimeout(timer); }
adopted = Boolean(ok && pipeline.slots());
if (adopted) {
retries = 0;
project.setField("models", { ...saved, slots: JSON.parse(JSON.stringify(pipeline.slots())), removed: pipeline.removedIds(), unwired: pipeline.unwiredMap() }, { quiet: true });
store(saved, pipeline.slots());
} else if (retries++ < 3) {
setTimeout(adopt, 5000);
} else {
@@ -24,8 +29,7 @@ export function linkPipeline({ project, pipeline, onOpen, say = () => {} }) {
pipeline.subscribe((slots) => {
if (!project.project || !adopted) return;
const had = project.project.models || {};
project.setField("models", { ...had, slots: JSON.parse(JSON.stringify(slots || [])), removed: pipeline.removedIds(), unwired: pipeline.unwiredMap() }, { quiet: true });
store(project.project.models || {}, slots);
});
onOpen(adopt);
return { adopt };
+4 -1
View File
@@ -407,7 +407,10 @@ export function createPipelineState(API) {
refusedNow = (res && res.refused) || [];
if (res && res.slots && !refusedNow.length) {
slots = res.slots;
removed = new Set(snap.removed || []);
// A default slot the new pipeline lacks is removed, whatever the caller said: otherwise the next
// open lays the default back over it (a preset, an import or the wizard would regain the default's loaders).
const kept = new Set(res.slots.map((s) => s.id));
removed = new Set([...(snap.removed || []), ...(offered || []).map((s) => s.id).filter((id) => !kept.has(id))]);
unwired = JSON.parse(JSON.stringify(snap.unwired || {}));
groupEdits = {}; pendingValues = {}; pendingBody = null; pending = false;
incomplete = res.incomplete || []; refused = []; queueable = !!res.queueable;
+28
View File
@@ -0,0 +1,28 @@
import test from "node:test";
import assert from "node:assert/strict";
import { linkPipeline } from "../pipeline_link.js";
const slots = [{ id: "model", node: "N", inputs: { ckpt_name: "a" } }];
function rig(models) {
const writes = [];
let opened, heard;
const project = { project: { models }, setField: (k, v) => { writes.push(v); project.project[k] = v; } };
const pipeline = { adopt: async () => true, slots: () => slots, removedIds: () => [], unwiredMap: () => ({}), subscribe: (fn) => { heard = fn; } };
linkPipeline({ project, pipeline, onOpen: (fn) => { opened = fn; } });
return { writes, open: () => opened(), edit: (s) => heard(s) };
}
test("opening a project whose pipeline is unchanged does not rewrite it; an edit does", async () => {
const r = rig({ slots, removed: [], unwired: {} });
await r.open();
assert.equal(r.writes.length, 0);
r.edit([{ ...slots[0], inputs: { ckpt_name: "b" } }]);
assert.equal(r.writes.length, 1);
});
test("a project with no saved pipeline gets the live one on open", async () => {
const r = rig(undefined);
await r.open();
assert.deepEqual(r.writes[0].slots, slots);
});
+12
View File
@@ -295,3 +295,15 @@ test("the settings of a Generate are taken once: frozenInputs is a copy, wired i
frozen.model.dtype = "fp16";
assert.strictEqual(PS.slots().find((s) => s.id === "model").inputs.dtype, "bf16"); // and is not the live object
});
test("a pipeline replaced whole (a preset) is reopened as it was: the default's own slots do not come back", async () => {
const PS = load([]);
await PS.ensureLoaded();
const preset = [{ id: "ckpt", node: "Checkpoint", inputs: { file: "sd.safetensors" } }, { id: "gen", node: "Gen", inputs: { steps: 20, model: ["ckpt", 0] } }];
await PS.restore({ slots: preset, removed: [], unwired: {} });
assert.deepStrictEqual(PS.removedIds(), ["model"]);
const saved = { slots: PS.slots(), removed: PS.removedIds() };
const again = load([]);
await again.adopt(saved.slots, saved.removed);
assert.deepStrictEqual(again.slots().map((s) => s.id).sort(), ["ckpt", "gen"]);
});
+4 -2
View File
@@ -91,8 +91,8 @@ class ModuleSpec:
return {key: spec["default"] for key, spec in self.settings.items()}
def to_manifest(self) -> dict:
# `nodes` is deliberately absent: it is not JSON, and the browser has no
# use for it. The graph is ComfyUI's surface, the manifest is the app's.
# `nodes` as their ids: a module whose settings are read only by its own node is inert, so
# hidden, in a pipeline that does not contain that node.
return {
"id": self.id,
"title": self.title,
@@ -105,4 +105,6 @@ class ModuleSpec:
"category": self.category,
"ui": self.ui,
"status": self.status,
"nodes": [getattr(n.GET_SCHEMA(), "node_id", n.__name__) if hasattr(n, "GET_SCHEMA") else n.__name__
for n in self.nodes],
}
+7 -3
View File
@@ -33,7 +33,11 @@ def machine(disk_free_gb=None) -> List[dict]:
try:
import torch
if not torch.cuda.is_available():
out.append(row("warn", "No NVIDIA GPU is visible to torch: this machine can edit but not generate."))
mps = getattr(torch.backends, "mps", None)
if mps is not None and mps.is_available():
out.append(row("ok", "Apple GPU (MPS): generates slowly, enough for small models like SD1.5; video models need an NVIDIA GPU."))
else:
out.append(row("warn", "No GPU is visible to torch: this machine can edit but not generate."))
else:
props = torch.cuda.get_device_properties(0)
out.append(row("ok", f"{props.name}, {props.total_memory / 1024 ** 3:.0f} GB, sm_{props.major}{props.minor}"))
@@ -41,10 +45,10 @@ def machine(disk_free_gb=None) -> List[dict]:
out.append(row("fail", "This GPU does not support bf16, which the H3 VAEs need."))
if props.major >= 12 and have("xformers"):
out.append(row("warn", "xformers has no masked-attention kernel for this GPU: start ComfyUI with --disable-xformers --use-sage-attention."))
if not have("sageattention"): # a CUDA-only library: nothing to install on a Mac
out.append(row("warn", "sageattention is not installed: attention runs slower (and the H3 SLA setting has no fast path)."))
except Exception as exc: # noqa: BLE001
out.append(row("warn", f"torch could not be inspected ({exc})."))
if not have("sageattention"):
out.append(row("warn", "sageattention is not installed: attention runs slower (and the H3 SLA setting has no fast path)."))
if disk_free_gb is not None and disk_free_gb < LOW_DISK_GB:
out.append(row("warn", f"Only {disk_free_gb} GB free: renders and previews fill a disk quickly (want {LOW_DISK_GB}+)."))
return out
+9
View File
@@ -30,3 +30,12 @@ def test_the_machine_checks_never_raise_and_low_disk_is_a_warning(monkeypatch):
rows = r.machine(disk_free_gb=3)
assert any(x["level"] == "fail" and "ffmpeg" in x["text"] for x in rows)
assert any(x["level"] == "warn" and "3 GB free" in x["text"] for x in rows)
def test_an_apple_gpu_can_generate_and_is_not_told_to_install_cuda_only_libraries(monkeypatch):
import torch
monkeypatch.setattr(torch.cuda, "is_available", lambda: False)
monkeypatch.setattr(torch.backends.mps, "is_available", lambda: True)
rows = r.machine()
assert any(x["level"] == "ok" and "MPS" in x["text"] for x in rows)
assert not any("sageattention" in x["text"] or "NVIDIA GPU is visible" in x["text"] for x in rows)
+7
View File
@@ -251,3 +251,10 @@ def test_an_ordinary_default_still_passes():
spec = validate({"id": "m", "title": "M", "mount": "x", "settings": {
"strength": {"type": "float", "default": 0.5, "min": 0.0, "max": 1.0, "label": "S"}}})
assert spec.defaults() == {"strength": 0.5}
def test_the_manifest_names_a_modules_nodes_by_id(comfyui):
from modules.conditioning import shot_camera
from core import routes
found = next(m for m in routes.manifest()["modules"] if m["id"] == shot_camera.ID)
assert found["nodes"] == ["FunPackShotCamera"]