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..a1bf464 100644 --- a/tests/js/workspace-inspection-state.test.js +++ b/tests/js/workspace-inspection-state.test.js @@ -464,3 +464,94 @@ 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'); +}); + +test('a definite server prompt is kept even when the legacy parser reads it differently', t => { + globalThis.ComfyMetadata = require('../../web/js/comfy-metadata.js'); + t.after(() => { delete globalThis.ComfyMetadata; }); + const entry = image(), data = envelope(entry); + // The legacy parser follows ConditioningZeroOut back to the positive text node. + const graph = detailerPipelineGraph(); + graph[30] = { class_type: 'ConditioningZeroOut', inputs: { conditioning: ['25', 0] } }; + graph[5].inputs.negative = ['30', 0]; + data.metadata.prompt_graph = { status: 'parsed', value: graph }; + data.prompts = { positive: { status: 'ambiguous', text: null, source: null }, negative: { status: 'resolved', text: '', source: 'zeroed-conditioning' } }; + const record = contract('toInspectionRecord')(data, entry, extractGenerationFacts); + assert.deepEqual(record.prompts.negative, { status: 'resolved', text: '', source: 'zeroed-conditioning' }); + assert.equal(record.prompts.positive.text, 'a lighthouse at dusk, oil painting'); +}); + +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); + data.prompts = { positive: { status: 'ambiguous', text: null, source: null }, negative: { status: 'ambiguous', text: null, source: null } }; + data.metadata.prompt_graph = { status: 'parsed', value: detailerPipelineGraph() }; + const identity = { kind: 'rooted-file', key: '0:x', root_index: 0, path: entry.path, root_scope: scopeA }; + const record = contract('projectInspectionPayload')(data, identity, null, extractGenerationFacts, false, parser); + assert.equal(record.prompts.positive.text, 'a lighthouse at dusk, oil painting'); + assert.equal(record.prompts.negative.text, 'blurry, watermark'); +}); + +test('a setting the file does not record is never filled from the legacy 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 extract = input => ({ ...extractGenerationFacts(input), cfg: { status: 'not-recorded', value: null, source: null, evidence: null, reason: null, sampler_id: null } }); + const record = contract('toInspectionRecord')(data, entry, extract); + assert.equal(record.generation.cfg.status, 'not-recorded'); + assert.equal(record.generation.cfg.value, null); +}); diff --git a/tests/js/workspace-media-viewer.test.js b/tests/js/workspace-media-viewer.test.js index e69187e..d4e9994 100644 --- a/tests/js/workspace-media-viewer.test.js +++ b/tests/js/workspace-media-viewer.test.js @@ -158,3 +158,34 @@ test('real inspection navigation preserves slideshow across two advances and def assert.equal(f.container.children.length,0);assert.equal(controller.state.record.identity.occurrence.output_node_id,'3'); } finally {view.dispose();controller.dispose();parentScope.dispose();globalThis.setTimeout=oldSetTimeout;globalThis.clearTimeout=oldClearTimeout;f.restore();} }); + +test('an inline Viewer still loading its image is destroyed only after it has viewed it', async () => { + const {reportedLocationAdapter:adapter} = await import('../../web/js/workspace/history-media.mjs'); + const f = mountedDom(), parentScope = createScope(), controller = borrowedController(), items = [occurrenceMedia('1',1,'c')]; + const instances = []; + // Viewer.js's inline destroy() leaves an in-flight view running; its callback then needs a live instance. + f.win.Viewer = class { constructor(node,options) { this.node = node; this.options = options; this.destroyed = 0; this.viewed = false; instances.push(this); } + destroy() { this.destroyed++; } resize() {} }; + const globals = {ResizeObserver:globalThis.ResizeObserver, requestAnimationFrame:globalThis.requestAnimationFrame, cancelAnimationFrame:globalThis.cancelAnimationFrame}; + Object.assign(globalThis, {ResizeObserver:class { observe() {} disconnect() {} }, requestAnimationFrame:() => 1, cancelAnimationFrame() {}}); + const view = module.createMediaViewer({container:f.container,parentScope,inspectionController:controller,adapter,sender:{open(){}}}); + try { + view.open({items,selectedKey:'c'.repeat(64)}); + const [loading] = instances, image = loading.node, removed = []; + image.removeAttribute = key => removed.push(key); + f.walk(f.container,n => n.attributes['aria-label'] === 'Close preview')[0].fire('click'); + assert.equal(loading.destroyed, 0, 'not destroyed while its image view is pending'); + // Viewer.js lists only images that still have a src; the pending view needs it. + assert.deepEqual(removed, [], 'the image keeps its src while the view is pending'); + loading.viewed = true; loading.options.viewed.call(loading); + assert.equal(loading.destroyed, 1, 'destroyed once its pending view settles'); + assert.deepEqual(removed, ['src'], 'the src is released after the viewer'); + loading.options.viewed.call(loading); + assert.equal(loading.destroyed, 1, 'never destroyed twice'); + + view.open({items,selectedKey:'c'.repeat(64)}); + const shown = instances[1]; shown.viewed = true; shown.options.viewed.call(shown); + f.walk(f.container,n => n.attributes['aria-label'] === 'Close preview')[0].fire('click'); + assert.equal(shown.destroyed, 1, 'a viewed image is destroyed at once'); + } finally { view.dispose(); parentScope.dispose(); f.restore(); Object.assign(globalThis, globals); } +}); diff --git a/web/js/workspace/inspection-state.mjs b/web/js/workspace/inspection-state.mjs index 9b7fb6b..da553ed 100644 --- a/web/js/workspace/inspection-state.mjs +++ b/web/js/workspace/inspection-state.mjs @@ -163,11 +163,41 @@ 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. A definite server answer is never replaced. +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') continue; + prompts[role] = { status: 'resolved', text: params[key], source: 'graph-inferred' }; + } + record.prompts = prompts; + for (const [field, key] of LEGACY_FACTS) { + // Only open answers are filled: a known value or a definite "not recorded" stays. + if (!found(key) || !['unresolved', 'ambiguous'].includes(record.generation[field]?.status)) 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 }])); } -export function toInspectionRecord(result, image, extractFacts = globalThis.ComfyMetadata?.extractGenerationFacts || globalThis.window?.ComfyMetadata?.extractGenerationFacts) { +export function toInspectionRecord(result, image, extractFacts = globalThis.ComfyMetadata?.extractGenerationFacts || globalThis.window?.ComfyMetadata?.extractGenerationFacts, parser = comfyMetadata()) { const scope = validateIdentity(image); if (scope !== null && (!object(result) || result.root_scope !== scope)) throw new InspectionSourceChangedError(); if (!object(result) || result.success === false) throw new TypeError('Invalid inspection response'); @@ -177,11 +207,11 @@ export function toInspectionRecord(result, image, extractFacts = globalThis.Comf if ((!legacy || result.image !== undefined) && (!object(result.image) || result.image.root_index !== image.root_index || result.image.path !== image.path || result.image.filename !== image.path.split('/').at(-1))) throw new TypeError('Mismatched inspection identity'); return projectInspectionPayload(result, { kind: 'rooted-file', key: imageKey(image), root_index: image.root_index, path: image.path, root_scope: scope }, - downloadFor(image, !legacy || scope !== null), extractFacts, legacy); + downloadFor(image, !legacy || scope !== null), extractFacts, legacy, parser); } /** Shared bounded payload projection; adapters validate identity before entering here. */ -export function projectInspectionPayload(result, identity, download, extractFacts = globalThis.ComfyMetadata?.extractGenerationFacts || globalThis.window?.ComfyMetadata?.extractGenerationFacts, legacy = false) { +export function projectInspectionPayload(result, identity, download, extractFacts = globalThis.ComfyMetadata?.extractGenerationFacts || globalThis.window?.ComfyMetadata?.extractGenerationFacts, legacy = false, parser = comfyMetadata()) { const record = { identity, file: null, prompts: promptsFor(result.prompts), library: null, metadata: null, generation: unavailableFacts(), historyTarget: null, download, @@ -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, parser); if (record.library.status === 'linked' && record.library.positive_id !== null) record.historyTarget = `#/library/${record.library.positive_id}`; return record; } diff --git a/web/js/workspace/media-viewer.mjs b/web/js/workspace/media-viewer.mjs index a3437a4..5f10bf0 100644 --- a/web/js/workspace/media-viewer.mjs +++ b/web/js/workspace/media-viewer.mjs @@ -4,6 +4,8 @@ import {rootedFileAdapter} from './inspection-state.mjs'; import {mountFileInspector} from './file-inspector.mjs'; let viewerId = 0; +// Upper bound on waiting for a closed Viewer's in-flight image view before destroying it. +const PENDING_VIEW_DESTROY_MS = 10000; export function readViewerPreferences(getStorage = () => window.localStorage, normalize = globalThis.GallerySettings.normalizeGallerySettings) { let stored; try { stored = JSON.parse(getStorage().getItem('gallerySettings')); } catch { stored = null; } @@ -127,12 +129,19 @@ export function createMediaViewer({container,parentScope,inspectionController,se } else { node.alt = selected.filename;node.decoding = 'async'; node.hidden = !!window.Viewer; - if (!window.Viewer) mediaLocal.listen(node,'load',() => {if (current()) session.ready(key);}); - mediaLocal.cleanup(() => node.removeAttribute('src')); + // With Viewer.js the src is released after the viewer (see release below). + if (!window.Viewer) { + mediaLocal.listen(node,'load',() => {if (current()) session.ready(key);}); + mediaLocal.cleanup(() => node.removeAttribute('src')); + } } node.src = url.href;media.append(node); if (!selected.is_video && window.Viewer) { - let fitFrame = 0, fitWidth, fitHeight; + let fitFrame = 0, fitWidth, fitHeight, viewSettled = false, released = false, fallback = 0; + const release = () => { + if (released) return; + released = true;clearTimeout(fallback);instance.destroy();node.removeAttribute('src'); + }; const scheduleFit = () => { if (!current() || fitFrame) return; fitFrame = requestAnimationFrame(() => { @@ -145,11 +154,21 @@ export function createMediaViewer({container,parentScope,inspectionController,se }; const instance = new window.Viewer(node,{inline:true,container:media,toolbar:false,navbar:false,title:false,button:false, keyboard:false,fullscreen:false,transition:false,viewed() { + viewSettled = true; + if (mediaLocal.signal.aborted) { release(); return; } if (!current()) return; session.ready(key);scheduleFit(); }}); const observer = new ResizeObserver(scheduleFit);observer.observe(stage); - viewer = instance;mediaLocal.cleanup(() => {observer.disconnect();cancelAnimationFrame(fitFrame);instance.destroy();}); + viewer = instance;mediaLocal.cleanup(() => { + observer.disconnect();cancelAnimationFrame(fitFrame); + // Inline Viewer.js keeps viewing a still-loading image after destroy(), and that view + // rebuilds its list from images that still have a src; keep both until the view + // settles, fails or times out. + if (viewSettled) { release(); return; } + node.addEventListener('error',release,{once:true}); + fallback = setTimeout(release,PENDING_VIEW_DESTROY_MS); + }); } updateControls(session.state); }