Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9678245f8 | ||
|
|
5f21894196 | ||
|
|
02e339ca1f | ||
|
|
91bd060344 | ||
|
|
5b3ddb887d |
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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); }
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user