fix(inspector): never show the positive as a zeroed negative from the legacy fill
When the server leaves the negative undecided, the workspace fills it from
the legacy browser parser (comfy-metadata.js), which follows
ConditioningZeroOut back to its input and so reads the positive text as
the negative. The workspace now checks the prompt graph first:
- every sampler's negative is a ConditioningZeroOut (Reroutes allowed):
the negative shows the server's explicitly empty zeroed state
({status: resolved, text: "", source: zeroed-conditioning});
- a zero-out elsewhere in a negative path (one branch zeroed, another
encoded; a zero-out combined with other conditioning), or a
workflow-only file holding one: the negative is not filled and keeps
the server's status;
- graphs without a zero-out keep the legacy fill unchanged.
comfy-metadata.js is untouched; the legacy pages keep their reading.
This commit is contained in:
@@ -538,6 +538,81 @@ test('a definite server prompt is kept even when the legacy parser reads it diff
|
||||
assert.equal(record.prompts.positive.text, 'a lighthouse at dusk, oil painting');
|
||||
});
|
||||
|
||||
// #249: a LoRA compare grid. Every sampler's negative is a ConditioningZeroOut of the
|
||||
// positive encode; the legacy parser follows the zero-out back to the positive text.
|
||||
function zeroedGridGraph(samplers = 3) {
|
||||
const graph = {
|
||||
1: { class_type: 'UNETLoader', inputs: { unet_name: 'base.safetensors', weight_dtype: 'default' } },
|
||||
2: { class_type: 'CLIPLoader', inputs: { clip_name: 'te.safetensors', type: 'stable_diffusion' } },
|
||||
4: { class_type: 'CLIPTextEncode', inputs: { text: 'a woman holding a coffee cup', clip: ['2', 0] } },
|
||||
5: { class_type: 'ConditioningZeroOut', inputs: { conditioning: ['4', 0] } },
|
||||
6: { class_type: 'EmptyLatentImage', inputs: { width: 512, height: 512, batch_size: 1 } },
|
||||
};
|
||||
for (let index = 0; index < samplers; index++) {
|
||||
graph[`1${index}`] = { class_type: 'LoraLoaderModelOnly', inputs: { lora_name: `epoch_${index}.safetensors`, strength_model: 1, model: ['1', 0] } };
|
||||
graph[`2${index}`] = { class_type: 'KSampler', inputs: { seed: 42, steps: 8, cfg: 1, sampler_name: 'euler', scheduler: 'simple', denoise: 1,
|
||||
model: [`1${index}`, 0], positive: ['4', 0], negative: ['5', 0], latent_image: ['6', 0] } };
|
||||
}
|
||||
return graph;
|
||||
}
|
||||
|
||||
function undecided(graph) {
|
||||
const entry = image(), data = envelope(entry);
|
||||
data.prompts = { positive: { status: 'ambiguous', text: null, source: null }, negative: { status: 'ambiguous', text: null, source: null } };
|
||||
data.metadata.prompt_graph = { status: 'parsed', value: graph };
|
||||
data.resolution = { state: 'ambiguous', sampler_id: null, reason: 'Multiple sampler branches' };
|
||||
return contract('toInspectionRecord')(data, entry, extractGenerationFacts);
|
||||
}
|
||||
|
||||
test('a zeroed negative is never filled with the positive text from the legacy parse (#249)', t => {
|
||||
const parser = require('../../web/js/comfy-metadata.js');
|
||||
globalThis.ComfyMetadata = parser;
|
||||
t.after(() => { delete globalThis.ComfyMetadata; });
|
||||
// The legacy pages keep their reading: the zero-out passes the positive through.
|
||||
assert.equal(parser.extractGenerationParams({ prompt: zeroedGridGraph() }).negativePrompt, 'a woman holding a coffee cup');
|
||||
const record = undecided(zeroedGridGraph());
|
||||
assert.deepEqual(record.prompts.positive, { status: 'resolved', text: 'a woman holding a coffee cup', source: 'graph-inferred' });
|
||||
assert.deepEqual(record.prompts.negative, { status: 'resolved', text: '', source: 'zeroed-conditioning' });
|
||||
// A Reroute between the sampler and the zero-out is still a zeroed negative.
|
||||
const rerouted = zeroedGridGraph(2);
|
||||
rerouted[7] = { class_type: 'Reroute', inputs: { value: ['5', 0] } };
|
||||
rerouted[20].inputs.negative = ['7', 0];
|
||||
assert.deepEqual(undecided(rerouted).prompts.negative, { status: 'resolved', text: '', source: 'zeroed-conditioning' });
|
||||
});
|
||||
|
||||
test('a negative whose zero-out path cannot be decided stays as the server left it (#249)', t => {
|
||||
globalThis.ComfyMetadata = require('../../web/js/comfy-metadata.js');
|
||||
t.after(() => { delete globalThis.ComfyMetadata; });
|
||||
const ambiguous = { status: 'ambiguous', text: null, source: null };
|
||||
// One branch zeroes its negative, another encodes a text: which one the image used is open.
|
||||
const mixed = zeroedGridGraph(2);
|
||||
mixed[8] = { class_type: 'CLIPTextEncode', inputs: { text: 'blurry', clip: ['2', 0] } };
|
||||
mixed[21].inputs.negative = ['8', 0];
|
||||
assert.deepEqual(undecided(mixed).prompts.negative, ambiguous);
|
||||
// A zero-out combined with another conditioning is not an empty negative.
|
||||
const combined = zeroedGridGraph(1);
|
||||
combined[8] = { class_type: 'CLIPTextEncode', inputs: { text: 'blurry', clip: ['2', 0] } };
|
||||
combined[9] = { class_type: 'ConditioningCombine', inputs: { conditioning_1: ['5', 0], conditioning_2: ['8', 0] } };
|
||||
combined[20].inputs.negative = ['9', 0];
|
||||
assert.deepEqual(undecided(combined).prompts.negative, ambiguous);
|
||||
// The positive still fills from the legacy parse in both cases.
|
||||
assert.equal(undecided(mixed).prompts.positive.text, 'a woman holding a coffee cup');
|
||||
// A workflow-only file holding a zero-out never fills the negative either.
|
||||
const entry = image(), data = envelope(entry);
|
||||
data.prompts = { positive: { status: 'unresolved', text: null, source: null }, negative: { status: 'unresolved', text: null, source: null } };
|
||||
const workflow = { nodes: [
|
||||
{ id: 4, type: 'CLIPTextEncode', inputs: [], outputs: [{ links: [1, 2] }], widgets_values: ['a woman holding a coffee cup'] },
|
||||
{ id: 5, type: 'ConditioningZeroOut', inputs: [{ name: 'conditioning', link: 1 }], outputs: [{ links: [3] }] },
|
||||
{ id: 1, type: 'UNETLoader', inputs: [], outputs: [{ links: [4] }], widgets_values: ['base.safetensors', 'default'] },
|
||||
{ id: 3, type: 'KSampler', inputs: [{ name: 'model', link: 4 }, { name: 'positive', link: 2 }, { name: 'negative', link: 3 }], widgets_values: [42, 'fixed', 8, 1, 'euler', 'simple', 1] },
|
||||
], links: [[1, 4, 0, 5, 0, 'CONDITIONING'], [2, 4, 0, 3, 1, 'CONDITIONING'], [3, 5, 0, 3, 2, 'CONDITIONING'], [4, 1, 0, 3, 0, 'MODEL']] };
|
||||
assert.equal(globalThis.ComfyMetadata.extractGenerationParams({ workflow }).negativePrompt, 'a woman holding a coffee cup', 'the legacy parse would fill it');
|
||||
Object.assign(data.metadata, { raw: { workflow: JSON.stringify(workflow) }, record_formats: { workflow: 'reserialized-json' },
|
||||
prompt_graph: { status: 'absent', value: null }, workflow_graph: { status: 'parsed', value: workflow } });
|
||||
const record = contract('toInspectionRecord')(data, entry, extractGenerationFacts);
|
||||
assert.deepEqual(record.prompts.negative, { status: 'unresolved', text: null, source: null });
|
||||
});
|
||||
|
||||
test('callers without the browser global pass the legacy parser explicitly', () => {
|
||||
const parser = require('../../web/js/comfy-metadata.js');
|
||||
const entry = image(), data = envelope(entry);
|
||||
|
||||
@@ -174,6 +174,48 @@ function comfyMetadata() {
|
||||
return globalThis.ComfyMetadata || globalThis.window?.ComfyMetadata;
|
||||
}
|
||||
|
||||
// #249: the legacy parse follows ConditioningZeroOut back to its input, so it reads the
|
||||
// positive text as the negative. The workspace shows the server's zeroed state instead
|
||||
// when every sampler's negative is a zero-out, and never fills the negative from the
|
||||
// legacy parse when a zero-out sits anywhere else in a negative path.
|
||||
const ZERO_OUT = 'ConditioningZeroOut';
|
||||
const ZEROED_NEGATIVE = Object.freeze({ status: 'resolved', text: '', source: 'zeroed-conditioning' });
|
||||
const MAX_NEGATIVE_VISITS = 2000;
|
||||
const isLink = value => Array.isArray(value) && value.length === 2 && ['string', 'number'].includes(typeof value[0]);
|
||||
|
||||
/** 'zeroed', 'undecided' (do not fill), or null (no zero-out: the legacy fill applies). */
|
||||
function zeroOutNegative(graph, workflow) {
|
||||
if (!graph) return Array.isArray(workflow?.nodes) && workflow.nodes.some(node => node?.type === ZERO_OUT) ? 'undecided' : null;
|
||||
const nodes = Object.values(graph).filter(object);
|
||||
if (!nodes.some(node => node.class_type === ZERO_OUT)) return null;
|
||||
const kinds = nodes.filter(node => object(node.inputs) && isLink(node.inputs.negative)).map(node => negativeKind(graph, node.inputs.negative));
|
||||
if (kinds.length && kinds.every(kind => kind === 'zeroed')) return 'zeroed';
|
||||
return kinds.every(kind => kind === 'plain') && kinds.length ? null : 'undecided';
|
||||
}
|
||||
|
||||
function negativeKind(graph, link) {
|
||||
let node = graph[String(link[0])];
|
||||
for (let hops = 0; object(node) && node.class_type === 'Reroute' && hops < MAX_NEGATIVE_VISITS; hops++) {
|
||||
const next = object(node.inputs) ? node.inputs.value : undefined;
|
||||
node = isLink(next) ? graph[String(next[0])] : undefined;
|
||||
}
|
||||
if (!object(node)) return 'undecided';
|
||||
if (node.class_type === ZERO_OUT) return 'zeroed';
|
||||
// Any zero-out further up (combined, averaged, ...) leaves the negative undecided.
|
||||
const queue = [node], seen = new Set();
|
||||
while (queue.length) {
|
||||
const current = queue.shift();
|
||||
if (current.class_type === ZERO_OUT) return 'undecided';
|
||||
if (seen.size >= MAX_NEGATIVE_VISITS) return 'undecided';
|
||||
for (const value of object(current.inputs) ? Object.values(current.inputs) : []) {
|
||||
const id = isLink(value) ? String(value[0]) : null;
|
||||
if (id === null || seen.has(id) || !object(graph[id])) continue;
|
||||
seen.add(id); queue.push(graph[id]);
|
||||
}
|
||||
}
|
||||
return 'plain';
|
||||
}
|
||||
|
||||
function withWorkflowParams(record, parser = comfyMetadata()) {
|
||||
const graph = record.metadata.prompt_graph.status === 'parsed' ? record.metadata.prompt_graph.value : null;
|
||||
const workflow = record.metadata.workflow_graph.status === 'parsed' ? record.metadata.workflow_graph.value : null;
|
||||
@@ -184,9 +226,15 @@ function withWorkflowParams(record, parser = comfyMetadata()) {
|
||||
const found = key => typeof params[key] === 'string' || typeof params[key] === 'number' ?
|
||||
String(params[key]) !== '' && params[key] !== parser.PLACEHOLDERS?.[key] : false;
|
||||
const prompts = { ...record.prompts };
|
||||
const zeroed = prompts.negative?.status === 'resolved' ? null : zeroOutNegative(graph, workflow);
|
||||
for (const [role, key] of [['positive', 'positivePrompt'], ['negative', 'negativePrompt']]) {
|
||||
const current = prompts[role];
|
||||
if (!found(key) || current?.status === 'resolved') continue;
|
||||
if (current?.status === 'resolved') continue;
|
||||
if (role === 'negative' && zeroed !== null) {
|
||||
if (zeroed === 'zeroed') prompts.negative = { ...ZEROED_NEGATIVE };
|
||||
continue;
|
||||
}
|
||||
if (!found(key)) continue;
|
||||
prompts[role] = { status: 'resolved', text: params[key], source: 'graph-inferred' };
|
||||
}
|
||||
record.prompts = prompts;
|
||||
|
||||
Reference in New Issue
Block a user