diff --git a/tests/js/workspace-inspection-state.test.js b/tests/js/workspace-inspection-state.test.js index 92bc176..975bfa9 100644 --- a/tests/js/workspace-inspection-state.test.js +++ b/tests/js/workspace-inspection-state.test.js @@ -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); diff --git a/web/js/workspace/inspection-state.mjs b/web/js/workspace/inspection-state.mjs index 98fb521..3f8932c 100644 --- a/web/js/workspace/inspection-state.mjs +++ b/web/js/workspace/inspection-state.mjs @@ -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;