fix: harden per-GPU preview outputs after review

- Typed serverless pools validate the unpruned workflow, so a preview on
  any new slot failed the whole run; both serverless compilers now accept
  every Collector output slot (COLLECTOR_OUTPUT_COUNT).
- Serverless pools are config workers: their serverless:<pool>:<job>
  participants now share the pool's worker_N slot instead of overflowing.
- A worker returning only empty frames no longer raises in the preview
  split (the combined batch already skipped them).
- Worker previews are views into the combined batch, not a second copy,
  and a frame-count guard skips them rather than misattribute frames.
- Frontend: one shared config fetch per load; unnamed slots drop their
  saved label instead of storing a redundant one.
- Tooltip notes that the per-GPU outputs are unavailable downstream of
  Ultimate SD Upscale Distributed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
speedyrulz
2026-10-31 16:42:40 -04:00
co-authored by Claude Fable 5.1
parent 18ff755001
commit 339719ece7
8 changed files with 217 additions and 50 deletions
+20 -2
View File
@@ -7,6 +7,7 @@ const WORKERS_CHANGED_EVENT = "distributed:workers-changed";
const trackedNodes = new Set();
let workers = [];
let workersPromise = null;
let listenerAttached = false;
async function fetchWorkers() {
@@ -20,13 +21,30 @@ async function fetchWorkers() {
}
}
// Collectors created together (a workflow load) share one config fetch.
function loadWorkers() {
if (!workersPromise) {
workersPromise = fetchWorkers().then((list) => {
workers = list;
if (list.length === 0) workersPromise = null;
return list;
});
}
return workersPromise;
}
// Only the displayed label changes; slot names stay fixed so links survive.
// Unnamed slots drop the label so saved workflows carry no redundant copies.
function labelWorkerOutputs(node, configWorkers) {
for (const output of node.outputs || []) {
if (!output?.name?.startsWith(WORKER_OUTPUT_PREFIX)) continue;
const index = parseInt(output.name.slice(WORKER_OUTPUT_PREFIX.length), 10) - 1;
const worker = configWorkers[index];
output.label = worker?.name ? `${output.name} (${worker.name})` : output.name;
if (worker?.name) {
output.label = `${output.name} (${worker.name})`;
} else {
delete output.label;
}
}
node.setDirtyCanvas?.(true, true);
}
@@ -67,7 +85,7 @@ app.registerExtension({
return originalOnRemoved ? originalOnRemoved.call(this) : undefined;
};
if (workers.length === 0) workers = await fetchWorkers();
if (workers.length === 0) await loadWorkers();
labelWorkerOutputs(node, workers);
} catch (error) {
console.error("Error in DistributedCollector outputs extension:", error);
+20 -3
View File
@@ -5,14 +5,15 @@ import { describe, expect, it } from "vitest";
const source = readFileSync(new URL("../collectorOutputs.js", import.meta.url), "utf8")
.replace(/^import .*;\r?\n/gm, "");
function setup(workers) {
function setup(workers, onFetch = () => {}) {
let extension;
const listeners = new Map();
vm.runInNewContext(source, {
app: { registerExtension: (ext) => { extension = ext; } },
ENDPOINTS: { CONFIG: "/distributed/config" },
NODE_CLASSES: { DISTRIBUTED_COLLECTOR: "DistributedCollector" },
fetch: async () => ({ ok: true, json: async () => ({ workers }) }),
fetch: async () => { onFetch(); return { ok: true, json: async () => ({ workers }) }; },
Promise,
window: { addEventListener: (name, fn) => listeners.set(name, fn) },
parseInt,
Array,
@@ -32,10 +33,26 @@ describe("DistributedCollector per-GPU outputs", () => {
expect(node.outputs.map((o) => o.name)).toEqual(
["images", "audio", "master", "worker_1", "worker_2", "worker_3"]);
expect(node.outputs.slice(3).map((o) => o.label)).toEqual(
["worker_1 (GPU A)", "worker_2 (GPU B)", "worker_3"]);
["worker_1 (GPU A)", "worker_2 (GPU B)", undefined]);
expect(node.outputs[2].label).toBeUndefined();
});
it("shares one config fetch across collectors created together", async () => {
let fetches = 0;
const { extension, node } = setup([{ id: "a", name: "GPU A" }], () => { fetches += 1; });
const second = { ...node, outputs: node.outputs.map((o) => ({ ...o })) };
await Promise.all([extension.nodeCreated(node), extension.nodeCreated(second)]);
expect(fetches).toBe(1);
expect(second.outputs[3].label).toBe("worker_1 (GPU A)");
});
it("clears a stale saved label when the slot has no configured worker", async () => {
const { extension, node } = setup([]);
node.outputs[3].label = "worker_1 (Old PC)";
await extension.nodeCreated(node);
expect("label" in node.outputs[3]).toBe(false);
});
it("relabels when the worker list changes", async () => {
const { extension, listeners, node } = setup([{ id: "a", name: "GPU A" }]);
await extension.nodeCreated(node);