Merge remote-tracking branch 'origin/main' into ci/fast-coverage-baseline-check
This commit is contained in:
@@ -158,6 +158,10 @@ async function filteredOf(page,apply,undo) {
|
||||
await apply();
|
||||
await expect.poll(()=>captionOf(page)).not.toBe(before);
|
||||
await expect.poll(()=>page.locator(CARDS).count()).toBeLessThan(CONCEPT_PROMPTS);
|
||||
// Production empties the shelf when the filter applies, writes the answer's caption at once and places its cards
|
||||
// in the next animation frame (one paint for a viewer), so the empty shelf already counts as "fewer": read the
|
||||
// titles only after that frame, as the Images and Inspector comparisons do.
|
||||
await settle(page);
|
||||
const result={caption:await captionOf(page),titles:await titlesOf(page)};
|
||||
await undo();
|
||||
await expect.poll(()=>captionOf(page)).toBe(before);
|
||||
|
||||
@@ -1487,6 +1487,30 @@ test('a star press leaves the continuation status, count and caption date untouc
|
||||
expect(await page.evaluate(() => window.__statusMutations)).toEqual([]);
|
||||
});
|
||||
|
||||
// Opened before the source and folder discovery answered (a slow server), Refine lists them once it does.
|
||||
test('Refine opened before discovery settles lists the discovered sources and folders', async ({ page }) => {
|
||||
let release, started;
|
||||
const held = new Promise(resolve => { release = resolve; }), hit = new Promise(resolve => { started = resolve; });
|
||||
await page.route('**/prompt_manager/gallery/subfolders?*', async route => { started(); await held; await route.continue(); }, { times: 1 });
|
||||
await page.goto(workspace);
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
await hit;
|
||||
await filtersButton(page).click();
|
||||
const source = refineDialog(page).getByLabel('Source', { exact: true });
|
||||
const folder = refineDialog(page).getByLabel('Folder', { exact: true });
|
||||
const values = select => select.locator('option').evaluateAll(options => options.map(option => option.value));
|
||||
expect(await values(source)).toEqual(['all']);
|
||||
expect(await values(folder)).toEqual(['']);
|
||||
release();
|
||||
await expect.poll(() => values(source)).toEqual(['all', '0', '1']);
|
||||
expect(await values(folder)).toEqual(['', 'shared']);
|
||||
await expect(source).toHaveValue('all');
|
||||
await source.selectOption('1');
|
||||
await refineDialog(page).getByRole('button', { name: 'Apply filters', exact: true }).click();
|
||||
await expect(cards(page)).toHaveCount(1);
|
||||
await expect(cards(page)).toHaveAttribute('data-image-key', '1:shared/same.png');
|
||||
});
|
||||
|
||||
// #249 final review m1: as in the old field row, a Source offers only its own folders.
|
||||
test('Refine offers only the chosen source folders', async ({ page, request }) => {
|
||||
await expect(await request.post('/__test__/add-nested-image')).toBeOK();
|
||||
@@ -1921,7 +1945,7 @@ test('Set category without a linked image posts nothing and opens no dialog', as
|
||||
await page.route(categoryRoute, route => { posted += 1; return route.continue(); });
|
||||
await checkbox(cards(page).last(), 'same.png').check();
|
||||
await categoryButton(page).click();
|
||||
await expect(toast(page)).toHaveText('None of the selected images has a linked prompt.');
|
||||
await expect(toast(page)).toHaveText("None of the selected images is linked to a saved prompt, so there is no prompt category to set.");
|
||||
await expect(page.locator('dialog[open]')).toHaveCount(0);
|
||||
expect(posted).toBe(0);
|
||||
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('1 selected');
|
||||
|
||||
@@ -224,6 +224,22 @@ test("facts use resolution.sampler_id for detailer-stage nodes", () => {
|
||||
assert.ok(Object.values(factsFor(wide, { resolution: { state: "ambiguous", sampler_id: "d" } })).every(fact => fact.status === "unresolved"), "the stage walk is bounded by MAX_VISITS");
|
||||
});
|
||||
|
||||
test("a shared pair resolved across sampler branches merges facts per field (#249)", () => {
|
||||
// The server resolves identical branches with no sampler named: settings the
|
||||
// branches agree on are known, those they differ on (a LoRA per epoch) are ambiguous.
|
||||
const shared = { resolution: { state: "resolved", sampler_id: null, reason: "Every sampler branch uses the same prompt pair" } };
|
||||
const grid = factGraph();
|
||||
grid.lora = { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "epoch_10.safetensors" } };
|
||||
grid.other = structuredClone(grid.s); grid.other.inputs.model = ["lora", 0];
|
||||
assert.deepEqual(statusValues(factsFor(grid, shared)), {
|
||||
seed: ["known", "9007199254740993"], steps: ["known", "28"], cfg: ["known", "6.5000000000000000001"],
|
||||
sampler: ["known", "dpmpp_2m"], scheduler: ["known", "karras"], model: ["ambiguous", null],
|
||||
});
|
||||
assert.ok(Object.values(factsFor(grid, shared)).every(fact => fact.sampler_id === null));
|
||||
// One listed sampler keeps reading that sampler alone.
|
||||
assert.equal(factsFor(factGraph(), shared).model.value, "model.safetensors");
|
||||
});
|
||||
|
||||
test("ambiguous results without a sampler_id merge facts across every listed stage", () => {
|
||||
const ambiguous = { resolution: { state: "ambiguous", sampler_id: null, reason: "Multiple sampler branches" } };
|
||||
// f01b's shape: an upstream sampler and a detailer whose settings partly differ.
|
||||
|
||||
@@ -30,7 +30,7 @@ test("facts guider providers are guiders in the semantics table", () => {
|
||||
});
|
||||
|
||||
test("the facts tables are not part of the legacy page contract", () => {
|
||||
assert.deepStrictEqual(Object.keys(metadata).filter(key => !key.startsWith("_")).sort(), ["PLACEHOLDERS", "extractGenerationFacts", "extractGenerationParams"]);
|
||||
assert.deepStrictEqual(Object.keys(metadata).filter(key => !key.startsWith("_")).sort(), ["PLACEHOLDERS", "extractGenerationFacts", "extractGenerationParams", "legacyNegativeSource"]);
|
||||
assert.ok(Object.isFrozen(metadata._factsTables));
|
||||
});
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ test('the outcome toast counts the prompts and the skipped images', async () =>
|
||||
test('the dialog and failure copy is the plan\'s', async () => {
|
||||
const {CATEGORY_DIALOG, CATEGORY_NONE_LINKED, CATEGORY_FAILED} = await source;
|
||||
assert.deepEqual(CATEGORY_DIALOG, {title:'Categorize selected images', label:'Category', action:'Apply'});
|
||||
assert.equal(CATEGORY_NONE_LINKED, 'None of the selected images has a linked prompt.');
|
||||
assert.equal(CATEGORY_NONE_LINKED, "None of the selected images is linked to a saved prompt, so there is no prompt category to set.");
|
||||
assert.equal(CATEGORY_FAILED, 'Category could not be set. Try again.');
|
||||
assert.ok(Object.isFrozen(CATEGORY_DIALOG));
|
||||
});
|
||||
|
||||
@@ -538,6 +538,136 @@ 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' });
|
||||
// Behind an API-graph Reroute the legacy parse reads no negative, so there is nothing
|
||||
// to correct: the server's answer stays.
|
||||
const rerouted = zeroedGridGraph(1);
|
||||
rerouted[7] = { class_type: 'Reroute', inputs: { value: ['5', 0] } };
|
||||
rerouted[20].inputs.negative = ['7', 0];
|
||||
assert.equal(parser.legacyNegativeSource({ prompt: rerouted }), null);
|
||||
assert.deepEqual(undecided(rerouted).prompts.negative, { status: 'ambiguous', text: null, source: null });
|
||||
});
|
||||
|
||||
test('the zero-out check follows the sampler the legacy parse read (#249)', t => {
|
||||
const parser = require('../../web/js/comfy-metadata.js');
|
||||
globalThis.ComfyMetadata = parser;
|
||||
t.after(() => { delete globalThis.ComfyMetadata; });
|
||||
const zeroed = { status: 'resolved', text: '', source: 'zeroed-conditioning' };
|
||||
// The legacy parse reads the first sampler (20): its negative is the zero-out.
|
||||
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, zeroed);
|
||||
// Reversed: the sampler it reads encodes "blurry", which it showed before and still does.
|
||||
const reversed = zeroedGridGraph(2);
|
||||
reversed[8] = { class_type: 'CLIPTextEncode', inputs: { text: 'blurry', clip: ['2', 0] } };
|
||||
reversed[20].inputs.negative = ['8', 0];
|
||||
assert.equal(parser.extractGenerationParams({ prompt: reversed }).negativePrompt, 'blurry');
|
||||
assert.deepEqual(undecided(reversed).prompts.negative, { status: 'resolved', text: 'blurry', source: 'graph-inferred' });
|
||||
// The positive fills from the legacy parse in both cases.
|
||||
assert.equal(undecided(mixed).prompts.positive.text, 'a woman holding a coffee cup');
|
||||
assert.equal(undecided(reversed).prompts.positive.text, 'a woman holding a coffee cup');
|
||||
});
|
||||
|
||||
test('a zero-out mixed into the read negative leaves it as the server left it (#249)', t => {
|
||||
globalThis.ComfyMetadata = require('../../web/js/comfy-metadata.js');
|
||||
t.after(() => { delete globalThis.ComfyMetadata; });
|
||||
// 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, { status: 'ambiguous', text: null, source: null });
|
||||
});
|
||||
|
||||
function workflowOnly(workflow) {
|
||||
const entry = image(), data = envelope(entry);
|
||||
data.prompts = { positive: { status: 'unresolved', text: null, source: null }, negative: { status: 'unresolved', text: null, source: null } };
|
||||
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 } });
|
||||
return contract('toInspectionRecord')(data, entry, extractGenerationFacts);
|
||||
}
|
||||
|
||||
// A UI workflow: encode 4 -> zero-out 5 -> sampler 3's negative, encode 4 -> positive.
|
||||
function zeroedWorkflow({ zeroMode = 0, extraNodes = [], extraLinks = [], negativeLink = 3 } = {}) {
|
||||
return { nodes: [
|
||||
{ id: 4, type: 'CLIPTextEncode', inputs: [], outputs: [{ links: [1, 2] }], widgets_values: ['a woman holding a coffee cup'] },
|
||||
{ id: 5, type: 'ConditioningZeroOut', mode: zeroMode, inputs: [{ name: 'conditioning', type: '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: negativeLink }], widgets_values: [42, 'fixed', 8, 1, 'euler', 'simple', 1] },
|
||||
...extraNodes,
|
||||
], 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'], ...extraLinks] };
|
||||
}
|
||||
|
||||
test('workflow-only files follow the legacy reading of their zero-out (#249)', t => {
|
||||
const parser = require('../../web/js/comfy-metadata.js');
|
||||
globalThis.ComfyMetadata = parser;
|
||||
t.after(() => { delete globalThis.ComfyMetadata; });
|
||||
// A connected zero-out on the read sampler's negative is the zeroed empty negative.
|
||||
const direct = zeroedWorkflow();
|
||||
assert.equal(parser.extractGenerationParams({ workflow: direct }).negativePrompt, 'a woman holding a coffee cup', 'the legacy parse would fill it');
|
||||
assert.deepEqual(workflowOnly(direct).prompts.negative, { status: 'resolved', text: '', source: 'zeroed-conditioning' });
|
||||
// A bypassed zero-out (mode 4) passes its conditioning through: the legacy reading is right.
|
||||
const bypassed = workflowOnly(zeroedWorkflow({ zeroMode: 4 }));
|
||||
assert.deepEqual(bypassed.prompts.negative, { status: 'resolved', text: 'a woman holding a coffee cup', source: 'graph-inferred' });
|
||||
// An unconnected zero-out beside a text negative changes nothing.
|
||||
const unconnected = workflowOnly(zeroedWorkflow({ negativeLink: 6,
|
||||
extraNodes: [{ id: 6, type: 'CLIPTextEncode', inputs: [], outputs: [{ links: [6] }], widgets_values: ['blurry'] }],
|
||||
extraLinks: [[6, 6, 0, 3, 2, 'CONDITIONING']] }));
|
||||
assert.deepEqual(unconnected.prompts.negative, { status: 'resolved', text: 'blurry', source: 'graph-inferred' });
|
||||
});
|
||||
|
||||
test('a negative read through a subgraph holding a zero-out is not filled (#249)', t => {
|
||||
const parser = require('../../web/js/comfy-metadata.js');
|
||||
globalThis.ComfyMetadata = parser;
|
||||
t.after(() => { delete globalThis.ComfyMetadata; });
|
||||
// The sampler's negative comes from a subgraph instance whose definition zeroes it.
|
||||
const definition = 'aaaaaaaa-0000-4000-8000-000000000001';
|
||||
const workflow = zeroedWorkflow({ negativeLink: 7,
|
||||
extraNodes: [{ id: 9, type: definition, inputs: [{ name: 'conditioning', type: 'CONDITIONING', link: 8 }], outputs: [{ links: [7] }] }],
|
||||
extraLinks: [[7, 9, 0, 3, 2, 'CONDITIONING'], [8, 4, 0, 9, 0, 'CONDITIONING']] });
|
||||
workflow.definitions = { subgraphs: [{ id: definition, nodes: [{ id: 1, type: 'ConditioningZeroOut', inputs: [], outputs: [] }], links: [] }] };
|
||||
assert.equal(parser.extractGenerationParams({ workflow }).negativePrompt, 'a woman holding a coffee cup', 'the legacy parse would fill it');
|
||||
assert.deepEqual(workflowOnly(workflow).prompts.negative, { status: 'unresolved', text: null, source: null });
|
||||
// The same instance of a definition without a zero-out keeps the legacy fill.
|
||||
workflow.definitions.subgraphs[0].nodes[0].type = 'Reroute';
|
||||
assert.equal(workflowOnly(workflow).prompts.negative.text, 'a woman holding a coffee cup');
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
@@ -342,6 +342,56 @@ test('refine dialog round-trips values and clear resets them', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('Refine lists options discovered after it opened and keeps the wanted values', async () => {
|
||||
const { openRefineDialog, openImportDialog, closeWorkspaceDialog } = await dialogsModule;
|
||||
const dom = fakeDOM();
|
||||
const scope = (await coreModule).createScope();
|
||||
try {
|
||||
let bySource = {};
|
||||
const optionsFor = source => [['', 'All output folders'], ...(bySource[source] ?? []).map(path => [path, path])];
|
||||
const fields = [{ key: 'source', options: [['all', 'All sources']] }, { key: 'folder', options: optionsFor('all'), optionsFor },
|
||||
REFINE_FIELDS[2]];
|
||||
const applied = [];
|
||||
const refine = openRefineDialog({ scope, fields, values: { source: '1', folder: 'temp-only', media: 'video' },
|
||||
onApply: value => applied.push(value), onClear() {} });
|
||||
const [source, folder, media] = byId(dom, 'modal-body').children.filter(child => child.className === 'field')
|
||||
.map(field => field.children[1]);
|
||||
// Opened before discovery: only the cleared values.
|
||||
assert.deepEqual(optionPairs(source), [['all', 'All sources']]);
|
||||
assert.equal(source.value, 'all');
|
||||
assert.equal(folder.value, '');
|
||||
bySource = { all: ['portraits', 'temp-only'], 0: ['portraits'], 1: ['temp-only'] };
|
||||
refine.setOptions('source', [['all', 'All sources'], ['0', 'output'], ['1', 'archive']]);
|
||||
// The values it opened with, now offered, and the chosen Source's folders.
|
||||
assert.deepEqual(optionPairs(source).map(([value]) => value), ['all', '0', '1']);
|
||||
assert.equal(source.value, '1');
|
||||
assert.deepEqual(optionPairs(folder), [['', 'All output folders'], ['temp-only', 'temp-only']]);
|
||||
assert.equal(folder.value, 'temp-only');
|
||||
// A choice made since wins over the opening value; tags and unknown keys are ignored.
|
||||
source.value = '0';
|
||||
source.dispatchEvent(new Event('change'));
|
||||
refine.setOptions('source', [['all', 'All sources'], ['0', 'output'], ['1', 'archive']]);
|
||||
refine.setOptions('tags', [['x', 'x']]);
|
||||
assert.equal(source.value, '0');
|
||||
assert.deepEqual(optionPairs(folder).map(([value]) => value), ['', 'portraits']);
|
||||
assert.equal(media.value, 'video');
|
||||
button(byId(dom, 'modal-body'), 'Apply filters').click();
|
||||
assert.deepEqual(applied, [{ source: '0', folder: '', media: 'video' }]);
|
||||
// Closed: a late discovery leaves the shell alone.
|
||||
refine.setOptions('source', [['all', 'All sources']]);
|
||||
assert.equal(source.options.length, 3);
|
||||
// Replaced by another dialog: the same.
|
||||
const again = openRefineDialog({ scope, fields, values: {}, onApply() {}, onClear() {} });
|
||||
openImportDialog({ scope, onInspect() {}, onRescan() {} });
|
||||
again.setOptions('source', [['all', 'All sources'], ['9', 'late']]);
|
||||
assert.equal(byId(dom, 'modal-title').textContent, 'Import & inspect');
|
||||
} finally {
|
||||
closeWorkspaceDialog();
|
||||
scope.dispose();
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('the Folder list follows the chosen Source', async () => {
|
||||
const { openRefineDialog, closeWorkspaceDialog } = await dialogsModule;
|
||||
const dom = fakeDOM();
|
||||
|
||||
@@ -128,7 +128,12 @@ class TestPromptInspector(ImageAPITestCase):
|
||||
for expected in ("ambiguous", "unresolved"):
|
||||
source = graph()
|
||||
if expected == "ambiguous":
|
||||
source["4"] = source["3"]
|
||||
# Branches with different prompts (#249: identical ones resolve).
|
||||
source["5"] = {"class_type": "CLIPTextEncode", "inputs": {"text": "x"}}
|
||||
source["4"] = {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {"positive": ["5", 0], "negative": ["2", 0]},
|
||||
}
|
||||
else:
|
||||
source["1"]["class_type"] = "UnknownEncoder"
|
||||
png(
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
"""Several sampler branches that share one prompt pair resolve to that pair (#249).
|
||||
|
||||
The shape is a LoRA epoch-compare grid: one sampler per LoRA epoch, every
|
||||
sampler fed by the same positive ``CLIPTextEncode`` and the same
|
||||
``ConditioningZeroOut`` negative, every decoded image labelled, batched and
|
||||
concatenated into one grid that a single ``SaveImage`` writes. Which branch
|
||||
made which tile does not matter to the prompt: every branch used the same pair.
|
||||
Branches that differ stay ambiguous.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
|
||||
from utils import prompt_pair_resolver as resolver
|
||||
from utils.prompt_inspector import inspect_prompt_evidence
|
||||
from utils.prompt_pair_resolver import resolve_prompt_pair
|
||||
|
||||
POSITIVE = "a woman holding a coffee cup, in a beanie, sitting by a window"
|
||||
OTHER = "a man reading a newspaper on a park bench"
|
||||
NEGATIVE = "blurry, low quality"
|
||||
# Each profile with the negative it can read: inspection reads the zero-out.
|
||||
PROFILES = {
|
||||
"default": (resolver.DEFAULT_PROFILE, NEGATIVE),
|
||||
"inspection": (resolver.INSPECTION_PROFILE, None),
|
||||
}
|
||||
|
||||
|
||||
def _grid(epochs: int = 4, positives: dict = None, negative: str = None) -> dict:
|
||||
"""A compare grid; ``positives`` maps an epoch to its own positive text.
|
||||
|
||||
The negative is a ``ConditioningZeroOut`` of the positive, or with
|
||||
``negative`` an encoded text (the default profile cannot read a zero-out).
|
||||
"""
|
||||
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"},
|
||||
},
|
||||
"3": {"class_type": "VAELoader", "inputs": {"vae_name": "vae.safetensors"}},
|
||||
"4": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": POSITIVE, "clip": ["2", 0]},
|
||||
},
|
||||
"5": (
|
||||
{"class_type": "ConditioningZeroOut", "inputs": {"conditioning": ["4", 0]}}
|
||||
if negative is None
|
||||
else {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": negative, "clip": ["2", 0]},
|
||||
}
|
||||
),
|
||||
"6": {
|
||||
"class_type": "EmptyLatentImage",
|
||||
"inputs": {"width": 512, "height": 512, "batch_size": 1},
|
||||
},
|
||||
}
|
||||
batch = {}
|
||||
for epoch in range(epochs):
|
||||
base = 10 + epoch * 10
|
||||
lora, sampler, decode, label = (str(base + offset) for offset in range(4))
|
||||
positive = "4"
|
||||
if positives and epoch in positives:
|
||||
positive = str(base + 5)
|
||||
graph[positive] = {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": positives[epoch], "clip": ["2", 0]},
|
||||
}
|
||||
graph[lora] = {
|
||||
"class_type": "LoraLoaderModelOnly",
|
||||
"inputs": {
|
||||
"lora_name": f"subject_epoch_{epoch:02d}.safetensors",
|
||||
"strength_model": 1.0,
|
||||
"model": ["1", 0],
|
||||
},
|
||||
}
|
||||
graph[sampler] = {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"seed": 42,
|
||||
"steps": 8,
|
||||
"cfg": 1.0,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "simple",
|
||||
"denoise": 1.0,
|
||||
"model": [lora, 0],
|
||||
"positive": [positive, 0],
|
||||
"negative": ["5", 0],
|
||||
"latent_image": ["6", 0],
|
||||
},
|
||||
}
|
||||
graph[decode] = {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {"samples": [sampler, 0], "vae": ["3", 0]},
|
||||
}
|
||||
graph[label] = {
|
||||
"class_type": "AddLabel",
|
||||
"inputs": {"text": f"epoch {epoch}", "image": [decode, 0]},
|
||||
}
|
||||
batch[f"images.image{epoch}"] = [label, 0]
|
||||
graph["900"] = {"class_type": "BatchImagesNode", "inputs": batch}
|
||||
graph["901"] = {
|
||||
"class_type": "ImageConcatFromBatch",
|
||||
"inputs": {"num_columns": 2, "images": ["900", 0]},
|
||||
}
|
||||
graph["902"] = {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {"filename_prefix": "compare/grid", "images": ["901", 0]},
|
||||
}
|
||||
return graph
|
||||
|
||||
|
||||
def _texts(result: dict) -> tuple:
|
||||
return tuple(
|
||||
result[role]["text"] if isinstance(result[role], dict) else None
|
||||
for role in ("positive", "negative")
|
||||
)
|
||||
|
||||
|
||||
class TestIdenticalSamplerBranches(unittest.TestCase):
|
||||
def test_grid_of_identical_branches_resolves_to_the_shared_pair(self):
|
||||
for name, (profile, negative) in PROFILES.items():
|
||||
with self.subTest(profile=name):
|
||||
result = resolve_prompt_pair(
|
||||
{"prompt": _grid(negative=negative)}, profile=profile
|
||||
)
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertEqual(_texts(result), (POSITIVE, negative or ""))
|
||||
# No one branch is named: every branch made this pair.
|
||||
self.assertIsNone(result["sampler_id"])
|
||||
self.assertEqual(result["reason"], resolver.IDENTICAL_BRANCHES_REASON)
|
||||
# Every branch stays listed as the evidence for the shared pair.
|
||||
self.assertEqual(
|
||||
[item["sampler_id"] for item in result["candidates"]],
|
||||
["11", "21", "31", "41"],
|
||||
)
|
||||
self.assertEqual(
|
||||
{_texts(item) for item in result["candidates"]},
|
||||
{(POSITIVE, negative or "")},
|
||||
)
|
||||
self.assertEqual(result["reason_code"], None)
|
||||
if negative is None:
|
||||
self.assertEqual(
|
||||
result["negative"]["provenance"]["text_source"],
|
||||
"zeroed-conditioning",
|
||||
)
|
||||
|
||||
def test_default_profile_keeps_an_unreadable_zero_out_grid_ambiguous(self):
|
||||
# Ingestion cannot read ConditioningZeroOut: every branch is unresolved,
|
||||
# so the grid stays ambiguous and the scan keeps its legacy fallback.
|
||||
result = resolve_prompt_pair({"prompt": _grid()})
|
||||
self.assertEqual(result["state"], "ambiguous")
|
||||
self.assertEqual(result["reason"], "Multiple sampler branches")
|
||||
self.assertEqual(
|
||||
{item["state"] for item in result["candidates"]}, {"unresolved"}
|
||||
)
|
||||
|
||||
def test_single_branch_grid_still_names_its_sampler(self):
|
||||
for name, (profile, negative) in PROFILES.items():
|
||||
with self.subTest(profile=name):
|
||||
result = resolve_prompt_pair(
|
||||
{"prompt": _grid(epochs=1, negative=negative)}, profile=profile
|
||||
)
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertEqual(result["sampler_id"], "11")
|
||||
self.assertEqual(result["reason"], "")
|
||||
|
||||
def test_branches_with_different_prompts_stay_ambiguous(self):
|
||||
for name, (profile, negative) in PROFILES.items():
|
||||
with self.subTest(profile=name):
|
||||
source = _grid(positives={2: OTHER}, negative=negative)
|
||||
result = resolve_prompt_pair({"prompt": source}, profile=profile)
|
||||
self.assertEqual(result["state"], "ambiguous")
|
||||
self.assertEqual(result["reason"], "Multiple sampler branches")
|
||||
self.assertIsNone(result["positive"])
|
||||
self.assertEqual(
|
||||
sorted(_texts(item)[0] for item in result["candidates"]),
|
||||
sorted([POSITIVE] * 3 + [OTHER]),
|
||||
)
|
||||
|
||||
def test_a_branch_that_cannot_be_read_keeps_the_grid_ambiguous(self):
|
||||
for name, (profile, negative) in PROFILES.items():
|
||||
with self.subTest(profile=name):
|
||||
source = _grid(negative=negative)
|
||||
source["31"]["inputs"]["negative"] = ["800", 0]
|
||||
source["800"] = {"class_type": "MysteryConditioning", "inputs": {}}
|
||||
result = resolve_prompt_pair({"prompt": source}, profile=profile)
|
||||
self.assertEqual(result["state"], "ambiguous")
|
||||
self.assertEqual(result["reason"], "Multiple sampler branches")
|
||||
|
||||
def test_identical_unreadable_branches_are_not_collapsed(self):
|
||||
source = _grid(epochs=2)
|
||||
source["5"] = {"class_type": "MysteryConditioning", "inputs": {}}
|
||||
for name, (profile, _negative) in PROFILES.items():
|
||||
with self.subTest(profile=name):
|
||||
result = resolve_prompt_pair({"prompt": source}, profile=profile)
|
||||
self.assertEqual(result["state"], "ambiguous")
|
||||
|
||||
def test_same_text_with_a_different_recipe_stays_ambiguous(self):
|
||||
adapters = {
|
||||
("RecipeText", 0): resolver.OutputAdapter(
|
||||
"text", "conditioning", compose=True
|
||||
)
|
||||
}
|
||||
source = _grid(epochs=2, negative=NEGATIVE)
|
||||
source["21"]["inputs"]["positive"] = ["700", 0]
|
||||
source["700"] = {
|
||||
"class_type": "RecipeText",
|
||||
"inputs": {"prepend_text": "a woman", "text": "holding a coffee cup"},
|
||||
}
|
||||
alone = resolve_prompt_pair(
|
||||
{"prompt": source}, output_node_id="21", adapters=adapters
|
||||
)
|
||||
# Branch "10" reads the same effective text through a plain encoder.
|
||||
source["4"]["inputs"]["text"] = alone["positive"]["text"]
|
||||
result = resolve_prompt_pair({"prompt": source}, adapters=adapters)
|
||||
self.assertEqual(result["state"], "ambiguous")
|
||||
self.assertEqual(
|
||||
{_texts(item) for item in result["candidates"]},
|
||||
{(alone["positive"]["text"], NEGATIVE)},
|
||||
)
|
||||
self.assertEqual(
|
||||
{item["positive"]["storage_text"] for item in result["candidates"]},
|
||||
{alone["positive"]["text"], "holding a coffee cup"},
|
||||
)
|
||||
|
||||
def test_inspector_shows_the_positive_and_an_explicitly_empty_negative(self):
|
||||
evidence = inspect_prompt_evidence({"prompt": _grid()})
|
||||
prompts = evidence["prompts"]
|
||||
self.assertEqual(prompts["positive"]["status"], "resolved")
|
||||
self.assertEqual(prompts["positive"]["text"], POSITIVE)
|
||||
self.assertEqual(prompts["negative"]["status"], "resolved")
|
||||
self.assertEqual(prompts["negative"]["text"], "")
|
||||
self.assertEqual(prompts["negative"]["source"], "zeroed-conditioning")
|
||||
self.assertEqual(evidence["resolution"]["state"], "resolved")
|
||||
self.assertIsNone(evidence["resolution"]["sampler_id"])
|
||||
|
||||
def test_inspector_keeps_differing_grids_ambiguous(self):
|
||||
evidence = inspect_prompt_evidence({"prompt": _grid(positives={0: OTHER})})
|
||||
self.assertEqual(evidence["resolution"]["state"], "ambiguous")
|
||||
self.assertEqual(evidence["prompts"]["positive"]["status"], "ambiguous")
|
||||
self.assertNotIn(POSITIVE, str(evidence["prompts"]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -254,7 +254,9 @@ class TestOutcomeMessages(unittest.TestCase):
|
||||
def test_candidates_and_absence_keep_their_status_lines(self):
|
||||
graph = _runtime()
|
||||
graph["7"] = _encode("positive")
|
||||
graph["5"] = _ksampler()
|
||||
# A second sampler with its own prompt (#249: identical ones resolve).
|
||||
graph["8"] = _encode("other positive")
|
||||
graph["5"] = _ksampler("8")
|
||||
evidence = inspect_prompt_evidence({"prompt": graph})
|
||||
self.assertEqual(evidence["resolution"]["state"], "ambiguous")
|
||||
self.assertEqual(_messages(evidence), {"positive": None, "negative": None})
|
||||
|
||||
@@ -51,11 +51,34 @@ class TestPairImageLinking(unittest.TestCase):
|
||||
self.assertEqual(pairs[0]["image_count"], 1)
|
||||
self.assertEqual(pairs[0]["run_count"], 0)
|
||||
|
||||
def test_parallel_identical_branches_create_their_shared_pair(self):
|
||||
# #249: two samplers on one positive and one negative are one certain pair.
|
||||
meta = metadata()
|
||||
meta["prompt"]["4"] = {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {"positive": ["1", 0], "negative": ["2", 0]},
|
||||
}
|
||||
self.handler.link_image_to_prompt(self.path, {"id": self.pid}, meta)
|
||||
pairs = self.db.list_prompt_pairs()["pairs"]
|
||||
self.assertEqual(
|
||||
[(p["positive_text"], p["negative_text"]) for p in pairs], [("cat", "blur")]
|
||||
)
|
||||
self.assertEqual(pairs[0]["image_count"], 1)
|
||||
self.assertEqual(pairs[0]["run_count"], 0)
|
||||
|
||||
def test_stale_context_or_ambiguous_metadata_does_not_create_pair(self):
|
||||
meta = metadata(positive="dog")
|
||||
self.handler.link_image_to_prompt(self.path, {"id": self.pid}, meta)
|
||||
self.assertEqual(self.db.list_prompt_pairs()["total"], 0)
|
||||
meta = metadata()
|
||||
meta["prompt"]["4"] = meta["prompt"]["3"]
|
||||
# Two samplers with different positives (#249: identical ones resolve).
|
||||
meta["prompt"]["5"] = {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": "dog"},
|
||||
}
|
||||
meta["prompt"]["4"] = {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {"positive": ["5", 0], "negative": ["2", 0]},
|
||||
}
|
||||
self.handler.link_image_to_prompt(self.path + "2", {"id": self.pid}, meta)
|
||||
self.assertEqual(self.db.list_prompt_pairs()["total"], 0)
|
||||
|
||||
@@ -44,10 +44,15 @@ class TestPairScan(AdminAPITestCase):
|
||||
return [p["text"] for p in self.api.db.get_recent_prompts()["prompts"]]
|
||||
|
||||
def test_samplers_sharing_one_prompt_link_that_prompt(self):
|
||||
# #249: every branch resolves to one exact pair, so the pair is ingested.
|
||||
g = sampled_graph()
|
||||
g["5"] = {**g["3"]}
|
||||
self.assertEqual(self._scanned("multi.png", g), "added")
|
||||
self.assertEqual(self._texts(), ["cat"])
|
||||
self.assertEqual(sorted(self._texts()), ["blur", "cat"])
|
||||
pairs = self.api.db.list_prompt_pairs()["pairs"]
|
||||
self.assertEqual(
|
||||
[(p["positive_text"], p["negative_text"]) for p in pairs], [("cat", "blur")]
|
||||
)
|
||||
|
||||
def test_samplers_with_different_prompts_link_the_first_sampler_prompt(self):
|
||||
# The prompt the inspector and the legacy panels show for this file.
|
||||
|
||||
@@ -54,13 +54,30 @@ class TestPromptPairResolver(unittest.TestCase):
|
||||
self.assertEqual(result["positive"]["text"], "portrait")
|
||||
self.assertIsNone(result["negative"])
|
||||
|
||||
def test_multiple_samplers_are_ambiguous_even_when_texts_match(self):
|
||||
def test_multiple_samplers_with_matching_texts_resolve_to_the_shared_pair(self):
|
||||
source = graph()
|
||||
source["4"] = {
|
||||
"class_type": "KSamplerAdvanced",
|
||||
"inputs": source["3"]["inputs"],
|
||||
}
|
||||
result = resolve_prompt_pair(source)
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertEqual(
|
||||
(result["positive"]["text"], result["negative"]["text"]),
|
||||
("portrait", "blurry"),
|
||||
)
|
||||
self.assertIsNone(result["sampler_id"])
|
||||
self.assertEqual(result["reason"], resolver.IDENTICAL_BRANCHES_REASON)
|
||||
self.assertEqual(len(result["candidates"]), 2)
|
||||
|
||||
def test_multiple_samplers_with_different_texts_are_ambiguous(self):
|
||||
source = graph()
|
||||
source["5"] = {"class_type": "CLIPTextEncode", "inputs": {"text": "other"}}
|
||||
source["4"] = {
|
||||
"class_type": "KSamplerAdvanced",
|
||||
"inputs": {"positive": ["5", 0], "negative": ["2", 0]},
|
||||
}
|
||||
result = resolve_prompt_pair(source)
|
||||
self.assertEqual(result["state"], "ambiguous")
|
||||
self.assertEqual(len(result["candidates"]), 2)
|
||||
self.assertIsNone(result["positive"])
|
||||
@@ -2253,12 +2270,14 @@ class TestDetailerStages(unittest.TestCase):
|
||||
self.assertEqual(_sides(result), (parity.DETAILER_POS, parity.NEG))
|
||||
self.assertEqual(result["sampler_id"], "50")
|
||||
|
||||
def test_raw_and_detailed_saves_stay_ambiguous_without_an_anchor(self):
|
||||
def test_raw_and_detailed_saves_of_one_prompt_share_it_without_an_anchor(self):
|
||||
source = _detailed(same_prompt=True)
|
||||
source["14"] = {"class_type": "SaveImage", "inputs": {"images": ["8", 0]}}
|
||||
result = _inspect(source)
|
||||
self.assertEqual(result["state"], "ambiguous")
|
||||
self.assertEqual(result["reason"], "Multiple sampler branches")
|
||||
# #249: both saves used one pair, so it resolves with no sampler named.
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertIsNone(result["sampler_id"])
|
||||
self.assertEqual(result["reason"], resolver.IDENTICAL_BRANCHES_REASON)
|
||||
self.assertEqual(
|
||||
sorted(item["sampler_id"] for item in result["candidates"]), ["3", "50"]
|
||||
)
|
||||
|
||||
@@ -17,8 +17,9 @@ ComfyUI, database or configuration dependencies; its only file read is the
|
||||
inspection profile's node semantics table (``utils.inspection_semantics``).
|
||||
|
||||
Two ``ResolverProfile`` values exist. ``DEFAULT_PROFILE`` (ingestion: scan, live
|
||||
monitor, admin import) keeps the historical behavior exactly, including the
|
||||
500-node gate, and only adds ``reason_code: None``. ``INSPECTION_PROFILE`` bounds
|
||||
monitor, admin import) keeps the historical behavior, including the 500-node
|
||||
gate, and only adds ``reason_code: None``; both profiles resolve several sampler
|
||||
branches that share one exact pair to that pair (#249). ``INSPECTION_PROFILE`` bounds
|
||||
work instead of graph size: a per-candidate visit allowance, a total visit budget
|
||||
shared by every candidate and attribution walk, a node ceiling backstop, and
|
||||
bounds on built provenance and text. It also names why a result is not resolved
|
||||
@@ -187,6 +188,8 @@ _MAX_BRANCH_CANDIDATES = 32
|
||||
_RUNTIME_REASON = "Text is decided at run time"
|
||||
# Uncoded outcomes the inspector explains by their exact reason.
|
||||
EMPTY_POSITIVE_REASON = "Positive prompt is empty"
|
||||
# Several sampler branches, every one resolved to the same pair (#249).
|
||||
IDENTICAL_BRANCHES_REASON = "Every sampler branch uses the same prompt pair"
|
||||
MULTISTAGE_UNATTRIBUTED_REASON = "Multistage sampler ancestry is ambiguous"
|
||||
_INCOMPATIBLE_SLOT = "Output slot has an incompatible type"
|
||||
# Before 3.2.3 a save patch overwrote every PromptManager node's saved ``text``
|
||||
@@ -1656,8 +1659,10 @@ def resolve_prompt_pair(
|
||||
|
||||
Accepts ``{"prompt": api_graph, "parameters": optional_a1111_text}`` or a
|
||||
direct API graph. An optional saver/sampler node ID anchors branch selection.
|
||||
Unanchored multiple samplers are ambiguous, even when their texts are equal
|
||||
(the inspection profile first drops stages that only reach previews).
|
||||
Unanchored multiple samplers are ambiguous unless every branch resolves to
|
||||
the same texts and recipes; then that shared pair is resolved with no
|
||||
``sampler_id`` (the inspection profile first drops stages that only reach
|
||||
previews).
|
||||
``profile`` selects the bounds; explicit ``max_*`` keywords override it.
|
||||
"""
|
||||
profile = _profile_with(profile, max_visits, max_depth, max_text_length)
|
||||
@@ -1781,14 +1786,50 @@ def _resolve(
|
||||
if not sampler_ids:
|
||||
return _no_sampler(normalized, profile)
|
||||
if len(sampler_ids) > 1:
|
||||
candidates = [resolver.pair(node_id) for node_id in sampler_ids]
|
||||
shared = _shared_pair(candidates)
|
||||
if shared is not None:
|
||||
return shared
|
||||
return _result(
|
||||
"ambiguous",
|
||||
reason="Multiple sampler branches",
|
||||
candidates=[resolver.pair(node_id) for node_id in sampler_ids],
|
||||
candidates=candidates,
|
||||
)
|
||||
return resolver.pair(sampler_ids[0])
|
||||
|
||||
|
||||
def _recipe_key(result: Dict[str, Any]) -> tuple:
|
||||
"""What a reader sees of a result, plus each side's stored recipe."""
|
||||
return _outcome_key(result) + tuple(
|
||||
result[role]["storage_text"] if isinstance(result[role], dict) else None
|
||||
for role in ("positive", "negative")
|
||||
)
|
||||
|
||||
|
||||
def _shared_pair(candidates: list) -> Optional[Dict[str, Any]]:
|
||||
"""The one pair every sampler branch resolved to, or None (#249).
|
||||
|
||||
A compare grid runs several samplers on one positive and one negative: the
|
||||
prompt is certain even though no single branch made the image. Every branch
|
||||
must be resolved with identical texts and recipes; any other mix stays
|
||||
ambiguous. No sampler is named, since none alone made the output; the sides
|
||||
(and their provenance) are the first branch's, and every branch stays listed
|
||||
in ``candidates`` as the evidence.
|
||||
"""
|
||||
first = candidates[0]
|
||||
if first["state"] != "resolved":
|
||||
return None
|
||||
if len({_recipe_key(item) for item in candidates}) != 1:
|
||||
return None
|
||||
return _result(
|
||||
"resolved",
|
||||
positive=first["positive"],
|
||||
negative=first["negative"],
|
||||
reason=IDENTICAL_BRANCHES_REASON,
|
||||
candidates=candidates,
|
||||
)
|
||||
|
||||
|
||||
# Bounds for walking a UI workflow's subgraph instances (R1 only).
|
||||
_MAX_SUBGRAPH_DEPTH = 8
|
||||
_MAX_WORKFLOW_NODES = 100_000
|
||||
|
||||
@@ -212,12 +212,17 @@
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The sampler the legacy parse reads: a custom sampler's guider, else the first sampler. */
|
||||
function selectSampler(ctx, nodes) {
|
||||
const custom = nodes.find(isCustomSampler);
|
||||
const [, guider] = custom ? enter(ctx, custom.inputs.guider, new Set()) : [];
|
||||
return { custom, sampler: isSampler(guider) ? guider : nodes.find(isSampler) };
|
||||
}
|
||||
|
||||
function fromPromptGraph(graph, workflow) {
|
||||
const ctx = createContext(graph, workflow);
|
||||
const nodes = Object.values(graph);
|
||||
const custom = nodes.find(isCustomSampler);
|
||||
const [, guider] = custom ? enter(ctx, custom.inputs.guider, new Set()) : [];
|
||||
const sampler = isSampler(guider) ? guider : nodes.find(isSampler);
|
||||
const { custom, sampler } = selectSampler(ctx, nodes);
|
||||
if (!sampler) {
|
||||
const loose = nodes.find((n) => n && n.inputs && /ksampler/i.test(n.class_type || ""));
|
||||
const inputs = (loose && loose.inputs) || {};
|
||||
@@ -402,6 +407,26 @@
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where `extractGenerationParams` reads its negative from (#249): the graph it used
|
||||
* (the API graph, else the workflow converted the same way) and the read sampler's
|
||||
* `negative` link, or null when it found no negative. It changes no reading.
|
||||
*/
|
||||
function legacyNegativeSource(comfyData) {
|
||||
const data = comfyData || {};
|
||||
const sources = [];
|
||||
if (data.prompt && typeof data.prompt === "object") sources.push([data.prompt, data.workflow]);
|
||||
if (data.workflow) sources.push([workflowToGraph(data.workflow), null]);
|
||||
for (const [graph, workflow] of sources) {
|
||||
const ctx = createContext(graph, workflow);
|
||||
const { sampler } = selectSampler(ctx, Object.values(graph));
|
||||
if (sampler && hasValue(traceConditioning(ctx, sampler.inputs.negative, "negative"))) {
|
||||
return { graph, link: sampler.inputs.negative };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const FACT_FIELDS = ["seed", "steps", "cfg", "sampler", "scheduler", "model"];
|
||||
const WORKFLOW_PASSTHROUGH = Symbol("inspection workflow passthrough");
|
||||
// These tables must equal inspection-semantics.json: SAMPLERS its `samplers` section
|
||||
@@ -631,6 +656,8 @@
|
||||
* until PR3), and unresolved or absent over several listed samplers, merge the same
|
||||
* way across every listed sampler and its stages. Otherwise (no resolution, or one
|
||||
* listed sampler) exactly one listed sampler supplies them; several leave every field ambiguous.
|
||||
* A resolved pair without a named sampler (identical branches, #249) merges like
|
||||
* ambiguous when several samplers are listed.
|
||||
*/
|
||||
function graphFacts(graph, source, resolution) {
|
||||
if (!object(graph)) return factSet("unresolved", source);
|
||||
@@ -650,8 +677,9 @@
|
||||
}
|
||||
const candidates = entries.filter(([id]) => samplerEntry(graph, id));
|
||||
// Review I-1: an unresolved or absent prompt does not make the run's settings
|
||||
// unknown, so several listed samplers merge the same way.
|
||||
if (state === "ambiguous" || ((state === "unresolved" || state === "absent") && candidates.length > 1)) {
|
||||
// unknown, so several listed samplers merge the same way. So does a pair the
|
||||
// server resolved across several branches without naming one (#249).
|
||||
if (state === "ambiguous" || (["unresolved", "absent", "resolved"].includes(state) && candidates.length > 1)) {
|
||||
// The server's branches: every listed sampler, a detailer judged with its stages.
|
||||
const stages = new Set();
|
||||
if (!candidates.length || !candidates.every(([id]) => collectStages(graph, id, new Set(), stages, { visits: 0 }))) return factSet("unresolved", source);
|
||||
@@ -758,7 +786,7 @@
|
||||
return factSet(metadataStatus === "available" || metadataStatus === "absent" ? "not-recorded" : "unresolved");
|
||||
}
|
||||
|
||||
const api = { extractGenerationParams, extractGenerationFacts, PLACEHOLDERS };
|
||||
const api = { extractGenerationParams, extractGenerationFacts, legacyNegativeSource, PLACEHOLDERS };
|
||||
// For the semantics sync test only; not part of the legacy page contract.
|
||||
Object.defineProperty(api, "_factsTables", {
|
||||
value: Object.freeze({
|
||||
|
||||
@@ -5,7 +5,7 @@ import { countOf, noun } from './plural.mjs';
|
||||
* filter matches the linked positive prompt's, so the action sets the category on those prompts.
|
||||
*/
|
||||
export const CATEGORY_DIALOG = Object.freeze({title:'Categorize selected images', label:'Category', action:'Apply'});
|
||||
export const CATEGORY_NONE_LINKED = 'None of the selected images has a linked prompt.';
|
||||
export const CATEGORY_NONE_LINKED = "None of the selected images is linked to a saved prompt, so there is no prompt category to set.";
|
||||
export const CATEGORY_FAILED = 'Category could not be set. Try again.';
|
||||
// The server's limit (`validate_category`): the field stops there instead of failing on Apply.
|
||||
export const CATEGORY_MAX_LENGTH = 100;
|
||||
|
||||
@@ -133,6 +133,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
let state, geometry = { positions: [], columns: 1, height: 0, maxHeight: 0 }, galleryWidth = 0, optionsRequest, categoryRequest, recovering = false, rescanning = false;
|
||||
// Discovered sources (each with its own folders) and every folder, for "Refine this view" and the scope chip.
|
||||
let roots = [], folders = [];
|
||||
// The open "Refine this view", so a discovery that settles after it opened still lists its sources and folders.
|
||||
let refine = null;
|
||||
const rendered = new Map();
|
||||
const itemIndexes = new Map();
|
||||
const filters = { root: 'all', q: '', category: '', tags: [], subfolder: '', media: 'all', sort: 'newest', ...visit?.filters, ...saved,
|
||||
@@ -643,8 +645,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
/** "Refine this view": Source, Folder, Media and Tags, the filters the old field row held (D14). */
|
||||
function openFilters(trigger) {
|
||||
if (recovering) return;
|
||||
openRefineDialog({scope, trigger, fields:[
|
||||
{key:'source', options:[['all', 'All sources'], ...roots.map(root => [String(root.index), root.label])]},
|
||||
refine = openRefineDialog({scope, trigger, fields:[
|
||||
{key:'source', options:sourceOptions()},
|
||||
{key:'folder', options:folderOptions('all'), optionsFor:folderOptions},
|
||||
{key:'media', options:MEDIA_OPTIONS},
|
||||
{key:'tags'},
|
||||
@@ -654,6 +656,9 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
applyRefinement(cleared);
|
||||
}});
|
||||
}
|
||||
function sourceOptions() {
|
||||
return [['all', 'All sources'], ...roots.map(root => [String(root.index), root.label])];
|
||||
}
|
||||
/** The chosen Source's folders, as the old field row reloaded them; every folder under All sources. */
|
||||
function folderOptions(source) {
|
||||
const root = source === 'all' ? null : roots.find(item => String(item.index) === source);
|
||||
@@ -750,6 +755,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
roots = data.roots.filter(root => Number.isSafeInteger(root?.index) && typeof root.label === 'string')
|
||||
.map(root => ({index:root.index, label:root.label, subfolders:paths(root.subfolders)}));
|
||||
folders = paths(data.subfolders);
|
||||
refine?.setOptions('source', sourceOptions());
|
||||
updateScope();
|
||||
sourcesRetry = null;
|
||||
return true;
|
||||
|
||||
@@ -174,6 +174,55 @@ 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. Only the negative it actually read is checked: a direct
|
||||
// zero-out shows the server's zeroed state; a zero-out elsewhere on
|
||||
// that path, or a subgraph instance whose definition holds one, leaves the negative
|
||||
// unfilled. Any other negative keeps the legacy fill, so this page never shows less.
|
||||
const ZERO_OUT = 'ConditioningZeroOut';
|
||||
const ZEROED_NEGATIVE = Object.freeze({ status: 'resolved', text: '', source: 'zeroed-conditioning' });
|
||||
const MAX_NEGATIVE_VISITS = 2000;
|
||||
const MODE_MUTED = 2, MODE_BYPASSED = 4;
|
||||
const isLink = value => Array.isArray(value) && value.length === 2 && ['string', 'number'].includes(typeof value[0]);
|
||||
|
||||
/** Subgraph definition IDs that run a zero-out, directly or through a nested instance. */
|
||||
function zeroOutSubgraphs(workflow) {
|
||||
const definitions = Array.isArray(workflow?.definitions?.subgraphs) ? workflow.definitions.subgraphs.filter(object) : [];
|
||||
const active = item => Array.isArray(item.nodes) ? item.nodes.filter(node => object(node) && ![MODE_MUTED, MODE_BYPASSED].includes(node.mode)) : [];
|
||||
const found = new Set();
|
||||
for (let changed = true; changed;) {
|
||||
changed = false;
|
||||
for (const item of definitions) {
|
||||
if (found.has(item.id) || !active(item).some(node => node.type === ZERO_OUT || found.has(node.type))) continue;
|
||||
found.add(item.id); changed = true;
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/** 'zeroed', 'undecided' (do not fill), or null (the legacy fill applies). */
|
||||
function zeroOutNegative(source, workflow) {
|
||||
if (!source || !object(source.graph) || !isLink(source.link)) return null;
|
||||
const { graph } = source, subgraphs = zeroOutSubgraphs(workflow);
|
||||
const zeroing = node => node.class_type === ZERO_OUT || subgraphs.has(node.class_type);
|
||||
if (!Object.values(graph).some(node => object(node) && zeroing(node))) return null;
|
||||
const node = graph[String(source.link[0])];
|
||||
if (!object(node)) return 'undecided';
|
||||
if (node.class_type === ZERO_OUT) return 'zeroed';
|
||||
// One bounded walk upstream; an index pointer keeps it linear in the nodes visited.
|
||||
const queue = [node], seen = new Set();
|
||||
for (let index = 0; index < queue.length; index++) {
|
||||
const current = queue[index];
|
||||
if (zeroing(current) || 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 null;
|
||||
}
|
||||
|
||||
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,14 +233,24 @@ 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 read = prompts.negative?.status === 'resolved' || typeof parser.legacyNegativeSource !== 'function' ? null :
|
||||
parser.legacyNegativeSource({ prompt: graph, workflow });
|
||||
const zeroed = zeroOutNegative(read, 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;
|
||||
for (const [field, key] of LEGACY_FACTS) {
|
||||
// Only open answers are filled: a known value or a definite "not recorded" stays.
|
||||
// An ambiguous field (e.g. a compare grid's per-branch model) is filled too, on
|
||||
// purpose: the legacy pages show this value, and this page never shows less.
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -152,6 +152,9 @@ function choose(select, value) {
|
||||
* Tags; Folder may add `optionsFor(source)`, the options of one Source, so its list follows the chosen Source and a
|
||||
* folder that Source lacks returns to the first option. `values` holds the current value per key. Apply reports the edited values; Clear resets every field and
|
||||
* reports the cleared values plus `category: ''`, so the page clears its toolbar category too. Both close first.
|
||||
* Returns `{setOptions(key, options)}` for options discovered after the dialog opened (a slow folder discovery): the
|
||||
* select is refilled and shows the value the field opened with, or the one chosen since, once it is offered; a Folder
|
||||
* that follows its Source is refilled from `optionsFor` again. It does nothing once this dialog closed or was replaced.
|
||||
*/
|
||||
export function openRefineDialog({ scope, fields, values = {}, onApply, onClear, trigger = null }) {
|
||||
requireScope(scope);
|
||||
@@ -172,16 +175,21 @@ export function openRefineDialog({ scope, fields, values = {}, onApply, onClear,
|
||||
choose(select, values[key]);
|
||||
return [{ key, control: select, cleared: item.options[0][0], optionsFor: item.optionsFor }];
|
||||
});
|
||||
// Each select's wanted value: the one it opened with until the person picks another (see setOptions).
|
||||
const wanted = new Map(controls.map(({ key }) => [key, values[key]]));
|
||||
for (const { key, control } of controls) {
|
||||
if (key !== 'tags') control.addEventListener('change', () => wanted.set(key, control.value));
|
||||
}
|
||||
const source = controls.find(item => item.key === 'source');
|
||||
const folder = controls.find(item => item.key === 'folder' && typeof item.optionsFor === 'function');
|
||||
const followSource = () => {
|
||||
const chosen = folder.control.value;
|
||||
const followSource = (chosen = folder.control.value) => {
|
||||
fillSelect(folder.control, 'folder', folder.optionsFor(source.control.value));
|
||||
choose(folder.control, chosen);
|
||||
};
|
||||
if (source && folder) {
|
||||
followSource();
|
||||
source.control.addEventListener('change', followSource);
|
||||
followSource(values.folder);
|
||||
// A Source that lacks the chosen folder clears it, and that cleared folder is the wanted one from then on.
|
||||
source.control.addEventListener('change', () => { followSource(); wanted.set('folder', folder.control.value); });
|
||||
}
|
||||
const read = () => Object.fromEntries(controls.map(({ key, control }) =>
|
||||
[key, key === 'tags' ? control.value.trim() : control.value]));
|
||||
@@ -207,6 +215,15 @@ export function openRefineDialog({ scope, fields, values = {}, onApply, onClear,
|
||||
...controls.map(({ key, control }) => field(FIELD_LABELS[key], control, `filter-${key}`)),
|
||||
actions,
|
||||
], { scope, trigger });
|
||||
const opened = session;
|
||||
function setOptions(key, options) {
|
||||
const item = controls.find(candidate => candidate.key === key);
|
||||
if (session !== opened || !item || key === 'tags') return;
|
||||
fillSelect(item.control, key, options);
|
||||
choose(item.control, wanted.get(key));
|
||||
if (source && folder && (item === source || item === folder)) followSource(wanted.get('folder'));
|
||||
}
|
||||
return { setOptions };
|
||||
}
|
||||
|
||||
/** One operation row; a press on its busy (aria-disabled) action is ignored and keeps the dialog open. */
|
||||
|
||||
Reference in New Issue
Block a user