diff --git a/tests/browser/full-inspector.spec.mjs b/tests/browser/full-inspector.spec.mjs index 5909afd..421e291 100644 --- a/tests/browser/full-inspector.spec.mjs +++ b/tests/browser/full-inspector.spec.mjs @@ -26,9 +26,9 @@ test('inspection facts survive actual API to browser transport', async ({ page, const complete = result.complete; expect(Object.values(complete.generation).map(fact => fact.value)).toEqual(['9007199254740993', '28', '6.5', 'dpmpp_2m', 'karras', 'fixture-model.safetensors']); expect(Object.values(complete.generation).every(fact => fact.source === 'api-graph' && fact.evidence === 'configuration')).toBe(true); - // Ambiguous branches merge per field: the differing seed is ambiguous, the shared fields known. + // Differing branches show what the legacy parser shows: the first sampler's seed. expect(Object.values(result.ambiguous.generation).map(fact => [fact.status, fact.value])).toEqual([ - ['ambiguous', null], ['known', '28'], ['known', '6.5'], ['known', 'dpmpp_2m'], ['known', 'karras'], ['known', 'fixture-model.safetensors']]); + ['known', '9007199254740993'], ['known', '28'], ['known', '6.5'], ['known', 'dpmpp_2m'], ['known', 'karras'], ['known', 'fixture-model.safetensors']]); expect(Object.values(result.ambiguous.generation).every(fact => fact.sampler_id === null)).toBe(true); expect(result['workflow-only'].generation.seed).toMatchObject({ status: 'known', value: '9007199254740993', source: 'workflow', evidence: 'configuration' }); expect(result['workflow-only'].prompts.positive.source).toBe('saved-association'); diff --git a/tests/js/workspace-inspection-state.test.js b/tests/js/workspace-inspection-state.test.js index fcb8e4d..bb884be 100644 --- a/tests/js/workspace-inspection-state.test.js +++ b/tests/js/workspace-inspection-state.test.js @@ -464,3 +464,57 @@ test('rooted controller keeps legacy descriptors with ignored noncloneable field let pending;assert.doesNotThrow(()=>{pending=controller.select(selected);}); requests[0].resolve(envelope());await pending;assert.equal(controller.state.record.identity.key,'0:folder/a #%.png');controller.dispose(); }); + +// The maintainer's standard pipeline: one PromptManager pair feeds a base pass, an +// upscale pass and a FaceDetailer, saved by a custom saver. Text and seeds are invented. +function detailerPipelineGraph() { + return { + 1: { class_type: 'CheckpointLoaderSimple', inputs: { ckpt_name: 'Base/example_model.safetensors' } }, + 5: { class_type: 'KSampler', inputs: { seed: 111, steps: 4, cfg: 1.0, sampler_name: 'lcm', scheduler: 'normal', denoise: 1.0, model: ['47', 0], positive: ['25', 0], negative: ['26', 0], latent_image: ['20', 0] } }, + 6: { class_type: 'VAEDecode', inputs: { samples: ['5', 0], vae: ['1', 2] } }, + 7: { class_type: 'PreviewImage', inputs: { images: ['6', 0] } }, + 8: { class_type: 'UpscaleModelLoader', inputs: { model_name: 'upscaler.pth' } }, + 9: { class_type: 'ImageUpscaleWithModel', inputs: { upscale_model: ['8', 0], image: ['6', 0] } }, + 10: { class_type: 'ImageScaleBy', inputs: { upscale_method: 'lanczos', scale_by: 0.75, image: ['9', 0] } }, + 11: { class_type: 'VAEEncode', inputs: { pixels: ['10', 0], vae: ['1', 2] } }, + 12: { class_type: 'KSampler', inputs: { seed: 222, steps: 6, cfg: 1.0, sampler_name: 'lcm', scheduler: 'normal', denoise: 0.45, model: ['47', 0], positive: ['25', 0], negative: ['26', 0], latent_image: ['11', 0] } }, + 13: { class_type: 'VAEDecode', inputs: { samples: ['12', 0], vae: ['1', 2] } }, + 17: { class_type: 'FaceDetailer', inputs: { seed: 333, steps: 4, cfg: 1.0, sampler_name: 'lcm', scheduler: 'normal', denoise: 0.4, image: ['13', 0], model: ['47', 0], clip: ['47', 1], vae: ['1', 2], positive: ['25', 0], negative: ['26', 0] } }, + 20: { class_type: 'EmptyLatentBatch', inputs: { width: 640, height: 1536, batch_size: 1 } }, + 25: { class_type: 'PromptManager', inputs: { text: 'a lighthouse at dusk, oil painting', category: '', tags: '', search_text: '', prepend_text: '', append_text: '', clip: ['47', 1] } }, + 26: { class_type: 'PromptManager', inputs: { text: 'blurry, watermark', category: '', tags: '', search_text: '', prepend_text: '', append_text: '', clip: ['47', 1] } }, + 47: { class_type: 'Lora Loader (LoraManager)', inputs: { text: '', model: ['1', 0], clip: ['1', 1] } }, + 48: { class_type: 'KikoSaveImage', inputs: { filename_prefix: ['49', 0], format: 'PNG', images: ['17', 0] } }, + 49: { class_type: 'KikoProjectFilePath', inputs: { project_name: 'example', filename: 'image' } }, + }; +} + +test('workflow prompts and settings match the legacy parser when the server cannot decide', t => { + const parser = require('../../web/js/comfy-metadata.js'), { extractGenerationParams } = parser; + globalThis.ComfyMetadata = parser; + t.after(() => { delete globalThis.ComfyMetadata; }); + 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: detailerPipelineGraph() }; + data.resolution = { state: 'ambiguous', sampler_id: null, reason: 'Multiple sampler branches' }; + const record = contract('toInspectionRecord')(data, entry, extractGenerationFacts); + const legacy = extractGenerationParams({ prompt: detailerPipelineGraph() }); + assert.deepEqual(record.prompts.positive, { status: 'resolved', text: legacy.positivePrompt, source: 'graph-inferred' }); + assert.deepEqual(record.prompts.negative, { status: 'resolved', text: legacy.negativePrompt, source: 'graph-inferred' }); + assert.equal(record.prompts.positive.text, 'a lighthouse at dusk, oil painting'); + assert.equal(record.prompts.negative.text, 'blurry, watermark'); + for (const [field, key] of [['seed', 'seed'], ['steps', 'steps'], ['cfg', 'cfgScale'], ['sampler', 'sampler'], ['model', 'checkpoint']]) { + assert.equal(record.generation[field].status, 'known', field); + assert.equal(record.generation[field].value, String(legacy[key]), field); + } +}); + +test('recorded and saved prompts are never replaced by the workflow parse', t => { + globalThis.ComfyMetadata = require('../../web/js/comfy-metadata.js'); + t.after(() => { delete globalThis.ComfyMetadata; }); + const entry = image(), data = envelope(entry); + data.metadata.prompt_graph = { status: 'parsed', value: detailerPipelineGraph() }; + const record = contract('toInspectionRecord')(data, entry, extractGenerationFacts); + assert.deepEqual(record.prompts.positive, { status: 'resolved', text: 'exact\npositive', source: 'execution' }); + assert.equal(record.prompts.negative.text, 'blurry, watermark'); +}); diff --git a/web/js/workspace/inspection-state.mjs b/web/js/workspace/inspection-state.mjs index 9b7fb6b..638cd8a 100644 --- a/web/js/workspace/inspection-state.mjs +++ b/web/js/workspace/inspection-state.mjs @@ -163,6 +163,36 @@ function libraryFor(value) { return library; } +// The legacy workflow parse (comfy-metadata.js, as of #170) also feeds the gallery and +// admin pages; it fills any prompt or setting the server could not decide, so this page +// never shows less than they do. Recorded and saved prompts are never replaced. +const KEPT_PROMPT_SOURCES = ['execution', 'library', 'saved-association']; +const LEGACY_FACTS = [['seed', 'seed'], ['steps', 'steps'], ['cfg', 'cfgScale'], ['sampler', 'sampler'], ['model', 'checkpoint']]; + +function comfyMetadata() { + return globalThis.ComfyMetadata || globalThis.window?.ComfyMetadata; +} + +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; + if (typeof parser?.extractGenerationParams !== 'function' || (!graph && !workflow)) return; + const params = parser.extractGenerationParams({ prompt: graph, workflow }); + const found = key => typeof params[key] === 'string' || typeof params[key] === 'number' ? + String(params[key]) !== '' && params[key] !== parser.PLACEHOLDERS?.[key] : false; + const prompts = { ...record.prompts }; + for (const [role, key] of [['positive', 'positivePrompt'], ['negative', 'negativePrompt']]) { + const current = prompts[role]; + if (!found(key) || (current?.status === 'resolved' && KEPT_PROMPT_SOURCES.includes(current.source))) continue; + prompts[role] = { status: 'resolved', text: params[key], source: 'graph-inferred' }; + } + record.prompts = prompts; + for (const [field, key] of LEGACY_FACTS) { + if (!found(key)) continue; + record.generation[field] = { status: 'known', value: String(params[key]), source: graph ? 'api-graph' : 'workflow', evidence: 'configuration', reason: null, sampler_id: null }; + } +} + function unavailableFacts() { return Object.fromEntries(FACT_FIELDS.map(key => [key, { status: 'unresolved', value: null, source: null, evidence: null, reason: null, sampler_id: null }])); } @@ -194,6 +224,7 @@ export function projectInspectionPayload(result, identity, download, extractFact if (typeof extractFacts !== 'function') throw new TypeError('Inspection facts parser unavailable'); record.generation = extractFacts({ prompt_graph: record.metadata.prompt_graph, workflow_graph: record.metadata.workflow_graph, parameters: record.metadata.parameters, recordFormats: record.metadata.record_formats, metadataStatus: record.metadata.status, resolution }); + withWorkflowParams(record); if (record.library.status === 'linked' && record.library.positive_id !== null) record.historyTarget = `#/library/${record.library.positive_id}`; return record; }