feat(annotations): show image tags in the inspector and viewer

The inspector parses the B3 envelope's image_tags facet (strictly: exact
keys, current/none/unavailable, at most 100 vocabulary names) and draws one
chip row after the prompt blocks: the file's current image tags first, then
D21's linked tags not already shown (case-insensitive). In the Images panel
image tags are Tags filter buttons and linked tags keep D21's rule; in the
viewer and wherever no Tags filter exists every chip is plain. none and
unavailable add no chips (D88, plan D65; D21 amended).

The tagging fixture reset now drops leftover captures, so a long run of
tagging tests in one worker stays below the capture store's limit.
This commit is contained in:
Vito Sansevero
2026-10-22 12:59:22 -07:00
parent b8489d0369
commit ea29af0976
10 changed files with 239 additions and 18 deletions
+24 -5
View File
@@ -292,8 +292,9 @@ The shared inline and collection inspector reads the current rooted file's suppo
embedded records, exact generation facts and recorded or inferred prompt evidence.
It renders in two concept surfaces (#249 D20–D22, D38). The Images side panel shows
only the concept content: cover, title, filename, the evidence badge with a category
chip, the + and − prompt blocks with copy and send, tag chips (the linked positive's
own tags filter Images, D21), the six facts, and a footer with "Send to ComfyUI" and
chip, the + and − prompt blocks with copy and send, tag chips (the file's own image
tags first, D88, then the linked tags; image tags and the linked positive's own tags
filter Images, D21), the six facts, and a footer with "Send to ComfyUI" and
"Prompt history" only when prompt roles resolved or a link is saved; the sticky panel
scrolls inside the window. The viewer's details (`#viewer-details`) repeat that
content without the cover, then "File & provenance" (the honest file facts, the
@@ -949,6 +950,8 @@ Recorded deviations on this page (spec §8):
history appear only with resolved prompt roles or a saved link; a video file keeps
`video.detail-cover`; and only the linked positive's own tags are `button.chip`
filters, while recipe, recorded-run and embedded tags stay plain `span.chip`.
Amended by D88 (#249 PR C): the file's own current image tags lead the same row
and are filters too; a linked tag already shown (case-insensitive) is left out.
- **D22**: an empty positive reads "No positive prompt (explicitly empty)" (the concept
applies its negative wording to both roles), and a confirmed `p.prompt-text` is
focusable (`tabindex=0`, marked focus ring) so long prompts stay keyboard-readable.
@@ -1080,7 +1083,7 @@ unconfirmed save), where the other workspace dialogs place their Retry. The Tags
page re-reads its lists after every confirmed save, so the Images counts follow.
The image-tagging plan's deviation numbers were renumbered by +23 because the
Inspect plan keeps D60–D66 (plan D60 → D83, D61 → D84, D68 → D91).
Inspect plan keeps D60–D66 (plan D60 → D83, D61 → D84, D65 → D88, D68 → D91).
- **D83** (plan D60, ruling T4): the suggestions are removable chips instead of
checkbox rows: `span.chip.tag-suggestion` in a `.tags` row, in the concept's
@@ -1097,6 +1100,19 @@ Inspect plan keeps D60–D66 (plan D60 → D83, D61 → D84, D68 → D91).
- **D91** (plan D68, ruling T3-bulk): a **Suggestions** number field (1–100) for
every model, prefilled from `autotag.defaults.suggestion_limit` (10); a model
change keeps it.
- **D88** (plan D65, ruling T7/C2): the inspector's chip row (side panel and viewer
details, after the prompt blocks) shows the file's current image tags first (the
B3 envelope's `image_tags`, `state: "current"`, S1), then D21's linked tags not
already shown (case-insensitive, the image tag's spelling wins). In the Images
panel every image tag is a Tags filter `button.chip` (the filter matches image
tags too) and linked tags keep D21's rule; in the viewer, and wherever no Tags
filter exists (the Library-embedded gallery, Activity and recorded runs), every
chip is a plain `span.chip`. `none` and `unavailable` add no chips and no other
chrome; a malformed facet fails the inspection like any malformed field. The
concept has one row of the image's own tags (`i.tags`, app.js a402), so with the
concept image's tags saved as its image tags production still equals the concept.
After a Tag save the selected file's inspection is re-read, so the panel redraws
the row without reselecting and the viewer, which shares that inspection, shows it.
- **D115** (beyond the plan's list): the concept always has a selected image, so
its "Auto-tag images" always opens the dialog. Production opens it for the image
selected in Images when it was last left; with none it is `aria-disabled` and
@@ -1107,8 +1123,11 @@ Test files: `tests/browser/workspace-tagging.spec.mjs` (the fixture is
pair, an equal-byte copy, a video, an unlinked file, the fake model with `hf`, and
no model files; every test asserts no download request),
`tests/js/workspace-image-tagging.test.js`,
`tests/js/workspace-image-tagging-state.test.js` and
`tests/js/workspace-images-lifecycle.test.js`.
`tests/js/workspace-image-tagging-state.test.js`,
`tests/js/workspace-images-lifecycle.test.js`, and for D88
`tests/js/workspace-file-inspector.test.js`,
`tests/js/workspace-inspection-state.test.js` and `tests/browser/inspector-concept.spec.mjs`
("image tags saved on the concept image keep the inspector equal to the concept").
```sh
node --test tests/js/workspace-image-tagging*.test.js tests/js/workspace-images-lifecycle.test.js
+17
View File
@@ -78,6 +78,23 @@ export async function seedConcept(request,data={}) {
return response.json();
}
/**
* Save `tags` as the current image tag set of the listed file at `path` (root 0) through the real observe and tag
* routes (#249 PR C), releasing the capture afterwards; returns the receipt's saved names.
*/
export async function saveImageTags(request,path,tags) {
const listing=await (await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json();
const target={root_index:0,root_scope:listing.images.find(item=>item.root_index===0).root_scope,path};
const observed=await (await request.post('/prompt_manager/image-annotations/observe',{data:{target}})).json();
expect(observed.success).toBe(true);
try {
const receipt=await (await request.put('/prompt_manager/image-annotations/tags',{data:{target,capture_token:observed.capture.token,
sha256:observed.capture.sha256,expected_epoch:observed.library_epoch,expected_tag_revision:observed.tag_revision,tags}})).json();
expect(receipt.success).toBe(true);
return receipt.tags;
} finally {await request.delete(`/prompt_manager/image-annotations/capture/${observed.capture.token}`);}
}
async function selectTheme(page,theme) {
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
}
+22 -1
View File
@@ -1,7 +1,7 @@
import {writeFile} from 'node:fs/promises';
import {test,expect} from './fixtures.mjs';
import {captureCanonicalState} from './history-canonical-comparison.mjs';
import {SIZES,THEMES,seedConcept,openConcept,openProduction,inspectorOf,diffInventory,mutate,countCopy} from './concept-inventory.mjs';
import {SIZES,THEMES,seedConcept,openConcept,openProduction,inspectorOf,diffInventory,mutate,countCopy,saveImageTags} from './concept-inventory.mjs';
// Task D4 of #249: the Images side panel (app.js imageDetails a400–414) and the viewer's full details (imageDetails(i,
// true)) against the immutable concept, on the concept-shaped real fixture, at 1600x1100 and 390x844 in both themes.
@@ -319,6 +319,27 @@ test('a recipe-linked image without recipe metadata shows its positive prompt me
await expect(panelOf(page).locator('.detail-footer button')).toHaveText(['Send to ComfyUI','Prompt history']);
});
// Task C3 (D88, #249 PR C): the concept's chip row is the image's own tags (`i.tags`, app.js a402). With those names
// saved as the file's current image tags, production draws the row from them (Tags filter buttons in the panel, plain
// in the viewer, the linked positive's equal tags not repeated) and still equals the concept, in both sizes.
for(const [width,height] of SIZES)test(`image tags saved on the concept image keep the inspector equal to the concept ${width}x${height} (D88)`,async({page,request},testInfo)=>{
test.setTimeout(120_000);
const viewport={width,height};await page.setViewportSize(viewport);
await openConceptPanel(page,'dark');
const names=await panelOf(page).locator('.detail-inner > .tags').last().locator('button.chip').allTextContents();
expect(names).toEqual(['cinematic','blue_hour','architecture']);
await captureCanonicalState(page,testInfo,`concept-image-tags-${width}`,'#image-inspector');
const reference=await inventory(page,true);
const {path}=testInfo.manifest.images.find(item=>item.filename==='alpine_00001.png');
expect(await saveImageTags(request,path,names)).toEqual(names);
const read=page.waitForResponse(item=>new URL(item.url()).pathname===`/prompt_manager/images/inspect/${path}`);
await openProductionPanel(page,'dark');
expect((await (await read).json()).image_tags).toEqual({state:'current',tags:names});
await expect(panelOf(page).locator('.detail-inner > .tags').last().locator('.chip')).toHaveText(names);
await captureCanonicalState(page,testInfo,`production-image-tags-${width}`,'#image-inspector');
assertInspectorMatch(reference,await inventory(page),viewport);
});
// The comparison is only worth something if it notices a change (spec §7 mutation check): on the panel one CSS
// mutation (the role label's letter spacing) and one DOM mutation (the footer removed); in the viewer (final review m5)
// its pair actions removed and D38's Show in folder removed; each applied after an exact match.
+71 -2
View File
@@ -3,7 +3,7 @@
// downloaded or loaded: the fake answers, and every test checks that no download request was sent.
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
import { seedConcept, openConcept, openProduction } from './concept-inventory.mjs';
import { seedConcept, openConcept, openProduction, saveImageTags } from './concept-inventory.mjs';
const images = '/prompt_manager/workspace#/images';
const MODULE = '/prompt_manager/js/workspace/image-tagging.mjs';
@@ -22,7 +22,7 @@ const status = page => dialog(page).locator('#tag-generation-status');
const toast = page => page.locator('#workspace-toast');
const nav = (page, label) => page.locator('aside.sidebar nav').getByRole('link', { name: new RegExp(`^${label}(?:\\s*[\\d,]+)?$`) });
const FIRST = 'tagging/dunes_00001.png', SECOND = 'tagging/dunes_00002.png', COPY = 'copies/dunes_00001.png';
const VIDEO = 'tagging/clip.webm';
const VIDEO = 'tagging/clip.webm', UNLINKED = 'loose/unlinked.png';
let manifest;
test.beforeEach(async ({ page, request, browserName }) => {
@@ -461,3 +461,72 @@ for (const [width, height] of [[1600, 1100], [390, 844]]) test(`paired Tag dialo
await shot(`${side}-tag-dialog-generated-${width}`);
}
});
// Task C3 (D88): the side panel and the viewer details draw the file's own current image tags first, then D21's linked
// tags not already shown (case-insensitive). Panel image tags are Tags filter buttons; the viewer's chips are plain.
const panel = page => page.getByRole('complementary', { name: 'Selected image details', exact: true });
const tagRow = page => panel(page).locator('.detail-inner > .tags').last();
const inspected = (page, path) => page.waitForResponse(item => new URL(item.url()).pathname === `/prompt_manager/images/inspect/${path}`);
const infoButton = (page, path) => card(page, path).getByRole('button', { name: `Inspect ${path.split('/').pop()}`, exact: true });
test('inspector shows saved image tags after Save without reselecting', async ({ page }) => {
await page.goto(images);
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
// D21/D39: the linked recipe's own tag, plain.
await expect(tagRow(page).locator('.chip')).toHaveText(manifest.pair_tags);
await expect(tagRow(page).locator('button.chip')).toHaveCount(0);
await openTag(page, FIRST, { goto: false });
await manual(page).fill('dunes_tag, Editorial');
const reread = inspected(page, FIRST);
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
expect((await (await reread).json()).image_tags).toEqual({ state: 'current', tags: ['dunes_tag', 'Editorial'] });
// The image tags lead as filter buttons; the recipe's "editorial" is already shown as "Editorial".
await expect(tagRow(page).locator('.chip')).toHaveText(['dunes_tag', 'Editorial']);
await expect(tagRow(page).locator('button.chip')).toHaveText(['dunes_tag', 'Editorial']);
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
await expect(panel(page).locator('.panel-heading h2')).toHaveText(manifest.title);
});
test('clicking an image tag chip filters Images to the tagged unlinked file', async ({ page, request }) => {
expect(await saveImageTags(request, UNLINKED, ['loose_tag'])).toEqual(['loose_tag']);
await page.goto(images);
const read = inspected(page, UNLINKED);
await infoButton(page, UNLINKED).click();
await read;
await expect(panel(page).locator('.panel-heading h2')).toHaveText('unlinked.png');
await expect(tagRow(page).locator('.chip')).toHaveText(['loose_tag']);
const listing = page.waitForRequest(item => item.url().includes('/images/output?') && new URL(item.url()).searchParams.get('tags') === 'loose_tag');
await tagRow(page).getByRole('button', { name: 'loose_tag', exact: true }).click();
await listing;
await expect(page.locator('.gallery-body article[data-image-key]')).toHaveCount(1);
await expect(card(page, UNLINKED)).toBeVisible();
await expect(page.getByRole('button', { name: 'Filters · 1', exact: true })).toBeVisible();
});
test('viewer details show the same chips', async ({ page, request }) => {
await saveImageTags(request, FIRST, ['dunes_tag', 'Editorial', 'sunlit']);
await page.goto(images);
await expect(tagRow(page).locator('button.chip')).toHaveText(['dunes_tag', 'Editorial', 'sunlit']);
await card(page, FIRST).locator('.image-open').click();
const details = page.locator('#viewer-dialog[open] #viewer-details');
await expect(details.locator('.panel-heading h2')).toHaveText(manifest.title);
const row = details.locator('.tags').last();
await expect(row.locator('.chip')).toHaveText(['dunes_tag', 'Editorial', 'sunlit']);
await expect(row.locator('span.chip')).toHaveCount(3);
await expect(row.locator('button')).toHaveCount(0);
// A file without image tags: the viewer shows only D21's linked tags.
await page.getByRole('button', { name: 'Next media', exact: true }).click();
await expect(page.locator('#viewer-dialog[open] #viewer-title')).toHaveText(`${manifest.title} · dunes_00002.png`);
await expect(details.locator('.tags').last().locator('.chip')).toHaveText(manifest.pair_tags);
});
test('search finds an image by its image tag', async ({ page, request }) => {
await saveImageTags(request, UNLINKED, ['Loose_Tag']);
await page.goto(images);
await expect(page.locator('.gallery-body article[data-image-key]')).toHaveCount(manifest.files.length);
const listing = page.waitForRequest(item => item.url().includes('/images/output?') && new URL(item.url()).searchParams.get('q') === 'loose_t');
await page.getByRole('searchbox', { name: 'Search workspace', exact: true }).fill('loose_t');
await listing;
await expect(page.locator('.gallery-body article[data-image-key]')).toHaveCount(1);
await expect(card(page, UNLINKED)).toBeVisible();
});
@@ -187,6 +187,9 @@ def register_tagging_commands(
raise web.HTTPBadRequest()
model.unavailable = set()
model.loaded = None
# A test that ends with the dialog open leaves its capture behind; drop
# every capture so the next test starts below the store's limits.
await api._run_in_executor(api.close_annotation_captures)
manifest = await api._run_in_executor(seed_tagging, api, root, extra)
return web.json_response({"success": True, **manifest})
+31
View File
@@ -86,3 +86,34 @@ test('Show in folder outcomes use the D38 toasts',async()=>{
for(const error of [{status:403,code:null},{status:409,code:'source-changed'},{status:415,code:'unsupported-media-type'},{status:429,code:null},{status:500},new TypeError('offline')])
assert.equal(revealOutcome(error),'The folder could not be opened. Try again.');
});
// #249 PR C Task C3 (D88): one chip row, the file's current image tags first, then D21's linked tags not already shown.
function tagged(imageTags, library = {status:'linked', tags:['Minimal', 'warm_light', 'editorial'], tags_scope:'positive'}) {
return {...record(), imageTags, library};
}
test('renders current image tags before linked tags without duplicates', async () => {
const {inspectorTagChips} = await load();
assert.equal(typeof inspectorTagChips, 'function', 'inspectorTagChips is exported');
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['dunes', 'minimal']}), {filterable:true}),
[{name:'dunes', filter:true}, {name:'minimal', filter:true}, {name:'warm_light', filter:true}, {name:'editorial', filter:true}]);
// D21's rule still decides the linked chips: a recipe's own tags stay plain, an image tag stays a filter.
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['Editorial']}, {status:'linked', tags:['editorial', 'cover'], tags_scope:'pair'}), {filterable:true}),
[{name:'Editorial', filter:true}, {name:'cover', filter:false}]);
// An unlinked file shows only its own tags; a facet-less record (history, local) only the linked ones.
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['loose']}, {status:'none', tags:[], tags_scope:null}), {filterable:true}), [{name:'loose', filter:true}]);
assert.deepEqual(inspectorTagChips(tagged(null), {filterable:true}).map(chip => chip.name), ['Minimal', 'warm_light', 'editorial']);
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:[]}, {status:'ambiguous', tags:['x'], tags_scope:'positive'}), {filterable:true}), []);
});
test('image tag chips filter in the panel and are plain in the viewer', async () => {
const {inspectorTagChips} = await load();
const source = tagged({state:'current', tags:['dunes']});
assert.deepEqual(inspectorTagChips(source, {filterable:true}).map(chip => chip.filter), [true, true, true, true]);
assert.deepEqual(inspectorTagChips(source, {filterable:false}).map(chip => chip.filter), [false, false, false, false]);
});
test('none and unavailable render no image chips', async () => {
const {inspectorTagChips} = await load();
for (const state of ['none', 'unavailable']) {
assert.deepEqual(inspectorTagChips(tagged({state, tags:[]}), {filterable:true}).map(chip => chip.name), ['Minimal', 'warm_light', 'editorial']);
assert.deepEqual(inspectorTagChips(tagged({state, tags:[]}, {status:'none', tags:[], tags_scope:null}), {filterable:true}), []);
}
});
@@ -702,3 +702,29 @@ test('a workflow the legacy parser throws on keeps the server result instead of
assert.deepEqual(record.prompts.positive, { status: 'resolved', text: 'exact\npositive', source: 'execution' });
assert.equal(record.prompts.negative.status, 'ambiguous');
});
// #249 PR C Task C3: the B3 envelope's `image_tags` facet, the file's own current tags (S1).
test('image_tags facet is kept exactly and absent stays null', () => {
const entry = image(), project = contract('toInspectionRecord');
assert.equal(project(envelope(entry), entry, extractGenerationFacts).imageTags, null);
for (const facet of [{ state: 'current', tags: ['dunes', 'warm_light'] }, { state: 'current', tags: [] }, { state: 'none', tags: [] }, { state: 'unavailable', tags: [] }]) {
const data = { ...envelope(entry), image_tags: facet };
const record = project(data, entry, extractGenerationFacts);
assert.deepEqual(record.imageTags, facet);
assert.notEqual(record.imageTags.tags, facet.tags);
}
const hundred = Array.from({ length: 100 }, (_, index) => `tag_${index}`);
assert.deepEqual(project({ ...envelope(entry), image_tags: { state: 'current', tags: hundred } }, entry, extractGenerationFacts).imageTags.tags, hundred);
});
test('rejects a malformed image_tags facet', () => {
const entry = image();
for (const facet of [null, [], 'current', { state: 'current' }, { tags: [] }, { state: 'current', tags: ['a'], extra: 1 },
{ state: 'stale', tags: [] }, { state: 'none', tags: ['a'] }, { state: 'unavailable', tags: ['a'] }, { state: 'current', tags: 'a' },
{ state: 'current', tags: [1] }, { state: 'current', tags: [''] }, { state: 'current', tags: [' a'] }, { state: 'current', tags: ['a,b'] },
{ state: 'current', tags: ['a\nb'] }, { state: 'current', tags: ['x'.repeat(51)] },
{ state: 'current', tags: Array.from({ length: 101 }, (_, index) => `tag_${index}`) }]) {
assert.throws(() => module.toInspectionRecord({ ...envelope(entry), image_tags: facet }, entry, extractGenerationFacts), TypeError, JSON.stringify(facet));
}
assert.equal(module.toInspectionRecord({ ...envelope(entry), image_tags: { state: 'current', tags: ['é'.repeat(50)] } }, entry, extractGenerationFacts).imageTags.tags[0].length, 50);
});
+1 -1
View File
@@ -271,7 +271,7 @@
},
"workspace/inspection-state.mjs": {
"lines": 98,
"branches": 93,
"branches": 94,
"functions": 98,
"statements": 98
},
+28 -8
View File
@@ -143,6 +143,25 @@ function evidenceBadge(prompts) {
function extensionOf(record) {
return (record.file?.extension || record.identity.path.split('.').at(-1)).replace(/^\./, '').toUpperCase();
}
/**
* The chip row after the prompt blocks (D88): the file's current image tags first, then D21's linked tags not already
* shown (case-insensitive). With `filterable` (a Tags filter exists, as in the Images panel) every image tag sets it, and
* a linked tag only when it is the positive's own (D21); otherwise, as in the viewer, every chip is plain.
*/
export function inspectorTagChips(record, {filterable}) {
const shown = new Set(), chips = [];
const add = (names, filter) => {
for (const name of names) {
const key = name.toLowerCase();
if (shown.has(key)) continue;
shown.add(key); chips.push({name, filter});
}
};
if (record.imageTags?.state === 'current') add(record.imageTags.tags, filterable);
const {library} = record;
if (library?.status === 'linked') add(library.tags, filterable && library.tags_scope === 'positive');
return chips;
}
/** A concept chip whose visible text carries a visually hidden prefix naming what it is. */
function prefixedChip(prefix, text) {
const chip = el('span', undefined, 'chip');
@@ -211,15 +230,16 @@ export function mountFileInspector(container, {api, sender, parentScope, variant
const prompts = el('div'); inner.append(prompts);
const blocks = mountPromptBlocks(prompts, {sender, parentScope:contentScope, includePairAction:false, contextLabel:promptContext, title});
blocks.setState({prompts:record.prompts}); contentScope.cleanup(blocks.dispose);
if (linked && library.tags.length) {
// D21: a chip sets the Images Tags filter, which matches the linked positive's tags; a recipe's own tags are
// not what it matches, so they (and chips where no Tags filter exists, as in the viewer) stay plain. With the
// D39 fallback a recipe without tags shows its positive's, which are filter buttons again.
const filterable = onTagFilter && library.tags_scope === 'positive';
// D21: a chip sets the Images Tags filter, which matches the linked positive's tags; a recipe's own tags are
// not what it matches, so they (and chips where no Tags filter exists, as in the viewer) stay plain. With the
// D39 fallback a recipe without tags shows its positive's, which are filter buttons again. D88: the file's own
// image tags lead the row and, where a Tags filter exists, always set it (it matches them too).
const chips = inspectorTagChips(record, {filterable:!!onTagFilter});
if (chips.length) {
const tags = el('div', undefined, 'tags');
for (const tag of library.tags) {
if (!filterable) {tags.append(el('span', tag, 'chip')); continue;}
const chip = createButton(tag, 'chip'); contentScope.listen(chip, 'click', () => onTagFilter(tag)); tags.append(chip);
for (const {name, filter} of chips) {
if (!filter) {tags.append(el('span', name, 'chip')); continue;}
const chip = createButton(name, 'chip'); contentScope.listen(chip, 'click', () => onTagFilter(name)); tags.append(chip);
}
inner.append(tags);
}
+16 -1
View File
@@ -167,6 +167,20 @@ function libraryFor(value) {
return library;
}
// #249 PR C (D88): the file's own current image tags (S1). `none` and `unavailable` carry no names; a name is a
// vocabulary tag name (1–50 characters, trimmed, no comma or control character), at most 100 of them.
const IMAGE_TAG_STATES = ['current', 'none', 'unavailable'], MAX_IMAGE_TAGS = 100, IMAGE_TAG_LENGTH = 50;
const imageTagName = name => typeof name === 'string' && name !== '' && name === name.trim() && [...name].length <= IMAGE_TAG_LENGTH &&
!/[,\x00-\x1f\x7f]/.test(name);
function imageTagsFor(value) {
if (value === undefined) return null;
if (!object(value) || Object.keys(value).sort().join() !== 'state,tags' || !IMAGE_TAG_STATES.includes(value.state) ||
!Array.isArray(value.tags) || value.tags.length > MAX_IMAGE_TAGS || !value.tags.every(imageTagName) ||
(value.state !== 'current' && value.tags.length)) throw new TypeError('Invalid inspection image tags');
return { state: value.state, tags: [...value.tags] };
}
// 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.
@@ -279,9 +293,10 @@ export function toInspectionRecord(result, image, extractFacts = globalThis.Comf
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,
generation: unavailableFacts(), historyTarget: null, download, imageTags: null,
};
if (legacy) return record;
record.imageTags = imageTagsFor(result.image_tags);
const resolution = resolutionFor(result.resolution);
record.file = fileFor(result.file, identity);
record.metadata = metadataFor(result.metadata);