diff --git a/py/api/history.py b/py/api/history.py index f50960fb..93d08a1d 100644 --- a/py/api/history.py +++ b/py/api/history.py @@ -5,6 +5,7 @@ import base64 import binascii from functools import wraps import json +import os import re import sqlite3 from typing import Callable @@ -27,6 +28,7 @@ from .history_current import ( snapshot_current_output_scope, ) from .inspection_service import CurrentFileChanged, inspect_current_file +from .images import image_category_key try: from ...database.execution_history import HistoryEpochChanged @@ -38,7 +40,11 @@ try: WindowPosition, ) from ...utils.history_read_fence import HistoryReadFence - from ...utils.inspection_response import encode_history_inspection_response + from ...utils.inspection_response import ( + OWN_CATEGORY_UNREAD, + apply_own_category, + encode_history_inspection_response, + ) except ImportError: from database.execution_history import HistoryEpochChanged from database.history_gallery import read_history_gallery @@ -49,7 +55,11 @@ except ImportError: WindowPosition, ) from utils.history_read_fence import HistoryReadFence - from utils.inspection_response import encode_history_inspection_response + from utils.inspection_response import ( + OWN_CATEGORY_UNREAD, + apply_own_category, + encode_history_inspection_response, + ) _RUN_MAX_BYTES = 8 * 1024 * 1024 @@ -675,6 +685,32 @@ class HistoryRoutesMixin: ) -> web.Response: return await self._history_current(request, "inspection") + def _history_own_category(self, reference: dict, scope): + """The recorded output's own category (#332), inside the snapshot. + + The path is the symlink-resolved output folder plus the receipt's + subfolder and filename, keyed by ``image_category_key`` over the + configured output sources as Images keys it. A temp output or a path + no source holds has none. A failed read is OWN_CATEGORY_UNREAD. + """ + if ( + reference.get("folder_type") != "output" + or scope.canonical_output is None + or candidate_locator(reference, scope) is None + ): + return None + path = os.path.join( + str(scope.canonical_output), reference["subfolder"], reference["filename"] + ) + try: + key = image_category_key(self._get_all_output_dirs(), path) + return None if key is None else self.db.image_category(*key) + except Exception: + self.logger.warning( + "History image category could not be read", exc_info=True + ) + return OWN_CATEGORY_UNREAD + async def _history_current( self, request: web.Request, kind: str ) -> web.StreamResponse: @@ -699,6 +735,7 @@ class HistoryRoutesMixin: if scope.current_scope != current_scope: raise _current_stale() result = db.get_history_occurrence(run_id, node, ordinal) + own = None if result is not None: receipt = result["receipt"] path = candidate_locator(receipt["reference"], scope) @@ -706,6 +743,10 @@ class HistoryRoutesMixin: db.model.get_connection(), (path,) if path is not None else (), ) + if kind == "inspection": + own = self._history_own_category( + receipt["reference"], scope + ) finally: db.model.close() if result is None: @@ -724,9 +765,16 @@ class HistoryRoutesMixin: resolved.canonical_target, source_label=resolved.public["source_label"], saved=saved.get("saved"), - library=saved.get( - "library", - {"status": "unavailable" if saved_unavailable else "none"}, + library=apply_own_category( + saved.get( + "library", + { + "status": ( + "unavailable" if saved_unavailable else "none" + ) + }, + ), + own, ), ) _reauthorize_current_target(resolved, scope) diff --git a/py/api/images.py b/py/api/images.py index edf54c3b..72b6f726 100644 --- a/py/api/images.py +++ b/py/api/images.py @@ -65,6 +65,8 @@ try: from ...utils.parallel import map_parallel from ...utils.json_strict import dumps_strict from ...utils.inspection_response import ( + OWN_CATEGORY_UNREAD, + apply_own_category, encode_inspection_response, library_metadata, ) @@ -85,6 +87,8 @@ except ImportError: from utils.parallel import map_parallel from utils.json_strict import dumps_strict from utils.inspection_response import ( + OWN_CATEGORY_UNREAD, + apply_own_category, encode_inspection_response, library_metadata, ) @@ -3749,13 +3753,20 @@ class ImageRoutesMixin: except Exception: # The link stays known; only its title is withheld. self.logger.warning("Inspection link title could not be read") + # An output file's own category (#332); LoRA roots have none. + own = ( + self._read_own_category(outputs, path) + if index < len(outputs) + else None + ) try: observed = inspect_current_file( path, source_label=_public_path(roots[index]), saved=saved, - library=self._inspection_library( - saved, unavailable=unavailable + library=apply_own_category( + self._inspection_library(saved, unavailable=unavailable), + own, ), image_tags=self._inspection_image_tags( roots[index], Path(relative).parts @@ -3822,6 +3833,19 @@ class ImageRoutesMixin: return read + def _read_own_category(self, sources, path): + """One file's own category, None, or OWN_CATEGORY_UNREAD (blocking). + + Keyed by ``image_category_key`` over the output sources, as Images + writes it. A failed read is logged and hides the category (DC5). + """ + try: + key = image_category_key(sources, path) + return None if key is None else self.db.image_category(*key) + except Exception: + self.logger.warning("Image category could not be read", exc_info=True) + return OWN_CATEGORY_UNREAD + def _inspection_library(self, saved: dict | None, *, unavailable: bool) -> dict: """The inspector's Library facts; the route sets the title afterwards.""" result = { diff --git a/tests/js/workspace-file-inspector.test.js b/tests/js/workspace-file-inspector.test.js index 6371f59c..9966c517 100644 --- a/tests/js/workspace-file-inspector.test.js +++ b/tests/js/workspace-file-inspector.test.js @@ -363,3 +363,32 @@ test('the D117 wrap rule targets only the settings summary button', async () => assert.deepEqual(rules.map(line => line.trim()), ['.ca-tools #viewer-details .settings-summary { white-space: normal; /* D117 */ }']); assert.doesNotMatch(css, /#viewer-details > div > button/); }); + +test('category chip names its origin and shows an own category for any link status (#332)', async () => { + const base = await localRecord(localPng('a.png')); + const chipTexts = record => { + const {f, done} = renderFull(record); + try { + return f.walk(f.container, n => /(^| )chip( |$)/.test(n.className ?? '')).map(n => n.textContent); + } finally { done(); } + }; + const cases = [ + [{status:'none', category:'Scans', category_scope:'image'}, 'Image category: Scans'], + [{status:'ambiguous', category:'Scans', category_scope:'image'}, 'Image category: Scans'], + [{status:'linked', category:'Portraits', category_scope:'image'}, 'Image category: Portraits'], + [{status:'linked', category:'Landscapes', category_scope:'positive'}, 'Positive prompt category: Landscapes'], + [{status:'linked', category:'Recipes', category_scope:'pair'}, 'Recipe category: Recipes'], + ]; + for (const [library, text] of cases) { + const chips = chipTexts({...base, library:{...base.library, tags:[], tags_scope:null, ...library}}); + assert.ok(chips.includes(text), `${JSON.stringify(library)} -> ${chips}`); + } + for (const library of [ + {status:'none', category:'Inherited', category_scope:'positive'}, + {status:'linked', category:null, category_scope:null}, + {status:'none', category:null, category_scope:null}, + ]) { + const chips = chipTexts({...base, library:{...base.library, tags:[], tags_scope:null, ...library}}); + assert.equal(chips.some(chip => /category: /.test(chip)), false, JSON.stringify(library)); + } +}); diff --git a/tests/js/workspace-inspection-state.test.js b/tests/js/workspace-inspection-state.test.js index 5bdd7d89..520dcbe4 100644 --- a/tests/js/workspace-inspection-state.test.js +++ b/tests/js/workspace-inspection-state.test.js @@ -278,6 +278,7 @@ test('record rejects root path media and invalid versioned fields with retryable data => { data.resolution.sampler_id = {}; }, data => { data.library.tags_scope = 'recipe'; }, data => { data.library.category_scope = 'recipe'; }, + data => { data.library.tags_scope = 'image'; }, data => { delete data.library.category_scope; }, data => { delete data.library.tags_scope; }, ]) { @@ -942,3 +943,14 @@ test("names the server quotes (parentheses, '!*') still match their original and assert.equal(record.download.url, entry.url); assert.deepEqual(record.preview, { url: entry.thumbnail_url }); }); + +test('record accepts an image\'s own category on any link status (#332)', () => { + const toRecord = contract('toInspectionRecord'); + for (const status of ['linked', 'none', 'ambiguous', 'unavailable']) { + const data = envelope(); + Object.assign(data.library, { status, category: 'Scans', category_scope: 'image' }); + const record = toRecord(data, image(), extractGenerationFacts); + assert.equal(record.library.category, 'Scans'); + assert.equal(record.library.category_scope, 'image'); + } +}); diff --git a/tests/test_inspect_image_categories.py b/tests/test_inspect_image_categories.py new file mode 100644 index 00000000..37e2d81a --- /dev/null +++ b/tests/test_inspect_image_categories.py @@ -0,0 +1,277 @@ +"""The inspector and History show an image's own category (#332 §5, DC4, DC5).""" + +import os +import sqlite3 +import unittest +from unittest.mock import patch + +from tests import test_api_history_outputs as history +from tests.test_api_images import make_png +from py.api.images import _favorite_root_key, image_category_key +from utils.inspection_response import ( + OWN_CATEGORY_UNREAD, + _library_projection, + apply_own_category, +) + + +class InspectCase(history.ImageAPITestCase): + async def tearDownAsync(self): + self.api.db.close_all() + await super().tearDownAsync() + + def own(self, path, value, sources=None): + sources = sources or self.api._get_all_output_dirs() + self.api.db.set_image_categories([image_category_key(sources, path)], value) + + async def library(self, path, root="0"): + response = await self.client.get( + f"/prompt_manager/images/inspect/{path}", params={"root": root} + ) + body = await response.json() + self.assertEqual(response.status, 200, body) + return body["library"] + + +class TestInspectorOwnCategory(InspectCase): + async def test_inspect_reports_image_scope(self): + path = make_png(self.output_dir / "image.png") + self.own(path, "Portraits") + library = await self.library("image.png") + self.assertEqual(library["status"], "none") + self.assertEqual( + (library["category"], library["category_scope"]), ("Portraits", "image") + ) + + async def test_own_overrides_linked_recipe_and_ambiguous(self): + db = self.api.db + linked = make_png(self.output_dir / "linked.png") + self._link_image(linked, "castle at dusk") + prompt = db.get_prompt_id_for_image(str(linked)) + with db.model.get_connection() as conn: + conn.execute( + "UPDATE prompts SET category='Landscapes' WHERE id=?", (prompt,) + ) + library = await self.library("linked.png") + self.assertEqual( + (library["category"], library["category_scope"]), + ("Landscapes", "positive"), + ) + self.own(linked, "Portraits") + library = await self.library("linked.png") + self.assertEqual(library["status"], "linked") + self.assertEqual( + (library["category"], library["category_scope"]), ("Portraits", "image") + ) + recipe = make_png(self.output_dir / "recipe.png") + self._save_prompt("Pair positive", category="Night") + pair = db.save_prompt_pair("Pair positive", "Noise", category="Recipes") + positive = db.get_prompt_pair(pair)["positive_prompt_id"] + image = db.link_image_to_prompt(positive, str(recipe)) + db.link_image_to_pair(pair, image, {"source": "fixture"}) + library = await self.library("recipe.png") + self.assertEqual( + (library["category"], library["category_scope"]), ("Recipes", "pair") + ) + self.own(recipe, "Mine") + library = await self.library("recipe.png") + self.assertEqual( + (library["category"], library["category_scope"]), ("Mine", "image") + ) + ambiguous = make_png(self.output_dir / "ambiguous.png") + self._link_image(ambiguous, "owner one") + self._link_image(ambiguous, "owner two") + self.own(ambiguous, "Scans") + library = await self.library("ambiguous.png") + self.assertEqual(library["status"], "ambiguous") + self.assertEqual( + (library["category"], library["category_scope"]), ("Scans", "image") + ) + + async def test_a_failed_read_hides_the_category(self): + linked = make_png(self.output_dir / "linked.png") + self._link_image(linked, "castle at dusk") + + def broken(*_args, **_kwargs): + raise sqlite3.OperationalError("secret") + + with patch.object(self.api.db, "image_category", side_effect=broken): + with self.assertLogs("prompt_manager", level="WARNING"): + library = await self.library("linked.png") + self.assertEqual(library["status"], "linked") + self.assertIsNone(library["category"]) + self.assertIsNone(library["category_scope"]) + + async def test_a_lora_root_reads_no_own_category(self): + lora = self.comfy_dir / "loras" + make_png(lora / "preview.png") + roots = [self.output_dir, lora] + reads = [] + real = self.api.db.image_category + + def recorded(*args): + reads.append(args) + return real(*args) + + with ( + patch.object(self.api, "_media_roots", lambda outputs: roots), + patch.object(self.api, "_linked_only_error", self._no_error), + patch.object(self.api.db, "image_category", side_effect=recorded), + ): + response = await self.client.get( + "/prompt_manager/images/inspect/preview.png", params={"root": "1"} + ) + body = await response.json() + self.assertEqual(response.status, 200, body) + self.assertEqual(reads, []) + self.assertIsNone(body["library"]["category"]) + + @staticmethod + async def _no_error(*_args): + return None + + +class TestProjection(unittest.TestCase): + def test_scope_image_is_kept_for_category_only(self): + base = {"status": "none", "scope": "saved-library", "tags": []} + projected = _library_projection( + {**base, "category": "Scans", "category_scope": "image"} + ) + self.assertEqual(projected["category_scope"], "image") + projected = _library_projection({**base, "tags_scope": "image"}) + self.assertNotIn("tags_scope", projected) + + def test_apply_own_category_three_cases(self): + library = {"status": "linked", "category": "L", "category_scope": "positive"} + self.assertEqual( + apply_own_category(library, "Mine"), + {"status": "linked", "category": "Mine", "category_scope": "image"}, + ) + self.assertEqual(apply_own_category(library, None), library) + self.assertEqual( + apply_own_category(library, OWN_CATEGORY_UNREAD), + {"status": "linked", "category": None, "category_scope": None}, + ) + self.assertEqual(library["category"], "L") # never mutated + + +class TestHistoryOwnCategory(history.ImageAPITestCase): + """Only the inspection request reads the own category, keyed as Images.""" + + # The History fixture's helpers, without inheriting its tests. + _base = history.HistoryOutputAPITests + ref = _base.ref + capture = _base.capture + outputs = _base.outputs + current = _base.current + media = _base.media + + async def get_application(self): + app = await super().get_application() + self.temp_dir = self.comfy_dir / "temp" + self.temp_dir.mkdir() + self.host_output = str(self.output_dir) + self.host_temp = str(self.temp_dir) + self.patches = history.ExitStack() + self.addCleanup(self.patches.close) + for name, value in ( + ("folder_paths.get_output_directory", lambda: self.host_output), + ("folder_paths.get_temp_directory", lambda: self.host_temp), + ): + self.patches.enter_context(patch(name, value, create=True)) + self.patches.enter_context( + patch.object( + history.GalleryConfig, + "allowed_gallery_parents", + lambda: [str(self.comfy_dir)], + ) + ) + return app + + async def tearDownAsync(self): + self.api.close_history_reads() + self.patches.close() + await super().tearDownAsync() + + async def inspect(self, start): + descriptor = await self.media(start) + response = await self.current(descriptor["inspection_url"]) + body = await response.json() + self.assertEqual(response.status, 200, body) + return body["library"] + + async def test_history_inspection_shows_own_category(self): + path = make_png(self.output_dir / "image.png") + self.api.db.set_image_categories( + [image_category_key([self.output_dir], path)], "Portraits" + ) + start = self.capture([self.ref()]) + library = await self.inspect(start) + self.assertEqual( + (library["category"], library["category_scope"]), ("Portraits", "image") + ) + + async def test_nested_sources_give_the_images_key(self): + inner = self.output_dir / "nested" + path = make_png(inner / "image.png") + self.api._get_all_output_dirs = lambda: [self.output_dir, inner] + self.api.db.set_image_categories( + [(_favorite_root_key(inner), "image.png")], "S" + ) + start = self.capture([self.ref(subfolder="nested")]) + library = await self.inspect(start) + self.assertEqual(library["category"], "S") + self.assertEqual( + image_category_key([self.output_dir, inner], path), + (_favorite_root_key(inner), "image.png"), + ) + + async def test_a_symlinked_output_folder_still_shows_it(self): + real = self.comfy_dir / "real-output" + real.mkdir() + link = self.comfy_dir / "linked-output" + self._make_symlink(real, link, target_is_directory=True) + make_png(real / "image.png") + self.host_output = str(link) + self.api._get_all_output_dirs = lambda: [real] + self.api.db.set_image_categories([(_favorite_root_key(real), "image.png")], "S") + start = self.capture([self.ref()]) + library = await self.inspect(start) + self.assertEqual(library["category"], "S") + + async def test_outputs_and_media_read_no_own_category(self): + make_png(self.output_dir / "image.png") + start = self.capture([self.ref()]) + reads = [] + real = self.api.db.image_category + + def recorded(*args): + reads.append(args) + return real(*args) + + with patch.object(self.api.db, "image_category", side_effect=recorded): + descriptor = await self.media(start) + for field in ("url", "thumbnail_url"): + response = await self.current(descriptor[field]) + await response.read() + self.assertEqual(response.status, 200) + self.assertEqual(reads, []) + await self.current(descriptor["inspection_url"]) + self.assertEqual(len(reads), 1) + + async def test_a_temp_output_has_none(self): + reference = {"folder_type": "temp", "subfolder": "", "filename": "a.png"} + self.assertIsNone(self.api._history_own_category(reference, None)) + + async def test_a_failed_read_hides_it(self): + make_png(self.output_dir / "image.png") + start = self.capture([self.ref()]) + + def broken(*_args): + raise sqlite3.OperationalError("secret") + + with patch.object(self.api.db, "image_category", side_effect=broken): + with self.assertLogs("prompt_manager", level="WARNING"): + library = await self.inspect(start) + self.assertIsNone(library["category"]) + self.assertTrue(os.path.exists(self.output_dir / "image.png")) diff --git a/utils/inspection_response.py b/utils/inspection_response.py index cfab5745..ba6a43f0 100644 --- a/utils/inspection_response.py +++ b/utils/inspection_response.py @@ -317,6 +317,32 @@ def inherited_category( return positive_category, "positive" +class _OwnCategoryUnread: + """The own-category read failed: hide the category (#332 DC5).""" + + def __repr__(self) -> str: + return "OWN_CATEGORY_UNREAD" + + +OWN_CATEGORY_UNREAD = _OwnCategoryUnread() + + +def apply_own_category(library: dict, own) -> dict: + """*library* with an image's own category applied (#332 §5). + + ``own`` is the own category (a string, shown with scope ``"image"`` + whatever the link status), None (the inherited values stay) or + OWN_CATEGORY_UNREAD (category and scope hidden rather than possibly + wrong). Returns a new dict. + """ + result = dict(library) + if own is OWN_CATEGORY_UNREAD: + result.update(category=None, category_scope=None) + elif isinstance(own, str): + result.update(category=own, category_scope="image") + return result + + def library_metadata( positive_category: str | bytes | None, positive_tags: list, @@ -363,9 +389,13 @@ def _library_projection(library: dict) -> dict: value = library.get(key) if value is None or isinstance(value, str): result[key] = value - for key in ("category_scope", "tags_scope"): + # An image's own category (#332) has the scope "image"; tags never do. + for key, scopes in ( + ("category_scope", ("pair", "positive", "image", None)), + ("tags_scope", ("pair", "positive", None)), + ): scope = library.get(key) - if scope in ("pair", "positive", None): + if scope in scopes: result[key] = scope tags = library.get("tags") result["tags"] = ( diff --git a/web/js/workspace/file-inspector.mjs b/web/js/workspace/file-inspector.mjs index 9a868a8f..d78ec554 100644 --- a/web/js/workspace/file-inspector.mjs +++ b/web/js/workspace/file-inspector.mjs @@ -20,6 +20,8 @@ const REPRESENTATIONS = { limited: 'Record exceeds inspection limits; text is unavailable', }; const TEXTLESS = new Set(['invalid', 'limited']); +// #332: the hidden prefix that names where a category chip's value came from. +const CATEGORY_PREFIX = {image: 'Image category: ', positive: 'Positive prompt category: ', pair: 'Recipe category: '}; const REVEAL_URL = '/prompt_manager/images/reveal'; // D38 Show in folder: the route's answers, in the maintainer's words. const REVEAL_OPENED = 'Opened the folder on this computer.'; @@ -263,7 +265,10 @@ 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.category_scope === 'pair' ? 'Recipe category: ' : 'Positive prompt category: ', library.category)); + // #332: an image's own category shows for any link status; an inherited one only when linked. + if (library?.category && (library.category_scope === 'image' || linked)) { + badges.append(prefixedChip(CATEGORY_PREFIX[library.category_scope] ?? CATEGORY_PREFIX.positive, 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}); diff --git a/web/js/workspace/inspection-state.mjs b/web/js/workspace/inspection-state.mjs index 7aa4e61e..44639bce 100644 --- a/web/js/workspace/inspection-state.mjs +++ b/web/js/workspace/inspection-state.mjs @@ -201,7 +201,9 @@ 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') || - ![value.category_scope, value.tags_scope].every(scope => [null, 'pair', 'positive'].includes(scope))) throw new TypeError('Invalid inspection library'); + ![null, 'pair', 'positive', 'image'].includes(value.category_scope) || + ![null, 'pair', 'positive'].includes(value.tags_scope)) throw new TypeError('Invalid inspection library'); + // #332: an image's own category (scope "image") may sit on any link status; tags never have that scope. // 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']) {