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:
co-authored by
Claude Opus 5.5
parent
927d8d7358
commit
22df12a487
@@ -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(
|
||||
|
||||
@@ -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
@@ -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"),
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user