fix(inspector): fall back to the positive's category and tags for recipe-linked images (#267)
* fix(inspector): fall back to the positive's category and tags for recipe-linked images Maintainer ruling D39 (2026-10-21): a recipe-linked image shows the recipe's own category and tags when set, otherwise the linked positive prompt's, each field on its own and without merging tag sets. Set category (#259) and the Images category and Tags filters work on the positive, so the panel now agrees with them. The inspection library replaces metadata_scope with category_scope and tags_scope so the category chip's hidden scope prefix (D21) and the tag chips' filter-button rule (#260 D2) follow the value actually shown. History's media inspector uses the same shared rule. The concept fixture's positive_links workaround is gone: the comparison now matches the concept without it. * test(inspector): assert the D39 fallback tag chips in their saved order * fix(inspector): treat Unicode whitespace as a blank History recipe category * test(inspector): cover mixed-scope panels, invalid library scopes and the skipped positive tags query
This commit is contained in:
+12
-6
@@ -657,12 +657,18 @@ Recorded deviations on this page (spec §8):
|
||||
`Host` and `Origin`. Such a client can open a folder of a listed file on the host's
|
||||
screen, at most once a second, and learn whether that file exists (which the
|
||||
listing routes already tell it). Operators who expose ComfyUI that way accept this.
|
||||
- **D39**: a recipe-linked image shows its recipe's own category and tags (#224), not
|
||||
its positive's. The concept shows the prompt's category and tags for every image;
|
||||
the concept fixture's recipes have neither, so their images show no category chip
|
||||
and no tag row. `positive_links` on `/__test__/concept/reset` links the newest
|
||||
images to their positive alone, so the inspector comparison sees the positive's
|
||||
category ("Positive prompt category: " hidden prefix, D21) and tag filters.
|
||||
- **D39** (ruled 2026-10-21: fallback): a recipe-linked image shows its recipe's own
|
||||
category when set, otherwise the linked positive prompt's, and its recipe's own tags
|
||||
when it has any, otherwise the positive's; each field falls back on its own and the
|
||||
two tag sets are never merged. Set category (#259) writes the positive's category and
|
||||
the Images category and Tags filters match the positive's, so the panel now agrees
|
||||
with them whenever the recipe adds nothing. The inspection response names each
|
||||
field's source (`library.category_scope`, `library.tags_scope`: `pair`, `positive` or
|
||||
null); the category chip's visually hidden prefix (D21) says "Recipe category: " or
|
||||
"Positive prompt category: " from it, and tag chips are Tags filter buttons only when
|
||||
they are the positive's (#260 D2). History's media inspector uses the same rule. The
|
||||
concept shows the prompt's category and tags for every image and the concept
|
||||
fixture's recipes have neither, so the inspector comparison needs no mapping for it.
|
||||
- **D42**: the concept never shows a toast and a page alert together. Production keeps
|
||||
the concept's fixed toast (centred, 22px from the bottom, 12px at 700px and below)
|
||||
and, when an Import → Rescan toast leaves the Images discovery alert
|
||||
|
||||
+24
-11
@@ -4,9 +4,17 @@ import os
|
||||
import sqlite3
|
||||
|
||||
try:
|
||||
from ..utils.inspection_response import MAX_LIBRARY_BYTES, _library_projection
|
||||
from ..utils.inspection_response import (
|
||||
MAX_LIBRARY_BYTES,
|
||||
_library_projection,
|
||||
library_metadata,
|
||||
)
|
||||
except ImportError:
|
||||
from utils.inspection_response import MAX_LIBRARY_BYTES, _library_projection
|
||||
from utils.inspection_response import (
|
||||
MAX_LIBRARY_BYTES,
|
||||
_library_projection,
|
||||
library_metadata,
|
||||
)
|
||||
|
||||
|
||||
def _display_bytes(value: bytes | None) -> str | None:
|
||||
@@ -52,15 +60,21 @@ def _saved_projection(conn: sqlite3.Connection, row: sqlite3.Row) -> tuple[dict,
|
||||
return saved, _library_projection({"status": "limited"})
|
||||
pair = pairs[0] if pairs else None
|
||||
title = pair["title"] if pair is not None else None
|
||||
category = pair["category"] if pair is not None else row["category"]
|
||||
tags, limited = _tags(
|
||||
conn,
|
||||
pair["id"] if pair is not None else row["prompt_id"],
|
||||
pair=pair is not None,
|
||||
recipe_tags, limited = (
|
||||
_tags(conn, pair["id"], pair=True) if pair is not None else ([], False)
|
||||
)
|
||||
positive_tags = []
|
||||
if not recipe_tags and not limited:
|
||||
# D39: only read the positive's tags when they are what the panel shows.
|
||||
positive_tags, limited = _tags(conn, row["prompt_id"], pair=False)
|
||||
metadata = library_metadata(
|
||||
row["category"],
|
||||
positive_tags,
|
||||
(pair["category"], recipe_tags) if pair is not None else None,
|
||||
)
|
||||
limited = limited or any(
|
||||
value is not None and len(value) > MAX_LIBRARY_BYTES
|
||||
for value in (title, category)
|
||||
for value in (title, metadata["category"])
|
||||
)
|
||||
if pair is not None:
|
||||
saved["pair"] = {
|
||||
@@ -73,9 +87,8 @@ def _saved_projection(conn: sqlite3.Connection, row: sqlite3.Row) -> tuple[dict,
|
||||
"positive_id": row["prompt_id"],
|
||||
"pair_id": pair["id"] if pair is not None else None,
|
||||
"title": _display_bytes(title),
|
||||
"category": _display_bytes(category),
|
||||
"tags": tags,
|
||||
"metadata_scope": "pair" if pair is not None else "positive",
|
||||
**metadata,
|
||||
"category": _display_bytes(metadata["category"]),
|
||||
}
|
||||
)
|
||||
if limited:
|
||||
|
||||
+23
-20
@@ -36,7 +36,10 @@ try:
|
||||
from ...database.models import normalize_image_path
|
||||
from ...utils.card_seed import card_seed, header_params, parsed_positive
|
||||
from ...utils.parallel import map_parallel
|
||||
from ...utils.inspection_response import encode_inspection_response
|
||||
from ...utils.inspection_response import (
|
||||
encode_inspection_response,
|
||||
library_metadata,
|
||||
)
|
||||
from ...utils.prompt_inspector import MetadataReadError, read_inspection_file
|
||||
from ...utils.file_reveal import (
|
||||
OpenerUnavailable,
|
||||
@@ -52,7 +55,10 @@ except ImportError:
|
||||
from database.models import normalize_image_path
|
||||
from utils.card_seed import card_seed, header_params, parsed_positive
|
||||
from utils.parallel import map_parallel
|
||||
from utils.inspection_response import encode_inspection_response
|
||||
from utils.inspection_response import (
|
||||
encode_inspection_response,
|
||||
library_metadata,
|
||||
)
|
||||
from utils.prompt_inspector import MetadataReadError, read_inspection_file
|
||||
from utils.file_reveal import (
|
||||
OpenerUnavailable,
|
||||
@@ -2464,7 +2470,8 @@ class ImageRoutesMixin:
|
||||
"title": None,
|
||||
"category": None,
|
||||
"tags": [],
|
||||
"metadata_scope": None,
|
||||
"category_scope": None,
|
||||
"tags_scope": None,
|
||||
}
|
||||
if not isinstance(saved, dict) or not saved:
|
||||
return result
|
||||
@@ -2472,23 +2479,19 @@ class ImageRoutesMixin:
|
||||
result["status"] = "ambiguous"
|
||||
return result
|
||||
pair = saved.get("pair")
|
||||
if isinstance(pair, dict):
|
||||
result.update(
|
||||
status="linked",
|
||||
positive_id=pair.get("positive_prompt_id"),
|
||||
pair_id=pair.get("id"),
|
||||
category=pair.get("category"),
|
||||
tags=pair.get("tags"),
|
||||
metadata_scope="pair",
|
||||
)
|
||||
else:
|
||||
result.update(
|
||||
status="linked",
|
||||
positive_id=saved.get("prompt_id"),
|
||||
category=saved.get("category"),
|
||||
tags=saved.get("tags"),
|
||||
metadata_scope="positive",
|
||||
)
|
||||
recipe = isinstance(pair, dict)
|
||||
result.update(
|
||||
status="linked",
|
||||
positive_id=(
|
||||
pair.get("positive_prompt_id") if recipe else saved.get("prompt_id")
|
||||
),
|
||||
pair_id=pair.get("id") if recipe else None,
|
||||
**library_metadata(
|
||||
saved.get("category"),
|
||||
saved.get("tags"),
|
||||
(pair.get("category"), pair.get("tags")) if recipe else None,
|
||||
),
|
||||
)
|
||||
return result
|
||||
|
||||
async def get_image_prompt(self, request):
|
||||
|
||||
@@ -75,7 +75,7 @@ test('inspection wire preserves exact rooted envelope', async ({ request }) => {
|
||||
expect(result.metadata.prompt_graph.value['3'].inputs.cfg).toBe('6.5');
|
||||
expect(result.metadata.record_formats).toEqual({ prompt: 'original-text', workflow: 'original-text' });
|
||||
expect(result.file).toMatchObject({ observation: 'current-file', format: 'PNG', width: 720, height: 480 });
|
||||
expect(result.library).toMatchObject({ status: 'linked', metadata_scope: 'pair', title: POSITIVE_TITLE, category: 'Inspection', tags: ['fixture', 'geometric'] });
|
||||
expect(result.library).toMatchObject({ status: 'linked', category_scope: 'pair', tags_scope: 'pair', title: POSITIVE_TITLE, category: 'Inspection', tags: ['fixture', 'geometric'] });
|
||||
expect((await inspect(collision)).prompts.positive.text).toBe('Input collision');
|
||||
const gallery = await (await request.get(`/prompt_manager/library/${result.library.positive_id}/images?pair_id=${result.library.pair_id}`)).json();
|
||||
expect(gallery.images[0].root_scope).toBe(complete.root_scope);
|
||||
@@ -163,10 +163,20 @@ test('panel matches the concept order and copy',async({page,request})=>{
|
||||
inner:['div.panel-heading','p.small','section.prompt-section.positive','section.prompt-section.negative','dl.meta-grid']});
|
||||
await expect(panel.locator('.meta-grid dd')).toHaveText(Array(6).fill('Not recorded'));
|
||||
// Ambiguous saved association: the same honest absence.
|
||||
reads.patch=data=>{data.library={...data.library,status:'ambiguous',positive_id:null,pair_id:null,title:null,category:null,tags:[],metadata_scope:null};};
|
||||
reads.patch=data=>{data.library={...data.library,status:'ambiguous',positive_id:null,pair_id:null,title:null,category:null,tags:[],category_scope:null,tags_scope:null};};
|
||||
await selectFile(page,'workflow-only.png');await expect(panel.locator('.panel-heading h2')).toHaveText('workflow-only.png');
|
||||
await expect(panel.locator('.chip:not(.blue)')).toHaveCount(1);await expect(panel.locator('.detail-inner > .tags button')).toHaveCount(0);
|
||||
await expect(panel.getByRole('button',{name:'Prompt history',exact:true})).toHaveCount(0);
|
||||
// D39 mixed scopes: each field follows its own source, so the prefix and the tag chips can disagree.
|
||||
const tagRow=panel.locator('.detail-inner > .tags').last();
|
||||
reads.patch=data=>{data.library={...data.library,status:'linked',category:'Subject',category_scope:'pair',tags_scope:'positive',tags:['alpha','beta']};};
|
||||
await selectFile(page,'complete.png');
|
||||
await expect(panel.locator('span.chip:not(.blue) .ca-visually-hidden')).toHaveText('Recipe category: ');
|
||||
await expect(tagRow.getByRole('button')).toHaveText(['alpha','beta']);
|
||||
reads.patch=data=>{data.library={...data.library,status:'linked',category:'Subject',category_scope:'positive',tags_scope:'pair',tags:['alpha','beta']};};
|
||||
await selectFile(page,'workflow-only.png');
|
||||
await expect(panel.locator('span.chip:not(.blue) .ca-visually-hidden')).toHaveText('Positive prompt category: ');
|
||||
await expect(tagRow.locator('span.chip')).toHaveText(['alpha','beta']);await expect(tagRow.getByRole('button')).toHaveCount(0);
|
||||
// Loading and error states render in `.detail-inner` as `.small` text with the existing buttons.
|
||||
reads.patch=null;await page.unroute('**/images/inspect/**');
|
||||
await page.route('**/images/inspect/**',route=>route.fulfill({status:500,json:{success:false,error:'broken'}}));
|
||||
|
||||
@@ -5,16 +5,15 @@ import {SIZES,THEMES,seedConcept,openConcept,openProduction,inspectorOf,diffInve
|
||||
|
||||
// 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.
|
||||
// Both documents select the first listed image, alpine_00001.png (D19: the first load selects it), which the fixture
|
||||
// links to its positive prompt alone (`positive_links`, D39), so its panel shows the positive's own category and tags
|
||||
// as the concept's does. The panel's box in the gallery layout is the Images spec's. Paired captures are artifacts only;
|
||||
// there are no PNG baselines.
|
||||
// Both documents select the first listed image, alpine_00001.png (D19: the first load selects it). The fixture links it
|
||||
// to a recipe without a category or tags, so (D39, ruled 2026-10-21) its panel falls back to the positive's own category
|
||||
// and tags, as the concept's does. The panel's box in the gallery layout is the Images spec's. Paired captures are
|
||||
// artifacts only; there are no PNG baselines.
|
||||
test.use({locale:'en-US',timezoneId:'UTC'});
|
||||
|
||||
const CARDS='.gallery-body > .image-grid > .media-card';
|
||||
const TITLE='Alpine observatory';
|
||||
const FIXTURE_IMAGES=24;
|
||||
const SEED={positive_links:1};
|
||||
// The concept's sticky panel never scrolls; production's scrolls inside the window above 700px (D20/D21 side panel).
|
||||
const PANEL_INSET=40;
|
||||
// D34: coarse pointers keep 40px targets for the viewer details' disclosure summaries and "Download original" (the
|
||||
@@ -39,15 +38,15 @@ function withFocusablePrompts(inventory) {
|
||||
return {...inventory,controls,sections:inventory.sections.map(section=>({...section,text:{...section.text,tabIndex:0}}))};
|
||||
}
|
||||
|
||||
/** D21: the category chip names its scope in a visually hidden prefix; here the linked positive's. */
|
||||
/** D21: the category chip names its scope in a visually hidden prefix; here the linked positive's (D39 fallback). */
|
||||
function withCategoryScope(inventory) {
|
||||
const [badge,category,...rest]=inventory.badges.chips;
|
||||
return {...inventory,badges:{...inventory.badges,chips:[badge,{...category,hidden:'Positive prompt category: '},...rest]}};
|
||||
}
|
||||
|
||||
/**
|
||||
* Recorded inspector deviations only (spec §8, TESTING.md D3, D20–D22, D34, D38 and D39 through the fixture's
|
||||
* `positive_links`); everything else must equal production. `reference.viewerPlain` is the concept's own full details
|
||||
* Recorded inspector deviations only (spec §8, TESTING.md D3, D20–D22, D34 and D38); everything else must equal
|
||||
* production. `reference.viewerPlain` is the concept's own full details
|
||||
* with its tag chips made plain (D20) and, under a coarse pointer, D34's targets, both at runtime: the browser lays out
|
||||
* what follows them, as the Images spec's removed toolbar items do.
|
||||
*/
|
||||
@@ -232,7 +231,7 @@ const historyOf=async page=>{
|
||||
return page.evaluate(()=>location.hash);
|
||||
};
|
||||
|
||||
test.beforeEach(async({request},testInfo)=>{testInfo.manifest=await seedConcept(request,SEED);});
|
||||
test.beforeEach(async({request},testInfo)=>{testInfo.manifest=await seedConcept(request);});
|
||||
|
||||
for(const [width,height] of SIZES)for(const theme of THEMES)test(`inspector panel geometry, tokens and controls match the concept ${width}x${height} ${theme}`,async({page,context,browserName},testInfo)=>{
|
||||
test.setTimeout(180_000);
|
||||
@@ -303,15 +302,20 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`inspector pane
|
||||
expect.soft(production.history,'history').toBe(`#/library/${manifest.prompts[0].positive_id}`);
|
||||
});
|
||||
|
||||
// D39: a recipe-linked image shows its recipe's own category and tags (#224), which the concept fixture's recipes do
|
||||
// not have, so its panel has neither (the concept shows the prompt's for every image).
|
||||
test('a recipe-linked image shows only its recipe metadata (D39)',async({page})=>{
|
||||
// D39 (ruled 2026-10-21): a recipe-linked image shows its recipe's own category and tags when set, otherwise its
|
||||
// positive's, field by field. The concept fixture's recipes have neither, so this recipe-linked image shows its
|
||||
// positive's, as the concept does, and its tag chips are Tags filter buttons again (D21: the positive's own tags).
|
||||
test('a recipe-linked image without recipe metadata shows its positive prompt metadata (D39)',async({page})=>{
|
||||
await page.setViewportSize({width:1600,height:1100});
|
||||
await openProductionPanel(page,'dark');
|
||||
await page.locator(CARDS).nth(1).locator('[aria-label^="Inspect"]').click();
|
||||
await expect(panelOf(page).locator('.panel-heading h2')).toHaveText('Quiet geometry');
|
||||
await expect(panelOf(page).locator('.detail-inner > .tags')).toHaveCount(1);
|
||||
await expect(panelOf(page).locator('.detail-inner > .tags .chip')).toHaveText(['Execution recorded']);
|
||||
const rows=panelOf(page).locator('.detail-inner > .tags');
|
||||
await expect(rows).toHaveCount(2);
|
||||
await expect(rows.first().locator('.chip')).toHaveText(['Execution recorded','Positive prompt category: Architecture']);
|
||||
await expect(rows.first().locator('.chip .ca-visually-hidden')).toHaveText('Positive prompt category: ');
|
||||
await expect(rows.nth(1).locator('button.chip')).toHaveText(['minimal','warm_light','editorial']);
|
||||
await expect(rows.nth(1).locator('span.chip')).toHaveCount(0);
|
||||
await expect(panelOf(page).locator('.detail-footer button')).toHaveText(['Send to ComfyUI','Prompt history']);
|
||||
});
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ from PIL.PngImagePlugin import PngInfo
|
||||
from tests.browser.workspace_concept_fixture import (
|
||||
MAX_EXTRA_IMAGES as CONCEPT_MAX_EXTRA_IMAGES,
|
||||
MAX_SEEDLESS_IMAGES as CONCEPT_MAX_SEEDLESS_IMAGES,
|
||||
MAX_POSITIVE_LINKS as CONCEPT_MAX_POSITIVE_LINKS,
|
||||
seed_workspace_concept,
|
||||
)
|
||||
from tests.browser.workspace_visual_fixture import seed_workspace_visual
|
||||
@@ -443,7 +442,6 @@ def create_app(root: Path) -> web.Application:
|
||||
limits = {
|
||||
"extra_images": CONCEPT_MAX_EXTRA_IMAGES,
|
||||
"seedless_images": CONCEPT_MAX_SEEDLESS_IMAGES,
|
||||
"positive_links": CONCEPT_MAX_POSITIVE_LINKS,
|
||||
}
|
||||
if (
|
||||
request.query
|
||||
@@ -467,7 +465,6 @@ def create_app(root: Path) -> web.Application:
|
||||
root,
|
||||
extra_images=data.get("extra_images", 0),
|
||||
seedless_images=data.get("seedless_images", 0),
|
||||
positive_links=data.get("positive_links", 0),
|
||||
)
|
||||
)
|
||||
return web.json_response({"success": True, **manifest})
|
||||
|
||||
@@ -157,9 +157,6 @@ CONCEPT_MODEL = "illustrative-model.safetensors"
|
||||
MAX_EXTRA_IMAGES = 120
|
||||
# Unlinked, seedless images (D11 "W × H · PNG") newer than the concept's, for D3 checks.
|
||||
MAX_SEEDLESS_IMAGES = 2
|
||||
# The newest images linked to their positive alone (no recipe), for the inspector's
|
||||
# positive-scope category and tag chips (D39).
|
||||
MAX_POSITIVE_LINKS = 6
|
||||
SEEDLESS_SIZE = (1536, 1152)
|
||||
SESSION = "5c1f9a7e-3f0b-4a55-9d0e-2b8c4f6a7d10"
|
||||
_TABLES = (
|
||||
@@ -344,7 +341,6 @@ def seed_workspace_concept(
|
||||
*,
|
||||
extra_images: int = 0,
|
||||
seedless_images: int = 0,
|
||||
positive_links: int = 0,
|
||||
) -> dict:
|
||||
"""Replace this server's disposable rows and files with the concept's data.
|
||||
|
||||
@@ -354,7 +350,6 @@ def seed_workspace_concept(
|
||||
"""
|
||||
_bounded("extra_images", extra_images, MAX_EXTRA_IMAGES)
|
||||
_bounded("seedless_images", seedless_images, MAX_SEEDLESS_IMAGES)
|
||||
_bounded("positive_links", positive_links, MAX_POSITIVE_LINKS)
|
||||
_clear(api, root)
|
||||
db = api.db
|
||||
epoch = db.get_execution_epoch()
|
||||
@@ -410,9 +405,8 @@ def seed_workspace_concept(
|
||||
os.utime(path, (stamp, stamp))
|
||||
owner = prompts[prompt_index]
|
||||
image_id = db.link_image_to_prompt(owner["positive_id"], str(path))
|
||||
pair_id = None if index < positive_links else owner["pair_ids"][variant]
|
||||
if pair_id is not None:
|
||||
db.link_image_to_pair(pair_id, image_id, {"source": "concept fixture"})
|
||||
pair_id = owner["pair_ids"][variant]
|
||||
db.link_image_to_pair(pair_id, image_id, {"source": "concept fixture"})
|
||||
if concept_starred(prompt_index, round_index):
|
||||
db.set_image_favorite(
|
||||
favorite_root, _favorite_relative_key(f"concepts/{name}")
|
||||
|
||||
@@ -5,7 +5,7 @@ const modulePath='../../web/js/workspace/file-inspector.mjs';
|
||||
const load=()=>import(modulePath);
|
||||
const rawWorkflow='{ "nodes": [], "seed": 9007199254740993, "cfg": 1.0000000000000000001 }';
|
||||
function record() {
|
||||
return {identity:{kind:'rooted-file',key:'2:a.png',root_index:2,path:'a.png',root_scope:'a'.repeat(64)},file:{format:'PNG'},prompts:{positive:{status:'resolved',text:'a\n猫'}},generation:{seed:{status:'known',value:'9007199254740993',source:'workflow',evidence:'configuration'}},library:{metadata_scope:'pair',tags:['saved']},download:{filename:'a.png',url:'/prompt_manager/images/serve/a.png?root=2'},metadata:{status:'available',omitted_reason:null,raw:{workflow:rawWorkflow,prompt:'{broken',parameters:'literal <svg> </pre>'},record_formats:{workflow:'original-text',prompt:'original-text',parameters:'original-text'},workflow_graph:{status:'parsed',value:{seed:'9007199254740993'}},prompt_graph:{status:'malformed',value:null},parameters:'literal <svg> </pre>'}};
|
||||
return {identity:{kind:'rooted-file',key:'2:a.png',root_index:2,path:'a.png',root_scope:'a'.repeat(64)},file:{format:'PNG'},prompts:{positive:{status:'resolved',text:'a\n猫'}},generation:{seed:{status:'known',value:'9007199254740993',source:'workflow',evidence:'configuration'}},library:{category_scope:'pair',tags_scope:'pair',tags:['saved']},download:{filename:'a.png',url:'/prompt_manager/images/serve/a.png?root=2'},metadata:{status:'available',omitted_reason:null,raw:{workflow:rawWorkflow,prompt:'{broken',parameters:'literal <svg> </pre>'},record_formats:{workflow:'original-text',prompt:'original-text',parameters:'original-text'},workflow_graph:{status:'parsed',value:{seed:'9007199254740993'}},prompt_graph:{status:'malformed',value:null},parameters:'literal <svg> </pre>'}};
|
||||
}
|
||||
|
||||
test('metadata export retains literal raw and scoped evidence',async()=>{
|
||||
|
||||
@@ -8,7 +8,7 @@ const m=await import('../../web/js/workspace/history-media.mjs').catch(e=>{if(e.
|
||||
const epoch='11111111-1111-1111-1111-111111111111',run='22222222-2222-2222-2222-222222222222',scope='a'.repeat(64),location='b'.repeat(64);
|
||||
function media(node='1',ordinal=0,key=location) {const occurrence={run_id:run,output_node_id:node,image_ordinal:ordinal},reference={folder_type:'output',subfolder:'folder',filename:'a #%.png',path_style:'posix',root_key:'c'.repeat(64)},base=`/prompt_manager/history/runs/${run}/output/current`,query=new URLSearchParams({node,ordinal:String(ordinal),epoch,current_scope:scope});return {kind:'reported-location',occurrence,reference,history_epoch:epoch,current_scope:scope,location_key:key,filename:reference.filename,is_video:false,url:`${base}/media?${query}`,inspection_url:`${base}/inspection?${query}`,thumbnail_url:`${base}/thumbnail?${query}`};}
|
||||
const receipt=value=>({occurrence:value.occurrence,reference:value.reference,file_identity:'unverified',newly_generated:'unknown',context_membership:'unknown',transient:false,current_candidate:{status:'available',reason:null,evidence_class:'locator_candidate',current_scope:value.current_scope,location_key:value.location_key,relative_path:'folder/a #%.png',source_label:'output',gallery_match:'none',gallery_image_id:null,media:value}});
|
||||
function envelope(target=media()) {return {success:true,inspection_version:1,history_version:1,history_epoch:epoch,current_output:{occurrence:target.occurrence,reference:target.reference,history_epoch:epoch,current_scope:scope,location_key:target.location_key,evidence_class:'locator_candidate',observation:'current-file',file_identity:'unverified',newly_generated:'unknown',context_membership:'unknown',transient:false},prompts:{positive:{status:'resolved',text:'current metadata only',source:'execution'},negative:{status:'unresolved',text:null,source:null}},file:{observation:'current-file',source_label:'output',media_type:'image',extension:'.png',format:'PNG',size:10,width:2,height:3,modified_time:1},metadata:{status:'absent',omitted_reason:null,raw:{},record_formats:{},prompt_graph:{status:'absent',value:null},workflow_graph:{status:'absent',value:null},parameters:null},resolution:{state:'absent',sampler_id:null,reason:'no graph'},library:{status:'none',scope:'saved-library',tags:[],metadata_scope:null,positive_id:null,pair_id:null,title:null,category:null}};}
|
||||
function envelope(target=media()) {return {success:true,inspection_version:1,history_version:1,history_epoch:epoch,current_output:{occurrence:target.occurrence,reference:target.reference,history_epoch:epoch,current_scope:scope,location_key:target.location_key,evidence_class:'locator_candidate',observation:'current-file',file_identity:'unverified',newly_generated:'unknown',context_membership:'unknown',transient:false},prompts:{positive:{status:'resolved',text:'current metadata only',source:'execution'},negative:{status:'unresolved',text:null,source:null}},file:{observation:'current-file',source_label:'output',media_type:'image',extension:'.png',format:'PNG',size:10,width:2,height:3,modified_time:1},metadata:{status:'absent',omitted_reason:null,raw:{},record_formats:{},prompt_graph:{status:'absent',value:null},workflow_graph:{status:'absent',value:null},parameters:null},resolution:{state:'absent',sampler_id:null,reason:'no graph'},library:{status:'none',scope:'saved-library',tags:[],category_scope:null,tags_scope:null,positive_id:null,pair_id:null,title:null,category:null}};}
|
||||
function adapter(){assert.ok(m.reportedLocationAdapter,'reported location adapter is exported');return m.reportedLocationAdapter;}
|
||||
|
||||
test('reported location uses explicit occurrence routes and never widens rooted defaults',()=>{const target=media(),a=adapter();assert.doesNotThrow(()=>a.validate(target));assert.equal(a.key(target),location);assert.equal(a.scope(target),`${epoch}:${scope}`);assert.equal(a.inspectionPath(target),target.inspection_url);assert.deepEqual(a.originalDownload(target),{url:target.url,filename:'a #%.png'});assert.throws(()=>originalMediaDownload(target),TypeError);assert.throws(()=>createViewerSession({items:[target],initialKey:location}),TypeError);});
|
||||
|
||||
@@ -27,7 +27,7 @@ function envelope(entry = image()) {
|
||||
workflow_graph: { status: 'absent', value: null }, parameters: null,
|
||||
},
|
||||
resolution: { state: 'ambiguous', sampler_id: null, reason: 'unknown negative' },
|
||||
library: { status: 'linked', scope: 'saved-library', positive_id: 7, pair_id: 8, title: 'Recipe', category: 'Subject', tags: ['cat'], metadata_scope: 'pair' },
|
||||
library: { status: 'linked', scope: 'saved-library', positive_id: 7, pair_id: 8, title: 'Recipe', category: 'Subject', tags: ['cat'], category_scope: 'pair', tags_scope: 'pair' },
|
||||
};
|
||||
}
|
||||
function pendingApi() {
|
||||
@@ -276,6 +276,10 @@ test('record rejects root path media and invalid versioned fields with retryable
|
||||
data => { data.file.size = -1; }, data => { data.library.positive_id = 9007199254740992; },
|
||||
data => { data.metadata.prompt_graph = { status: 'limited', value: {} }; },
|
||||
data => { data.resolution.sampler_id = {}; },
|
||||
data => { data.library.tags_scope = 'recipe'; },
|
||||
data => { data.library.category_scope = 'recipe'; },
|
||||
data => { delete data.library.category_scope; },
|
||||
data => { delete data.library.tags_scope; },
|
||||
]) {
|
||||
const data = envelope(); mutate(data);
|
||||
assert.throws(() => toRecord(data, image(), extractGenerationFacts), error => error instanceof TypeError && !error.refreshRequired);
|
||||
|
||||
@@ -119,7 +119,7 @@ function currentInspectionEnvelope(target) {
|
||||
file:{observation:'current-file',source_label:'output',media_type:'image',extension:'.png',format:'PNG',size:10,width:2,height:3,modified_time:1},
|
||||
prompts:{positive:{status:'resolved',text:'current positive',source:'execution'},negative:{status:'resolved',text:'',source:'execution'}},
|
||||
metadata:{status:'absent',omitted_reason:null,raw:{},record_formats:{},prompt_graph:{status:'absent',value:null},workflow_graph:{status:'absent',value:null},parameters:null},
|
||||
resolution:{state:'absent',sampler_id:null,reason:'no graph'},library:{status:'none',scope:'saved-library',positive_id:null,pair_id:null,title:null,category:null,tags:[],metadata_scope:null}};
|
||||
resolution:{state:'absent',sampler_id:null,reason:'no graph'},library:{status:'none',scope:'saved-library',positive_id:null,pair_id:null,title:null,category:null,tags:[],category_scope:null,tags_scope:null}};
|
||||
}
|
||||
|
||||
test('real inspection navigation preserves slideshow across two advances and deferred metadata loads',async()=>{
|
||||
|
||||
@@ -375,7 +375,9 @@ class TestLibraryAPI(ImageAPITestCase):
|
||||
result["image"],
|
||||
{"root_index": 0, "path": relative, "filename": "猫 % #.png"},
|
||||
)
|
||||
self.assertEqual(result["library"]["metadata_scope"], "pair")
|
||||
# D39: the recipe has no category or tags, so both fall back.
|
||||
self.assertEqual(result["library"]["category_scope"], "positive")
|
||||
self.assertEqual(result["library"]["tags_scope"], "positive")
|
||||
self.assertEqual(result["library"]["pair_id"], pair)
|
||||
self.assertEqual(result["library"]["positive_id"], positive)
|
||||
# D10/D20: the linked positive's title; "Pair title" names the variant.
|
||||
|
||||
@@ -1302,6 +1302,64 @@ class TestPromptInspector(ImageAPITestCase):
|
||||
)
|
||||
roots.assert_called_once_with()
|
||||
|
||||
async def test_recipe_link_falls_back_to_positive_category_and_tags_per_field(
|
||||
self,
|
||||
):
|
||||
# D39 ruling (2026-10-21): a recipe's own category and tags win when set;
|
||||
# otherwise each field falls back to the linked positive's, separately, and
|
||||
# the scopes say where each value came from (no merging of tag sets).
|
||||
cases = (
|
||||
("Recipe cat", ["recipe"], "Recipe cat", "pair", ["recipe"], "pair"),
|
||||
(None, [], "Positive cat", "positive", ["positive"], "positive"),
|
||||
(" ", [], "Positive cat", "positive", ["positive"], "positive"),
|
||||
("Recipe cat", [], "Recipe cat", "pair", ["positive"], "positive"),
|
||||
(None, ["recipe"], "Positive cat", "positive", ["recipe"], "pair"),
|
||||
)
|
||||
for index, case in enumerate(cases):
|
||||
recipe_category, recipe_tags = case[:2]
|
||||
category, category_scope, tags, tags_scope = case[2:]
|
||||
with self.subTest(case=index):
|
||||
text = f"Fallback owner {index}"
|
||||
positive = self._save_prompt(
|
||||
text, category="Positive cat", tags=["positive"]
|
||||
)
|
||||
pair = self.api.db.save_prompt_pair(
|
||||
text,
|
||||
f"Fallback noise {index}",
|
||||
category=recipe_category,
|
||||
tags=recipe_tags,
|
||||
)
|
||||
self.assertEqual(
|
||||
self.api.db.get_prompt_pair(pair)["positive_prompt_id"], positive
|
||||
)
|
||||
filename = f"fallback-{index}.png"
|
||||
path = png(self.output_dir / filename)
|
||||
image = self.api.db.link_image_to_prompt(positive, str(path))
|
||||
self.api.db.link_image_to_pair(pair, image, {"source": "fixture"})
|
||||
response, result = await self.inspect(filename)
|
||||
self.assertEqual(response.status, 200)
|
||||
library = result["library"]
|
||||
self.assertEqual(library["status"], "linked")
|
||||
self.assertEqual(library["pair_id"], pair)
|
||||
self.assertEqual(library["category"], category)
|
||||
self.assertEqual(library["category_scope"], category_scope)
|
||||
self.assertEqual(library["tags"], tags)
|
||||
self.assertEqual(library["tags_scope"], tags_scope)
|
||||
self.assertNotIn("metadata_scope", library)
|
||||
|
||||
async def test_positive_only_link_keeps_positive_scopes(self):
|
||||
positive = self._save_prompt("Solo owner", tags=["solo"])
|
||||
path = png(self.output_dir / "solo.png")
|
||||
self.api.db.link_image_to_prompt(positive, str(path))
|
||||
response, result = await self.inspect("solo.png")
|
||||
self.assertEqual(response.status, 200)
|
||||
library = result["library"]
|
||||
self.assertEqual(library["pair_id"], None)
|
||||
self.assertEqual(library["category"], None)
|
||||
self.assertEqual(library["category_scope"], "positive")
|
||||
self.assertEqual(library["tags"], ["solo"])
|
||||
self.assertEqual(library["tags_scope"], "positive")
|
||||
|
||||
async def test_exact_saved_association_ambiguity_preserves_physical_file_evidence(
|
||||
self,
|
||||
):
|
||||
@@ -1340,8 +1398,9 @@ class TestPromptInspector(ImageAPITestCase):
|
||||
if count == 1:
|
||||
self.assertEqual(result["library"]["positive_id"], positive)
|
||||
self.assertEqual(
|
||||
result["library"]["metadata_scope"], "positive"
|
||||
result["library"]["category_scope"], "positive"
|
||||
)
|
||||
self.assertEqual(result["library"]["tags_scope"], "positive")
|
||||
self.assertEqual(result["library"]["category"], "Category 1")
|
||||
self.assertEqual(result["library"]["tags"], ["owner-1"])
|
||||
if not readable:
|
||||
@@ -1361,7 +1420,8 @@ class TestPromptInspector(ImageAPITestCase):
|
||||
"title": None,
|
||||
"category": None,
|
||||
"tags": [],
|
||||
"metadata_scope": None,
|
||||
"category_scope": None,
|
||||
"tags_scope": None,
|
||||
},
|
||||
)
|
||||
for field in (
|
||||
|
||||
@@ -16,6 +16,7 @@ from tests.test_api_images import make_png
|
||||
from database.operations import PromptDatabase
|
||||
from py.config import GalleryConfig
|
||||
from utils.execution_outputs import snapshot_output_roots
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
|
||||
class CurrentLocationTests(unittest.TestCase):
|
||||
@@ -331,6 +332,93 @@ class CurrentLocationTests(unittest.TestCase):
|
||||
self.assertEqual(result[unique_locator]["library"]["status"], "limited")
|
||||
self.assertIsNone(result[unique_locator]["library"].get("pair_id"))
|
||||
|
||||
def test_gallery_recipe_display_falls_back_to_positive_per_field(self):
|
||||
# D39 ruling (2026-10-21), shared with the Images inspector.
|
||||
mod = self.module("database.history_gallery")
|
||||
db = PromptDatabase(str(self.base / "gallery.db"))
|
||||
self.addCleanup(db.close_all)
|
||||
cases = (
|
||||
("Recipe cat", ["recipe"], ("Recipe cat", "pair", ["recipe"], "pair")),
|
||||
(None, [], ("Positive cat", "positive", ["positive"], "positive")),
|
||||
("Recipe cat", [], ("Recipe cat", "pair", ["positive"], "positive")),
|
||||
("", ["recipe"], ("Positive cat", "positive", ["recipe"], "pair")),
|
||||
)
|
||||
for index, (recipe_category, recipe_tags, expected) in enumerate(cases):
|
||||
with self.subTest(case=index):
|
||||
text = f"owner {index}"
|
||||
positive = db.save_prompt(
|
||||
text,
|
||||
prompt_hash=generate_prompt_hash(text),
|
||||
category="Positive cat",
|
||||
)
|
||||
db.set_prompt_tags(positive, ["positive"])
|
||||
pair = db.save_prompt_pair(
|
||||
text, "noise", category=recipe_category, tags=recipe_tags
|
||||
)
|
||||
path = make_png(self.output / f"fallback-{index}.png")
|
||||
locator = os.path.normcase(str(path))
|
||||
image = db.link_image_to_prompt(positive, str(path))
|
||||
db.link_image_to_pair(pair, image, {"source": "fixture"})
|
||||
conn = db.model.get_connection()
|
||||
with db.recorded_history_snapshot():
|
||||
result = mod.read_history_gallery(conn, (locator,))
|
||||
library = result[locator]["library"]
|
||||
self.assertEqual(library["pair_id"], pair)
|
||||
self.assertEqual(
|
||||
(
|
||||
library["category"],
|
||||
library["category_scope"],
|
||||
library["tags"],
|
||||
library["tags_scope"],
|
||||
),
|
||||
expected,
|
||||
)
|
||||
|
||||
def test_gallery_blank_recipe_category_and_skipped_positive_tags_query(self):
|
||||
mod = self.module("database.history_gallery")
|
||||
db = PromptDatabase(str(self.base / "blank.db"))
|
||||
self.addCleanup(db.close_all)
|
||||
cases = (
|
||||
(" ", ["recipe"], 0),
|
||||
("\u3000", [], 1),
|
||||
("\u00a0 ", ["recipe"], 0),
|
||||
)
|
||||
for index, (blank, recipe_tags, positive_tag_queries) in enumerate(cases):
|
||||
with self.subTest(case=index):
|
||||
text = f"blank owner {index}"
|
||||
positive = db.save_prompt(
|
||||
text,
|
||||
prompt_hash=generate_prompt_hash(text),
|
||||
category="Positive cat",
|
||||
)
|
||||
db.set_prompt_tags(positive, ["positive"])
|
||||
pair = db.save_prompt_pair(text, "noise", tags=recipe_tags)
|
||||
path = make_png(self.output / f"blank-{index}.png")
|
||||
locator = os.path.normcase(str(path))
|
||||
image = db.link_image_to_prompt(positive, str(path))
|
||||
db.link_image_to_pair(pair, image, {"source": "fixture"})
|
||||
conn = db.model.get_connection()
|
||||
# Legacy rows can hold a blank-looking category the writers now trim.
|
||||
conn.execute(
|
||||
"UPDATE prompt_pairs SET category=? WHERE id=?", (blank, pair)
|
||||
)
|
||||
conn.commit()
|
||||
statements = []
|
||||
conn.set_trace_callback(statements.append)
|
||||
self.addCleanup(conn.set_trace_callback, None)
|
||||
with db.recorded_history_snapshot():
|
||||
result = mod.read_history_gallery(conn, (locator,))
|
||||
conn.set_trace_callback(None)
|
||||
library = result[locator]["library"]
|
||||
self.assertEqual(library["category"], "Positive cat")
|
||||
self.assertEqual(library["category_scope"], "positive")
|
||||
tag_queries = [
|
||||
statement
|
||||
for statement in statements
|
||||
if "FROM prompt_tags pt" in statement
|
||||
]
|
||||
self.assertEqual(len(tag_queries), positive_tag_queries, statements)
|
||||
|
||||
def test_host_root_failure_and_removal_do_not_search_another_root(self):
|
||||
make_png(self.output / "image.png")
|
||||
make_png(self.temp / "image.png")
|
||||
|
||||
@@ -36,7 +36,8 @@ LIBRARY = {
|
||||
"title": None,
|
||||
"category": None,
|
||||
"tags": [],
|
||||
"metadata_scope": None,
|
||||
"category_scope": None,
|
||||
"tags_scope": None,
|
||||
}
|
||||
NODE_TIMEOUT_SECONDS = 120
|
||||
|
||||
|
||||
@@ -77,7 +77,8 @@ def encode(read, *, prompts=None, file=None, library=None):
|
||||
"title": None,
|
||||
"category": None,
|
||||
"tags": [],
|
||||
"metadata_scope": None,
|
||||
"category_scope": None,
|
||||
"tags_scope": None,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -663,6 +664,21 @@ class TestInspectionResponse(unittest.TestCase):
|
||||
for key in ("positive_id", "pair_id", "category", "title"):
|
||||
self.assertNotIn(key, result["library"])
|
||||
self.assertEqual(result["library"]["tags"], ["valid"])
|
||||
# An unknown or non-string scope is dropped, never passed through.
|
||||
for bad in ("recipe", 3, ["pair"]):
|
||||
result = json.loads(
|
||||
encode(
|
||||
None,
|
||||
library={
|
||||
"status": "linked",
|
||||
"category_scope": bad,
|
||||
"tags_scope": bad,
|
||||
"tags": [],
|
||||
},
|
||||
)
|
||||
)
|
||||
self.assertNotIn("category_scope", result["library"])
|
||||
self.assertNotIn("tags_scope", result["library"])
|
||||
result = json.loads(
|
||||
encode(inspection({}), library={"status": "linked", "tags": ["猫" * 12000]})
|
||||
)
|
||||
|
||||
@@ -289,6 +289,39 @@ def _file_projection(file: dict, inspection: "InspectionRead | None") -> dict:
|
||||
return result
|
||||
|
||||
|
||||
def _blank(value: str | bytes | None) -> bool:
|
||||
if isinstance(value, bytes):
|
||||
value = value.decode("utf-8", "replace")
|
||||
return value is None or not value.strip()
|
||||
|
||||
|
||||
def library_metadata(
|
||||
positive_category: str | bytes | None,
|
||||
positive_tags: list,
|
||||
recipe: tuple | None = None,
|
||||
) -> dict:
|
||||
"""The panel's saved category and tags, each with the scope it came from.
|
||||
|
||||
D39 (maintainer ruling, 2026-10-21): a recipe-linked image shows the recipe's
|
||||
own category and tags when set; otherwise each field separately falls back to
|
||||
the linked positive prompt's, the values Set category writes and the Images
|
||||
filters match. Tag sets are never merged. ``recipe`` is (category, tags).
|
||||
"""
|
||||
category, category_scope = positive_category, "positive"
|
||||
tags, tags_scope = positive_tags, "positive"
|
||||
if recipe is not None:
|
||||
if not _blank(recipe[0]):
|
||||
category, category_scope = recipe[0], "pair"
|
||||
if recipe[1]:
|
||||
tags, tags_scope = recipe[1], "pair"
|
||||
return {
|
||||
"category": category,
|
||||
"category_scope": category_scope,
|
||||
"tags": tags,
|
||||
"tags_scope": tags_scope,
|
||||
}
|
||||
|
||||
|
||||
def _library_projection(library: dict) -> dict:
|
||||
status = library.get("status")
|
||||
result = {
|
||||
@@ -307,9 +340,10 @@ def _library_projection(library: dict) -> dict:
|
||||
value = library.get(key)
|
||||
if value is None or isinstance(value, str):
|
||||
result[key] = value
|
||||
scope = library.get("metadata_scope")
|
||||
if scope in ("pair", "positive", None):
|
||||
result["metadata_scope"] = scope
|
||||
for key in ("category_scope", "tags_scope"):
|
||||
scope = library.get(key)
|
||||
if scope in ("pair", "positive", None):
|
||||
result[key] = scope
|
||||
tags = library.get("tags")
|
||||
result["tags"] = (
|
||||
[tag for tag in tags if isinstance(tag, str)] if isinstance(tags, list) else []
|
||||
@@ -323,7 +357,8 @@ def _library_projection(library: dict) -> dict:
|
||||
"title": None,
|
||||
"category": None,
|
||||
"tags": [],
|
||||
"metadata_scope": None,
|
||||
"category_scope": None,
|
||||
"tags_scope": None,
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
@@ -206,15 +206,16 @@ export function mountFileInspector(container, {api, sender, parentScope, variant
|
||||
inner.append(heading, el('p', filename, 'small'));
|
||||
const badges = el('div', undefined, 'tags'), badge = evidenceBadge(record.prompts);
|
||||
if (badge) badges.append(el('span', badge, 'chip blue'));
|
||||
if (linked && library.category) badges.append(prefixedChip(library.metadata_scope === 'pair' ? 'Recipe category: ' : 'Positive prompt category: ', library.category));
|
||||
if (linked && library.category) badges.append(prefixedChip(library.category_scope === 'pair' ? 'Recipe category: ' : 'Positive prompt category: ', library.category));
|
||||
if (badges.childNodes.length) inner.append(badges);
|
||||
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.
|
||||
const filterable = onTagFilter && library.metadata_scope === 'positive';
|
||||
// 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';
|
||||
const tags = el('div', undefined, 'tags');
|
||||
for (const tag of library.tags) {
|
||||
if (!filterable) {tags.append(el('span', tag, 'chip')); continue;}
|
||||
|
||||
@@ -153,8 +153,10 @@ function resolutionFor(value) {
|
||||
|
||||
function libraryFor(value) {
|
||||
if (!object(value) || !['linked', 'none', 'ambiguous', 'unavailable', 'limited'].includes(value.status) || value.scope !== 'saved-library' ||
|
||||
!Array.isArray(value.tags) || value.tags.some(tag => typeof tag !== 'string') || ![null, 'pair', 'positive'].includes(value.metadata_scope)) throw new TypeError('Invalid inspection library');
|
||||
const library = { status: value.status, scope: value.scope, tags: [...value.tags], metadata_scope: value.metadata_scope };
|
||||
!Array.isArray(value.tags) || value.tags.some(tag => typeof tag !== 'string') ||
|
||||
![value.category_scope, value.tags_scope].every(scope => [null, 'pair', 'positive'].includes(scope))) throw new TypeError('Invalid inspection library');
|
||||
// D39: each field names its own source, so a recipe's category can sit beside its positive's tags.
|
||||
const library = { status: value.status, scope: value.scope, tags: [...value.tags], category_scope: value.category_scope, tags_scope: value.tags_scope };
|
||||
for (const key of ['positive_id', 'pair_id', 'title', 'category']) {
|
||||
const item = value[key] ?? null;
|
||||
if (item !== null && (key.endsWith('_id') ? !Number.isSafeInteger(item) || item <= 0 : typeof item !== 'string')) throw new TypeError('Invalid inspection library field');
|
||||
|
||||
Reference in New Issue
Block a user