diff --git a/app/ui/features/inspector/scene.js b/app/ui/features/inspector/scene.js index b56d1e44..b94a287e 100644 --- a/app/ui/features/inspector/scene.js +++ b/app/ui/features/inspector/scene.js @@ -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( diff --git a/app/ui/features/settings/engine.js b/app/ui/features/settings/engine.js index a7f67fea..f24a60f6 100644 --- a/app/ui/features/settings/engine.js +++ b/app/ui/features/settings/engine.js @@ -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]; diff --git a/app/ui/shell/api.js b/app/ui/shell/api.js index 0be579d4..f1472e53 100644 --- a/app/ui/shell/api.js +++ b/app/ui/shell/api.js @@ -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"), diff --git a/app/ui/shell/pipeline_link.js b/app/ui/shell/pipeline_link.js index c3f2eff5..7552731e 100644 --- a/app/ui/shell/pipeline_link.js +++ b/app/ui/shell/pipeline_link.js @@ -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 }; diff --git a/app/ui/shell/pipeline_state.js b/app/ui/shell/pipeline_state.js index 507ea240..9ad9cc35 100644 --- a/app/ui/shell/pipeline_state.js +++ b/app/ui/shell/pipeline_state.js @@ -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; diff --git a/app/ui/shell/tests/pipeline_link.test.js b/app/ui/shell/tests/pipeline_link.test.js new file mode 100644 index 00000000..5c039afe --- /dev/null +++ b/app/ui/shell/tests/pipeline_link.test.js @@ -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); +}); diff --git a/app/ui/shell/tests/pipeline_state.test.js b/app/ui/shell/tests/pipeline_state.test.js index 5489a976..895c2ff7 100644 --- a/app/ui/shell/tests/pipeline_state.test.js +++ b/app/ui/shell/tests/pipeline_state.test.js @@ -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"]); +}); diff --git a/core/contract.py b/core/contract.py index 8da84294..81b42d38 100644 --- a/core/contract.py +++ b/core/contract.py @@ -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], } diff --git a/core/readiness.py b/core/readiness.py index 31b07eb1..073acb8c 100644 --- a/core/readiness.py +++ b/core/readiness.py @@ -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 diff --git a/core/tests/test_readiness.py b/core/tests/test_readiness.py index 2bda81bd..e4334569 100644 --- a/core/tests/test_readiness.py +++ b/core/tests/test_readiness.py @@ -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) diff --git a/core/tests/test_schema.py b/core/tests/test_schema.py index 0acd0cb6..11a94bf5 100644 --- a/core/tests/test_schema.py +++ b/core/tests/test_schema.py @@ -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"]