diff --git a/.github/workflows/viewerjs-check.yml b/.github/workflows/viewerjs-check.yml index f6f05f3..6f0118d 100644 --- a/.github/workflows/viewerjs-check.yml +++ b/.github/workflows/viewerjs-check.yml @@ -13,7 +13,7 @@ permissions: jobs: viewerjs: runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 25 steps: - uses: actions/checkout@v7 - uses: actions/setup-python@v7 diff --git a/TESTING.md b/TESTING.md index 67c1ca2..0e356ad 100644 --- a/TESTING.md +++ b/TESTING.md @@ -145,6 +145,32 @@ cover cancellation, malformed responses and bounded visibility lookup. These run automatically in the existing four-browser-project workflow and frontend coverage job. `tests/comfyui/workspace.spec.mjs` verifies the page against the managed host with both renderer startup configurations. +The shared inline and collection inspector reads the current rooted file's supported +embedded records, exact generation facts and recorded or inferred prompt evidence. +`tests/browser/full-inspector.spec.mjs` covers original/metadata/workflow downloads, +response limits, source changes, nested sending, matched dark/light and mobile +geometry, and the loaded-collection viewer. The viewer never fetches another page +or preloads all originals. Its explicit slideshow waits 3.5 seconds after image +readiness, pauses at native video without advancing on `ended`, and resumes on a +manually selected image only while prior intent remains. Pause/Stop, inspector +reading/copy/download/send, hidden document, media failure, close, query/route +disposal and pagehide cancel that intent; returning visibility or closing the sender +does not resume it. Stop leaves native video playback alone. Video uses only the +existing normalized `gallerySettings` autoplay/mute/loop values, read without writes; +denied/malformed storage falls back to autoplay off, mute on and loop on. Rejected +autoplay keeps native controls usable. + +The inspector does not establish historical file identity, infer run membership, +edit image tags, export all EXIF, or load/execute a workflow. Saved vocabulary is +labelled by its recipe or positive-prompt scope. Limited raw evidence is omitted +atomically, with JSON exports unavailable and the rooted original still downloadable. +The inspection API uses one bounded read (2 MiB aggregate metadata, 8 MiB non-PNG +header, 10,000 PNG chunks, three-second ffprobe, 500 graph nodes/semantic expansions, +64 link/JSON-container levels and an 8 MiB serialized response); retry does not raise +these limits. Installing the Python inspection/source-scope changes requires a +maintainer ComfyUI restart. Automated tests use disposable hosts and never restart +the running personal ComfyUI. + Run the focused page checks with: ```sh diff --git a/database/operations.py b/database/operations.py index 3df8f5f..55c2614 100644 --- a/database/operations.py +++ b/database/operations.py @@ -2014,12 +2014,18 @@ class PromptDatabase( return count def get_image_prompt_info( - self, image_path: str, allow_filename_fallback: bool = True + self, + image_path: str, + allow_filename_fallback: bool = True, + *, + report_ambiguity: bool = False, ) -> Optional[Dict[str, Any]]: """Look up prompt + image metadata for a given image path. Returns a dict with prompt_id, text, category, tags, rating, notes, workflow_data, prompt_metadata, generation_time, parameters — or None. + Opting into report_ambiguity returns only an association_status marker + for multiple matches; default callers still receive None. """ with self.model.get_connection() as conn: cursor = conn.execute( @@ -2045,6 +2051,8 @@ class PromptDatabase( ] matches = exact or rows if len(matches) != 1: + if report_ambiguity and matches: + return {"association_status": "ambiguous"} return None row = matches[0] associations = self.get_image_prompt_pairs(row[9]) diff --git a/docs/application-redesign.md b/docs/application-redesign.md index 0d920b3..a18b637 100644 --- a/docs/application-redesign.md +++ b/docs/application-redesign.md @@ -11,7 +11,7 @@ code. This plan proposes implementation order and acceptance gates. - Shared role-aware sending and the physical-file prompt inspector merged in [#203](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/203) and [#206](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/206). - The native Prompt Library merged in [#210](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/210) for [#208](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/208): one positive parent, saved negative variants, full selected text and a shared filtered image gallery. It does not invent chronological runs from counters or scan dates. - [Durable history storage](prompt-history-storage.md) records job attempts and outcomes from [#212](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/212), frozen prompt contexts from [#213](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/213), and observed run-owned output locators from [#214](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/214). Those locators do not verify file bytes or establish gallery-image associations. That association contract and the chronological execution timeline remain pending under [#209](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/209). -- [#215](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/215) brings the shell, Images and natural-height Prompt Library presentation into the approved geometry and themes while retaining their existing operations. Full inspector/viewer parity remains pending under [#216](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/216); this presentation slice does not establish whole-application parity. Tagging/review, System consolidation and the remaining organization capabilities continue under #196. +- [#215](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/215) brings the shell, Images and natural-height Prompt Library presentation into the approved geometry and themes while retaining their existing operations. [#216](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/216) adds the shared current-file inspector with exact generation facts, scoped provenance, raw/download evidence and loaded-collection viewer controls, including a slideshow that pauses at native video. This delivery does not establish whole-application parity, change the default entry point or constitute a release. Recorded chronological run/context browsing continues under [#209](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/209); image tagging/review, System consolidation and remaining organization capabilities continue under #196. ## Scope and compatibility diff --git a/py/api/images.py b/py/api/images.py index 8f42289..b920ec3 100644 --- a/py/api/images.py +++ b/py/api/images.py @@ -11,6 +11,7 @@ import re import threading import time as _time import urllib.parse +from collections.abc import Mapping from pathlib import Path from aiohttp import web @@ -22,17 +23,19 @@ from .prompts import safe_error_message as _safe_error try: from ...utils.parallel import map_parallel + from ...utils.inspection_response import encode_inspection_response from ...utils.prompt_inspector import ( MetadataReadError, - inspect_prompt_roles, - read_inspection_metadata, + inspect_prompt_evidence, + read_inspection_file, ) except ImportError: from utils.parallel import map_parallel + from utils.inspection_response import encode_inspection_response from utils.prompt_inspector import ( MetadataReadError, - inspect_prompt_roles, - read_inspection_metadata, + inspect_prompt_evidence, + read_inspection_file, ) # Only these file types are ever served by the image routes, regardless of @@ -375,7 +378,9 @@ def _iter_media_files(output_path, max_files): yield root_path / name -def _output_image_entry(media_path, output_path, root_index, public_root=None): +def _output_image_entry( + media_path, output_path, root_index, public_root=None, *, root_scope=None +): """Build one gallery entry for *media_path* (blocking stat). ``path`` is relative to its root (what delete/autotag resolve against) and @@ -396,15 +401,20 @@ def _output_image_entry(media_path, output_path, root_index, public_root=None): if thumbnail_url is not None: thumbnail_url += f"&root={root_index}" encoded_path = urllib.parse.quote(rel_path.as_posix(), safe="/") + url = f"/prompt_manager/images/serve/{encoded_path}?root={root_index}" + if root_scope is not None: + url += f"&root_scope={root_scope}" + if thumbnail_url is not None: + thumbnail_url += f"&root_scope={root_scope}" - return { + entry = { "id": hashlib.sha1(str(media_path).encode("utf-8")).hexdigest()[:16], "filename": media_path.name, "path": rel_path.as_posix(), "relative_path": str(rel_path), "root_dir": public_root, "root_index": root_index, - "url": f"/prompt_manager/images/serve/{encoded_path}?root={root_index}", + "url": url, "thumbnail_url": thumbnail_url, "size": stat.st_size, "modified_time": stat.st_mtime, @@ -412,6 +422,9 @@ def _output_image_entry(media_path, output_path, root_index, public_root=None): "media_type": "video" if is_video else "image", "is_video": is_video, } + if root_scope is not None: + entry["root_scope"] = root_scope + return entry def _in_subfolder(rel_dir, subfolder): @@ -421,6 +434,48 @@ def _in_subfolder(rel_dir, subfolder): class ImageRoutesMixin: """Mixin providing image and gallery-related API endpoints.""" + def _gallery_root_scope(self, root: Path, index: int) -> str: + payload = json.dumps( + ["prompt-manager-gallery-root-v1", index, str(_canonical(root))], + separators=(",", ":"), + ensure_ascii=False, + ).encode("utf-8") + return hmac.new(_WORKSPACE_CURSOR_SECRET, payload, hashlib.sha256).hexdigest() + + def _gallery_scope_error( + self, query: Mapping, roots: list[Path] + ) -> web.Response | None: + if "root_scope" not in query: + return None + scope, root = query.get("root_scope"), query.get("root") + if ( + not isinstance(scope, str) + or re.fullmatch(r"[0-9a-f]{64}", scope) is None + or not isinstance(root, str) + or re.fullmatch(r"[0-9]+", root) is None + ): + return bad_request("Invalid gallery root scope") + try: + index = int(root) + except ValueError: + return bad_request("Invalid gallery root scope") + if index >= len(roots) or not hmac.compare_digest( + scope, self._gallery_root_scope(roots[index], index) + ): + return web.json_response( + { + "success": False, + "code": "source-changed", + "error": ( + "The source changed. Refresh the image collection before " + "inspecting this file." + ), + }, + status=409, + headers={"Cache-Control": "no-store"}, + ) + return None + def _register_image_routes(self, routes): @routes.get("/prompt_manager/prompts/{prompt_id}/images") async def get_prompt_images_route(request): @@ -910,15 +965,17 @@ class ImageRoutesMixin: async def serve_output_image(self, request): """Serve a media file from the ComfyUI output folder(s).""" try: + roots = list(self._get_all_output_dirs()) + scope_error = self._gallery_scope_error(request.query, roots) + if scope_error is not None: + return scope_error filepath = request.match_info["filepath"] if _has_traversal(filepath): return _forbidden() if Path(filepath).suffix.lower() not in SERVABLE_EXTENSIONS: return _forbidden("Only media files can be served") - output_dirs = self._select_root( - list(self._get_all_output_dirs()), request.query.get("root") - ) + output_dirs = self._select_root(roots, request.query.get("root")) if not output_dirs: return _forbidden("No allowed image directories configured") @@ -1645,12 +1702,18 @@ class ImageRoutesMixin: async def inspect_image(self, request): """Read prompt evidence for one explicitly rooted physical media file.""" roots = list(self._get_all_output_dirs()) + scope_error = self._gallery_scope_error(request.query, roots) + if scope_error is not None: + return scope_error try: index = _workspace_root(request.query, roots) if index is None: raise ValueError("Gallery root is required") except ValueError as error: return bad_request(str(error)) + root_scope = request.query.get("root_scope") or self._gallery_root_scope( + roots[index], index + ) relative = request.match_info.get("image_path", "") if ( _has_traversal(relative) @@ -1668,32 +1731,89 @@ class ImageRoutesMixin: ) try: - def inspect() -> dict: + def inspect() -> bytes | None: + before = path.stat() try: - metadata = read_inspection_metadata(path) + inspection = read_inspection_file(path) except MetadataReadError: - return { - role: {"status": "unresolved", "text": None, "source": None} - for role in ("positive", "negative") + inspection = None + try: + after = path.stat() + except FileNotFoundError: + return None + fields = ("st_dev", "st_ino", "st_size", "st_mtime_ns", "st_ctime_ns") + if any(getattr(before, key) != getattr(after, key) for key in fields): + return None + saved, unavailable = None, False + try: + saved = self.db.get_image_prompt_info( + str(path), False, report_ambiguity=True + ) + except Exception: + unavailable = True + self.logger.warning("Saved inspection display could not be read") + if inspection is None: + evidence = { + "prompts": { + role: {"status": "unresolved", "text": None, "source": None} + for role in ("positive", "negative") + }, + "resolution": { + "state": "unresolved", + "sampler_id": None, + "reason": "Media metadata could not be read", + }, } - saved = ( - self.db.get_image_prompt_info(str(path), False) - if not metadata - else None - ) - return inspect_prompt_roles(metadata, saved) - - prompts = await self._run_in_executor(inspect) - return web.json_response( - { - "success": True, - "image": { + else: + role_saved = ( + None + if saved and saved.get("association_status") == "ambiguous" + else saved + ) + evidence = inspect_prompt_evidence(inspection.metadata, role_saved) + return encode_inspection_response( + image={ "root_index": index, "path": Path(relative).as_posix(), "filename": path.name, }, - "prompts": prompts, - } + root_scope=root_scope, + file={ + "observation": "current-file", + "source_label": _public_path(roots[index]), + "media_type": ( + "video" + if path.suffix.lower() in VIDEO_EXTENSIONS + else "image" + ), + "extension": path.suffix.lower(), + "size": before.st_size, + "modified_time": before.st_mtime, + "format": None, + "width": None, + "height": None, + }, + prompts=evidence["prompts"], + resolution=evidence["resolution"], + inspection=inspection, + library=self._inspection_library(saved, unavailable=unavailable), + ) + + body = await self._run_in_executor(inspect) + if body is None: + return web.json_response( + { + "success": False, + "error": "The file changed while being inspected. Try again.", + }, + status=409, + headers={"Cache-Control": "no-store"}, + ) + return web.Response( + body=body, + content_type="application/json", + charset="utf-8", + headers={"Cache-Control": "no-store"}, ) except Exception: self.logger.exception("Prompt inspection failed") @@ -1701,6 +1821,43 @@ class ImageRoutesMixin: {"success": False, "error": "Prompt inspection failed"}, status=500 ) + def _inspection_library(self, saved: dict | None, *, unavailable: bool) -> dict: + result = { + "status": "unavailable" if unavailable else "none", + "scope": "saved-library", + "positive_id": None, + "pair_id": None, + "title": None, + "category": None, + "tags": [], + "metadata_scope": None, + } + if not isinstance(saved, dict) or not saved: + return result + if saved.get("association_status") == "ambiguous": + 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"), + title=pair.get("title"), + 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", + ) + return result + async def get_image_prompt(self, request): """Get prompt information for a specific image path.""" try: @@ -1799,7 +1956,7 @@ class ImageRoutesMixin: ] def _format_workspace_page(self, page: list[tuple]) -> list[dict]: - images = self._format_output_page(page) + images = self._format_output_page(page, scoped=True) paths = { (entry[3], entry[0].relative_to(entry[2]).as_posix()): entry[0] for entry in page @@ -1818,7 +1975,7 @@ class ImageRoutesMixin: continue return images - def _format_output_page(self, page): + def _format_output_page(self, page, *, scoped=False): entries = [] public_roots = {} for media_path, _mtime, output_path, root_index in page: @@ -1827,7 +1984,15 @@ class ImageRoutesMixin: try: entries.append( _output_image_entry( - media_path, output_path, root_index, public_roots[output_path] + media_path, + output_path, + root_index, + public_roots[output_path], + root_scope=( + self._gallery_root_scope(output_path, root_index) + if scoped + else None + ), ) ) except Exception as e: diff --git a/tests/browser/full-inspector.spec.mjs b/tests/browser/full-inspector.spec.mjs new file mode 100644 index 0000000..a8bd232 --- /dev/null +++ b/tests/browser/full-inspector.spec.mjs @@ -0,0 +1,681 @@ +import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs'; +import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs'; + +test.use({locale:'en-US',timezoneId:'UTC',reducedMotion:'reduce'}); +test.beforeEach(async({page,browserName})=>{await startCoverage(page,browserName);page.inspectorErrors=[];page.on('pageerror',error=>page.inspectorErrors.push(error.message));}); +test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.inspectorErrors).toEqual([]);}); + +test('inspection facts survive actual API to browser transport', async ({ page, request }) => { + await expect(await request.post('/__test__/reset', { data: { full_inspector: true } })).toBeOK(); + await page.goto('/prompt_manager/workspace'); + const result = await page.evaluate(async () => { + if (typeof window.ComfyMetadata?.extractGenerationFacts !== 'function') return { missingFactsExport: true }; + const { inspectPath, toInspectionRecord } = await import('/prompt_manager/js/workspace/inspection-state.mjs'); + const { createApiClient } = window.ApiClient; + const api = createApiClient(); + const collection = await api.get('/prompt_manager/images/output?mode=workspace&subfolder=inspector&limit=40'); + const records = {}; + for (const name of ['complete', 'ambiguous', 'workflow-only', 'control-heavy']) { + const image = collection.images.find(entry => entry.root_index === 0 && entry.path === `inspector/${name}.png`); + const envelope = await api.get(inspectPath(image)); + records[name] = toInspectionRecord(envelope, image, window.ComfyMetadata.extractGenerationFacts); + } + return records; + }); + expect(result.missingFactsExport).toBeUndefined(); + const complete = result.complete; + expect(Object.values(complete.generation).map(fact => fact.value)).toEqual(['9007199254740993', '28', '6.5', 'dpmpp_2m', 'karras', 'fixture-model.safetensors']); + expect(Object.values(complete.generation).every(fact => fact.source === 'api-graph' && fact.evidence === 'configuration')).toBe(true); + expect(Object.values(result.ambiguous.generation).every(fact => fact.status === 'ambiguous' && fact.value === null)).toBe(true); + expect(result['workflow-only'].generation.seed).toMatchObject({ status: 'known', value: '9007199254740993', source: 'workflow', evidence: 'configuration' }); + expect(result['workflow-only'].prompts.positive.source).toBe('saved-association'); + expect(Object.values(result['control-heavy'].generation).every(fact => fact.status === 'unresolved' && fact.value === null && fact.reason === 'projection-limited')).toBe(true); + await expect(await request.post('/__test__/reset')).toBeOK(); +}); + +test('inspection wire preserves exact rooted envelope', async ({ request }) => { + for (const data of [ + { full_inspector: 'true' }, { full_inspector: 1 }, + { full_inspector: true, workspace_visual: true }, + { full_inspector: true, prompt_inspector: true }, + { full_inspector: true, native_library: true }, + { full_inspector: true, extra_prompts: 1 }, + { full_inspector: true, extra_variants: 1 }, + { full_inspector: true, extra_images: true }, + { full_inspector: true, include_video: 'true' }, + ]) expect((await request.post('/__test__/reset', { data })).status()).toBe(400); + await expect(await request.post('/__test__/reset', { data: { full_inspector: true, include_video: true, extra_images: 2 } })).toBeOK(); + const collection = await (await request.get('/prompt_manager/images/output?mode=workspace&subfolder=inspector&limit=40')).json(); + expect(collection.total).toBe(11); + const complete = collection.images.find(image => image.root_index === 0 && image.path === 'inspector/complete.png'); + const collision = collection.images.find(image => image.root_index === 1 && image.path === complete.path); + expect(complete.root_scope).toMatch(/^[0-9a-f]{64}$/); + expect(collision.root_scope).not.toBe(complete.root_scope); + const inspect = async entry => { + const response = await request.get(`/prompt_manager/images/inspect/${entry.path.split('/').map(encodeURIComponent).join('/')}?root=${entry.root_index}&root_scope=${entry.root_scope}`); + await expect(response).toBeOK(); + expect(response.headers()['cache-control']).toBe('no-store'); + expect((await response.body()).length).toBeLessThanOrEqual(8 * 1024 * 1024); + return response.json(); + }; + const result = await inspect(complete); + expect(result.inspection_version).toBe(1); + expect(result.image).toEqual({ root_index: 0, path: complete.path, filename: 'complete.png' }); + expect(result.root_scope).toBe(complete.root_scope); + expect(result.prompts.positive.text).toContain('Inspector portrait 猫 😀'); + expect(result.prompts.negative.text).toBe('blurred edges, watermark'); + expect(result.metadata.prompt_graph.value['3'].inputs.seed).toBe('9007199254740993'); + 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: 'Inspector recipe', 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); + const library = await (await request.get('/prompt_manager/library')).json(); + expect(library.items.find(item => item.id === result.library.positive_id).previews.some(entry => entry.root_scope === complete.root_scope)).toBe(true); + const workflow = await inspect(collection.images.find(image => image.path === 'inspector/workflow-only.png')); + expect(workflow.prompts.positive.source).toBe('saved-association'); + const limited = await inspect(collection.images.find(image => image.path === 'inspector/control-heavy.png')); + expect(limited.metadata).toMatchObject({ status: 'limited', omitted_reason: 'response-size-limit', raw: {}, parameters: null }); + expect(collection.images.some(image => image.path.endsWith('clip.webm'))).toBe(true); + await expect(await request.post('/__test__/reset')).toBeOK(); +}); + +const inspector=page=>page.locator('.image-details'); +const selectFile=async(page,name,root=0)=>page.locator(`article[data-image-key="${root}:inspector/${name}"] .image-open`).click(); +async function fullFixture(page,request) { + await expect(await request.post('/__test__/reset',{data:{full_inspector:true}})).toBeOK(); + await page.goto('/prompt_manager/workspace#/images');await selectFile(page,'complete.png'); +} +async function downloadText(page,action) { + const [download]=await Promise.all([page.waitForEvent('download'),action.click()]); + const fs=await import('node:fs/promises');return {name:download.suggestedFilename(),text:await fs.readFile(await download.path(),'utf8')}; +} + +test('full inspector shows six facts and scoped provenance',async({page,request},testInfo)=>{ + await page.setViewportSize({width:1600,height:1100});await fullFixture(page,request);const panel=inspector(page); + await expect(panel.getByRole('heading',{name:'Inspector recipe',exact:true})).toBeVisible();await expect(panel.locator('.file-format')).toHaveText('PNG');await expect(panel.locator('.file-name')).toHaveText('complete.png'); + await expect(panel.locator('.file-evidence')).toHaveText('Workflow prompt evidence'); + expect(await panel.evaluate(node=>node.getBoundingClientRect().width)).toBe(310); + expect(await panel.locator('.file-inspector-inner').evaluate(node=>getComputedStyle(node).padding)).toBe('17px'); + if (!await page.evaluate(()=>matchMedia('(pointer: coarse)').matches)) { + const copy=await panel.getByRole('button',{name:'Copy positive prompt',exact:true}).boundingBox();expect(copy.width).toBe(30);expect(copy.height).toBe(26); + expect((await panel.getByRole('button',{name:'Send prompt pair',exact:true}).boundingBox()).height).toBe(30); + } + await expect(panel.locator('.generation-facts dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']); + await expect(panel.locator('.generation-facts dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']); + await expect(panel).toContainText('Recipe tags');await expect(panel).toContainText('Recipe category'); + await expect(panel.locator('.file-vocabulary button')).toHaveCount(0); + await panel.getByText('File & provenance',{exact:true}).click(); + await expect(panel).toContainText('Current file at this source; historical bytes are not verified.');await expect(panel).toContainText('Saved association; not verified against current file'); + await expect(panel).toContainText('Configuration evidence');await expect(panel.getByRole('link',{name:'Prompt history',exact:true})).toHaveAttribute('href',/^#\/library\/\d+$/); + await expect(panel.getByText('Seed: Configuration evidence · Embedded API configuration',{exact:true})).toBeVisible(); + if (testInfo.project.name === 'chromium') { + await panel.getByText('Seed: Configuration evidence · Embedded API configuration',{exact:true}).scrollIntoViewIfNeeded(); + const path=testInfo.outputPath('configuration-api-source.png');await page.screenshot({path});await testInfo.attach('configuration-api-source',{path,contentType:'image/png'}); + } + await expect(panel).not.toContainText(/Run:|Original file unchanged|Execution recorded/); + await selectFile(page,'empty.png');await expect(panel.locator('.file-format')).toHaveText('PNG');await expect(panel.getByRole('heading',{name:'empty.png',exact:true})).toBeVisible(); + await expect(panel.getByRole('button',{name:'Send negative prompt',exact:true})).toHaveCount(0);await expect(panel).toContainText('Not recorded in this file.');await expect(panel.getByRole('link',{name:'Prompt history',exact:true})).toHaveCount(0); + await selectFile(page,'explicit-empty.png');await expect(panel).toContainText('Empty negative prompt');await expect(panel.getByRole('button',{name:'Send prompt pair',exact:true})).toHaveCount(1); + await selectFile(page,'workflow-only.png');await panel.getByText('File & provenance',{exact:true}).click(); + await expect(panel.getByText('Seed: Configuration evidence · Saved workflow configuration',{exact:true})).toBeVisible(); + if (testInfo.project.name === 'chromium') { + await panel.getByText('Seed: Configuration evidence · Saved workflow configuration',{exact:true}).scrollIntoViewIfNeeded(); + const path=testInfo.outputPath('configuration-workflow-source.png');await page.screenshot({path});await testInfo.attach('configuration-workflow-source',{path,contentType:'image/png'}); + } +}); + +test('full inspector preserves long role text and exact sends',async({page,request,context})=>{ + const host=await context.newPage();await host.goto('/prompt_manager/workspace#/overview'); + await host.evaluate(async()=>{const {createBridgeHost}=await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');const target={id:'pair',label:'Exact pair',type:'PromptManagerPair',roles:['positive','negative']};window.sent=[];window.bridge=createBridgeHost({origin:location.origin,namespace:'/',label:'Exact host',adapter:{listTargets:()=>[target],preview:(_id,roles)=>({preview:{target,fields:Object.entries(roles).map(([role,after])=>({role,before:'',after}))},snapshot:roles}),apply:roles=>{window.sent.push(roles);return{targetId:target.id,values:roles};}}});}); + try { + await fullFixture(page,request);const panel=inspector(page);const positive=panel.locator('.prompt-positive .prompt-text');await expect(positive).toContainText('猫 😀');const text=await positive.textContent();expect(text.length).toBeGreaterThan(1000); + await panel.getByRole('button',{name:'Send prompt pair',exact:true}).click();const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true}); + await expect(sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).locator('option')).toContainText(['Exact host']); + await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'Exact host'});await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'Exact pair'}); + await sender.getByRole('button',{name:'Preview changes',exact:true}).click();await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.'); + expect(await host.evaluate(()=>window.sent)).toEqual([{positive:text,negative:'blurred edges, watermark'}]); + } finally {await host.evaluate(()=>window.bridge.dispose());await host.close();} +}); + +test('full inspector downloads actual bounded evidence',async({page,request})=>{ + await fullFixture(page,request);const panel=inspector(page);await panel.getByText('Workflow & raw metadata',{exact:true}).click(); + const list=await(await request.get('/prompt_manager/images/output?mode=workspace&subfolder=inspector')).json();const image=list.images.find(image=>image.root_index===0&&image.path==='inspector/complete.png'); + const result=await(await request.get(`/prompt_manager/images/inspect/inspector/complete.png?root=0&root_scope=${image.root_scope}`)).json(); + await expect(panel).toContainText('Original embedded text');const metadata=await downloadText(page,panel.getByRole('button',{name:'Download metadata JSON',exact:true}));expect(metadata.name).toBe('complete.metadata.json');expect(JSON.parse(metadata.text).metadata.raw).toEqual(result.metadata.raw);expect(JSON.parse(metadata.text).identity.root_scope).toBe(image.root_scope); + const workflow=await downloadText(page,panel.getByRole('button',{name:'Download workflow JSON',exact:true}));expect(workflow.name).toBe('complete.workflow.json');expect(workflow.text).toBe(result.metadata.raw.workflow); + await panel.getByText('File & provenance',{exact:true}).click();await expect(panel.getByRole('link',{name:'Download original',exact:true})).toHaveAttribute('href',image.url); + await selectFile(page,'malformed.png');await panel.getByText('Workflow & raw metadata',{exact:true}).click();await expect(panel.locator('pre code').filter({hasText:'{broken'})).toHaveText('{broken');await expect(panel.getByRole('button',{name:'Download workflow JSON',exact:true})).toHaveAttribute('aria-disabled','true'); + await selectFile(page,'control-heavy.png');await panel.getByText('Workflow & raw metadata',{exact:true}).click();await expect(panel).toContainText('Raw metadata exceeds the response limit. Download the original file to inspect it externally.'); + let downloads=0;page.on('download',()=>downloads++);for(const name of ['Download metadata JSON','Download workflow JSON']) {const button=panel.getByRole('button',{name,exact:true});await expect(button).toHaveAttribute('aria-disabled','true');await button.focus();await page.keyboard.press('Enter');}expect(downloads).toBe(0); + await panel.getByText('File & provenance',{exact:true}).click();await expect(panel.getByRole('link',{name:'Download original',exact:true})).toHaveAttribute('href',/root=0&root_scope=[a-f0-9]{64}/); +}); + +test('full inspector rejects stale shared action state',async({page,request})=>{ + let reads=0,stale=false;await page.route('**/images/inspect/**',route=>{reads++;return stale?route.fulfill({status:409,json:{success:false,code:'source-changed',error:'Source changed'}}):route.continue();}); + await fullFixture(page,request);const panel=inspector(page);await expect(panel.getByRole('button',{name:'Send prompt pair',exact:true})).toBeVisible();const before=reads; + await panel.getByRole('button',{name:'Open full preview',exact:true}).click();const preview=page.getByRole('dialog',{name:'Image preview',exact:true});await expect(preview.locator('.generation-facts dd').first()).toHaveText('9007199254740993');expect(reads).toBe(before);await preview.getByRole('button',{name:'Close preview',exact:true}).click(); + stale=true;await selectFile(page,'complete.png',1);await expect(panel).toContainText('The source changed. Refresh the image collection before inspecting this file.'); + await expect(panel.getByRole('button',{name:/Retry/})).toHaveCount(0);await expect(panel.locator('[data-copy-role],[data-send-role],a[download]')).toHaveCount(0);await expect(panel.getByRole('button',{name:/Download/})).toHaveCount(0); + stale=false;const refreshed=page.waitForResponse(response=>response.url().includes('/images/output?'));await panel.getByRole('button',{name:'Refresh image collection',exact:true}).click();await refreshed;await selectFile(page,'complete.png',1);await expect(panel).toContainText('Input collision'); + const urls=await page.evaluate(()=>performance.getEntriesByType('resource').map(item=>item.name).filter(name=>name.includes('/images/inspect/')));expect(urls.every(value=>new URL(value).searchParams.has('root_scope'))).toBe(true); +}); + +test('full inspector keeps long literal content reachable on mobile',async({page,request})=>{ + await page.addInitScript(()=>Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async()=>{throw Error('Denied');}}})); + const literal=''+'verylongmodelpath/'.repeat(30); + let external=0;await page.route('https://invalid.test/**',route=>{external++;return route.abort();}); + await page.route('**/images/inspect/**',async route=>{const response=await route.fetch();const data=await response.json();data.metadata.raw.parameters=literal;data.metadata.record_formats.parameters='original-text';data.metadata.parameters=literal;data.metadata.prompt_graph.value['4'].inputs.ckpt_name=literal;await route.fulfill({response,json:data});}); + await page.setViewportSize({width:390,height:844});await fullFixture(page,request);const panel=inspector(page);await expect(panel.locator('.prompt-positive pre')).toContainText('fine geometry and light.');await panel.getByRole('button',{name:'Copy positive prompt',exact:true}).click();await expect(panel).toContainText('Copy unavailable. Select the prompt text to copy it.'); + await panel.getByText('Workflow & raw metadata',{exact:true}).click();await expect(panel.locator('pre code').filter({hasText:literal}).last()).toHaveText(literal);await expect(panel.locator('.generation-facts dd').last()).toHaveText(literal); + for(const zoom of [1,2]) {await page.evaluate(zoom=>{document.documentElement.style.zoom=String(zoom);},zoom);await expect.poll(()=>page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);const footer=panel.getByRole('button',{name:'Send prompt pair',exact:true});await footer.focus();await expect(footer).toBeFocused();const summary=panel.getByText('File & provenance',{exact:true});await summary.focus();await page.keyboard.press('Enter');await expect(panel).toContainText('historical bytes are not verified');} + expect(await page.evaluate(()=>window.executed)).toBeUndefined();expect(external).toBe(0); +}); + +test('embedded gallery uses the same full inspector',async({page,request})=>{ + await expect(await request.post('/__test__/reset',{data:{full_inspector:true}})).toBeOK();const list=await(await request.get('/prompt_manager/library')).json();const id=list.items.find(item=>item.text.startsWith('Inspector portrait')).id; + await page.goto(`/prompt_manager/workspace#/library/${id}`);await selectFile(page,'complete.png');const panel=inspector(page);await expect(panel.getByRole('heading',{name:'Inspector recipe',exact:true})).toBeVisible();await expect(panel.locator('.generation-facts dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);await expect(panel).toContainText('Recipe tags');await panel.getByRole('button',{name:'Open full preview',exact:true}).click();await expect(page.locator('.image-preview .file-inspector')).toHaveCount(1); +}); + +test('full inspector labels container representations and releases action URLs',async({page,request})=>{ + await fullFixture(page,request); + await page.evaluate(async()=>{ + const {mountFileInspector}=await import('/prompt_manager/js/workspace/file-inspector.mjs'); + const {toInspectionRecord,inspectPath}=await import('/prompt_manager/js/workspace/inspection-state.mjs'); + const {createScope}=await import('/prompt_manager/js/shared/toolkit/core.js'); + const api=window.ApiClient.createApiClient();const list=await api.get('/prompt_manager/images/output?mode=workspace&subfolder=inspector');const image=list.images.find(item=>item.root_index===0&&item.path==='inspector/complete.png'); + const record=toInspectionRecord(await api.get(inspectPath(image)),image);record.file.media_type='video';record.metadata.record_formats.workflow='reserialized-json';record.metadata.record_formats.comfyassets_prompt_pairs='invalid'; + window.renderFixture={record,scope:createScope(),created:[],revoked:[],timers:0}; + const create=URL.createObjectURL.bind(URL),revoke=URL.revokeObjectURL.bind(URL),timeout=window.setTimeout.bind(window); + URL.createObjectURL=blob=>{const url=create(blob);window.renderFixture.created.push(url);return url;};URL.revokeObjectURL=url=>{window.renderFixture.revoked.push(url);revoke(url);}; + window.setTimeout=(callback,delay,...args)=>{if(delay===1000)window.renderFixture.timers++;return timeout(callback,delay,...args);}; + const container=document.createElement('section');container.id='render-fixture';document.body.append(container); + window.renderFixture.view=mountFileInspector(container,{parentScope:window.renderFixture.scope});window.renderFixture.view.setState({record}); + }); + const panel=page.locator('#render-fixture');await panel.getByText('Workflow & raw metadata',{exact:true}).click(); + await expect(panel).toContainText('Reserialized container JSON; numeric spelling/precision may differ');await expect(panel).toContainText('Invalid container record; text is unavailable'); + await expect(panel.getByRole('button',{name:'Download workflow JSON',exact:true})).toHaveAttribute('aria-disabled','true');await expect(panel).toContainText('Original workflow JSON text is unavailable for this container record. Download metadata or the original file.'); + const metadata=await downloadText(page,panel.getByRole('button',{name:'Download metadata JSON',exact:true}));const value=JSON.parse(metadata.text);expect(value.metadata.record_formats.workflow).toBe('reserialized-json');expect(value.metadata.raw.workflow).toContain('9007199254740993');expect(value.metadata.raw.comfyassets_prompt_pairs).toBeUndefined(); + await page.evaluate(()=>window.renderFixture.view.setState({error:true,refreshRequired:true,message:'The source changed. Refresh the image collection before inspecting this file.'})); + await expect(panel).toContainText('Reopen this file from a refreshed image collection.');await expect(panel.locator('button,a[download]')).toHaveCount(0); + expect(await page.evaluate(()=>({created:window.renderFixture.created,revoked:window.renderFixture.revoked,timers:window.renderFixture.timers}))).toMatchObject({timers:1}); + await expect.poll(()=>page.evaluate(()=>window.renderFixture.created.length===window.renderFixture.revoked.length)).toBe(true); + await page.evaluate(()=>{window.renderFixture.view.dispose();window.renderFixture.view.dispose();window.renderFixture.scope.dispose();});await expect(panel).toBeEmpty(); + expect(await page.evaluate(()=>window.renderFixture.revoked)).toEqual(await page.evaluate(()=>window.renderFixture.created)); +}); + +test('full inspector distinguishes missing, limited and legacy unknown generation facts',async({page,request})=>{ + await fullFixture(page,request);const panel=inspector(page); + await selectFile(page,'empty.png');await expect(panel.locator('.generation-facts dd')).toHaveText(Array(6).fill('Not recorded')); + await page.locator('article[data-image-key="0:architecture/study-01.png"] .image-open').click();await expect(panel.locator('.generation-facts dd')).toHaveText(Array(6).fill('Not recorded')); + await selectFile(page,'control-heavy.png'); + const explanation='Configuration exceeds inspection limits; download the original file to inspect it locally.'; + await expect(panel.getByText(explanation,{exact:true})).toHaveCount(1);await expect(panel.getByText(explanation,{exact:true})).toBeVisible(); + for(const value of await panel.locator('.generation-facts dd').all())await expect(value).toHaveAccessibleDescription(explanation); + await page.evaluate(async()=>{ + const {mountFileInspector}=await import('/prompt_manager/js/workspace/file-inspector.mjs'); + const {toInspectionRecord}=await import('/prompt_manager/js/workspace/inspection-state.mjs'); + const {createScope}=await import('/prompt_manager/js/shared/toolkit/core.js'); + const container=document.createElement('section');container.id='legacy-facts';document.body.append(container); + window.legacyFactsScope=createScope();window.legacyFactsView=mountFileInspector(container,{parentScope:window.legacyFactsScope}); + window.legacyFactsView.setState({record:toInspectionRecord({prompts:{}},{root_index:0,path:'legacy.png'})}); + }); + const legacy=page.locator('#legacy-facts');await expect(legacy.locator('.generation-facts dd')).toHaveText(Array(6).fill('Unresolved'));await expect(legacy).not.toContainText(explanation);await expect(legacy.locator('.generation-facts dd[aria-describedby]')).toHaveCount(0); + await page.evaluate(()=>{window.legacyFactsView.dispose();window.legacyFactsScope.dispose();}); +}); + +test.describe('full inspector coarse pointer',()=>{ + test.use({hasTouch:true,viewport:{width:390,height:844}}); + test('full inspector meets coarse target sizes and keeps long content reachable at zoom',async({page,request})=>{ + await fullFixture(page,request);expect(await page.evaluate(()=>matchMedia('(pointer: coarse)').matches)).toBe(true);const panel=inspector(page); + await expect(panel.locator('.prompt-positive pre')).toContainText('Full prompt context, fine geometry and light.'); + const fullText=await panel.locator('.prompt-positive pre').textContent();expect(fullText.length).toBeGreaterThan(1000); + await panel.getByText('File & provenance',{exact:true}).click();await panel.getByText('Workflow & raw metadata',{exact:true}).click(); + const bounds=await panel.locator('button,a,summary').evaluateAll(nodes=>nodes.map(node=>({name:node.getAttribute('aria-label')||node.textContent,role:node.matches('.prompt-actions button'),width:node.getBoundingClientRect().width,height:node.getBoundingClientRect().height}))); + for(const target of bounds){expect(target.width,target.name).toBeGreaterThanOrEqual(target.role?36:40);expect(target.height,target.name).toBeGreaterThanOrEqual(target.role?36:40);} + for(const zoom of [1,2]) { + await page.evaluate(zoom=>{document.documentElement.style.zoom=String(zoom);},zoom); + await expect.poll(()=>page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true); + await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))); + for(const target of [panel.locator('summary').first(),panel.getByRole('button',{name:'Download metadata JSON',exact:true}),panel.getByRole('link',{name:'Prompt history',exact:true})]) { + await target.scrollIntoViewIfNeeded();await target.focus();await expect(target).toBeFocused();await expect(target).toBeInViewport(); + } + await expect(panel.locator('.prompt-positive pre')).toHaveText(fullText); + } + await panel.getByRole('button',{name:'Open full preview',exact:true}).click();const viewer=page.getByRole('dialog',{name:'Image preview',exact:true}); + await expect(viewer.locator('.viewer-canvas img')).toBeVisible(); + for(const target of await viewer.locator('.image-preview-controls > :is(button,a),.image-preview-header > button').all()) { + const box=await target.evaluate(node=>{const {width,height}=node.getBoundingClientRect();return {width,height,name:node.getAttribute('aria-label')};}); + expect(box.width,box.name).toBeGreaterThanOrEqual(80);expect(box.height,box.name).toBeGreaterThanOrEqual(80); + } + const raw=viewer.getByText('Workflow & raw metadata',{exact:true});await raw.scrollIntoViewIfNeeded();await raw.focus();await expect(raw).toBeInViewport(); + }); +}); + +const mediaDialog=page=>page.getByRole('dialog',{name:'Image preview',exact:true}); +const mediaStage=page=>mediaDialog(page).getByRole('region',{name:'Media preview',exact:true}); +const position=page=>mediaDialog(page).locator('.image-preview-position'); +async function openCollection(page) { + await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click(); + await expect(mediaDialog(page).locator('.viewer-canvas img')).toBeVisible(); + await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false'); +} + +test('collection viewer follows filtered loaded order beyond mounted cards',async({page,request})=>{ + await page.addInitScript(()=>{window.IntersectionObserver=undefined;}); + await expect(await request.post('/__test__/reset',{data:{full_inspector:true,extra_images:200}})).toBeOK(); + await page.goto('/prompt_manager/workspace#/images'); + await page.getByRole('combobox',{name:'Source',exact:true}).selectOption('0'); + await page.getByRole('combobox',{name:'Folder',exact:true}).selectOption('inspector'); + await page.getByRole('combobox',{name:'Sort',exact:true}).selectOption('name'); + const more=page.getByRole('button',{name:'Load more images',exact:true}); + await expect(page.locator('.image-toolbar')).toContainText('40 of'); + for(const n of [80,120,160]) {await more.click();await expect(page.locator('.image-toolbar')).toContainText(`${n} of`);} + const listed=await(await request.get('/prompt_manager/images/output?mode=workspace&root=0&subfolder=inspector&sort=name&limit=200')).json(); + const loaded=listed.images.slice(0,160);expect(loaded).toHaveLength(160); + await page.evaluate(()=>scrollTo(0,0));await expect.poll(()=>page.locator('article[data-image-key]').count()).toBeLessThan(120); + let release,started;const held=new Promise(resolve=>{release=resolve;});const hit=new Promise(resolve=>{started=resolve;}); + await page.route('**/images/output?*',async route=>{started();await held;await route.continue();},{times:1}); + await more.click();await hit;await page.evaluate(()=>scrollTo(0,0)); + const opener=page.locator(`article[data-image-key="0:${loaded[0].path}"] .image-card-footer button`); + await opener.click();release();await expect(page.locator('.image-toolbar')).toContainText('200 of'); + const modal=mediaDialog(page);await expect(position(page)).toHaveText(`1 of 160 loaded: ${loaded[0].filename}`); + let pages=0;page.on('request',req=>{if(req.url().includes('/images/output?'))pages++;}); + await modal.getByRole('button',{name:'Previous media',exact:true}).click(); + await expect(position(page)).toHaveText(`160 of 160 loaded: ${loaded[159].filename}`); + await expect(modal.locator('.file-name')).toHaveText(loaded[159].filename); + await expect(modal.getByRole('group',{name:'Media controls',exact:true}).getByRole('link',{name:'Download original',exact:true})).toHaveAttribute('href',loaded[159].url); + await expect(page.locator(`article[data-image-key="0:${loaded[159].path}"]`)).toHaveCount(0); + await modal.getByRole('button',{name:'Next media',exact:true}).click();await expect(position(page)).toHaveText(`1 of 160 loaded: ${loaded[0].filename}`); + await mediaStage(page).focus();await page.keyboard.press('ArrowRight');await expect(position(page)).toHaveText(`2 of 160 loaded: ${loaded[1].filename}`); + expect(pages).toBe(0);await modal.getByRole('button',{name:'Close preview',exact:true}).click(); + await expect(opener).toBeFocused(); + await page.getByRole('combobox',{name:'Source',exact:true}).selectOption('1'); + await page.getByRole('combobox',{name:'Folder',exact:true}).selectOption('inspector'); + await selectFile(page,'complete.png',1);await openCollection(page);await expect(position(page)).toHaveText('1 of 1 loaded: complete.png'); + for(const name of ['Previous media','Next media'])await expect(mediaDialog(page).getByRole('button',{name,exact:true})).toHaveAttribute('aria-disabled','true'); + const onePlay=mediaDialog(page).getByRole('button',{name:'Play slideshow',exact:true}); + await expect(onePlay).toHaveAccessibleDescription('Slideshow requires at least two loaded media entries.');await onePlay.dispatchEvent('click');await expect(onePlay).toHaveText('Play'); + await mediaDialog(page).getByRole('button',{name:'Close preview',exact:true}).click(); + const library=await(await request.get('/prompt_manager/library')).json();const id=library.items.find(item=>item.text.startsWith('Inspector portrait')).id; + await page.goto(`/prompt_manager/workspace#/library/${id}`);await page.getByRole('button',{name:'Select negative variant 1',exact:true}).click();await selectFile(page,'complete.png');await openCollection(page); + await expect(position(page)).toContainText('of 1 loaded: complete.png'); + await mediaDialog(page).getByRole('button',{name:'Close preview',exact:true}).click(); + await page.getByRole('button',{name:'All images for this positive',exact:true}).click(); + await selectFile(page,'complete.png');await openCollection(page);await expect(position(page)).toContainText('of 2 loaded:'); +}); + +test('collection viewer uses real public transforms and resets on navigation',async({page,request})=>{ + await fullFixture(page,request); + await page.evaluate(()=>{ + window.viewerCalls=[];window.viewerInstances=[]; + const Native=window.Viewer; + for(const method of ['zoom','reset','rotate','scaleX']) {const original=Native.prototype[method];Native.prototype[method]=function(...args){window.viewerCalls.push([method,...args]);return original.apply(this,args);};} + window.Viewer=class extends Native {constructor(...args){super(...args);window.viewerInstances.push(this);}}; + }); + await openCollection(page);const modal=mediaDialog(page),controls=modal.getByRole('group',{name:'Media controls',exact:true}); + await expect(modal.locator('.viewer-container')).toHaveCount(1);await expect(modal.locator(':is(.viewer-toolbar,.viewer-navbar,.viewer-title,.viewer-button):visible')).toHaveCount(0); + const fitted=await page.evaluate(()=>({...window.viewerInstances.at(-1).imageData})); + await controls.getByRole('button',{name:'Zoom in',exact:true}).click();expect(await page.evaluate(()=>window.viewerInstances.at(-1).imageData.width)).toBeGreaterThan(fitted.width); + await controls.getByRole('button',{name:'Zoom out',exact:true}).click(); + await controls.getByRole('button',{name:'Rotate image 90 degrees',exact:true}).click();await controls.getByRole('button',{name:'Flip image horizontally',exact:true}).click(); + expect(await page.evaluate(()=>({rotate:window.viewerInstances.at(-1).imageData.rotate,scaleX:window.viewerInstances.at(-1).imageData.scaleX}))).toEqual({rotate:90,scaleX:-1}); + const fit=controls.getByRole('button',{name:'Fit image',exact:true});await fit.click();await expect(fit).toBeFocused(); + expect(await page.evaluate(()=>({rotate:window.viewerInstances.at(-1).imageData.rotate,scaleX:window.viewerInstances.at(-1).imageData.scaleX}))).toEqual({rotate:0,scaleX:1}); + expect(await page.evaluate(()=>window.viewerCalls)).toEqual(expect.arrayContaining([['zoom',0.2],['zoom',-0.2],['reset'],['rotate',90],['scaleX',-1]])); + await page.evaluate(()=>{window.currentMedia=document.querySelector('.viewer-canvas img');window.currentFit=document.querySelector('[aria-label="Fit image"]');}); + await modal.getByText('File & provenance',{exact:true}).click();await modal.getByText('Workflow & raw metadata',{exact:true}).click(); + expect(await page.evaluate(()=>window.currentMedia===document.querySelector('.viewer-canvas img')&&window.currentFit===document.querySelector('[aria-label="Fit image"]'))).toBe(true); + await controls.getByRole('button',{name:'Rotate image 90 degrees',exact:true}).click();await controls.getByRole('button',{name:'Next media',exact:true}).click(); + await expect(controls.getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');await expect(modal.locator('.viewer-container')).toHaveCount(1); + expect(await page.evaluate(()=>window.currentMedia!==document.querySelector('.viewer-canvas img')&&window.currentFit===document.querySelector('[aria-label="Fit image"]'))).toBe(true); + expect(await page.evaluate(()=>({rotate:window.viewerInstances.at(-1).imageData.rotate,scaleX:window.viewerInstances.at(-1).imageData.scaleX}))).toEqual({rotate:0,scaleX:1}); + await expect(controls.getByRole('button',{name:'Play slideshow',exact:true})).toBeVisible(); +}); + +async function slideshowFixture(page,request,preferences={videoAutoplay:false,videoMute:true,videoLoop:false}) { + await expect(await request.post('/__test__/reset',{data:{full_inspector:true,include_video:true}})).toBeOK(); + await page.goto('/prompt_manager/workspace#/images'); + await page.evaluate(async preferences=>{ + if(preferences) localStorage.setItem('gallerySettings',JSON.stringify(preferences)); + const [{createMediaViewer},{createInspectionController},{createScope},{createPromptSender}]=await Promise.all([ + import('/prompt_manager/js/workspace/media-viewer.mjs'),import('/prompt_manager/js/workspace/inspection-state.mjs'), + import('/prompt_manager/js/shared/toolkit/core.js'),import('/prompt_manager/js/workspace/prompt-sender.mjs')]); + const api=window.ApiClient.createApiClient(),collection=await api.get('/prompt_manager/images/output?mode=workspace&subfolder=inspector&limit=40'); + const items=['complete.png','clip.webm','explicit-empty.png'].map(filename=>collection.images.find(image=>image.root_index===0&&image.filename===filename)); + window.slideshowTimers=new Set();window.slideshowCallbacks=[];window.slideshowReady=[]; + const schedule=window.setTimeout.bind(window),cancel=window.clearTimeout.bind(window); + window.setTimeout=(callback,delay,...args)=>{let id;id=schedule(()=>{window.slideshowTimers.delete(id);callback(...args);},delay);if(delay===3500){window.slideshowTimers.add(id);window.slideshowCallbacks.push(callback);}return id;}; + window.clearTimeout=id=>{window.slideshowTimers.delete(id);cancel(id);}; + const Native=window.Viewer; + window.Viewer=class extends Native {constructor(node,options){window.slideshowReady.push(options.viewed);super(node,options);}}; + const scope=createScope(),controller=createInspectionController({api}),sender=createPromptSender({scope});scope.cleanup(()=>controller.dispose()); + const view=createMediaViewer({container:document.getElementById('workspace-main'),parentScope:scope,inspectionController:controller,sender,onSelect:entry=>controller.select(entry)}); + window.slideshowFixture={scope,controller,view,items,open:()=>view.open({items,selectedKey:'0:inspector/complete.png'})};window.slideshowFixture.open(); + },preferences); + await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false'); + await expect(mediaDialog(page).locator('.file-name')).toHaveText('complete.png'); + await page.clock.install();await page.clock.pauseAt(new Date(Date.now()+1000)); +} +const slideshowButton=page=>mediaDialog(page).getByRole('group',{name:'Media controls',exact:true}).getByRole('button',{name:/^(Play|Pause|Stop) slideshow$/}); +const timerCount=page=>page.evaluate(()=>window.slideshowTimers.size); +const mediaStep=async(page,direction)=>mediaDialog(page).getByRole('button',{name:`${direction} media`,exact:true}).click(); +async function readyClockImage(page) { + await expect.poll(async()=>{await page.clock.runFor(50);return mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true}).getAttribute('aria-disabled');}).toBe('false'); +} + +test('slideshow exposes exact state labels and respects video playback',async({page,request})=>{ + const originals=[];page.on('request',request=>{if(request.url().includes('_pm_viewer='))originals.push(request.url());}); + await slideshowFixture(page,request);const button=slideshowButton(page),modal=mediaDialog(page); + await expect(button).toHaveText('Play');await expect(button).not.toHaveAttribute('aria-pressed'); + expect(originals).toHaveLength(1); + await button.click();await expect(button).toHaveText('Pause');await expect(button).toBeFocused();await expect(position(page)).toContainText('Slideshow playing.');expect(await timerCount(page)).toBe(1); + await page.clock.runFor(3499);await expect(position(page)).toContainText('1 of 3 loaded: complete.png'); + await page.clock.runFor(1);await expect(position(page)).toContainText('2 of 3 loaded: clip.webm'); + await expect(button).toHaveAccessibleName('Stop slideshow');await expect(button).toHaveText('Stop');await expect(button).toBeFocused(); + await expect(position(page)).toContainText('Slideshow paused for video. Next or Previous continues it on an image; Stop ends slideshow.');expect(await timerCount(page)).toBe(0); + const video=modal.locator('video');await expect.poll(()=>video.evaluate(node=>node.readyState)).toBeGreaterThanOrEqual(1); + expect(await video.evaluate(node=>({controls:node.controls,inline:node.playsInline,muted:node.muted,loop:node.loop}))).toEqual({controls:true,inline:true,muted:true,loop:false}); + await expect(modal.getByRole('button',{name:'Zoom in',exact:true})).toHaveAccessibleDescription('Image transforms are unavailable for video.'); + await video.evaluate(node=>node.play());await expect.poll(()=>video.evaluate(node=>node.ended)).toBe(true); + await page.clock.runFor(10500);await expect(position(page)).toContainText('2 of 3 loaded');await expect(button).toHaveText('Stop'); + await video.evaluate(node=>{node.loop=true;node.currentTime=0;return node.play();}); + await button.click();await expect(button).toHaveText('Play');expect(await video.evaluate(node=>node.paused)).toBe(false); + await expect(button).toHaveAttribute('aria-disabled','true');await mediaStep(page,'Next'); + await readyClockImage(page);await expect(button).toHaveText('Play');expect(await timerCount(page)).toBe(0); + await button.click();await mediaStep(page,'Previous');await expect(button).toHaveText('Stop'); + await mediaStep(page,'Previous');await expect(button).toHaveText('Pause');await expect(position(page)).toContainText('Slideshow waiting for image.');await readyClockImage(page);await expect.poll(()=>timerCount(page)).toBe(1); + expect(new Set(originals.map(url=>new URL(url).pathname)).size).toBe(3); + await button.click();await modal.locator('.image-preview-details').evaluate(node=>{node.scrollTop=node.scrollHeight;});await page.clock.runFor(50); + await button.click();await mediaStep(page,'Previous');await readyClockImage(page);await expect(button).toHaveText('Pause');expect(await timerCount(page)).toBe(1); +}); + +test('slideshow stop fences old timer and readiness callbacks',async({page,request})=>{ + await slideshowFixture(page,request);const button=slideshowButton(page); + await button.click();await page.clock.runFor(3499);await button.click(); + await page.evaluate(()=>{for(const callback of window.slideshowCallbacks)callback();for(const callback of window.slideshowReady)callback();}); + await page.clock.runFor(7000);await expect(position(page)).toContainText('1 of 3 loaded');await expect(button).toHaveText('Play');expect(await timerCount(page)).toBe(0); + await button.click();await mediaStep(page,'Previous');await expect(position(page)).toContainText('Slideshow waiting for image.');await button.click(); + await page.evaluate(()=>{for(const callback of window.slideshowCallbacks)callback();for(const callback of window.slideshowReady)callback();}); + await expect(button).toHaveText('Play');expect(await timerCount(page)).toBe(0);await mediaStep(page,'Next');await readyClockImage(page); + await page.route('**/images/serve/inspector/explicit-empty.png?*',route=>route.abort()); + await button.click();await mediaStep(page,'Previous');await expect(position(page)).toContainText('Media could not be loaded.'); + await expect(button).toHaveText('Play');await expect(button).toHaveAttribute('aria-disabled','true');expect(await timerCount(page)).toBe(0); + await mediaStep(page,'Next');await expect(button).toHaveText('Play'); + await page.evaluate(()=>window.slideshowFixture.scope.dispose());await expect(mediaDialog(page)).toHaveCount(0); + await page.evaluate(()=>{for(const callback of window.slideshowCallbacks)callback();for(const callback of window.slideshowReady)callback();window.slideshowFixture.view.stopSlideshow();}); + expect(await timerCount(page)).toBe(0); +}); + +test('slideshow production query and route disposal cancel the active deadline',async({page,request})=>{ + await fullFixture(page,request);await page.clock.install(); + await page.evaluate(()=>{ + window.slideshowTimers=new Set();const schedule=window.setTimeout.bind(window),cancel=window.clearTimeout.bind(window); + window.setTimeout=(callback,delay,...args)=>{let id;id=schedule(()=>{window.slideshowTimers.delete(id);callback(...args);},delay);if(delay===3500)window.slideshowTimers.add(id);return id;}; + window.clearTimeout=id=>{window.slideshowTimers.delete(id);cancel(id);}; + }); + await openCollection(page);await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false'); + await page.clock.pauseAt(new Date(Date.now()+1000));await slideshowButton(page).click();expect(await timerCount(page)).toBe(1); + await page.clock.runFor(3499); + const pendingAtReset=await page.evaluate(()=>{const folder=document.getElementById('image-folder');folder.value='inspector';folder.dispatchEvent(new Event('change'));return window.slideshowTimers.size;}); + expect(pendingAtReset).toBe(0);await page.clock.runFor(1); + await expect(mediaDialog(page)).toHaveCount(0);expect(await timerCount(page)).toBe(0); + await selectFile(page,'complete.png');await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();await readyClockImage(page);await expect(slideshowButton(page)).toHaveText('Play'); + await slideshowButton(page).click();await page.evaluate(()=>{location.hash='/overview';});await expect(page.locator('h1')).toHaveText('Overview');await expect(mediaDialog(page)).toHaveCount(0);expect(await timerCount(page)).toBe(0); + await page.clock.runFor(7000);await expect(page.locator('.viewer-container')).toHaveCount(0); +}); + +test('slideshow interaction visibility sender and disposal clear intent',async({page,request})=>{ + await slideshowFixture(page,request);const button=slideshowButton(page),modal=mediaDialog(page); + for(const action of [ + ()=>modal.locator('.prompt-positive pre').click(), + ()=>modal.locator('.image-preview-details').dispatchEvent('wheel',{deltaY:100}), + ()=>modal.getByRole('button',{name:'Copy positive prompt',exact:true}).click(), + async()=>{await modal.getByText('Workflow & raw metadata',{exact:true}).click();await downloadText(page,modal.getByRole('button',{name:'Download metadata JSON',exact:true}));}, + async()=>{await page.evaluate(()=>{Object.defineProperty(document,'hidden',{configurable:true,value:true});document.dispatchEvent(new Event('visibilitychange'));Object.defineProperty(document,'hidden',{configurable:true,value:false});document.dispatchEvent(new Event('visibilitychange'));});}, + async()=>{const trigger=modal.getByRole('button',{name:'Send prompt pair',exact:true});await trigger.click();await expect(page.getByRole('dialog',{name:'Send to ComfyUI',exact:true})).toBeVisible();await page.keyboard.press('Escape');await expect(trigger).toBeFocused();}, + ()=>modal.getByRole('group',{name:'Media controls',exact:true}).getByRole('link',{name:'Download original',exact:true}).click(), + ()=>page.evaluate(()=>window.slideshowFixture.view.stopSlideshow()), + ]) { + await button.click();expect(await timerCount(page)).toBe(1);await action();await expect(button).toHaveText('Play');expect(await timerCount(page)).toBe(0);await page.clock.runFor(3500);await expect(position(page)).toContainText('1 of 3 loaded'); + } + for(let mount=0;mount<3;mount++) { + await button.click();await modal.getByRole('button',{name:'Close preview',exact:true}).click();await expect(modal).toHaveCount(0);expect(await timerCount(page)).toBe(0); + await page.evaluate(()=>window.slideshowFixture.open());await expect(button).toHaveText('Play'); + await readyClockImage(page); + } + await button.click();await page.evaluate(()=>window.dispatchEvent(new PageTransitionEvent('pagehide',{persisted:true})));await expect(modal).toHaveCount(0);expect(await timerCount(page)).toBe(0); + await page.evaluate(()=>window.dispatchEvent(new PageTransitionEvent('pageshow',{persisted:true})));await expect(modal).toHaveCount(0); +}); + +test('slideshow preference failures leave native playback usable',async({page,request})=>{ + await slideshowFixture(page,request,null);const modal=mediaDialog(page); + for(const failure of ['denied','malformed','rejected']) { + await page.evaluate(failure=>{ + if(failure==='denied') {window.normalStorage=Object.getOwnPropertyDescriptor(window,'localStorage');Object.defineProperty(window,'localStorage',{configurable:true,get(){throw new DOMException('Denied','SecurityError');}});} + else {if(window.normalStorage)Object.defineProperty(window,'localStorage',window.normalStorage);localStorage.setItem('gallerySettings',failure==='malformed'?'{broken':JSON.stringify({videoAutoplay:true,videoMute:true,videoLoop:false}));} + if(failure==='rejected'){window.nativePlay=HTMLMediaElement.prototype.play;window.playRejections=0;HTMLMediaElement.prototype.play=function(){window.playRejections++;return Promise.reject(new DOMException('Blocked','NotAllowedError'));};} + },failure); + await mediaStep(page,'Next');const video=modal.locator('video');await expect.poll(()=>video.evaluate(node=>node.readyState)).toBeGreaterThanOrEqual(1); + expect(await video.evaluate(node=>({controls:node.controls,inline:node.playsInline,muted:node.muted,loop:node.loop}))).toEqual({controls:true,inline:true,muted:true,loop:failure!=='rejected'}); + if(failure==='rejected') {expect(await page.evaluate(()=>window.playRejections)).toBe(1);await page.evaluate(()=>{HTMLMediaElement.prototype.play=window.nativePlay;});} + await video.evaluate(node=>node.play());expect(await video.evaluate(node=>node.paused)).toBe(false); + await mediaStep(page,'Previous');await expect(slideshowButton(page)).toHaveText('Play'); + } +}); + +test('full inspector attaches matched theme and mobile evidence with reachable content',async({page,request},testInfo)=>{ + await expect(await request.post('/__test__/reset',{data:{full_inspector:true,include_video:true}})).toBeOK(); + await page.goto('/prompt_manager/workspace#/images'); + const measurements=[]; + async function capture(name,viewer=false) { + await page.evaluate(()=>document.fonts.ready); + if(viewer) { + const modal=mediaDialog(page),rail=modal.locator('.image-preview-details'); + expect(await modal.evaluate(node=>node.scrollWidth<=node.clientWidth+1)).toBe(true); + measurements.push({name,...await modal.evaluate(node=>{ + const box=element=>{const bounds=element.getBoundingClientRect();return{x:bounds.x,y:bounds.y,width:bounds.width,height:bounds.height};}; + return{frame:box(node),header:box(node.querySelector('.image-preview-header')),stage:box(node.querySelector('.image-preview-stage')), + toolbar:box(node.querySelector('.image-preview-toolbar')),rail:box(node.querySelector('.image-preview-details')), + footer:node.querySelector('.file-inspector-footer')?box(node.querySelector('.file-inspector-footer')):null}; + })}); + if(page.viewportSize().width===1600)expect(await rail.evaluate(node=>node.getBoundingClientRect().width)).toBe(340); + else await expect(rail).toHaveCSS('overflow-y','visible'); + } + const file=testInfo.outputPath(`${name}.png`);await page.screenshot({path:file});await testInfo.attach(name,{path:file,contentType:'image/png'}); + } + for(const [width,height,theme]of [[1600,1100,'dark'],[1600,1100,'light'],[390,844,'dark'],[390,844,'light']]) { + const label=`${theme}-${width}`;await page.setViewportSize({width,height}); + await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption(theme); + await selectFile(page,'complete.png');await expect(inspector(page).locator('.generation-facts dd').first()).toHaveText('9007199254740993'); + if(width===1600)await page.evaluate(()=>scrollTo(0,0));else await inspector(page).locator('.file-cover').evaluate(node=>node.scrollIntoView({block:'start'})); + await capture(`inline-${label}`); + await openCollection(page);const modal=mediaDialog(page),rail=modal.locator('.image-preview-details'); + await expect(modal.locator('.viewer-canvas img')).toBeVisible();await expect.poll(()=>modal.locator('.viewer-canvas img').evaluate(node=>node.complete&&node.naturalWidth>0)).toBe(true); + await modal.evaluate(node=>{node.scrollTop=0;});await capture(`viewer-${label}`,true); + await expect(rail.locator('.prompt-negative')).toContainText('blurred edges, watermark');await rail.locator('.generation-facts').scrollIntoViewIfNeeded();await capture(`facts-${label}`,true); + await rail.getByText('File & provenance',{exact:true}).click();await rail.locator('.file-facts').scrollIntoViewIfNeeded();await capture(`provenance-${label}`,true); + await rail.getByText('Workflow & raw metadata',{exact:true}).click();const raw=rail.locator('pre code').first();await raw.scrollIntoViewIfNeeded();await expect(raw).toBeInViewport();await capture(`raw-${label}`,true); + const send=rail.getByRole('button',{name:'Send prompt pair',exact:true});await send.focus();await expect(send).toBeInViewport();await send.click();await expect(page.getByRole('dialog',{name:'Send to ComfyUI',exact:true})).toBeVisible();await capture(`sender-${label}`);await page.keyboard.press('Escape');await expect(send).toBeFocused(); + await modal.getByRole('button',{name:'Close preview',exact:true}).click();await selectFile(page,'clip.webm');await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click(); + await expect.poll(()=>modal.locator('video').evaluate(node=>node.readyState)).toBeGreaterThanOrEqual(1);await capture(`video-${label}`,true);await modal.getByRole('button',{name:'Close preview',exact:true}).click(); + await selectFile(page,'control-heavy.png');await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click(); + await expect(modal).toContainText('Unavailable within inspection limits');await modal.getByText('Workflow & raw metadata',{exact:true}).click(); + const limitedDownload=modal.getByRole('button',{name:'Download metadata JSON',exact:true});await limitedDownload.scrollIntoViewIfNeeded();await expect(limitedDownload).toHaveAttribute('aria-disabled','true');await capture(`limited-${label}`,true); + await modal.getByRole('button',{name:'Close preview',exact:true}).click(); + } + const fs=await import('node:fs/promises'),geometryPath=testInfo.outputPath('inspector-geometry.json'); + await fs.writeFile(geometryPath,JSON.stringify(measurements,null,2));await testInfo.attach('inspector-geometry',{path:geometryPath,contentType:'application/json'}); +}); + +test('collection viewer preserves original download and rooted retries',async({page,request})=>{ + await fullFixture(page,request);let deny=true;const originals=[]; + await page.route('**/images/serve/inspector/complete.png?*',route=>{originals.push(route.request().url());return deny?route.fulfill({status:404,body:'Missing'}):route.continue();}); + await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();const modal=mediaDialog(page); + await expect(modal).toContainText('Media could not be loaded. Retry this file.');await expect(modal.locator('.generation-facts dd').first()).toHaveText('9007199254740993'); + await expect(modal.locator('.image-preview-position')).toContainText('Media could not be loaded.'); + const download=modal.getByRole('group',{name:'Media controls',exact:true}).getByRole('link',{name:'Download original',exact:true});const original=await download.getAttribute('href'); + await expect(modal.getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','true'); + const denied=page.waitForResponse(response=>response.url().includes('/images/serve/inspector/complete.png?')&&response.status()===404); + await modal.getByRole('button',{name:'Retry media',exact:true}).click();await denied; + await expect.poll(()=>mediaStage(page).locator('.image-preview-media > img').evaluate(node=>node.complete&&node.naturalWidth===0)).toBe(true); + await expect(modal.getByRole('button',{name:'Retry media',exact:true})).toBeVisible(); + deny=false;await modal.getByRole('button',{name:'Retry media',exact:true}).click();await expect(modal.locator('.viewer-canvas img')).toBeVisible(); + await expect(download).toHaveAttribute('href',original); + const stages=originals.map(value=>new URL(value)).filter(url=>url.searchParams.has('_pm_viewer')); + expect(new Set(stages.map(url=>url.searchParams.get('_pm_viewer'))).size).toBe(3); + for(const value of originals) {const actual=new URL(value),expected=new URL(original,'http://127.0.0.1');expect(actual.pathname).toBe(expected.pathname);for(const key of ['root','root_scope','v'])expect(actual.searchParams.get(key)).toBe(expected.searchParams.get(key));expect(actual.searchParams.getAll('_pm_viewer').length).toBeLessThanOrEqual(1);expect([...actual.searchParams.keys()].filter(key=>!expected.searchParams.has(key))).toEqual(actual.searchParams.has('_pm_viewer')?['_pm_viewer']:[]);} + const [saved]=await Promise.all([page.waitForEvent('download'),download.click()]);expect(saved.suggestedFilename()).toBe('complete.png'); + await modal.getByRole('button',{name:'Close preview',exact:true}).click();await page.evaluate(()=>{window.availableViewer=window.Viewer;window.Viewer=undefined;}); + await page.route('**/images/inspect/inspector/explicit-empty.png?*',route=>route.fulfill({status:503,json:{success:false,error:'Unavailable'}}),{times:1}); + await selectFile(page,'explicit-empty.png');await expect(inspector(page)).toContainText('Could not read prompts from this file.'); + await page.locator('article[data-image-key="0:inspector/explicit-empty.png"] .image-card-footer button').click(); + await expect(mediaStage(page).locator('img')).toBeVisible();await expect(mediaStage(page).locator('img')).toHaveJSProperty('naturalWidth',720); + await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAccessibleDescription('Image transforms are unavailable because Viewer.js could not load.'); + await page.evaluate(()=>{window.Viewer=window.availableViewer;});await mediaDialog(page).getByRole('button',{name:'Retry prompt details',exact:true}).click(); + await expect(mediaDialog(page).locator('.file-name')).toHaveText('explicit-empty.png');await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','true'); +}); + +test('collection viewer keeps nested dialog focus and route cleanup',async({page,request})=>{ + await fullFixture(page,request);await openCollection(page);const modal=mediaDialog(page); + const send=modal.getByRole('button',{name:'Send prompt pair',exact:true});await send.click();const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true}); + await expect(sender).toBeVisible();const before=await position(page).textContent();await page.keyboard.press('ArrowRight');await expect(position(page)).toHaveText(before); + await page.keyboard.press('Escape');await expect(sender).toBeHidden();await expect(modal).toBeVisible();await expect(send).toBeFocused(); + const text=modal.locator('.prompt-positive pre');await text.focus();await page.keyboard.press('ArrowRight');await expect(position(page)).toHaveText(before); + await modal.getByText('Workflow & raw metadata',{exact:true}).focus();await page.keyboard.press('ArrowLeft');await expect(position(page)).toHaveText(before); + await modal.getByRole('button',{name:'Next media',exact:true}).click();const key=(await position(page).textContent()).split('loaded: ')[1]; + await page.keyboard.press('Escape');await expect(modal).toHaveCount(0);await expect(page.locator('.image-open[aria-pressed="true"]')).toBeFocused();await expect(inspector(page).locator('.file-name')).toHaveText(key); + await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();await page.evaluate(()=>{location.hash='/overview';}); + await expect(page.locator('h1')).toHaveText('Overview');await expect(page.locator('.viewer-container,.image-preview')).toHaveCount(0); +}); + +test('collection viewer matches approved responsive frame',async({page,request},testInfo)=>{ + await page.setViewportSize({width:1600,height:1100});await fullFixture(page,request);await openCollection(page); + const modal=mediaDialog(page),body=modal.locator('.image-preview-body'),rail=modal.locator('.image-preview-details'); + // Absolute gallery cards relayout through ResizeObserver and rAF after resize or CSS zoom. + const settleViewport=width=>expect.poll(()=>page.evaluate(()=>({inner:innerWidth,client:document.documentElement.clientWidth,scroll:document.documentElement.scrollWidth})),{message:`Images gallery relayout settles the ${width}px viewport`}).toEqual({inner:width,client:width,scroll:width}); + await settleViewport(1600); + for(const theme of ['dark','light']) { + await page.evaluate(theme=>{const preference=document.getElementById('workspace-appearance');preference.value=theme;preference.dispatchEvent(new Event('change'));},theme); + const geometry=await modal.evaluate(node=>{const style=getComputedStyle(node),box=node.getBoundingClientRect(),rail=node.querySelector('.image-preview-details').getBoundingClientRect();return{width:box.width,height:box.height,padding:style.padding,radius:style.borderRadius,rail:rail.width,gap:getComputedStyle(node.querySelector('.image-preview-body')).columnGap};}); + expect(geometry).toEqual({width:1500,height:940,padding:'18px',radius:'9px',rail:340,gap:'20px'}); + await page.screenshot({path:testInfo.outputPath(`viewer-${theme}-1600.png`)}); + } + for(const [width,expected] of [[1200,300],[900,270]]) {await page.setViewportSize({width,height:1000});await settleViewport(width);await expect(rail).toHaveCSS('width',`${expected}px`);} + await page.setViewportSize({width:390,height:844});await settleViewport(390);expect(await modal.evaluate(node=>node.getBoundingClientRect().width)).toBeCloseTo(356,1); + for(const [width,expected]of [[840,806],[850,816],[860,825.6]]) { + await page.setViewportSize({width,height:1000});await settleViewport(width);expect(await modal.evaluate(node=>node.getBoundingClientRect().width)).toBeCloseTo(expected,1); + } + await page.setViewportSize({width:390,height:844});await settleViewport(390); + for(const zoom of [1,2]) { + await page.evaluate(zoom=>{document.documentElement.style.zoom=String(zoom);},zoom); + await settleViewport(390); + expect(await modal.evaluate(node=>node.getBoundingClientRect().width)).toBeCloseTo(zoom===1?356:322,1); + expect(await modal.evaluate(node=>{const box=node.getBoundingClientRect();return box.top>=0&&box.bottom<=innerHeight+1;})).toBe(true); + await expect.poll(()=>modal.evaluate(node=>node.scrollWidth<=node.clientWidth+1)).toBe(true); + await expect(rail).toHaveCSS('overflow-y','visible');expect(await body.evaluate(node=>getComputedStyle(node).gridTemplateColumns.split(' ').length)).toBe(1); + await rail.getByText('Workflow & raw metadata',{exact:true}).click();const raw=rail.locator('pre code').first();await expect(raw).toBeVisible(); + await rail.getByRole('button',{name:'Send prompt pair',exact:true}).focus();await expect(rail.getByRole('button',{name:'Send prompt pair',exact:true})).toBeInViewport(); + await page.screenshot({path:testInfo.outputPath(`viewer-mobile-${zoom}x.png`)});await rail.getByText('Workflow & raw metadata',{exact:true}).click(); + } + await rail.evaluate(node=>{node.style.fontFamily='"DejaVu Sans", sans-serif';}); + await rail.getByText('Workflow & raw metadata',{exact:true}).click(); + const sendPair=rail.getByRole('button',{name:'Send prompt pair',exact:true});await sendPair.focus();await expect(sendPair).toBeInViewport(); + await rail.getByText('Workflow & raw metadata',{exact:true}).click();await sendPair.focus();await expect(sendPair).toBeFocused();await expect(sendPair).toBeInViewport(); + const footer=await sendPair.evaluate(node=>{const box=node.getBoundingClientRect(),parent=node.closest('.file-inspector-footer').getBoundingClientRect();return{width:box.width,right:box.right,available:parent.width,parentRight:parent.right,height:box.height,minimum:matchMedia('(pointer: coarse)').matches?80:60};}); + expect(footer.width).toBeLessThanOrEqual(footer.available);expect(footer.right).toBeLessThanOrEqual(footer.parentRight);expect(footer.height).toBeGreaterThanOrEqual(footer.minimum);await expect(sendPair.locator('svg')).toHaveCount(1); + expect(await modal.evaluate(node=>node.getBoundingClientRect().width)).toBeCloseTo(322,1);await expect.poll(()=>modal.evaluate(node=>node.scrollWidth<=node.clientWidth+1)).toBe(true); + await testInfo.attach('wider-font-footer-geometry',{body:JSON.stringify({footer,modal:await modal.evaluate(node=>({width:node.getBoundingClientRect().width,scrollWidth:node.scrollWidth,clientWidth:node.clientWidth}))}),contentType:'application/json'}); + await page.screenshot({path:testInfo.outputPath('viewer-mobile-wider-font-2x.png')}); + await modal.getByRole('button',{name:'Close preview',exact:true}).click();await page.evaluate(()=>{document.documentElement.style.zoom='1';});await settleViewport(390); + const longFiles=await(await request.get('/prompt_manager/images/output?mode=workspace&q=a-very-long&limit=40')).json();const long=longFiles.images[0]; + await page.locator(`article[data-image-key="${long.root_index}:${long.path}"] .image-open`).click();await openCollection(page); + await page.evaluate(()=>{document.documentElement.style.zoom='2';});await settleViewport(390);await modal.evaluate(node=>{node.scrollTop=0;}); + await expect(modal.getByRole('button',{name:'Close preview',exact:true})).toBeInViewport(); +}); + +async function currentStageFit(page) { + return page.evaluate(()=>{ + const instance=window.fitInstances.at(-1),stage=document.querySelector('.image-preview-stage'); + const bounds=stage.getBoundingClientRect(),image=instance.image.getBoundingClientRect(); + return {stage:{width:stage.offsetWidth,height:stage.offsetHeight},viewer:{...instance.viewerData}, + image:{width:image.width,height:image.height}, + center:{x:image.x+image.width/2-bounds.x-bounds.width/2,y:image.y+image.height/2-bounds.y-bounds.height/2}, + contained:image.left>=bounds.left-1&&image.top>=bounds.top-1&&image.right<=bounds.right+1&&image.bottom<=bounds.bottom+1}; + }); +} +async function openStageFitFixture(page,request) { + await expect(await request.post('/__test__/reset',{data:{full_inspector:true}})).toBeOK(); + await page.setViewportSize({width:1600,height:1100});await page.goto('/prompt_manager/workspace#/images'); + let release;const pending=new Promise(resolve=>{release=resolve;}); + await page.route('**/images/inspect/architecture/study-02.png?*',async route=>{ + const response=await route.fetch(),data=await response.json();data.library.title='W'.repeat(220);await pending; + await route.fulfill({response,json:data}).catch(()=>{}); + }); + await page.evaluate(async()=>{ + const [{createMediaViewer},{createInspectionController},{createScope},{createPromptSender}]=await Promise.all([ + import('/prompt_manager/js/workspace/media-viewer.mjs'),import('/prompt_manager/js/workspace/inspection-state.mjs'), + import('/prompt_manager/js/shared/toolkit/core.js'),import('/prompt_manager/js/workspace/prompt-sender.mjs')]); + const api=window.ApiClient.createApiClient(),list=await api.get('/prompt_manager/images/output?mode=workspace&limit=40'); + const tall=list.images.find(image=>image.root_index===1&&image.path==='architecture/study-02.png'); + const next=list.images.find(image=>image.root_index===0&&image.path==='inspector/explicit-empty.png'); + window.fitInstances=[];window.fitResizeCalls=[];window.fitObservers=[]; + const Native=window.Viewer,resize=Native.prototype.resize; + Native.prototype.resize=function(...args){window.fitResizeCalls.push({destroyed:!!this.destroyed,connected:this.element.isConnected});return resize.apply(this,args);}; + window.Viewer=class extends Native {constructor(...args){super(...args);window.fitInstances.push(this);}}; + const NativeObserver=window.ResizeObserver; + window.ResizeObserver=class extends NativeObserver { + constructor(callback){super(callback);this.probe={callback,observer:this,disconnected:false};window.fitObservers.push(this.probe);} + observe(target,...args){this.probe.target=target;return super.observe(target,...args);} + disconnect(){this.probe.disconnected=true;super.disconnect();} + }; + const scope=createScope(),controller=createInspectionController({api}),sender=createPromptSender({scope});scope.cleanup(()=>controller.dispose()); + const view=createMediaViewer({container:document.getElementById('workspace-main'),parentScope:scope,inspectionController:controller,sender,onSelect:entry=>controller.select(entry)}); + window.stageFitFixture={scope,controller,view};view.open({items:[tall,next],selectedKey:`${tall.root_index}:${tall.path}`,trigger:document.activeElement}); + }); + await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false'); + await expect(mediaDialog(page).locator('.image-preview-reason')).toBeHidden(); + return release; +} +async function attachStageFit(page,testInfo,name) { + const data=await currentStageFit(page);await testInfo.attach(name,{body:JSON.stringify(data,null,2),contentType:'application/json'});return data; +} + +test('collection viewer fits current stage after readiness reason removal',async({page,request},testInfo)=>{ + const release=await openStageFitFixture(page,request); + try { + await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))); + await attachStageFit(page,testInfo,'ready-stage-fit'); + await page.screenshot({path:testInfo.outputPath('ready-stage-fit.png')}); + await expect.poll(async()=>{const fit=await currentStageFit(page);return Math.abs(fit.viewer.height-fit.stage.height);}).toBeLessThanOrEqual(1); + const fit=await currentStageFit(page);expect(Math.abs(fit.center.x)).toBeLessThanOrEqual(1);expect(Math.abs(fit.center.y)).toBeLessThanOrEqual(1);expect(fit.contained).toBe(true); + } finally {release();await page.evaluate(()=>window.stageFitFixture.scope.dispose());} +}); + +test('collection viewer refits a late wrapped title without replacing current media',async({page,request},testInfo)=>{ + const release=await openStageFitFixture(page,request); + try { + const modal=mediaDialog(page),fit=modal.getByRole('button',{name:'Fit image',exact:true}); + await page.evaluate(()=>{window.fitImage=document.querySelector('.viewer-canvas img');window.fitControl=document.querySelector('[aria-label="Fit image"]');}); + await fit.focus();const initial=await attachStageFit(page,testInfo,'before-late-title');release(); + await expect(modal.locator('.image-preview-header h2')).toHaveText(`${'W'.repeat(220)} · study-02.png`); + await expect(fit).toBeFocused();await fit.click();await attachStageFit(page,testInfo,'late-title-fit'); + await expect.poll(async()=>{const data=await currentStageFit(page);return Math.abs(data.viewer.height-data.stage.height);}).toBeLessThanOrEqual(1); + const current=await currentStageFit(page);expect(current.stage.height).toBeLessThan(initial.stage.height);expect(Math.abs(current.center.y)).toBeLessThanOrEqual(1);expect(current.contained).toBe(true); + await page.screenshot({path:testInfo.outputPath('late-title-fit.png')}); + expect(await page.evaluate(()=>window.fitImage===document.querySelector('.viewer-canvas img')&&window.fitControl===document.querySelector('[aria-label="Fit image"]')&&window.fitInstances.length===1)).toBe(true); + await modal.getByRole('button',{name:'Zoom in',exact:true}).click();await modal.getByRole('button',{name:'Rotate image 90 degrees',exact:true}).click();await modal.getByRole('button',{name:'Flip image horizontally',exact:true}).click(); + const transformed=await page.evaluate(()=>({...window.fitInstances[0].imageData}));await page.evaluate(()=>window.stageFitFixture.controller.retry()); + await expect(modal.locator('.file-name')).toHaveText('study-02.png');await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))); + expect(await page.evaluate(()=>window.fitInstances[0].imageData)).toEqual(transformed); + await fit.click();const fitted=await currentStageFit(page);expect(Math.abs(fitted.center.y)).toBeLessThanOrEqual(1);expect(fitted.contained).toBe(true); + await modal.getByRole('button',{name:'Next media',exact:true}).click();await expect(modal.getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false'); + await page.evaluate(()=>{for(const probe of window.fitObservers.filter(probe=>probe.disconnected))probe.callback([],probe.observer);}); + await modal.getByRole('button',{name:'Close preview',exact:true}).click();await expect(modal).toHaveCount(0); + await page.evaluate(async()=>{for(const probe of window.fitObservers)probe.callback([],probe.observer);await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));}); + expect(await page.evaluate(()=>window.fitObservers.every(probe=>probe.disconnected))).toBe(true); + expect(await page.evaluate(()=>window.fitResizeCalls.filter(call=>call.destroyed||!call.connected))).toEqual([]); + } finally {release();await page.evaluate(()=>window.stageFitFixture.scope.dispose());} +}); diff --git a/tests/browser/prompt-inspector.spec.mjs b/tests/browser/prompt-inspector.spec.mjs index 49cb015..e509be9 100644 --- a/tests/browser/prompt-inspector.spec.mjs +++ b/tests/browser/prompt-inspector.spec.mjs @@ -36,6 +36,34 @@ async function chooseDestination(page) { return sender; } +test('collection viewer rejects stale source and sends only refreshed API evidence',async({page,request,context})=>{ + const host=await fakeHost(context,page); + await page.addInitScript(()=>{window.copied=[];Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async text=>window.copied.push(text)}});}); + try { + const loaded=await(await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json();const stale=loaded.images.find(image=>image.root_index===0&&image.path==='shared/same.png'); + await page.goto(workspace);await select(page,'1:shared/same.png');await expect(details(page)).toContainText('Dog landscape'); + await expect(await request.post('/prompt_manager/settings',{data:{gallery_root_paths:['input','output']}})).toBeOK(); + const response=page.waitForResponse(result=>result.url().includes('/images/inspect/shared/same.png?root=0')); + await select(page,'0:shared/same.png');const changed=await response;expect(changed.status()).toBe(409);expect((await changed.json()).code).toBe('source-changed'); + await page.locator('article[data-image-key="0:shared/same.png"] .image-card-footer button').click();const preview=page.getByRole('dialog',{name:'Image preview',exact:true}); + const message='The source changed. Refresh the image collection before inspecting this file.'; + await expect(preview).toContainText(message);await expect(preview.locator('[data-copy-role],[data-send-role],.file-inspector a[download]')).toHaveCount(0); + const original=preview.getByRole('group',{name:'Media controls',exact:true}).getByLabel('Download original',{exact:true}); + await expect(original).toHaveAttribute('aria-disabled','true');await expect(original).not.toHaveAttribute('href',/./); + let downloads=0;page.on('download',()=>downloads++);await original.focus();await page.keyboard.press('Enter');expect(downloads).toBe(0); + await expect(preview.getByRole('button',{name:'Retry media',exact:true})).toBeHidden(); + await expect(preview.getByRole('button',{name:'Retry prompt details',exact:true})).toHaveCount(0);await expect(preview).not.toContainText('Dog landscape');await expect(preview).not.toContainText('Cat portrait'); + expect(await page.evaluate(()=>window.copied)).toEqual([]);expect(await host.evaluate(()=>window.applied)).toEqual([]); + const refreshed=page.waitForResponse(result=>result.url().includes('/images/output?')); + await preview.getByRole('button',{name:'Refresh image collection',exact:true}).click();await refreshed;await expect(preview).toHaveCount(0); + const collection=await(await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json();const fresh=collection.images.find(image=>image.root_index===0&&image.path===stale.path);expect(fresh.root_scope).not.toBe(stale.root_scope); + const evidence=await(await request.get(`/prompt_manager/images/inspect/shared/same.png?root=0&root_scope=${fresh.root_scope}`)).json(); + await select(page,'0:shared/same.png');await details(page).getByRole('button',{name:'Open full preview',exact:true}).click();await expect(preview).toContainText(evidence.prompts.positive.text); + await preview.getByRole('button',{name:'Send prompt pair',exact:true}).click();const sender=await chooseDestination(page);await sender.getByRole('button',{name:'Preview changes',exact:true}).click();await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.'); + expect(await host.evaluate(()=>window.applied)).toEqual([{positive:evidence.prompts.positive.text,negative:evidence.prompts.negative.text}]);expect(await page.evaluate(()=>window.copied)).toEqual([]); + } finally {await expect(await request.post('/prompt_manager/settings',{data:{gallery_root_paths:['output','input']}})).toBeOK();await host.evaluate(()=>window.fixtureHost.dispose());await host.close();} +}); + test('physical image inspector resolves positive and negative metadata separately for duplicate filenames',async({page})=>{ await page.goto(workspace);await select(page,'0:shared/same.png'); await expect(role(details(page),'Positive')).toContainText('Cat portrait');await expect(role(details(page),'Negative')).toContainText('blurry, watermark'); @@ -141,7 +169,7 @@ test('no open ComfyUI window is actionable and rediscovery finds a newly opened test('mobile inspector exposes full long prompts, truthful uncertainty and copy failure feedback',async({page})=>{ const text=`A long detailed prompt ${'with readable full composition, '.repeat(150)}END OF FULL PROMPT`; await page.addInitScript(()=>Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async()=>{throw new Error('Permission denied');}}})); - await page.route('**/images/inspect/**',route=>route.fulfill({json:{success:true,image:{root_index:0,path:'shared/same.png',filename:'same.png'},prompts:{positive:{status:'resolved',text,source:'execution'},negative:{status:'ambiguous',text:null,source:null}}}})); + await page.route('**/images/inspect/**',route=>route.fulfill({json:{success:true,root_scope:new URL(route.request().url()).searchParams.get('root_scope'),image:{root_index:0,path:'shared/same.png',filename:'same.png'},prompts:{positive:{status:'resolved',text,source:'execution'},negative:{status:'ambiguous',text:null,source:null}}}})); await page.setViewportSize({width:390,height:844});await page.goto(workspace);await select(page,'0:shared/same.png'); const positive=role(details(page),'Positive');await expect(positive.locator('pre')).toHaveText(text); await expect(role(details(page),'Negative')).toContainText('Multiple possible prompts. Source could not be determined.'); @@ -195,7 +223,7 @@ test('responsive gallery defers observed size writes and still fits the new view }); test('unverified saved associations explain uncertainty and cannot be sent',async({page})=>{ - await page.route('**/images/inspect/uncertain.png?root=0',route=>route.fulfill({json:{success:true,prompts:{ + await page.route('**/images/inspect/uncertain.png?root=0*',route=>route.fulfill({json:{success:true,root_scope:new URL(route.request().url()).searchParams.get('root_scope'),image:{root_index:0,path:'uncertain.png',filename:'uncertain.png'},prompts:{ positive:{status:'unresolved',text:null,source:'saved-association',message:'A saved library association exists, but it cannot be verified against this file.'}, negative:{status:'absent',text:null,source:null}, }}})); diff --git a/tests/browser/server.py b/tests/browser/server.py index b2575af..419fa3a 100644 --- a/tests/browser/server.py +++ b/tests/browser/server.py @@ -15,6 +15,7 @@ from PIL import Image from PIL.PngImagePlugin import PngInfo from tests.browser.workspace_visual_fixture import seed_workspace_visual +from tests.browser.workspace_inspector_fixture import seed_workspace_inspector class TestModel: @@ -226,6 +227,7 @@ def create_app(root: Path) -> web.Application: async def reset(request: web.Request) -> web.Response: data = await request.json() if request.can_read_body else {} workspace_visual = data.get("workspace_visual", False) + full_inspector = data.get("full_inspector", False) extra = data.get("extra_images", 0) include_video = data.get("include_video", False) prompt_inspector = data.get("prompt_inspector", False) @@ -234,6 +236,17 @@ def create_app(root: Path) -> web.Application: extra_variants = data.get("extra_variants", 0) if ( not isinstance(workspace_visual, bool) + or not isinstance(full_inspector, bool) + or ( + full_inspector + and ( + workspace_visual + or prompt_inspector + or native_library + or extra_prompts != 0 + or extra_variants != 0 + ) + ) or ( workspace_visual and any( @@ -245,6 +258,7 @@ def create_app(root: Path) -> web.Application: "native_library", "extra_prompts", "extra_variants", + "full_inspector", ) ) ) @@ -265,6 +279,13 @@ def create_app(root: Path) -> web.Application: if workspace_visual: await api._run_in_executor(seed_workspace_visual, api, root) return web.json_response({"success": True}) + if full_inspector: + await api._run_in_executor( + lambda: seed_workspace_inspector( + api, root, extra_images=extra, include_video=include_video + ) + ) + return web.json_response({"success": True}) await api._run_in_executor( seed, extra, diff --git a/tests/browser/workspace-images.spec.mjs b/tests/browser/workspace-images.spec.mjs index 219170e..520c438 100644 --- a/tests/browser/workspace-images.spec.mjs +++ b/tests/browser/workspace-images.spec.mjs @@ -97,6 +97,66 @@ test('a short visible batch automatically continues until the sentinel is filled await expect(page.getByRole('button', { name: 'Load more images', exact: true })).toBeHidden(); }); +for (const embedded of [false, true]) { + for (const close of ['Close', 'Escape', 'parent abort']) { + test(`viewer closure resumes native continuation after a short pending page: ${embedded ? 'embedded' : 'Images'} ${close}`, async ({ page, request }, testInfo) => { + await expect(await request.post('/__test__/reset', { data: { extra_images: 80 } })).toBeOK(); + const data = await (await request.get('/prompt_manager/images/output?limit=200')).json(); + await page.setViewportSize({ width: 1600, height: 1100 }); + await page.route('**/prompt_manager/workspace', async route => { + const response = await route.fetch(); + const html = (await response.text()).replace('', ''); + await route.fulfill({ response, body: html }); + }); + await page.goto('/prompt_manager/workspace'); + await page.evaluate(async ({ images, embedded }) => { + const { mountImages } = await import('/prompt_manager/js/workspace/images.mjs'); + const { createScope } = await import('/prompt_manager/js/shared/toolkit/core.js'); + window.ApiClient = { resolveUrl: value => value }; + const fixture = window.continuationFixture = { reads: [], scope: createScope() }; + const api = { get: async (url, options) => (await fetch(url, options)).json() }; + const fetchPage = ({ parameters }) => { + fixture.reads.push(parameters.get('cursor')); + if (fixture.reads.length === 1) return { images: images.slice(0, 40), total: 80, has_more: true, next_cursor: 'next-0' }; + if (fixture.reads.length === 2) return new Promise(resolve => { + fixture.finishPage = () => resolve({ images: [], total: 80, has_more: true, next_cursor: 'next-1' }); + }); + return { images: images.slice(40, 80), total: 80, has_more: false, next_cursor: null }; + }; + mountImages(document.querySelector('#workspace-main'), api, fixture.scope, { embedded, fetchPage }); + }, { images: data.images, embedded }); + await expect(cards(page)).toHaveCount(40); + expect(await page.evaluate(() => window.continuationFixture.reads)).toEqual([null]); + await page.locator('.image-continuation').scrollIntoViewIfNeeded(); + await expect.poll(() => page.evaluate(() => window.continuationFixture.reads)).toEqual([null, 'next-0']); + const key = await cards(page).last().getAttribute('data-image-key'); + const preview = page.locator(`article[data-image-key="${key}"]`).getByRole('button', { name: /^Preview / }); + await preview.click(); + const viewer = page.getByRole('dialog', { name: 'Image preview', exact: true }); + await expect(viewer.locator('.viewer-canvas img')).toBeVisible(); + await page.evaluate(() => window.continuationFixture.finishPage()); + await expect(page.getByRole('button', { name: 'Load more images', exact: true })).toHaveAttribute('aria-disabled', 'false'); + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + expect(await page.evaluate(() => ({ reads: window.continuationFixture.reads, nativeObserver: typeof IntersectionObserver, visible: document.querySelector('.image-continuation').getBoundingClientRect().top <= innerHeight }))) + .toEqual({ reads: [null, 'next-0'], nativeObserver: 'function', visible: true }); + if (close === 'parent abort') await page.evaluate(() => window.continuationFixture.scope.dispose()); + else if (close === 'Close') await viewer.getByRole('button', { name: 'Close preview', exact: true }).click(); + else await page.keyboard.press('Escape'); + await expect(viewer).toHaveCount(0); + if (close !== 'parent abort') { + await expect(preview).toBeFocused(); + await expect.poll(() => page.evaluate(() => window.continuationFixture.reads)).toEqual([null, 'next-0', 'next-1']); + await expect(page.locator('.image-toolbar')).toContainText(embedded ? '80 loaded' : '80 of 80'); + } + await page.evaluate(() => window.continuationFixture.scope.dispose()); + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + const reads = await page.evaluate(() => window.continuationFixture.reads); + expect(reads).toEqual(close === 'parent abort' ? [null, 'next-0'] : [null, 'next-0', 'next-1']); + await testInfo.attach('native-continuation-reads', { body: JSON.stringify({ embedded, close, reads }), contentType: 'application/json' }); + }); + } +} + test('source, folder and media filters operate on real backend records', async ({ page, request }) => { await expect(await request.post('/__test__/reset', { data: { extra_images: 3 } })).toBeOK(); await page.goto(workspace); @@ -336,6 +396,44 @@ test('real video media has a usable card and native playback cleans up on close expect(await page.evaluate(() => window.__previewVideo.paused && !window.__previewVideo.isConnected)).toBe(true); }); +test('collection viewer ignores delayed media callbacks and keeps inspection retries independent',async({page})=>{ + await page.goto(workspace);await expect(cards(page)).toHaveCount(2); + await page.evaluate(()=>{const Native=window.Viewer;window.liveViewers=[];window.Viewer=class extends Native {constructor(...args){super(...args);window.liveViewers.push(this);}};}); + let release,held;const waiting=new Promise(resolve=>{held=resolve;});const pending=new Promise(resolve=>{release=resolve;}); + await page.route('**/images/serve/shared/same.png?*',async route=>{ + const url=new URL(route.request().url()); + if(url.searchParams.get('root')==='0'&&url.searchParams.has('_pm_viewer')) {held();await pending;} + await route.continue().catch(()=>{}); + }); + await cards(page).first().getByRole('button',{name:'Preview same.png',exact:true}).click();await waiting; + const preview=page.getByRole('dialog',{name:'Image preview',exact:true}); + await expect(preview.getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','true'); + await page.route('**/images/inspect/shared/same.png?*',route=>route.fulfill({status:503,json:{success:false,error:'Unavailable'}}),{times:1}); + await preview.getByRole('button',{name:'Next media',exact:true}).click(); + await expect(preview).toContainText('Could not read prompts from this file.');await expect(preview.getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false'); + await expect(preview.getByRole('group',{name:'Media controls',exact:true}).getByRole('link',{name:'Download original',exact:true})).toHaveAttribute('href',/root=1&root_scope=/); + await preview.getByRole('button',{name:'Rotate image 90 degrees',exact:true}).click(); + await page.evaluate(()=>{window.latestCanvas=document.querySelector('.viewer-canvas img');window.liveViewers[0].options.viewed();});release(); + expect(await page.evaluate(()=>window.liveViewers.at(-1).imageData.rotate)).toBe(90); + await preview.getByRole('button',{name:'Retry prompt details',exact:true}).click(); + await expect(preview).toContainText('same.png');await expect(preview.getByRole('button',{name:'Retry prompt details',exact:true})).toHaveCount(0); + expect(await page.evaluate(()=>window.latestCanvas===document.querySelector('.viewer-canvas img'))).toBe(true); + await page.evaluate(()=>{location.hash='/overview';});await expect(page.locator('.viewer-container,.image-preview')).toHaveCount(0); +}); + +test('collection viewer describes video transforms and releases native playback on navigation',async({page,request})=>{ + await expect(await request.post('/__test__/reset',{data:{include_video:true}})).toBeOK();await page.goto(workspace); + await page.locator('article[data-image-key="0:clip.webm"] .image-card-footer button').click(); + const preview=page.getByRole('dialog',{name:'Image preview',exact:true}),video=preview.locator('video'); + await expect(video).toHaveJSProperty('controls',true);await expect(video).toHaveJSProperty('playsInline',true); + const zoom=preview.getByRole('button',{name:'Zoom in',exact:true});await expect(zoom).toHaveAttribute('aria-disabled','true');await expect(zoom).toHaveAccessibleDescription('Image transforms are unavailable for video.'); + await video.focus();const position=await preview.locator('.image-preview-position').textContent();await page.keyboard.press('ArrowRight');await expect(preview.locator('.image-preview-position')).toHaveText(position); + await video.evaluate(async node=>{window.previousClip=node;node.muted=true;await node.play();}); + await preview.getByRole('button',{name:'Next media',exact:true}).click();await expect(preview.locator('video')).toHaveCount(0);await expect(zoom).toHaveAttribute('aria-disabled','false'); + expect(await page.evaluate(()=>window.previousClip.paused&&!window.previousClip.isConnected&&!window.previousClip.hasAttribute('src'))).toBe(true); + await preview.getByRole('button',{name:'Close preview',exact:true}).click();await expect(page.locator('.image-preview')).toHaveCount(0); +}); + test('failed image requests offer retry while stale results cannot replace a newer route', async ({ page }) => { await page.route('**/prompt_manager/images/output?*', route => route.fulfill({ status: 503, json: { success: false, error: 'Images unavailable' }, @@ -380,6 +478,92 @@ test('source and folder discovery failure stays visible and Refresh restores usa await expect(cards(page)).toHaveCount(1); }); +async function staleSourceRecovery(page, request, roots) { + await expect(await request.post('/__test__/reset', { data: { extra_images: 80 } })).toBeOK(); + await page.setViewportSize({ width: 1600, height: 1100 }); + await page.goto(workspace); + const source = page.getByRole('combobox', { name: 'Source', exact: true }); + const folder = page.getByRole('combobox', { name: 'Folder', exact: true }); + await source.selectOption('0');await expect(folder.locator('option[value="bulk"]')).toHaveCount(1);await folder.selectOption('bulk'); + const configured = page.waitForResponse(response => { + const url = new URL(response.url());return url.pathname.endsWith('/images/output') && url.searchParams.get('root') === '0' && url.searchParams.get('subfolder') === 'bulk' && url.searchParams.get('q') === 'png' && url.searchParams.get('media') === 'image' && url.searchParams.get('sort') === 'name'; + }); + await page.getByRole('searchbox', { name: 'Search filenames', exact: true }).fill('png'); + await page.getByRole('combobox', { name: 'Media', exact: true }).selectOption('image');await page.getByRole('combobox', { name: 'Sort', exact: true }).selectOption('name'); + const initial = await (await configured).json();await expect(page.locator('.image-toolbar')).toContainText('40 of 80'); + let oldPage; + await page.route('**/prompt_manager/images/output?*', async route => { + if (new URL(route.request().url()).searchParams.has('cursor') && !oldPage) { + const response = await route.fetch();oldPage = { route, data: await response.json() }; + } else await route.continue(); + }); + await page.getByRole('button', { name: 'Load more images', exact: true }).scrollIntoViewIfNeeded();await expect.poll(() => !!oldPage).toBe(true); + await expect(await request.post('/prompt_manager/settings', { data: { gallery_root_paths: roots } })).toBeOK(); + const selected = initial.images.at(-1), key = `${selected.root_index}:${selected.path}`; + const stale = page.waitForResponse(response => response.url().includes('/images/inspect/') && response.status() === 409); + await page.locator(`article[data-image-key="${key}"] .image-open`).click();expect((await stale).status()).toBe(409); + await expect(details(page)).toContainText('The source changed. Refresh the image collection before inspecting this file.'); + await page.locator(`article[data-image-key="${key}"] .image-card-footer button`).click(); + const viewer = page.getByRole('dialog', { name: 'Image preview', exact: true });await expect(viewer).toContainText('The source changed.'); + const pages = [], inspections = [], options = [], mutations = []; + page.on('request', request => { + const url = new URL(request.url()); + if (url.pathname.endsWith('/images/output')) pages.push(url); + if (url.pathname.includes('/images/inspect/')) inspections.push(url); + if (request.method() !== 'GET') mutations.push(url.pathname); + }); + await page.route('**/prompt_manager/gallery/subfolders?*', route => { options.push(route); }); + await viewer.getByRole('button', { name: 'Refresh image collection', exact: true }).click(); + await expect(viewer).toHaveCount(0);await expect.poll(() => options.length).toBe(1); + await oldPage.route.fulfill({ json: oldPage.data }); + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + return { source, folder, pages, inspections, options, mutations }; +} + +for (const [name, roots] of [['reordered', ['input', 'output']], ['removed', ['input']]]) { + test(`source-change recovery clears a ${name} root before discovery and safe retry`, async ({ page, request }, testInfo) => { + const recovery = await staleSourceRecovery(page, request, roots); + try { + expect(recovery.pages.map(url => url.search)).toEqual([]);expect(recovery.inspections).toEqual([]); + await expect(recovery.source).toHaveValue('all');await expect(recovery.source.locator('option')).toHaveText(['All sources']);await expect(recovery.folder).toHaveValue(''); + for (const name of ['Source', 'Folder', 'Media', 'Sort']) await expect(page.getByRole('combobox', { name, exact: true })).toBeDisabled(); + await expect(page.getByRole('searchbox', { name: 'Search filenames', exact: true })).toBeDisabled(); + await expect(page.getByRole('button', { name: 'Load more images', exact: true })).toBeDisabled(); + await page.getByRole('button', { name: 'Load more images', exact: true }).evaluate(node => node.click());await recovery.source.dispatchEvent('change'); + await recovery.options[0].fulfill({ status: 503, json: { success: false, error: 'Discovery unavailable' } }); + await expect(page.getByRole('alert').filter({ hasText: 'Sources and folders could not be loaded.' })).toBeVisible(); + expect(recovery.pages).toEqual([]);expect(recovery.inspections).toEqual([]);await expect(cards(page)).toHaveCount(0); + const retry = details(page).getByRole('button', { name: 'Refresh image collection', exact: true });await expect(retry).toBeEnabled(); + if (testInfo.project.name === 'chromium' && name === 'reordered') await page.screenshot({ path: testInfo.outputPath('safe-source-discovery-failure.png') }); + await retry.click();await expect.poll(() => recovery.options.length).toBe(2); + expect(new URL(recovery.options[1].request().url()).searchParams.has('root')).toBe(false); + const response = await recovery.options[1].fetch();await recovery.options[1].fulfill({ response }); + await expect.poll(() => recovery.pages.length).toBe(1); + const query = recovery.pages[0].searchParams;expect(query.has('root')).toBe(false);expect(query.has('subfolder')).toBe(false); + expect(query.get('q')).toBe('png');expect(query.get('media')).toBe('image');expect(query.get('sort')).toBe('name'); + await expect(recovery.source).toBeEnabled();await expect(recovery.source).toHaveValue('all'); + await expect(recovery.source.locator('option')).toHaveText(['All sources', ...roots]); + await expect.poll(() => recovery.inspections.length).toBe(1);expect(recovery.inspections[0].searchParams.get('root')).toBe(name === 'reordered' ? '1' : '0'); + expect(recovery.mutations).toEqual([]); + if (testInfo.project.name === 'chromium' && name === 'reordered') await page.screenshot({ path: testInfo.outputPath('safe-source-recovery-success.png') }); + } finally { await expect(await request.post('/prompt_manager/settings', { data: { gallery_root_paths: ['output', 'input'] } })).toBeOK(); } + }); +} + +test('source-change recovery ignores superseded discovery and route disposal', async ({ page, request }) => { + const recovery = await staleSourceRecovery(page, request, ['input', 'output']); + try { + expect(recovery.pages).toEqual([]);await details(page).getByRole('button', { name: 'Refresh image collection', exact: true }).click();await expect.poll(() => recovery.options.length).toBe(2); + await recovery.options[0].fulfill({ json: { roots: [{ index: 0, label: 'Obsolete source label' }], subfolders: ['obsolete'] } }); + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + expect(recovery.pages).toEqual([]);await expect(recovery.source.locator('option')).toHaveText(['All sources']); + await page.getByRole('navigation', { name: 'Workspace', exact: true }).getByRole('link', { name: 'Overview', exact: true }).click(); + await recovery.options[1].fulfill({ json: { roots: [{ index: 0, label: 'Late source label' }], subfolders: ['late'] } }); + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + await expect(page.locator('#workspace-main h1')).toHaveText('Overview');expect(recovery.pages).toEqual([]);expect(recovery.inspections).toEqual([]);expect(recovery.mutations).toEqual([]); + } finally { await expect(await request.post('/prompt_manager/settings', { data: { gallery_root_paths: ['output', 'input'] } })).toBeOK(); } +}); + test('mobile Images keeps filters, layouts and selected details within the viewport', async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await page.goto(workspace); @@ -518,9 +702,10 @@ test('Images uses approved geometry while keeping real controls', async ({page,r expect(geometry[0].mediaWidth/geometry[0].mediaHeight).toBeCloseTo(4/3,2); expect((await page.locator('.image-toolbar').boundingBox()).y).toBeLessThan(geometry[0].y); await expect(cards(page).first()).toHaveCSS('outline-style','solid'); - const cover=details(page).locator(':scope > img'); + const cover=details(page).getByRole('button',{name:'Open full preview',exact:true}); await expect(cover).toHaveCSS('height','150px'); - expect(await details(page).evaluate(node=>node.firstElementChild.tagName)).toBe('IMG'); + await expect(cover.locator('img')).toBeVisible();await expect(cover.locator('img')).toHaveCSS('height','150px'); + expect(await cover.evaluate(node=>node===node.parentElement.firstElementChild&&node.tagName==='BUTTON')).toBe(true); await page.setViewportSize({width:390,height:844}); await expect.poll(()=>cards(page).nth(1).evaluate(node=>node.offsetTop)).toBe(0); await expect.poll(()=>cards(page).nth(2).evaluate(node=>node.offsetTop)).toBeGreaterThan(0); diff --git a/tests/browser/workspace-library.spec.mjs b/tests/browser/workspace-library.spec.mjs index a6a530d..2b29e30 100644 --- a/tests/browser/workspace-library.spec.mjs +++ b/tests/browser/workspace-library.spec.mjs @@ -195,6 +195,8 @@ test('history shares real image metadata, vendored Viewer and the acknowledged p await sender.getByRole('button',{name:'Close',exact:true}).click(); await media(page).first().getByRole('button',{name:'Open same.png',exact:true}).click(); const inspector=page.getByRole('region',{name:'Image details',exact:true});await expect(inspector.getByRole('region',{name:'Positive prompt',exact:true})).toContainText('Cat portrait'); + await expect(inspector.locator('.generation-facts dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']); + await expect(inspector.locator('summary')).toHaveText(['File & provenance','Workflow & raw metadata']); await inspector.getByRole('button',{name:'Open full preview',exact:true}).click();await expect(page.locator('dialog[open] .viewer-canvas img')).toBeVisible(); await page.keyboard.press('Escape');await expect(page.locator('dialog[open]')).toHaveCount(0); await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Overview',exact:true}).click();await expect(page.locator('.viewer-container')).toHaveCount(0); diff --git a/tests/browser/workspace_inspector_fixture.py b/tests/browser/workspace_inspector_fixture.py new file mode 100644 index 0000000..cfd8776 --- /dev/null +++ b/tests/browser/workspace_inspector_fixture.py @@ -0,0 +1,138 @@ +"""Owned, deterministic physical-file evidence for the full inspector workflows.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import shutil +from typing import TYPE_CHECKING + +from PIL import Image +from PIL.PngImagePlugin import PngInfo + +from tests.browser.workspace_visual_fixture import seed_workspace_visual + +if TYPE_CHECKING: + from py.api import PromptManagerAPI + + +INSPECTOR_POSITIVE = ( + "Inspector portrait 猫 😀\n" + "Full prompt context, fine geometry and light. " * 40 +) +INSPECTOR_NEGATIVE = "blurred edges, watermark" + + +def _graph(positive: str, negative: str) -> dict: + return { + "1": {"class_type": "CLIPTextEncode", "inputs": {"text": positive}}, + "2": {"class_type": "CLIPTextEncode", "inputs": {"text": negative}}, + "3": { + "class_type": "KSampler", + "inputs": { + "positive": ["1", 0], + "negative": ["2", 0], + "model": ["4", 0], + "seed": 9007199254740993, + "steps": 28, + "cfg": 6.5, + "sampler_name": "dpmpp_2m", + "scheduler": "karras", + }, + }, + "4": { + "class_type": "CheckpointLoaderSimple", + "inputs": {"ckpt_name": "fixture-model.safetensors"}, + }, + } + + +def _image(path: Path, index: int, metadata: dict) -> None: + info = PngInfo() + for key, value in metadata.items(): + text = ( + value if isinstance(value, str) else json.dumps(value, ensure_ascii=False) + ) + info.add_itxt(key, text, zip=True) + path.parent.mkdir(parents=True, exist_ok=True) + with Image.new("RGB", (720, 480), (30 + index * 9, 74, 98)) as image: + image.save(path, pnginfo=info) + stamp = 1_780_000_000 + index + os.utime(path, (stamp, stamp)) + + +def seed_workspace_inspector( + api: PromptManagerAPI, + root: Path, + *, + extra_images: int = 0, + include_video: bool = False, +) -> None: + """Seed only disposable harness media and exact saved associations.""" + seed_workspace_visual(api, root) + complete = _graph(INSPECTOR_POSITIVE, INSPECTOR_NEGATIVE) + workflow = { + "nodes": [ + { + "id": 3, + "type": "KSampler", + "widgets_values": [ + 9007199254740993, + "fixed", + 28, + 6.5, + "dpmpp_2m", + "karras", + 1, + ], + } + ], + "links": [], + } + ambiguous = {**complete, "5": complete["3"]} + examples = ( + ("complete.png", {"prompt": complete, "workflow": workflow}), + ("empty.png", {}), + ("explicit-empty.png", {"prompt": _graph("Explicit empty negative", "")}), + ("ambiguous.png", {"prompt": ambiguous}), + ("workflow-only.png", {"workflow": workflow}), + ("malformed.png", {"prompt": "not-json", "workflow": "{broken"}), + ("control-heavy.png", {"parameters": "\x01" * 1_450_000}), + ) + for index, (name, metadata) in enumerate(examples): + _image(root / "output" / "inspector" / name, index, metadata) + _image( + root / "input" / "inspector" / "complete.png", + 8, + {"prompt": _graph("Input collision", "")}, + ) + pair = api.db.save_prompt_pair( + INSPECTOR_POSITIVE, + INSPECTOR_NEGATIVE, + title="Inspector recipe", + category="Inspection", + tags=["fixture", "geometric"], + ) + positive = api.db.get_prompt_pair(pair)["positive_prompt_id"] + image = api.db.link_image_to_prompt( + positive, str(root / "output" / "inspector" / "complete.png") + ) + api.db.link_image_to_pair(pair, image, {"source": "fixture"}) + api.db.link_image_to_prompt( + positive, str(root / "output" / "inspector" / "workflow-only.png") + ) + for number in range(extra_images): + _image( + root / "output" / "inspector" / f"extra-{number:03}.png", + 9 + number % 12, + {"prompt": complete}, + ) + if include_video: + shutil.copyfile( + Path(__file__).resolve().parents[1] / "fixtures" / "tiny.webm", + root / "output" / "inspector" / "clip.webm", + ) + os.utime( + root / "output" / "inspector" / "clip.webm", (1_780_000_100, 1_780_000_100) + ) + api.invalidate_gallery_cache() diff --git a/tests/comfyui/prompt-sender.spec.mjs b/tests/comfyui/prompt-sender.spec.mjs index a8beeeb..b015a5a 100644 --- a/tests/comfyui/prompt-sender.spec.mjs +++ b/tests/comfyui/prompt-sender.spec.mjs @@ -7,11 +7,13 @@ installHostHooks(test,['PromptManagerPair']); test('native image inspector previews and sends a pair into real visible widgets without loading or queueing',async({page,context,request},testInfo)=>{ const positive=`Inspector host positive ${testInfo.project.name}`;const negative='host negative, watermark'; const image=execFileSync(process.env.PM_TEST_PYTHON||'python',['-c',[ - 'import io,sys', + 'import io,json,sys', 'from PIL import Image', 'from PIL.PngImagePlugin import PngInfo', 'info=PngInfo()', - 'info.add_text("parameters",sys.argv[1]+"\\nNegative prompt: "+sys.argv[2]+"\\nSteps: 20, Sampler: Euler")', + 'graph={"1":{"class_type":"CLIPTextEncode","inputs":{"text":sys.argv[1]}},"2":{"class_type":"CLIPTextEncode","inputs":{"text":sys.argv[2]}},"3":{"class_type":"KSampler","inputs":{"positive":["1",0],"negative":["2",0],"model":["4",0],"seed":9007199254740993,"steps":28,"cfg":6.5,"sampler_name":"dpmpp_2m","scheduler":"karras"}},"4":{"class_type":"CheckpointLoaderSimple","inputs":{"ckpt_name":"host-fixture.safetensors"}}}', + 'info.add_text("prompt",json.dumps(graph))', + 'info.add_text("workflow",json.dumps({"nodes":[],"links":[]}))', 'buffer=io.BytesIO()', 'Image.new("RGB",(64,64),(30,80,140)).save(buffer,format="PNG",pnginfo=info)', 'sys.stdout.buffer.write(buffer.getvalue())', @@ -44,7 +46,23 @@ test('native image inspector previews and sends a pair into real visible widgets await card.getByRole('button',{name:`Open ${filename}`,exact:true}).click(); const inspector=workspace.getByRole('region',{name:'Image details',exact:true}); await expect(inspector.getByRole('region',{name:'Positive prompt',exact:true})).toContainText(positive); - await inspector.getByRole('button',{name:'Send prompt pair',exact:true}).click(); + await inspector.getByRole('button',{name:'Open full preview',exact:true}).click(); + const viewer=workspace.getByRole('dialog',{name:'Image preview',exact:true}); + await expect(viewer.locator('.viewer-canvas img')).toBeVisible(); + await expect(viewer.locator('.generation-facts dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','host-fixture.safetensors']); + await viewer.getByText('Workflow & raw metadata',{exact:true}).click(); + await expect(viewer.locator('pre code').first()).toContainText('9007199254740993'); + await expect(viewer.getByRole('button',{name:'Download workflow JSON',exact:true})).not.toHaveAttribute('aria-disabled','true'); + expect(await page.evaluate(()=>window.senderEffects)).toEqual({queue:0,load:0}); + for(const renderer of [!testInfo.project.metadata.initialRenderer,testInfo.project.metadata.initialRenderer]) { + await page.evaluate(async renderer=>{const {app}=await import('/scripts/app.js');await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled',renderer);await app.loadGraphData(app.graph.serialize());app.canvas.centerOnNode(app.graph._nodes[0]);},renderer); + await expect.poll(()=>page.evaluate(()=>LiteGraph.vueNodesMode)).toBe(renderer); + await expect(viewer).toBeVisible();await expect(viewer.locator('.generation-facts dd').first()).toHaveText('9007199254740993'); + await expect(nodeRoot.getByRole('textbox',{name:'Positive prompt',exact:true})).toHaveValue('Before positive'); + } + expect(await page.evaluate(()=>window.senderEffects.queue)).toBe(0); + await page.evaluate(()=>{window.senderSendBaseline={...window.senderEffects};}); + await viewer.getByRole('button',{name:'Send prompt pair',exact:true}).click(); const sender=workspace.getByRole('dialog',{name:'Send to ComfyUI',exact:true}); const hosts=sender.getByRole('combobox',{name:'ComfyUI window',exact:true}); await expect(hosts.locator('option')).toHaveCount(2); @@ -60,9 +78,11 @@ test('native image inspector previews and sends a pair into real visible widgets const state=await page.evaluate(async id=>{ const {app}=await import('/scripts/app.js');const node=app.graph.getNodeById(id);const serialized=node.serialize(); return{inputs:(await app.graphToPrompt()).output[String(id)].inputs, - serialized:Object.fromEntries(node.widgets.map((widget,index)=>[widget.name,serialized.widgets_values[index]])),effects:window.senderEffects}; + serialized:Object.fromEntries(node.widgets.map((widget,index)=>[widget.name,serialized.widgets_values[index]])), + effects:{queue:window.senderEffects.queue-window.senderSendBaseline.queue,load:window.senderEffects.load-window.senderSendBaseline.load}}; },id); for(const name of ['inputs','serialized'])expect(state[name]).toMatchObject({text:positive,negative_text:negative,prepend_text:'LoRA trigger',append_text:'fine detail'}); expect(state.effects).toEqual({queue:0,load:0});expect(errors).toEqual([]); + await workspace.screenshot({path:testInfo.outputPath('full-inspector-host-send.png')}); } finally {await workspace.close();} }); diff --git a/tests/js/api-client.test.js b/tests/js/api-client.test.js index 9a6a362..5595111 100644 --- a/tests/js/api-client.test.js +++ b/tests/js/api-client.test.js @@ -113,6 +113,31 @@ test("a 2xx body with success:false throws ApiError", async () => { await assert.rejects(pending, (err) => err instanceof ApiError && err.status === 200 && err.message === "Nope"); }); +test("ApiError preserves only a bounded machine code without changing status or message", async () => { + assert.equal(new ApiError(409, "unchanged").code, null); + for (const [code, expected] of [ + ["source-changed", "source-changed"], ["a".repeat(64), "a".repeat(64)], + [undefined, null], [null, null], [4, null], [{ code: "x" }, null], + ["", null], ["Source-changed", null], ["source_changed", null], + ["source-changed\n", null], ["a".repeat(65), null], + ]) { + for (const status of [409, 200]) { + const api = createApiClient(async () => jsonResponse(status, { + success: false, error: "unchanged", code, private: "never expose", + })); + await assert.rejects(api.get("/x"), error => { + assert.ok(error instanceof ApiError); + assert.equal(error.status, status); + assert.equal(error.message, "unchanged"); + assert.equal(error.code, expected); + assert.equal(error.private, undefined); + assert.equal(error.body, undefined); + return true; + }); + } + } +}); + test("a 2xx body that is not JSON throws ApiError", async () => { const fetch = fakeFetch(); const api = createApiClient(fetch); diff --git a/tests/js/comfy-metadata.test.js b/tests/js/comfy-metadata.test.js index 12f23c0..451d6b2 100644 --- a/tests/js/comfy-metadata.test.js +++ b/tests/js/comfy-metadata.test.js @@ -1,7 +1,391 @@ // Run with: node --test tests/js/ const test = require("node:test"); const assert = require("node:assert/strict"); -const { extractGenerationParams } = require("../../web/js/comfy-metadata.js"); +const { extractGenerationParams, extractGenerationFacts } = require("../../web/js/comfy-metadata.js"); + +const parsedFactGraph = value => ({ status: "parsed", value }); +const absentFactGraph = () => ({ status: "absent", value: null }); +const factFields = ["seed", "steps", "cfg", "sampler", "scheduler", "model"]; +function factsFor(graph, extra = {}) { + assert.equal(typeof extractGenerationFacts, "function", "the conservative facts contract is exported"); + return extractGenerationFacts({ prompt_graph: parsedFactGraph(graph), workflow_graph: absentFactGraph(), metadataStatus: "available", ...extra }); +} +function factGraph(inputs = {}) { + return { + s: { class_type: "KSampler", inputs: { seed: "9007199254740993", steps: 28, cfg: "6.5000000000000000001", sampler_name: "dpmpp_2m", scheduler: "karras", model: ["model", 0], ...inputs } }, + model: { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "model.safetensors" } }, + }; +} + +test("facts keep unsafe seed and decimal strings exact", () => { + const facts = factsFor(factGraph()); + assert.deepEqual(Object.keys(facts), factFields); + assert.deepEqual(factFields.map(field => facts[field].value), ["9007199254740993", "28", "6.5000000000000000001", "dpmpp_2m", "karras", "model.safetensors"]); + for (const fact of Object.values(facts)) assert.deepEqual(fact, { + status: "known", value: fact.value, source: "api-graph", evidence: "configuration", reason: null, sampler_id: "s", + }); + const zero = factsFor(factGraph({ seed: 0, cfg: "0", steps: "1" })); + assert.equal(zero.seed.value, "0"); assert.equal(zero.cfg.value, "0"); + for (const inputs of [{ seed: 9007199254740992 }, { seed: "1.5" }, { steps: "0" }, { cfg: "NaN" }, { cfg: {} }, { cfg: true }]) { + const key = Object.keys(inputs)[0]; + assert.equal(factsFor(factGraph(inputs))[key].status, "unresolved"); + } +}); + +test("facts do not choose equal sampler branches or unrelated model", () => { + const graph = factGraph(); graph.other = structuredClone(graph.s); + const ambiguous = factsFor(graph); + assert.ok(Object.values(ambiguous).every(fact => fact.status === "ambiguous" && fact.value === null)); + const selected = factsFor(graph, { resolution: { state: "resolved", sampler_id: "other" } }); + assert.equal(selected.seed.sampler_id, "other"); + const unrelated = factsFor(factGraph({ model: ["missing", 0] })); + assert.equal(unrelated.model.status, "unresolved"); + assert.equal(unrelated.model.value, null); + assert.equal(factsFor({ only: graph.model }).seed.status, "unresolved"); +}); + +test("facts respect named scalar outputs and custom scheduler paths", () => { + const graph = factGraph({ seed: ["primitive", 0], steps: ["steps", 0], cfg: ["cfg", 0], sampler_name: ["sampler", 0], scheduler: ["scheduler", 0], model: ["lora", 0] }); + graph.primitive = { class_type: "PrimitiveInt", inputs: { value: "0" } }; + graph.steps = { class_type: "PrimitiveInt", inputs: { value: "50" } }; + graph.cfg = { class_type: "PrimitiveFloat", inputs: { value: "0" } }; + graph.sampler = { class_type: "PrimitiveString", inputs: { value: "euler" } }; + graph.scheduler = { class_type: "PrimitiveStringMultiline", inputs: { value: "normal" } }; + graph.lora = { class_type: "LoraLoader", inputs: { lora_name: "style", model: ["model", 0] } }; + assert.deepEqual(factFields.map(field => factsFor(graph)[field].value), ["0", "50", "0", "euler", "normal", "model.safetensors"]); + graph.settings = { class_type: "UnknownSettings", inputs: { first: 20, second: 30 } }; + graph.s.inputs.steps = ["settings", 0]; + assert.equal(factsFor(graph).steps.status, "unresolved"); + const custom = { + s: { class_type: "SamplerCustomAdvanced", inputs: { guider: ["g", 0], noise: ["n", 0], sampler: ["k", 0], sigmas: ["sigmas", 0] } }, + g: { class_type: "CFGGuider", inputs: { model: ["m", 0], cfg: "4.0000000000000001" } }, + n: { class_type: "RandomNoise", inputs: { noise_seed: "9007199254740993" } }, + k: { class_type: "KSamplerSelect", inputs: { sampler_name: "euler" } }, + sigmas: { class_type: "BasicScheduler", inputs: { steps: 22, scheduler: "karras" } }, + m: { class_type: "UNETLoader", inputs: { unet_name: "flux.safetensors" } }, + }; + assert.deepEqual(factFields.map(field => factsFor(custom)[field].value), ["9007199254740993", "22", "4.0000000000000001", "euler", "karras", "flux.safetensors"]); + custom.s = { class_type: "SamplerCustom", inputs: { model: ["m", 0], cfg: 0, noise_seed: 0, sampler: ["k", 0], sigmas: ["sigmas", 0] } }; + assert.deepEqual([factsFor(custom).seed.value, factsFor(custom).steps.value, factsFor(custom).cfg.value], ["0", "22", "0"]); + custom.sigmas.inputs = { steps: 22 }; + assert.equal(factsFor(custom).scheduler.status, "not-recorded"); + assert.equal(factsFor(custom).sampler.value, "euler"); +}); + +test("facts require supported scalar producer types and output slots", () => { + const cases = [ + ["PrimitiveInt", "seed", "42", true], ["PrimitiveFloat", "cfg", "6.5000000000000000001", true], + ["PrimitiveString", "sampler_name", "euler", true], ["PrimitiveStringMultiline", "scheduler", "normal", true], + ["PrimitiveString", "seed", "42", false], ["PrimitiveStringMultiline", "steps", "42", false], + ["PrimitiveInt", "sampler_name", "euler", false], ["PrimitiveFloat", "scheduler", "normal", false], + ["PrimitiveBoolean", "seed", "42", false], ["PrimitiveNode", "seed", "42", false], + ["UnknownSettings", "seed", "42", false], ["SamplerCombo", "steps", "42", false], + ]; + for (const [class_type, input, value, supported] of cases) { + for (const slot of [0, 1, 999, -1, 0.5]) { + const graph = factGraph({ [input]: ["settings", slot] }); + graph.settings = { class_type, inputs: { value, [input]: value } }; + const fact = factsFor(graph)[input === "sampler_name" ? "sampler" : input]; + assert.equal(fact.status, supported && slot === 0 ? "known" : "unresolved", `${class_type} output${slot} as ${input}`); + assert.equal(fact.value, supported && slot === 0 ? value : null); + } + } + const graph = factGraph({ seed: ["primitive", 0] }); + graph.primitive = { class_type: "PrimitiveInt", inputs: { seed: "987" } }; + assert.equal(factsFor(graph).seed.status, "unresolved", "a matching input is not the output's value"); + graph.primitive.inputs = { value: "42", seed: "987" }; + assert.equal(factsFor(graph).seed.value, "42"); + for (const [input, class_type, inputs] of [ + ["seed", "RandomNoise", { noise_seed: "42", seed: "42" }], + ["sampler_name", "KSamplerSelect", { sampler_name: "euler" }], + ["steps", "BasicScheduler", { steps: "42" }], + ["cfg", "CFGGuider", { cfg: "7" }], + ]) { + const graph = factGraph({ [input]: ["provider", 0] }); graph.provider = { class_type, inputs }; + assert.equal(factsFor(graph)[input === "sampler_name" ? "sampler" : input].status, "unresolved", `${class_type} is not a generic scalar output`); + } +}); + +function customFactGraph() { + return { + s: { class_type: "SamplerCustomAdvanced", inputs: { guider: ["g", 0], noise: ["n", 0], sampler: ["k", 0], sigmas: ["sigmas", 0] } }, + g: { class_type: "CFGGuider", inputs: { model: ["m", 0], cfg: ["cfg", 0] } }, + n: { class_type: "RandomNoise", inputs: { noise_seed: ["seed", 0] } }, + k: { class_type: "KSamplerSelect", inputs: { sampler_name: ["sampler", 0] } }, + sigmas: { class_type: "BasicScheduler", inputs: { steps: ["steps", 0], scheduler: ["scheduler", 0] } }, + seed: { class_type: "PrimitiveInt", inputs: { value: "9007199254740993" } }, + cfg: { class_type: "PrimitiveFloat", inputs: { value: "4.0000000000000001" } }, + steps: { class_type: "PrimitiveInt", inputs: { value: 22 } }, + sampler: { class_type: "PrimitiveString", inputs: { value: "euler" } }, + scheduler: { class_type: "PrimitiveString", inputs: { value: "karras" } }, + m: { class_type: "UNETLoader", inputs: { unet_name: "flux.safetensors" } }, + }; +} + +test("facts require supported custom providers and guider outputs", () => { + assert.deepEqual(factFields.map(field => factsFor(customFactGraph())[field].value), ["9007199254740993", "22", "4.0000000000000001", "euler", "karras", "flux.safetensors"]); + for (const [input, nodeId, fields] of [["noise", "n", ["seed"]], ["sampler", "k", ["sampler"]], ["sigmas", "sigmas", ["steps", "scheduler"]], ["guider", "g", ["cfg", "model"]]]) { + for (const slot of [1, 999, -1, 0.5]) { + const graph = customFactGraph(); graph.s.inputs[input] = [nodeId, slot]; + for (const field of fields) assert.equal(factsFor(graph)[field].status, "unresolved", `${input} output${slot} ${field}`); + } + for (const class_type of ["UnknownProvider", "PrimitiveInt", "PrimitiveString", "BasicGuider"]) { + if (input === "guider" && class_type === "BasicGuider") continue; + const graph = customFactGraph(); graph[nodeId].class_type = class_type; + for (const field of fields) assert.equal(factsFor(graph)[field].status, "unresolved", `${class_type} cannot provide ${input}`); + } + } + const basic = customFactGraph(); basic.g = { class_type: "BasicGuider", inputs: { model: ["m", 0] } }; + assert.equal(factsFor(basic).model.value, "flux.safetensors"); + assert.equal(factsFor(basic).cfg.status, "not-recorded"); +}); + +test("facts require model output ancestry with supported loaders and passthroughs", () => { + for (const [class_type, key] of [["CheckpointLoader", "ckpt_name"], ["CheckpointLoaderSimple", "ckpt_name"], ["unCLIPCheckpointLoader", "ckpt_name"], ["UNETLoader", "unet_name"]]) { + for (const slot of [0, 1, 2, 999, -1, 0.5]) { + const graph = factGraph({ model: ["loader", slot] }); + graph.loader = { class_type, inputs: { [key]: "selected.safetensors" } }; + assert.equal(factsFor(graph).model.value, slot === 0 ? "selected.safetensors" : null, `${class_type} output${slot}`); + assert.equal(factsFor(graph).model.status, slot === 0 ? "known" : "unresolved"); + } + } + for (const class_type of ["LoraLoader", "LoraLoaderModelOnly", "UnknownModelPassThrough"]) { + for (const slot of [0, 1, 2, 999]) { + const graph = factGraph({ model: ["lora", slot] }); graph.lora = { class_type, inputs: { model: ["model", 0] } }; + assert.equal(factsFor(graph).model.status, class_type !== "UnknownModelPassThrough" && slot === 0 ? "known" : "unresolved", `${class_type} output${slot}`); + } + } + for (const [class_type, inputs] of [["UnknownLoader", { ckpt_name: "fake" }], ["PrimitiveString", { model_name: "fake", value: "fake" }], ["__proto__", { "[object Object]": "fake" }]]) { + const graph = factGraph({ model: ["unknown", 0] }); graph.unknown = { class_type, inputs }; + assert.equal(factsFor(graph).model.status, "unresolved"); + } + for (const [input, field] of [["sampler_name", "sampler"], ["scheduler", "scheduler"]]) assert.equal(factsFor(factGraph({ [input]: 42 }))[field].status, "unresolved"); + const numericModel = factGraph(); numericModel.model.inputs.ckpt_name = 42; + assert.equal(factsFor(numericModel).model.status, "unresolved", "model names require string evidence"); +}); + +test("wide workflow lookups retain selected core sampler facts", () => { + const nodes = [{ id: "text", type: "PrimitiveString", widgets_values_named: { value: "unrelated text" } }], links = []; + let linkId = 1; + for (let index = 0; index < 20; index++) { + nodes.push({ id: `r${index}`, type: "Reroute", inputs: [{ name: "value", link: linkId }] }); + links.push([linkId++, index ? `r${index - 1}` : "text", 0, `r${index}`, 0, "STRING"]); + } + for (let index = 0; index < 40; index++) { + nodes.push({ id: `encoder${index}`, type: "CLIPTextEncode", inputs: [{ name: "text", link: linkId }] }); + links.push([linkId++, "r19", 0, `encoder${index}`, 0, "STRING"]); + } + nodes.push({ id: "model", type: "CheckpointLoaderSimple", widgets_values_named: { ckpt_name: "selected.safetensors" } }, + { id: "steps", type: "PrimitiveInt", widgets_values_named: { value: 28 } }, + { id: "s", type: "KSampler", widgets_values: ["9007199254740993", "fixed", 28, "6.5000000000000000001", "euler", "normal"], inputs: [{ name: "model", link: linkId }, { name: "steps", link: linkId + 1 }] }); + links.push([linkId, "model", 0, "s", 0, "MODEL"], [linkId + 1, "steps", 0, "s", 1, "INT"]); + const facts = factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph({ nodes, links }) }); + assert.deepEqual(factFields.map(field => facts[field].value), ["9007199254740993", "28", "6.5000000000000000001", "euler", "normal", "selected.safetensors"]); +}); + +test("workflow facts require supported passthrough output slots and bypass ancestry", () => { + function workflow(type, slot, mode) { + const pass = { id: "pass", type, ...(mode === undefined ? {} : { mode }), widgets_values: ["selected"], inputs: [{ name: type === "Reroute" ? "value" : "model", type: "MODEL", link: 1 }] }; + const nodes = [{ id: "m", type: "CheckpointLoaderSimple", widgets_values_named: { ckpt_name: "selected.safetensors" } }, pass, + { id: "s", type: "KSampler", widgets_values: [0, "fixed", 28, "0", "euler", "normal"], inputs: [{ name: "model", link: 2 }] }]; + if (type === "GetNode") { + nodes.push({ id: "set", type: "SetNode", widgets_values: ["selected"], inputs: [{ name: "model", link: 1 }] }); + pass.inputs = []; + } + return { nodes, links: [[1, "m", 0, "pass", 0, "MODEL"], [2, "pass", slot, "s", 0, "MODEL"]] }; + } + const model = value => factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph(value) }).model; + for (const [type, mode] of [["Reroute", undefined], ["GetNode", undefined], ["SetNode", undefined], ["LoraLoader", 4], ["LoraLoaderModelOnly", 4]]) { + for (const slot of [0, 1, 999, -1, 0.5]) { + assert.equal(model(workflow(type, slot, mode)).status, slot === 0 ? "known" : "unresolved", `${type} output${slot}`); + } + } + assert.equal(model(workflow("UnknownBypass", 0, 4)).status, "unresolved"); + const duplicate = workflow("LoraLoader", 0, 4); + duplicate.nodes[1].inputs.push({ name: "model", type: "MODEL", link: 1 }); + assert.equal(model(duplicate).status, "unresolved", "bypass model input is uniquely named"); + const wrongOrigin = workflow("Reroute", 0); wrongOrigin.links[0][2] = 1; + assert.equal(model(wrongOrigin).status, "unresolved", "passthrough cannot turn CLIP into MODEL"); + const cycle = workflow("Reroute", 0); cycle.links[0][1] = "pass"; + assert.equal(model(cycle).status, "unresolved"); + const unnamed = workflow("GetNode", 0); + delete unnamed.nodes.find(node => node.id === "pass").widgets_values; + delete unnamed.nodes.find(node => node.id === "set").widgets_values; + assert.equal(model(unnamed).status, "unresolved", "missing Get/Set names are not a source association"); + const scalar = factGraph({ seed: ["bypass", 0] }); + scalar.bypass = { class_type: "LoraLoader", inputs: { model: ["primitive", 0] } }; + scalar.primitive = { class_type: "PrimitiveInt", inputs: { value: 42 } }; + const scalarWorkflow = factWorkflowFromGraph(scalar); scalarWorkflow.nodes.find(node => node.id === "bypass").mode = 4; + assert.equal(factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph(scalarWorkflow) }).seed.status, "unresolved", "bypassed MODEL output cannot become a scalar"); + for (const [input, field] of [["noise", "seed"], ["sampler", "sampler"], ["sigmas", "steps"], ["guider", "model"]]) { + const graph = customFactGraph(), target = graph.s.inputs[input]; + graph.s.inputs[input] = ["bypass", 0]; graph.bypass = { class_type: "LoraLoaderModelOnly", inputs: { model: target } }; + const workflow = factWorkflowFromGraph(graph); workflow.nodes.find(node => node.id === "bypass").mode = 4; + assert.equal(factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph(workflow) })[field].status, "unresolved", `MODEL output cannot become ${input}`); + } +}); + +function factWorkflowFromGraph(graph) { + const nodes = [], links = []; + for (const [id, node] of Object.entries(graph)) { + const inputs = [], widgets_values_named = {}; + for (const [name, value] of Object.entries(node.inputs)) { + if (Array.isArray(value)) { + const link = links.length + 1; + links.push([link, value[0], value[1], id, inputs.length, "*"]); + inputs.push({ name, link }); + } else widgets_values_named[name] = value; + } + nodes.push({ id, type: node.class_type, inputs, widgets_values_named }); + } + return { nodes, links }; +} + +test("workflow facts count combined original ancestry once across passthroughs and providers", () => { + const inspect = graph => factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph(factWorkflowFromGraph(graph)) }); + function chain(graph, reroutes, primitives, model = false) { + let link = model ? ["model", 0] : null; + for (let index = 0; index < primitives; index++) { + graph[`p${index}`] = { class_type: model ? "LoraLoader" : "PrimitiveInt", inputs: model ? { model: link } : { value: link || "42" } }; + link = [`p${index}`, 0]; + } + for (let index = 0; index < reroutes; index++) { + graph[`r${index}`] = { class_type: "Reroute", inputs: { value: link } }; + link = [`r${index}`, 0]; + } + return link; + } + for (const [reroutes, known] of [[32, true], [33, false]]) { + const scalar = factGraph(); scalar.s.inputs.seed = chain(scalar, reroutes, 32); + assert.equal(inspect(scalar).seed.status, known ? "known" : "unresolved", `${reroutes}+32 scalar links`); + const model = factGraph(); model.s.inputs.model = chain(model, reroutes, 31, true); + assert.equal(inspect(model).model.status, known ? "known" : "unresolved", `${reroutes}+31 LoRA+loader links`); + const noise = customFactGraph(); + noise.n.inputs.noise_seed = chain(noise, reroutes - 1, 31); + noise.noiseAlias = { class_type: "Reroute", inputs: { value: ["n", 0] } }; + noise.s.inputs.noise = ["noiseAlias", 0]; + assert.equal(inspect(noise).seed.status, known ? "known" : "unresolved", `noise alias+provider+${reroutes - 1}+31 links`); + const guider = customFactGraph(); + guider.model = { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "model.safetensors" } }; + guider.g.inputs.model = chain(guider, reroutes - 1, 30, true); + guider.guiderAlias = { class_type: "Reroute", inputs: { value: ["g", 0] } }; + guider.s.inputs.guider = ["guiderAlias", 0]; + assert.equal(inspect(guider).model.status, known ? "known" : "unresolved", `guider alias+provider+${reroutes - 1}+30 LoRA+loader links`); + } + const cycle = factGraph(); + cycle.s.inputs.seed = ["r", 0]; cycle.r = { class_type: "Reroute", inputs: { value: ["p", 0] } }; cycle.p = { class_type: "PrimitiveInt", inputs: { value: ["r", 0] } }; + assert.equal(inspect(cycle).seed.status, "unresolved"); + cycle.s.inputs.model = ["r", 0]; cycle.p = { class_type: "LoraLoader", inputs: { model: ["r", 0] } }; + assert.equal(inspect(cycle).model.status, "unresolved"); + for (const [reroutes, known] of [[32, true], [33, false]]) { + const graph = factGraph(), value = chain(graph, reroutes, 31); + const workflow = factWorkflowFromGraph(graph); + const sourceLink = workflow.links.length + 1; + workflow.nodes.push({ id: "set", type: "SetNode", widgets_values: ["seed"], inputs: [{ name: "value", link: sourceLink }] }, { id: "get", type: "GetNode", widgets_values: ["seed"], inputs: [] }); + workflow.links.push([sourceLink, value[0], 0, "set", 0, "INT"], [sourceLink + 1, "get", 0, "s", 0, "INT"]); + workflow.nodes.find(node => node.id === "s").inputs.push({ name: "seed", link: sourceLink + 1 }); + const fact = factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph(workflow) }).seed; + assert.equal(fact.status, known ? "known" : "unresolved", `Get+${reroutes}+31 links, no extra setter expansion`); + } +}); + +test("workflow sampler selection retains prototype-named node identities", () => { + const workflow = { nodes: [ + { id: "__proto__", type: "KSampler", widgets_values: [1, "fixed", 20, "7", "euler", "normal"] }, + { id: "other", type: "KSampler", widgets_values: [2, "fixed", 20, "7", "euler", "normal"] }, + ], links: [] }; + assert.ok(Object.values(factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph(workflow) })).every(fact => fact.status === "ambiguous")); +}); + +test("workflow-only facts are configuration without creating sendable roles", () => { + const workflow = { nodes: [ + { id: 1, type: "UNETLoader", widgets_values: ["flux.safetensors", "default"] }, + { id: 2, type: "SetNode", widgets_values: ["model"], inputs: [{ name: "MODEL", link: 1 }] }, + { id: 3, type: "GetNode", widgets_values: ["model"] }, + { id: 4, type: "KSamplerAdvanced", widgets_values: ["enable", "9007199254740993", "fixed", 28, "6.5000000000000000001", "euler", "normal", 0, 28, "disable"], inputs: [{ name: "model", link: 2 }] }, + ], links: [[1, 1, 0, 2, 0, "MODEL"], [2, 3, 0, 4, 0, "MODEL"]] }; + const facts = factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph(workflow) }); + assert.deepEqual(factFields.map(field => facts[field].value), ["9007199254740993", "28", "6.5000000000000000001", "euler", "normal", "flux.safetensors"]); + assert.ok(Object.values(facts).every(fact => fact.source === "workflow" && fact.evidence === "configuration")); + assert.equal(facts.positive, undefined); + workflow.nodes.push({ ...workflow.nodes[3] }); + assert.ok(Object.values(factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph(workflow) })).every(fact => fact.status === "unresolved")); +}); + +test("limited projection stays unresolved", () => { + for (const extra of [ + { prompt_graph: { status: "limited", value: null } }, + { prompt_graph: absentFactGraph(), workflow_graph: { status: "limited", value: null } }, + { prompt_graph: absentFactGraph(), metadataStatus: "limited", parameters: null }, + ]) { + const facts = factsFor(null, extra); + assert.ok(Object.values(facts).every(fact => fact.status === "unresolved" && fact.value === null && fact.reason === "projection-limited")); + } + const unreadable = factsFor(null, { prompt_graph: absentFactGraph(), metadataStatus: "unreadable" }); + assert.equal(unreadable.seed.reason, "metadata-unreadable"); + const absent = factsFor(null, { prompt_graph: absentFactGraph(), metadataStatus: "absent" }); + assert.equal(absent.seed.status, "not-recorded"); +}); + +test("facts preserve exact video-projected decimals without reading reserialized raw", () => { + const graph = factGraph(); + const facts = factsFor(graph, { recordFormats: { prompt: "reserialized-json" }, raw: { prompt: '{"s":{"inputs":{"seed":9007199254740992,"cfg":6.5}}}' } }); + assert.equal(facts.seed.value, "9007199254740993"); + assert.equal(facts.cfg.value, "6.5000000000000000001"); + assert.equal(factsFor(factGraph({ cfg: "1E+1000000000" })).cfg.value, "1E+1000000000"); +}); + +test("nontext parameters remain unresolved", () => { + const parameters = "A portrait\nNegative prompt: blur\nSteps: 28, Sampler: Euler, Schedule type: Karras, CFG scale: 6.5000000000000000001, Seed: 9007199254740993, Model: model"; + const base = { prompt_graph: absentFactGraph(), parameters }; + for (const format of [undefined, "reserialized-json", "invalid"]) { + const facts = factsFor(null, { ...base, recordFormats: format ? { parameters: format } : {} }); + assert.ok(Object.values(facts).every(fact => fact.status === "unresolved" && fact.value === null)); + } + const facts = factsFor(null, { ...base, recordFormats: { parameters: "original-text" } }); + assert.deepEqual(factFields.map(field => facts[field].value), ["9007199254740993", "28", "6.5000000000000000001", "Euler", "Karras", "model"]); + assert.ok(Object.values(facts).every(fact => fact.source === "embedded-parameters" && fact.evidence === "embedded-parameters")); + for (const invalid of [parameters + "\nSteps: 5, Seed: 2", parameters + ", Seed: 1", parameters + "\ntrailing", parameters.replace("Seed: 9007199254740993", "Seed: -1"), parameters.replace("Karras", "invented"), parameters.replace("Negative prompt: blur", "Negative prompt: blur\nNegative prompt: again")]) { + assert.ok(Object.values(factsFor(null, { ...base, parameters: invalid, recordFormats: { parameters: "original-text" } })).some(fact => fact.status === "unresolved")); + } +}); + +test("malformed or unsupported API evidence blocks plausible workflow and parameter fallback", () => { + const workflow = { nodes: [{ id: 1, type: "KSampler", widgets_values: [42, "fixed", 20, 7, "euler", "normal"] }], links: [] }; + const extra = { workflow_graph: parsedFactGraph(workflow), parameters: "portrait\nSteps: 20, Sampler: Euler, CFG scale: 7, Seed: 42", recordFormats: { parameters: "original-text" } }; + for (const prompt_graph of [{ status: "malformed", value: null }, parsedFactGraph({ s: { class_type: "UnknownSampler", inputs: { seed: 99 } } }), parsedFactGraph({ s: { class_type: "KSampler", inputs: [] } })]) { + const facts = factsFor(null, { ...extra, prompt_graph }); + assert.ok(Object.values(facts).every(fact => fact.status === "unresolved" && fact.value === null)); + assert.ok(Object.values(facts).every(fact => fact.source !== "workflow" && fact.source !== "embedded-parameters")); + } +}); + +test("conservative facts bound cycles fan-out visits and link depth", () => { + const graph = factGraph({ seed: ["loop", 0], model: ["loop", 0] }); + graph.loop = { class_type: "Unknown", inputs: { seed: ["loop", 0], model: ["loop", 0], a: ["loop", 0], b: ["loop", 0] } }; + assert.equal(factsFor(graph).seed.status, "unresolved"); + assert.equal(factsFor(graph).model.status, "unresolved"); + graph.loop = { class_type: "PrimitiveInt", inputs: { value: ["loop", 0] } }; + assert.equal(factsFor(graph).seed.status, "unresolved", "registered scalar cycle is bounded"); + graph.loop = { class_type: "LoraLoader", inputs: { model: ["loop", 0] } }; + assert.equal(factsFor(graph).model.status, "unresolved", "registered model cycle is bounded"); + const chain = depth => { + const value = factGraph({ seed: ["p0", 0] }); + for (let index = 0; index < depth; index++) value[`p${index}`] = { class_type: "PrimitiveInt", inputs: { value: index === depth - 1 ? "42" : [`p${index + 1}`, 0] } }; + return value; + }; + assert.equal(factsFor(chain(64)).seed.value, "42"); + assert.equal(factsFor(chain(65)).seed.status, "unresolved"); + const custom = chain(64); + custom.s = { class_type: "SamplerCustomAdvanced", inputs: { noise: ["noise", 0] } }; + custom.noise = { class_type: "RandomNoise", inputs: { noise_seed: ["p0", 0] } }; + assert.equal(factsFor(custom).seed.status, "unresolved", "the provider itself consumes one of the 64 allowed links"); + const large = factGraph(); + for (let index = 0; index < 498; index++) large[`unused${index}`] = { class_type: "PrimitiveInt", inputs: { value: index } }; + assert.equal(factsFor(large).seed.value, "9007199254740993"); + large.extra = { class_type: "PrimitiveInt", inputs: { value: 1 } }; + assert.equal(factsFor(large).seed.status, "unresolved"); +}); // Same shape as the workflow attached to #75: PromptManager nodes are saved as // CLIPTextEncode with extra inputs, and the checkpoint loader is pysssss'. diff --git a/tests/js/prompt-inspector.test.js b/tests/js/prompt-inspector.test.js index d662877..72095bc 100644 --- a/tests/js/prompt-inspector.test.js +++ b/tests/js/prompt-inspector.test.js @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { inspectPath, promptRoles, createInspectionLoader } from '../../web/js/workspace/prompt-inspector.mjs'; +import { inspectPath, promptRoles, createInspectionLoader, mountPromptBlocks } from '../../web/js/workspace/prompt-inspector.mjs'; test('inspection keeps exact physical root and encodes every path component', () => { assert.equal(inspectPath({root_index:2,path:'a b/x#%.png'}), '/prompt_manager/images/inspect/a%20b/x%23%25.png?root=2'); @@ -13,6 +13,14 @@ test('only confirmed strings can be sent, including explicit empty negatives', ( } assert.deepEqual(promptRoles(null), {}); }); +test('already aborted prompt blocks touch no container DOM or active handlers', () => { + const parent = new AbortController(); parent.abort(); + const container = new Proxy({}, { get() { assert.fail('aborted mount accessed the container'); } }); + const parentScope = { signal: parent.signal }; + const view = mountPromptBlocks(container, { parentScope, sender: { open() { assert.fail('aborted action'); } } }); + view.update({ positive: { status: 'resolved', text: 'late' } }); + view.setState({ loading: true }); view.dispose(); +}); test('superseded requests and disposed loaders cannot replace current metadata', async () => { const pending = [], changes = []; const loader = createInspectionLoader({get:(url,{signal})=>new Promise(resolve=>pending.push({url,signal,resolve}))}, value=>changes.push(value)); diff --git a/tests/js/workspace-file-inspector.test.js b/tests/js/workspace-file-inspector.test.js new file mode 100644 index 0000000..8843406 --- /dev/null +++ b/tests/js/workspace-file-inspector.test.js @@ -0,0 +1,50 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +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 '},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 '}}; +} + +test('metadata export retains literal raw and scoped evidence',async()=>{ + const {inspectionDownloads}=await load(),source=record(),download=inspectionDownloads(source).metadata; + assert.equal(download.available,true);assert.equal(download.mime,'application/json');assert.equal(download.filename,'a.metadata.json'); + const value=JSON.parse(download.bodyText);assert.equal(value.inspection_version,1); + for(const key of ['identity','file','prompts','generation','library'])assert.deepEqual(value[key],source[key]); + assert.deepEqual(value.metadata.raw,source.metadata.raw);assert.deepEqual(value.metadata.record_formats,source.metadata.record_formats); + assert.equal(value.metadata.prompt_graph,undefined);assert.equal(value.metadata.workflow_graph,undefined); +}); +test('workflow export never reserializes unsafe numeric literals',async()=>{ + const {inspectionDownloads}=await load();assert.equal(inspectionDownloads(record()).workflow.bodyText,rawWorkflow); + const source=record();source.metadata.workflow_graph.status='malformed';assert.equal(inspectionDownloads(source).workflow.available,false); +}); +test('omitted raw forbids partial export',async()=>{ + const {inspectionDownloads}=await load(),source=record();source.metadata.status='limited';source.metadata.omitted_reason='response-size-limit';source.metadata.raw={}; + for(const value of Object.values(inspectionDownloads(source))) {assert.equal(value.available,false);assert.equal(value.bodyText,null);assert.equal(value.reason,'Raw metadata exceeds the response limit. Download the original file to inspect it externally.');} + assert.ok(source.download.url); +}); +test('download names cannot introduce path segments',async()=>{ + const {inspectionDownloads}=await load(),source=record();source.download.filename='../../evil\\nested\u0000.png'; + for(const value of Object.values(inspectionDownloads(source)))assert.doesNotMatch(value.filename,/[\\/\x00-\x1f]/); +}); +test('already disposed inspector creates no DOM URLs or handlers',async()=>{ + const {mountFileInspector,mountPromptInspector}=await load(),parent=new AbortController();parent.abort(); + const forbidden=new Proxy({}, {get(){assert.fail('disposed mount touched DOM/API');}}); + for(const mount of [mountFileInspector,mountPromptInspector]) { + const view=mount(forbidden,{parentScope:{signal:parent.signal},sender:forbidden,api:forbidden,image:forbidden}); + view.setState?.({record:record()});view.dispose();view.dispose(); + } +}); +test('reserialized container workflow never exports as original',async()=>{ + const {inspectionDownloads}=await load(),source=record();source.metadata.record_formats.workflow='reserialized-json'; + const workflow=inspectionDownloads(source).workflow;assert.equal(workflow.available,false);assert.equal(workflow.bodyText,null); + assert.equal(workflow.reason,'Original workflow JSON text is unavailable for this container record. Download metadata or the original file.'); +}); +test('metadata export preserves representation formats and omits private carrier',async()=>{ + const {inspectionDownloads}=await load(),source=record();source.metadata.record_formats.workflow='reserialized-json';source.metadata.raw.private_carrier='SECRET';source.metadata.carrier='SECRET'; + const download=inspectionDownloads(source).metadata,value=JSON.parse(download.bodyText); + assert.equal(value.metadata?.record_formats?.workflow,'reserialized-json');assert.equal(value.metadata.raw.workflow,rawWorkflow);assert.ok(!download.bodyText.includes('SECRET')); + assert.equal(value.metadata.representations.workflow,'Reserialized container JSON; numeric spelling/precision may differ'); +}); diff --git a/tests/js/workspace-images-state.test.js b/tests/js/workspace-images-state.test.js index 36b7897..e7bcbaf 100644 --- a/tests/js/workspace-images-state.test.js +++ b/tests/js/workspace-images-state.test.js @@ -44,6 +44,22 @@ test('reset aborts pending requests and ignores stale success and failure', asyn assert.equal(stream.state.loading,false); }); +test('deferred reset cancels stale source work without fetching until discovery succeeds', async () => { + const {createImageStream}=await source;const requests=[]; + const stream=createImageStream({api:{get:(url,{signal})=>{const d=deferred();requests.push({...d,url,signal});return d.promise;}}}); + const old=stream.reset({root:1,subfolder:'old'}); + const deferredReset=stream.reset({root:'all',q:'png',media:'image',sort:'name'},{load:false}); + assert.equal(requests[0].signal.aborted,true);assert.equal(requests.length,1); + assert.equal(await deferredReset,0); + assert.equal(stream.state.items.length,0);assert.equal(stream.state.cursor,null);assert.equal(stream.state.loading,false); + requests[0].resolve(page([image('old',1)],2,'stale'));assert.equal(await old,0);assert.equal(stream.state.items.length,0); + const fresh=stream.loadMore();assert.equal(requests.length,2); + const query=new URL(requests[1].url,'http://local').searchParams; + assert.equal(query.has('root'),false);assert.equal(query.has('subfolder'),false);assert.equal(query.get('q'),'png');assert.equal(query.get('media'),'image');assert.equal(query.get('sort'),'name'); + requests[1].resolve(page([image('fresh',0)]));assert.equal(await fresh,1);assert.equal(stream.state.items[0].path,'fresh'); + stream.dispose();assert.equal(await stream.reset({}, {load:false}),0);assert.equal(requests.length,2); +}); + test('concurrent loads do not duplicate requests, and failures retain data and cursor for retry', async () => { const {createImageStream}=await source; const pending=deferred(); const calls=[]; const responses=[page([image('a')],2,'next'),pending.promise,page([image('b')],2)]; diff --git a/tests/js/workspace-inspection-state.test.js b/tests/js/workspace-inspection-state.test.js new file mode 100644 index 0000000..f514ef8 --- /dev/null +++ b/tests/js/workspace-inspection-state.test.js @@ -0,0 +1,326 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +const require = createRequire(import.meta.url); +const { extractGenerationFacts } = require('../../web/js/comfy-metadata.js'); +const { ApiError } = require('../../web/js/api-client.js'); +const module = await import('../../web/js/workspace/inspection-state.mjs').catch(error => { + if (error.code !== 'ERR_MODULE_NOT_FOUND') throw error; + return {}; +}); +const scopeA = 'a'.repeat(64), scopeB = 'b'.repeat(64); +const refreshMessage = 'The source changed. Refresh the image collection before inspecting this file.'; +const fields = ['seed', 'steps', 'cfg', 'sampler', 'scheduler', 'model']; +function contract(name) { assert.equal(typeof module[name], 'function', `${name} contract is exported`); return module[name]; } +function image(root_scope = scopeA) { + return { root_index: 0, path: 'folder/a #%.png', filename: 'a #%.png', url: `/prompt_manager/images/serve/folder/a%20%23%25.png?root=0${root_scope === undefined ? '' : `&root_scope=${root_scope}`}`, ...(root_scope === undefined ? {} : { root_scope }) }; +} +function envelope(entry = image()) { + return { + success: true, inspection_version: 1, root_scope: entry.root_scope ?? scopeA, + image: { root_index: entry.root_index, path: entry.path, filename: entry.filename }, + file: { observation: 'current-file', source_label: 'same label', media_type: 'image', extension: '.png', format: 'PNG', size: 123, width: 720, height: 480, modified_time: 1234 }, + prompts: { positive: { status: 'resolved', text: 'exact\npositive', source: 'execution' }, negative: { status: 'ambiguous', text: null, source: null } }, + metadata: { + status: 'available', omitted_reason: null, raw: { prompt: '{"rounded":"raw copy"}' }, record_formats: { prompt: 'reserialized-json' }, + prompt_graph: { status: 'parsed', value: { s: { class_type: 'KSampler', inputs: { seed: '9007199254740993', steps: 28, cfg: '6.5000000000000000001', sampler_name: 'euler', scheduler: 'normal', model: ['model', 0] } }, model: { class_type: 'CheckpointLoaderSimple', inputs: { ckpt_name: 'model.safetensors' } } } }, + 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' }, + }; +} +function pendingApi() { + const requests = []; + const api = { get: (url, { signal }) => new Promise((resolve, reject) => requests.push({ url, signal, resolve, reject })) }; + return { api, requests }; +} + +test('original media validation is strict before inspection and preserves rooted URLs', () => { + const validate = contract('originalMediaDownload'), entry = image(); + assert.deepEqual(validate(entry), {url:entry.url,filename:'a #%.png'}); + for (const change of [{url:'https://other.test/prompt_manager/images/serve/folder/a%20%23%25.png?root=0'}, + {url:entry.url.replace('root=0','root=1')},{url:entry.url.replace(scopeA,scopeB)}, + {url:entry.url.replace('folder/a','elsewhere/a')},{url:entry.url.replace(`&root_scope=${scopeA}`,'')}, + {url:entry.url+'&root=0'},{url:entry.url+'#wrong'},{path:'../a.png'},{root_scope:''}]) { + assert.throws(() => validate({...entry,...change}), TypeError); + } + const before = Object.getOwnPropertyDescriptor(globalThis,'window'); + Object.defineProperty(globalThis,'window',{configurable:true,value:{location:{href:'https://example.test/proxy/prompt_manager/workspace'},ApiClient:{resolveUrl:url=>`/proxy${url}`}}}); + try { assert.deepEqual(validate(entry),{url:`/proxy${entry.url}`,filename:'a #%.png'}); } + finally { if (before) Object.defineProperty(globalThis,'window',before); else delete globalThis.window; } +}); + +test('record preserves exact projections and unknown roles without execution fact promotion', () => { + const entry = image(), data = envelope(entry); + const record = contract('toInspectionRecord')(data, entry, extractGenerationFacts); + assert.deepEqual(record.identity, { kind: 'rooted-file', key: '0:folder/a #%.png', root_index: 0, path: entry.path, root_scope: scopeA }); + assert.equal(record.generation.seed.value, '9007199254740993'); + assert.equal(record.generation.cfg.value, '6.5000000000000000001'); + assert.equal(record.generation.seed.evidence, 'configuration'); + assert.equal(record.prompts.negative.status, 'ambiguous'); + assert.equal(record.prompts.negative.text, null); + assert.equal(record.historyTarget, '#/library/7'); + assert.deepEqual(record.download, { url: entry.url, filename: entry.filename }); + assert.equal(record.file.source_label, 'same label'); + for (const status of ['none', 'ambiguous', 'unavailable', 'limited']) { + const result = envelope(entry); result.library.status = status; + assert.equal(module.toInspectionRecord(result, entry, extractGenerationFacts).historyTarget, null); + } +}); + +test('record format validation never guesses original text', () => { + const toRecord = contract('toInspectionRecord'), entry = image(); + for (const mutate of [ + data => delete data.metadata.record_formats, + data => { data.metadata.record_formats = { prompt: 'guess' }; }, + data => { data.metadata.record_formats.extra = 'original-text'; }, + data => { data.metadata.record_formats.prompt = 'invalid'; }, + data => { data.metadata.raw.workflow = '{}'; }, + data => { data.metadata.raw.prompt = {}; }, + data => { data.metadata.raw.extra = 'secret'; }, + data => { data.metadata.parameters = 'manufactured'; }, + data => { data.metadata.prompt_graph = { status: 'absent', value: null }; }, + data => { data.metadata.record_formats = {}; data.metadata.raw = {}; }, + data => { data.metadata.status = 'limited'; }, + ]) { + const data = envelope(entry); mutate(data); + assert.throws(() => toRecord(data, entry, extractGenerationFacts), TypeError); + } + const data = envelope(entry); + data.metadata.record_formats.workflow = 'invalid'; + data.metadata.workflow_graph = { status: 'malformed', value: null }; + assert.equal(toRecord(data, entry, extractGenerationFacts).metadata.record_formats.workflow, 'invalid'); + data.metadata.raw = {}; data.metadata.status = 'limited'; data.metadata.omitted_reason = 'response-size-limit'; + data.metadata.prompt_graph = { status: 'limited', value: null }; + data.metadata.workflow_graph = { status: 'limited', value: null }; + const limited = toRecord(data, entry, extractGenerationFacts); + assert.deepEqual(limited.metadata.record_formats, { prompt: 'reserialized-json', workflow: 'invalid' }); + assert.equal(limited.generation.seed.status, 'unresolved'); + assert.equal(limited.generation.seed.reason, 'projection-limited'); + let seen; + toRecord(envelope(entry), entry, input => { seen = input; return extractGenerationFacts(input); }); + assert.deepEqual(seen.recordFormats, { prompt: 'reserialized-json' }); + assert.equal(seen.raw, undefined, 'facts never receive raw graphs for reparsing'); +}); + +test('record accepts actual atomic omission producer states', () => { + const toRecord = contract('toInspectionRecord'); + for (const wholeRaw of [false, true]) { + const data = envelope(); + data.metadata.record_formats.workflow = 'invalid'; + data.metadata.prompt_graph = { status: 'limited', value: null }; + data.metadata.workflow_graph = { status: 'limited', value: null }; + if (wholeRaw) Object.assign(data.metadata, { raw: {}, parameters: null, status: 'limited', omitted_reason: 'response-size-limit' }); + let record; + assert.doesNotThrow(() => { record = toRecord(data, image(), extractGenerationFacts); }, 'actual producer omission remains renderable'); + assert.deepEqual(record.metadata.raw, wholeRaw ? {} : { prompt: '{"rounded":"raw copy"}' }); + assert.equal(record.metadata.status, wholeRaw ? 'limited' : 'available'); + assert.equal(record.metadata.omitted_reason, wholeRaw ? 'response-size-limit' : null); + assert.deepEqual(record.metadata.record_formats, { prompt: 'reserialized-json', workflow: 'invalid' }); + assert.equal(record.prompts.positive.text, 'exact\npositive'); + assert.equal(record.download.url, image().url); + assert.ok(Object.values(record.generation).every(fact => fact.status === 'unresolved' && fact.reason === 'projection-limited')); + } + const absent = envelope(); + Object.assign(absent.metadata, { status: 'limited', omitted_reason: 'response-size-limit', raw: {}, record_formats: { parameters: 'original-text' }, parameters: null, prompt_graph: { status: 'absent', value: null } }); + assert.equal(toRecord(absent, image(), extractGenerationFacts).generation.seed.reason, 'projection-limited'); +}); + +test('record rejects contradictory omission provenance before extracting facts', () => { + const toRecord = contract('toInspectionRecord'); + for (const mutate of [ + data => Object.assign(data.metadata, { status: 'limited', omitted_reason: 'response-size-limit' }), + data => Object.assign(data.metadata, { status: 'limited', omitted_reason: 'response-size-limit', raw: {} }), + data => { data.metadata.raw = {}; }, + data => { data.metadata.record_formats.workflow = 'original-text'; data.metadata.workflow_graph = { status: 'parsed', value: { nodes: [] } }; }, + data => { Object.assign(data.metadata, { status: 'limited', omitted_reason: 'response-size-limit', raw: {}, prompt_graph: { status: 'limited', value: null } }); data.metadata.record_formats.workflow = 'invalid'; data.metadata.workflow_graph = { status: 'malformed', value: null }; }, + data => { data.metadata.record_formats.workflow = 'invalid'; data.metadata.raw.workflow = 'null'; data.metadata.workflow_graph = { status: 'limited', value: null }; }, + data => { data.metadata.record_formats.workflow = 'invalid'; data.metadata.workflow_graph = { status: 'limited', value: null }; }, + ]) { + const data = envelope(); mutate(data); let extractions = 0; + assert.throws(() => toRecord(data, image(), () => { extractions++; return extractGenerationFacts({}); }), TypeError); + assert.equal(extractions, 0, 'contradictory transport cannot produce known facts'); + } +}); + +test('record rejects root path media and invalid versioned fields with retryable TypeError', () => { + const toRecord = contract('toInspectionRecord'); + for (const mutate of [ + data => { data.image.root_index = 1; }, data => { data.image.path = 'other.png'; }, + data => { data.inspection_version = 2; }, data => { data.file.width = 0; }, + data => { delete data.success; }, + data => { data.file.size = -1; }, data => { data.library.positive_id = 9007199254740992; }, + data => { data.metadata.prompt_graph = { status: 'limited', value: {} }; }, + data => { data.resolution.sampler_id = {}; }, + ]) { + const data = envelope(); mutate(data); + assert.throws(() => toRecord(data, image(), extractGenerationFacts), error => error instanceof TypeError && !error.refreshRequired); + } + for (const url of ['https://attacker.invalid/prompt_manager/images/serve/folder/a%20%23%25.png?root=0', 'data:text/plain,unsafe', '/other/a.png', '/prompt_manager/images/serve/other.png?root=0', `/prompt_manager/images/serve/folder/a%20%23%25.png?root=1&root_scope=${scopeA}`, `/prompt_manager/images/serve/folder/a%20%23%25.png?root=0&root_scope=${scopeB}`, `/prompt_manager/images/serve/folder/a%20%23%25.png?root=0&root=1&root_scope=${scopeA}`]) { + assert.throws(() => toRecord(envelope(), { ...image(), url }, extractGenerationFacts), TypeError); + } +}); + +test('record preserves proxy originals and rejects opaque or normalized path escapes', () => { + const toRecord = contract('toInspectionRecord'); + const previousLocation = globalThis.location, previousApi = globalThis.ApiClient; + globalThis.location = { href: 'https://workspace.example/nested/proxy/prompt_manager/workspace', pathname: '/nested/proxy/prompt_manager/workspace' }; + globalThis.ApiClient = require('../../web/js/api-client.js'); + try { + assert.equal(toRecord(envelope(), image(), extractGenerationFacts).download.url, `/nested/proxy${image().url}`); + const absolute = { ...image(), url: `https://workspace.example:443/nested/proxy${image().url}` }; + assert.equal(toRecord(envelope(), absolute, extractGenerationFacts).download.url, absolute.url); + for (const url of [`https://workspace.example.attacker.invalid/nested/proxy${image().url}`, `/nested/proxy/prompt_manager/images/serve/folder/../a%20%23%25.png?root=0&root_scope=${scopeA}`, `//attacker.invalid${image().url}`]) assert.throws(() => toRecord(envelope(), { ...image(), url }, extractGenerationFacts), TypeError); + } finally { globalThis.location = previousLocation; globalThis.ApiClient = previousApi; } +}); + +test('inspectPath does not coerce or strip malformed scopes', () => { + const inspectPath = contract('inspectPath'); + assert.equal(inspectPath(image()), `/prompt_manager/images/inspect/folder/a%20%23%25.png?root=0&root_scope=${scopeA}`); + assert.equal(inspectPath({ root_index: 2, path: 'a b/x#%.png' }), '/prompt_manager/images/inspect/a%20b/x%23%25.png?root=2'); + for (const root_scope of [null, true, 1, {}, 'a'.repeat(63), 'A'.repeat(64), scopeA + '\n']) { + assert.throws(() => inspectPath({ ...image(), root_scope }), TypeError); + } + for (const entry of [{ root_index: '0', path: 'a.png' }, { root_index: 0, path: '../a.png' }, { root_index: 0, path: 'a//b.png' }, { root_index: 0, path: '/a.png' }, { root_index: 0, path: 'C:file.png' }]) assert.throws(() => inspectPath(entry), TypeError); +}); + +test('controller shares current request across subscribers', async () => { + const { api, requests } = pendingApi(), updates = [[], []]; + const controller = contract('createInspectionController')({ api, extractFacts: extractGenerationFacts }); + const off = controller.subscribe(state => updates[0].push(state)); + controller.subscribe(state => updates[1].push(state)); + assert.equal(updates[0][0].record, null); + const first = controller.select(image()), second = controller.select({ ...image() }); + assert.equal(requests.length, 1); + assert.equal(controller.state.loading, true); + requests[0].resolve(envelope()); await Promise.all([first, second]); + assert.equal(controller.state.record.generation.seed.value, '9007199254740993'); + assert.equal(updates[0].at(-1), updates[1].at(-1)); + await controller.select(image()); assert.equal(requests.length, 1); + off(); const count = updates[0].length; + const retry = controller.retry(); assert.equal(controller.state.record, null); assert.equal(controller.state.prompts, undefined); + assert.equal(requests.length, 2); assert.ok(requests[1].url.endsWith(`root_scope=${scopeA}`)); + requests[1].resolve(envelope()); await retry; + assert.equal(updates[0].length, count); + controller.clear(); assert.equal(controller.state.record, null); assert.equal(controller.state.loading, false); + const reselect = controller.select(image()); assert.equal(requests.length, 3); + requests[2].resolve(envelope()); await reselect; controller.dispose(); +}); + +test('root scope echo and source-changed require refresh', async () => { + const SourceChanged = contract('InspectionSourceChangedError'), toRecord = contract('toInspectionRecord'); + for (const root_scope of [undefined, scopeB]) { + const data = envelope(); + if (root_scope === undefined) delete data.root_scope; else data.root_scope = root_scope; + assert.throws(() => toRecord(data, image(), extractGenerationFacts), error => error instanceof SourceChanged && error.refreshRequired && error.message === refreshMessage); + const { api, requests } = pendingApi(); + const controller = contract('createInspectionController')({ api, extractFacts: extractGenerationFacts }); + const load = controller.select(image()); requests[0].resolve(data); await load; + assert.deepEqual([controller.state.error, controller.state.refreshRequired, controller.state.record, controller.state.prompts, controller.state.message], [true, true, null, undefined, refreshMessage]); + await controller.retry(); assert.equal(requests.length, 1); controller.dispose(); + } + for (const [status, code, refresh] of [[409, 'source-changed', true], [409, null, false], [409, 'file-changed', false], [500, 'source-changed', false]]) { + const { api, requests } = pendingApi(); + const controller = module.createInspectionController({ api, extractFacts: extractGenerationFacts }); + const load = controller.select(image()); requests[0].reject(new ApiError(status, refreshMessage, code)); await load; + assert.equal(controller.state.refreshRequired, refresh); + const retry = controller.retry(); assert.equal(requests.length, refresh ? 1 : 2); + if (!refresh) { requests[1].resolve(envelope()); await retry; assert.equal(controller.state.error, false); } + controller.dispose(); + } +}); + +test('same image key with changed scope supersedes pending actions', async () => { + const { api, requests } = pendingApi(); + const controller = contract('createInspectionController')({ api, extractFacts: extractGenerationFacts }); + const first = controller.select(image()); + requests[0].resolve(envelope()); await first; + const changed = image(scopeB), next = controller.select(changed); + assert.equal(controller.state.record, null); assert.equal(controller.state.prompts, undefined); + assert.equal(requests.length, 2); + requests[1].resolve(envelope(changed)); await next; + assert.equal(controller.state.record.identity.root_scope, scopeB); + assert.equal(controller.state.record.file.source_label, 'same label'); + controller.dispose(); +}); + +test('unscoped role-only callers stay compatible', async () => { + const toRecord = contract('toInspectionRecord'), entry = { root_index: 0, path: 'old.png' }; + const data = { prompts: { positive: { status: 'resolved', text: 'old', source: 'graph-inferred' }, negative: { status: 'unresolved', text: null, source: null } } }; + const record = toRecord(data, entry, extractGenerationFacts); + assert.equal(record.identity.root_scope, null); assert.equal(record.identity.key, '0:old.png'); + for (const key of ['file', 'metadata', 'library', 'download', 'historyTarget']) assert.equal(record[key], null); + for (const field of fields) assert.deepEqual(record.generation[field], { status: 'unresolved', value: null, source: null, evidence: null, reason: null, sampler_id: null }); + assert.deepEqual(record.prompts, data.prompts); + const changes = []; + const loader = contract('createInspectionLoader')({ get: async () => data }, value => changes.push(value)); + await loader.load(entry); + assert.deepEqual(changes.at(-1).prompts, data.prompts); assert.equal(changes.at(-1).record.identity.key, '0:old.png'); + loader.dispose(); + assert.throws(() => toRecord(data, image(), extractGenerationFacts), error => error.refreshRequired === true); + const scoped = toRecord({ ...data, root_scope: scopeA }, image(), extractGenerationFacts); + assert.equal(scoped.metadata, null); assert.equal(scoped.download.url, image().url); +}); + +test('disposed or superseded responses never publish', async () => { + const { api, requests } = pendingApi(), updates = []; + const controller = contract('createInspectionController')({ api, extractFacts: extractGenerationFacts }); + controller.subscribe(state => updates.push(state)); + const first = controller.select(image()), newer = image(scopeB), second = controller.select(newer); + assert.equal(requests[0].signal.aborted, true); + requests[1].resolve(envelope(newer)); await second; + const count = updates.length; + requests[0].reject(new ApiError(409, refreshMessage, 'source-changed')); await first; + assert.equal(updates.length, count); assert.equal(controller.state.record.identity.root_scope, scopeB); + const third = controller.retry(); controller.clear(); + assert.equal(requests[2].signal.aborted, true); + requests[2].resolve(envelope(newer)); await third; + assert.equal(controller.state.record, null); + const final = controller.select(image()); controller.dispose(); const disposedCount = updates.length; + requests[3].resolve(envelope()); await final; + await controller.select(newer); await controller.retry(); controller.clear(); + assert.equal(updates.length, disposedCount); assert.equal(requests.length, 4); +}); + +const invalidSelections = () => [null, undefined, 0, 'new.png', {}, { ...image(), path: '../new.png' }, { ...image(), root_scope: null }, { ...image(), root_scope: 'malformed' }]; + +test('invalid attempted selections clear completed actions and retry validation only', async () => { + for (const invalid of invalidSelections()) { + const { api, requests } = pendingApi(); + const controller = contract('createInspectionController')({ api, extractFacts: extractGenerationFacts }); + const previous = controller.select(image()); requests[0].resolve(envelope()); await previous; + assert.equal(controller.state.prompts.positive.text, 'exact\npositive'); + let rejected, thrown; + try { rejected = controller.select(invalid); } catch (error) { thrown = error; } + assert.equal(controller.state.record, null, 'invalid navigation cannot retain completed actions'); assert.equal(controller.state.prompts, undefined); + assert.equal(thrown, undefined, 'selection validation uses the retryable state'); + await rejected; + assert.deepEqual([controller.state.loading, controller.state.error, controller.state.refreshRequired, controller.state.message], [false, true, false, 'Could not read details from this file.']); + await controller.retry(); await controller.retry(); assert.equal(requests.length, 1); + const valid = controller.select(image()); assert.equal(requests.length, 2); + requests[1].resolve(envelope()); await valid; + await controller.select({ ...image() }); assert.equal(requests.length, 2); + controller.dispose(); + } +}); + +test('invalid attempted selections abort pending reads and suppress late results', async () => { + for (const invalid of invalidSelections()) { + const { api, requests } = pendingApi(), changes = []; + const controller = contract('createInspectionController')({ api, extractFacts: extractGenerationFacts }); + controller.subscribe(state => changes.push(state)); + const previous = controller.select(image()); + let rejected, thrown; + try { rejected = controller.select(invalid); } catch (error) { thrown = error; } + assert.equal(requests[0].signal.aborted, true); + assert.equal(thrown, undefined, 'selection validation uses the retryable state'); + assert.equal(controller.state.record, null); assert.equal(controller.state.prompts, undefined); + await rejected; const count = changes.length; + requests[0].resolve(envelope()); await previous; + assert.equal(changes.length, count); assert.equal(controller.state.error, true); + assert.equal(controller.state.record, null); assert.equal(controller.state.prompts, undefined); + await controller.retry(); assert.equal(requests.length, 1); + controller.dispose(); + } +}); diff --git a/tests/js/workspace-media-viewer.test.js b/tests/js/workspace-media-viewer.test.js new file mode 100644 index 0000000..b50e1f6 --- /dev/null +++ b/tests/js/workspace-media-viewer.test.js @@ -0,0 +1,34 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createScope} from '../../web/js/shared/toolkit/core.js'; +import gallerySettings from '../../web/js/gallery-settings.js'; + +const module = await import('../../web/js/workspace/media-viewer.mjs').catch(error => { + if (error.code !== 'ERR_MODULE_NOT_FOUND') throw error; + return {}; +}); +test('disposed media viewer touches no DOM observers or requests', () => { + assert.equal(typeof module.createMediaViewer,'function','media viewer contract is exported'); + const forbidden = new Proxy({}, {get() {assert.fail('Disposed viewer touched DOM or inspection');}}); + for (const aborted of [false,true]) { + const parentScope = createScope(); if (aborted) parentScope.dispose(); + const view = module.createMediaViewer({container:forbidden,parentScope,inspectionController:forbidden,sender:forbidden, + onSelect:() => assert.fail('Disposed viewer selected an entry'),onRefresh:() => assert.fail('Disposed viewer refreshed')}); + if (!aborted) view.dispose(); + view.open(forbidden); view.stopSlideshow?.(); view.dispose(); view.dispose(); parentScope.dispose(); + } +}); + +test('preferences handle denied malformed storage without mutation', () => { + assert.equal(typeof module.readViewerPreferences,'function'); + const normalize=gallerySettings.normalizeGallerySettings,defaults={videoAutoplay:false,videoMute:true,videoLoop:true}; + for(const getStorage of [() => {throw new Error('Denied');},() => ({getItem:() => {throw new Error('Denied');}}), + ...['{broken','null','[]','42'].map(value=>() => ({getItem:key=>{assert.equal(key,'gallerySettings');return value;},setItem:()=>assert.fail('Read-only preferences')}))]) { + assert.deepEqual(module.readViewerPreferences(getStorage,normalize),defaults); + } + let raw=JSON.stringify({videoAutoplay:true,videoMute:false,videoLoop:false,infiniteScroll:true}); + const storage={getItem:key=>{assert.equal(key,'gallerySettings');return raw;},setItem:()=>assert.fail('Read-only preferences')}; + assert.deepEqual(module.readViewerPreferences(()=>storage,normalize),{videoAutoplay:true,videoMute:false,videoLoop:false}); + raw=JSON.stringify({videoAutoplay:'true',videoMute:0,videoLoop:null}); + assert.deepEqual(module.readViewerPreferences(()=>storage,normalize),defaults); +}); diff --git a/tests/js/workspace-viewer-state.test.js b/tests/js/workspace-viewer-state.test.js new file mode 100644 index 0000000..3bf7265 --- /dev/null +++ b/tests/js/workspace-viewer-state.test.js @@ -0,0 +1,110 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +const module = await import('../../web/js/workspace/viewer-state.mjs').catch(error => { + if (error.code !== 'ERR_MODULE_NOT_FOUND') throw error; + return {}; +}); +function session(options) { + assert.equal(typeof module.createViewerSession, 'function', 'viewer session contract is exported'); + return module.createViewerSession(options); +} +const entries = () => Array.from({length: 161}, (_, index) => ({root_index: index % 2, + path: `folder/${index}.png`, filename: `${index}.png`, root_scope: 'a'.repeat(64), + thumbnail_url: `/prompt_manager/images/serve/thumbnails/${index}.png?root=${index % 2}&v=17&root_scope=${'a'.repeat(64)}`, + url: `/prompt_manager/images/serve/folder/${index}.png?root=${index % 2}&root_scope=${'a'.repeat(64)}`})); + +test('viewer freezes loaded identities and wraps across unmounted entries', () => { + const items = entries(), updates = []; + const view = session({items, initialKey: '0:folder/0.png', onChange: (state, entry) => updates.push({state, entry})}); + items[160].url = '/wrong-root'; items[160].thumbnail_url = '/wrong-version?v=18';items[160].root_scope = 'b'.repeat(64); items.reverse(); items.push(items[0]); + view.navigate(-1); + assert.deepEqual(view.state, {key:'0:folder/160.png',index:160,total:161,phase:'stopped',requested:false,mediaReady:false}); + assert.equal(updates.at(-1).entry.url, `/prompt_manager/images/serve/folder/160.png?root=0&root_scope=${'a'.repeat(64)}`); + assert.equal(updates.at(-1).entry.root_scope, 'a'.repeat(64)); + assert.equal(updates.at(-1).entry.thumbnail_url, `/prompt_manager/images/serve/thumbnails/160.png?root=0&v=17&root_scope=${'a'.repeat(64)}`); + assert.ok(Object.isFrozen(updates.at(-1).entry)); assert.ok(Object.isFrozen(view.state)); + view.navigate(1); assert.equal(view.state.key, '0:folder/0.png'); + view.navigate(1); assert.equal(view.state.key, '1:folder/1.png'); + view.dispose(); +}); + +test('viewer rejects empty duplicate or unknown identity', () => { + for (const [items, initialKey] of [[[], '0:a.png'], [[{path:'a.png'}], 'undefined:a.png'], + [[{root_index:0,path:''}], '0:'], [[{root_index:0,path:'a.png'},{root_index:0,path:'a.png'}], '0:a.png'], + [entries(), '0:missing.png']]) assert.throws(() => session({items,initialKey}), TypeError); + const view = session({items:entries(),initialKey:'0:folder/0.png'}); + for (const direction of [0,2,-2,'1',null]) assert.throws(() => view.navigate(direction), TypeError); + assert.equal(view.state.index, 0); view.dispose(); +}); + +test('viewer ignores stale readiness after navigation and disposal', () => { + let calls = 0; + const view = session({items:entries(),initialKey:'0:folder/0.png',onChange:() => calls++, + setTimer:() => assert.fail('Task4 must not start a timer'),clearTimer:() => assert.fail('No timer exists')}); + view.ready('0:folder/0.png'); assert.equal(view.state.mediaReady,true); + view.navigate(1); view.failed('0:folder/0.png'); view.ready('0:folder/0.png'); + assert.equal(view.state.mediaReady,false); assert.equal(view.state.phase,'stopped'); + view.failed('1:folder/1.png'); assert.equal(view.state.phase,'error'); + view.ready('1:folder/1.png'); assert.equal(view.state.phase,'stopped'); + view.dispose(); const after = calls; + view.ready('1:folder/1.png'); view.failed('1:folder/1.png'); view.navigate(1); view.dispose(); + assert.equal(calls,after); assert.equal(view.state.phase,'disposed'); assert.equal(view.state.requested,false); +}); + +function timedSession(items = entries().slice(0,3)) { + let now = 0, serial = 0; + const pending = new Map(), callbacks = []; + const view = session({items,initialKey:`${items[0].root_index}:${items[0].path}`, + setTimer: (callback, delay) => {const id=++serial;pending.set(id,{at:now+delay,callback});callbacks.push(callback);return id;}, + clearTimer: id => pending.delete(id)}); + const tick = ms => {now+=ms;for(const [id,timer] of [...pending])if(timer.at<=now){pending.delete(id);timer.callback();}}; + return {view,pending,callbacks,tick}; +} + +test('slideshow schedules once only after current image ready', () => { + const {view,pending,tick}=timedSession(); + view.play?.();assert.equal(view.state.phase,'waiting-image');assert.equal(view.state.requested,true);assert.equal(pending.size,0); + view.ready(view.state.key);assert.equal(view.state.phase,'playing-image');assert.equal(pending.size,1); + view.ready(view.state.key);view.play();assert.equal(pending.size,1); + tick(3499);assert.equal(view.state.index,0);tick(1);assert.equal(view.state.index,1); + assert.equal(view.state.phase,'waiting-image');assert.equal(pending.size,0); + view.ready(view.state.key);assert.equal(pending.size,1);view.dispose();assert.equal(pending.size,0); +}); + +test('slideshow pauses at video and resumes retained intent only after manual image navigation', () => { + const items=entries().slice(0,3);items[1].is_video=true; + const {view,pending,tick}=timedSession(items); + view.ready(view.state.key);view.play?.();tick(3500); + assert.equal(view.state.index,1);assert.equal(view.state.phase,'paused-video');assert.equal(view.state.requested,true);assert.equal(pending.size,0); + view.ready(view.state.key);tick(7000);assert.equal(view.state.index,1);assert.equal(view.state.phase,'paused-video'); + view.navigate(1);assert.equal(view.state.phase,'waiting-image');view.ready(view.state.key);assert.equal(view.state.phase,'playing-image'); + view.navigate(-1);view.stop();view.ready(view.state.key);view.navigate(1);view.ready(view.state.key); + assert.equal(view.state.phase,'stopped');assert.equal(pending.size,0);view.dispose(); +}); + +test('stop cancels waiting and paused intent despite late callbacks', () => { + const {view,pending,callbacks}=timedSession();view.play?.();view.stop?.();view.ready(view.state.key); + assert.equal(view.state.requested,false);assert.equal(pending.size,0); + view.play?.();assert.equal(pending.size,1);const late=callbacks.at(-1); + view.stop();late();view.ready(view.state.key);assert.equal(view.state.index,0);assert.equal(view.state.phase,'stopped'); + view.play();view.navigate(1);late();assert.equal(view.state.index,1);assert.equal(view.state.phase,'waiting-image'); + view.ready(view.state.key);assert.equal(pending.size,1);view.dispose(); +}); + +test('hidden interaction error and disposal never resume', () => { + const {view,pending,callbacks}=timedSession();view.ready(view.state.key);view.play?.(); + assert.equal(pending.size,1);view.stop();for(const callback of callbacks)callback();view.ready(view.state.key); + assert.equal(view.state.phase,'stopped');view.play();view.failed(view.state.key);view.ready(view.state.key); + assert.equal(view.state.phase,'stopped');assert.equal(view.state.requested,false);assert.equal(pending.size,0); + view.play();view.dispose();const disposed=view.state; + for(const callback of callbacks)callback();view.play();view.stop();view.ready(view.state.key);view.failed(view.state.key);view.navigate(1);view.dispose(); + assert.equal(view.state,disposed);assert.equal(pending.size,0); +}); + +test('slideshow cannot start on video one entry or media error', () => { + for(const items of [[entries()[0]],[{...entries()[0],is_video:true},entries()[1]]]) { + const {view,pending}=timedSession(items);view.play?.();assert.equal(view.state.requested,false);assert.equal(pending.size,0);view.dispose(); + } + const {view,pending}=timedSession();view.failed(view.state.key);view.play?.();assert.equal(view.state.phase,'error');assert.equal(pending.size,0);view.dispose(); +}); diff --git a/tests/test_api_images.py b/tests/test_api_images.py index c43cedd..5221c82 100644 --- a/tests/test_api_images.py +++ b/tests/test_api_images.py @@ -2067,3 +2067,128 @@ class TestGalleryRootIdentity(ThumbnailTestCase): ) self.assertEqual(response.status, 200) self.assertFalse((await response.json())["success"]) + + +class TestRootScopeServing(ImageAPITestCase): + async def scoped_request(self, path="image.png", root="0", scope="0" * 64): + params = {"root_scope": scope} + if root is not None: + params["root"] = root + return await self.client.get( + f"/prompt_manager/images/serve/{path}", params=params + ) + + async def assert_no_access(self, path, root="0", scope="0" * 64, status=409): + original_stat = Path.stat + + def stat(candidate, *args, **kwargs): + if candidate.name in ("image.png", "image_thumb.png"): + raise AssertionError("candidate stat before scope guard") + return original_stat(candidate, *args, **kwargs) + + with ( + patch.object(Path, "stat", stat), + patch.object( + self.api, "_file_response", side_effect=AssertionError("file response") + ), + patch.object( + self.api.db, + "get_image_prompt_info", + side_effect=AssertionError("saved lookup"), + ), + patch( + "py.api.images.read_inspection_file", + side_effect=AssertionError("metadata read"), + ), + patch( + "utils.prompt_inspector.subprocess.Popen", + side_effect=AssertionError("probe"), + ), + ): + response = await self.scoped_request(path, root, scope) + self.assertEqual(response.status, status) + result = await response.json() + self.assertFalse(result["success"]) + if status == 409: + self.assertEqual(result["code"], "source-changed") + self.assertEqual(response.headers.get("Cache-Control"), "no-store") + self.assertNotIn(str(self.comfy_dir), json.dumps(result)) + + async def test_root_scope_stale_original_and_thumbnail_fail_before_access(self): + make_png(self.output_dir / "image.png") + make_png(self.output_dir / "thumbnails" / "image_thumb.png") + for path in ("image.png", "thumbnails/image_thumb.png"): + await self.assert_no_access(path) + + async def test_root_scope_malformed_tokens_are_bad_requests(self): + make_png(self.output_dir / "image.png") + for scope in ("", "g" * 64, "a" * 63, "a" * 65, "a" * 4097, "A" * 64): + await self.assert_no_access("image.png", scope=scope, status=400) + for root in (None, "", "x", "-1", "0.0", "+0"): + await self.assert_no_access("image.png", root=root, status=400) + + async def test_root_scope_rotated_key_and_removed_index_require_refresh(self): + make_png(self.output_dir / "image.png") + listing = await self.client.get("/prompt_manager/images/output?mode=workspace") + descriptor = (await listing.json())["images"][0] + self.assertRegex(descriptor.get("root_scope", ""), r"^[0-9a-f]{64}$") + with patch.object( + images_module, "_WORKSPACE_CURSOR_SECRET", b"different-process-key" + ): + await self.assert_no_access("image.png", scope=descriptor["root_scope"]) + self.api._get_all_output_dirs = lambda: [] + await self.assert_no_access("image.png", scope=descriptor["root_scope"]) + + async def test_root_scope_keeps_containment_and_unscoped_access(self): + make_png(self.output_dir / "image.png") + listing = await self.client.get("/prompt_manager/images/output?mode=workspace") + descriptor = (await listing.json())["images"][0] + self.assertRegex(descriptor.get("root_scope", ""), r"^[0-9a-f]{64}$") + scope = descriptor["root_scope"] + self.assertEqual((await self.scoped_request(scope=scope)).status, 200) + outside = make_png(self.comfy_dir / "outside.png") + self._make_symlink(outside, self.output_dir / "escape.png") + for path in ("..%2Foutside.png", "escape.png"): + self.assertEqual((await self.scoped_request(path, scope=scope)).status, 403) + self.assertEqual( + (await self.client.get("/prompt_manager/images/serve/image.png")).status, + 200, + ) + + async def test_root_scope_equal_label_reorder_replacement_and_symlink_are_stale( + self, + ): + roots = [self.comfy_dir / name / "output" for name in ("a", "b", "c")] + for index, root in enumerate(roots): + make_png(root / "image.png", color=(index, 0, 0)) + make_png(root / "thumbnails" / "image_thumb.png", color=(index, 0, 0)) + # Both roots have the same display label under disposable public anchors. + with patch.object( + GalleryConfig, + "path_anchors", + classmethod(lambda cls: [str(root.parent) for root in roots]), + ): + self.api._get_all_output_dirs = lambda: roots[:2] + listing = await self.client.get( + "/prompt_manager/images/output?mode=workspace" + ) + descriptors = (await listing.json())["images"] + self.assertEqual({entry["root_dir"] for entry in descriptors}, {"output"}) + token = next( + entry for entry in descriptors if entry["root_index"] == 0 + ).get("root_scope", "0" * 64) + for changed in ([roots[1], roots[0]], [roots[2], roots[1]]): + self.api._get_all_output_dirs = lambda: changed + for path in ("image.png", "thumbnails/image_thumb.png"): + await self.assert_no_access(path, scope=token) + link = self.comfy_dir / "linked-output" + self._make_symlink(roots[0], link) + self.api._get_all_output_dirs = lambda: [link] + listing = await self.client.get( + "/prompt_manager/images/output?mode=workspace" + ) + token = (await listing.json())["images"][0]["root_scope"] + link.unlink() + self._make_symlink(roots[1], link) + for path in ("image.png", "thumbnails/image_thumb.png"): + await self.assert_no_access(path, scope=token) diff --git a/tests/test_api_library.py b/tests/test_api_library.py index 99d8c30..5d6386e 100644 --- a/tests/test_api_library.py +++ b/tests/test_api_library.py @@ -260,6 +260,44 @@ class TestLibraryAPI(ImageAPITestCase): self.assertTrue(item["previews"][1]["path"].endswith("-1.png")) self.assertTrue(all(image["root_index"] == 0 for image in item["previews"])) + async def test_root_scope_library_gallery_and_previews_share_formatter(self): + from urllib.parse import parse_qs, quote, urlsplit + + relative = "nested/猫 % #.png" + pair = self.api.db.save_prompt_pair("Cat", "Noise", title="Pair title") + positive = self.api.db.get_prompt_pair(pair)["positive_prompt_id"] + path = make_png(self.output_dir / relative) + make_png(self.output_dir / "thumbnails/nested/猫 % #_thumb.png") + image = self.api.db.link_image_to_prompt(positive, str(path)) + self.api.db.link_image_to_pair(pair, image, {"source": "fixture"}) + _, listing = await self.get() + preview = listing["items"][0]["previews"][0] + _, gallery = await self.get(f"/{positive}/images", pair_id=pair) + descriptor = gallery["images"][0] + self.assertRegex(descriptor.get("root_scope", ""), r"^[0-9a-f]{64}$") + self.assertEqual(preview["root_scope"], descriptor["root_scope"]) + for entry in (preview, descriptor): + self.assertIn(quote(relative, safe="/"), entry["url"]) + for url in (entry["url"], entry["thumbnail_url"]): + query = parse_qs(urlsplit(url).query) + self.assertEqual(query["root_scope"], [entry["root_scope"]]) + self.assertEqual((await self.client.get(url)).status, 200) + self.assertIn("v", parse_qs(urlsplit(entry["thumbnail_url"]).query)) + response = await self.client.get( + f"/prompt_manager/images/inspect/{quote(relative, safe='/')}", + params={"root": 0, "root_scope": descriptor["root_scope"]}, + ) + result = await response.json() + self.assertEqual(result["root_scope"], descriptor["root_scope"]) + self.assertEqual( + result["image"], + {"root_index": 0, "path": relative, "filename": "猫 % #.png"}, + ) + self.assertEqual(result["library"]["metadata_scope"], "pair") + self.assertEqual(result["library"]["pair_id"], pair) + self.assertEqual(result["library"]["positive_id"], positive) + self.assertEqual(result["library"]["title"], "Pair title") + class TestNameCursorBudget(unittest.TestCase): def test_lru_byte_budget_scope_and_oversize_keys_are_bounded(self): diff --git a/tests/test_api_prompt_inspector.py b/tests/test_api_prompt_inspector.py index 9932197..a297b5a 100644 --- a/tests/test_api_prompt_inspector.py +++ b/tests/test_api_prompt_inspector.py @@ -347,7 +347,7 @@ class TestPromptInspector(ImageAPITestCase): self.assertEqual(response.status, 200) self.assertEqual(result["prompts"]["positive"]["status"], "absent") with patch( - "py.api.images.read_inspection_metadata", + "py.api.images.read_inspection_file", side_effect=RuntimeError(str(self.comfy_dir)), ): response, result = await self.inspect("image.jpg") @@ -369,7 +369,9 @@ class TestPromptInspector(ImageAPITestCase): wraps=self.api.db.get_image_prompt_info, ) as lookup: _, result = await self.inspect(root="1") - lookup.assert_called_once_with(str(other / "image.png"), False) + lookup.assert_called_once_with( + str(other / "image.png"), False, report_ambiguity=True + ) self.assertEqual(result["prompts"]["positive"]["status"], "absent") async def test_video_inspection_uses_bounded_container_evidence(self): @@ -641,3 +643,622 @@ class TestPromptInspector(ImageAPITestCase): self.assertEqual(record["source"], "saved-association") self.assertIn("cannot be verified", record["message"]) self.assertNotIn("Original portrait", json.dumps(replaced)) + + async def test_full_inspection_envelope_preserves_existing_roles_and_identity(self): + from pathlib import Path + + source = graph(negative="") + source["3"]["inputs"].update(seed=9007199254740993, cfg=6.5, steps=28) + raw = json.dumps(source) + workflow = '{"nodes":[],"seed":9007199254740993}' + path = png(self.output_dir / "image.png", prompt=raw, workflow=workflow) + self._link_image(path, "Saved text must not replace file roles") + original_open = Path.open + reads = [] + + def opened(candidate, *args, **kwargs): + if candidate == path: + reads.append(candidate) + return original_open(candidate, *args, **kwargs) + + with ( + patch.object(Path, "open", opened), + patch.object(Image.Image, "load", side_effect=AssertionError("pixels")), + patch.object( + self.api.db, + "get_image_prompt_info", + wraps=self.api.db.get_image_prompt_info, + ) as lookup, + ): + response, result = await self.inspect() + self.assertEqual(response.status, 200) + self.assertEqual(result.get("inspection_version"), 1) + self.assertEqual( + result["image"], + { + "root_index": 0, + "path": "image.png", + "filename": "image.png", + }, + ) + self.assertEqual( + result["prompts"], + { + "positive": self.side("portrait"), + "negative": self.side(""), + }, + ) + self.assertRegex(result["root_scope"], r"^[0-9a-f]{64}$") + self.assertEqual( + result["metadata"]["raw"], {"prompt": raw, "workflow": workflow} + ) + self.assertEqual( + result["metadata"]["record_formats"], + { + "prompt": "original-text", + "workflow": "original-text", + }, + ) + self.assertEqual( + result["metadata"]["prompt_graph"]["value"]["3"]["inputs"]["seed"], + "9007199254740993", + ) + self.assertEqual(result["file"]["format"], "PNG") + self.assertEqual((result["file"]["width"], result["file"]["height"]), (4, 4)) + self.assertEqual(result["file"]["observation"], "current-file") + self.assertEqual(result["resolution"]["sampler_id"], "3") + self.assertEqual(result["library"]["status"], "linked") + self.assertEqual(reads, [path]) + lookup.assert_called_once_with(str(path), False, report_ambiguity=True) + self.assertLessEqual(len(await response.read()), 8 * 1024 * 1024) + self.assertNotIn(str(self.comfy_dir), json.dumps(result)) + + async def test_workflow_raw_uses_same_aggregate_budget_and_read(self): + from utils.prompt_inspector import MAX_METADATA_BYTES + + path = self.output_dir / "image.png" + png(path, prompt=json.dumps(graph()), workflow="x" * MAX_METADATA_BYTES) + response, result = await self.inspect() + self.assertEqual(response.status, 200) + self.assertEqual(result.get("metadata", {}).get("status"), "unreadable") + self.assertEqual(result["prompts"]["positive"]["status"], "unresolved") + self.assertEqual(result["metadata"]["raw"], {}) + self.assertEqual(result["metadata"]["record_formats"], {}) + + async def test_workflow_only_keeps_existing_role_uncertainty(self): + path = png(self.output_dir / "image.png", workflow={"nodes": []}) + self._link_image(path, "Saved uncertainty") + _, result = await self.inspect() + self.assertEqual(result["prompts"]["positive"]["source"], "saved-association") + self.assertEqual( + result.get("metadata", {}).get("workflow_graph", {}).get("status"), "parsed" + ) + self.assertIsNone(result["prompts"]["positive"]["text"]) + self.assertEqual(result["prompts"]["negative"]["status"], "absent") + + async def test_library_failure_does_not_erase_file_evidence(self): + png(self.output_dir / "image.png", prompt=graph("File portrait")) + with patch.object( + self.api.db, + "get_image_prompt_info", + side_effect=OSError("private DB detail"), + ): + response, result = await self.inspect() + self.assertEqual(response.status, 200) + self.assertEqual(result["prompts"]["positive"], self.side("File portrait")) + self.assertEqual(result.get("library", {}).get("status"), "unavailable") + self.assertEqual(result["metadata"]["prompt_graph"]["status"], "parsed") + self.assertNotIn("private DB detail", json.dumps(result)) + + async def test_file_change_is_retryable_without_saved_text_fallback(self): + from pathlib import Path + + path = png(self.output_dir / "image.png", prompt=graph()) + self._link_image(path, "Saved recipe") + original_open = Path.open + + def opened(candidate, *args, **kwargs): + stream = original_open(candidate, *args, **kwargs) + if candidate == path and args and args[0] == "rb": + with original_open(path, "ab") as output: + output.write(b"changed") + return stream + + with patch.object(Path, "open", opened): + response, result = await self.inspect() + self.assertEqual(response.status, 409) + self.assertFalse(result["success"]) + self.assertNotIn("code", result) + self.assertNotIn("Saved recipe", json.dumps(result)) + self.assertEqual(response.headers.get("Cache-Control"), "no-store") + + async def test_surrogate_and_ordinary_unicode_round_trip_without_replacement(self): + source = graph("猫 😀 \ud800", "\udfff") + raw = json.dumps(source) + png( + self.output_dir / "image.png", + prompt=raw, + workflow='{"nodes":[],"label":"\\ud800 猫 😀"}', + ) + response, result = await self.inspect() + self.assertEqual(response.status, 200) + self.assertEqual(result["prompts"]["positive"], self.side("猫 😀 \ud800")) + self.assertEqual(result["prompts"]["negative"], self.side("\udfff")) + self.assertEqual(result["metadata"]["raw"]["prompt"], raw) + body = await response.read() + self.assertIn("猫 😀".encode(), body) + self.assertIn(b"\\ud800", body) + self.assertNotIn("\ufffd".encode(), body) + self.assertLessEqual(len(body), 8 * 1024 * 1024) + + async def video_inspect(self, comment=None, description=None): + import io + import threading + from unittest.mock import MagicMock + from utils.prompt_inspector import MAX_METADATA_BYTES + + path = self.output_dir / "clip.mp4" + path.write_bytes(b"fixture") + tags = { + key: value + for key, value in (("comment", comment), ("description", description)) + if value is not None + } + payload = json.dumps({"format": {"tags": tags}}).encode() + + class ProbeOutput(io.BytesIO): + def read(self, size=-1): + self.requested_size = size + return super().read(size) + + process = MagicMock() + process.stdout = ProbeOutput(payload) + process.poll.return_value = 0 + process.returncode = 0 + timers = [] + timer_type = threading.Timer + + def timer(*args): + instance = timer_type(*args) + timers.append(instance) + return instance + + with ( + patch("utils.prompt_inspector.ffprobe_path", return_value="/tool/ffprobe"), + patch( + "utils.prompt_inspector.subprocess.Popen", return_value=process + ) as spawn, + patch("utils.prompt_inspector.threading.Timer", side_effect=timer), + ): + response, result = await self.inspect("clip.mp4") + self.assertEqual(spawn.call_count, 1) + self.assertTrue(process.stdout.closed) + self.assertEqual(process.stdout.requested_size, MAX_METADATA_BYTES + 1) + self.assertEqual(len(timers), 1) + self.assertTrue(timers[0].finished.is_set()) + return response, result + + async def test_video_role_description_wins_over_workflow_only_comment(self): + evidence = {} + source = graph("chosen", "") + # CLIP execution is unnecessary here: use the established pair-node carrier. + source["1"] = { + "class_type": "PromptManagerPair", + "inputs": {"text": "chosen", "negative_text": ""}, + } + source["3"]["inputs"]["negative"] = ["1", 1] + record_pair_execution( + evidence, + "1", + text="chosen", + negative_text="", + positive_text="chosen trigger", + negative_effective_text="", + ) + _, result = await self.video_inspect( + json.dumps({"workflow": {"nodes": [{"id": "ignored"}]}}), + json.dumps( + { + "prompt": source, + "workflow": {"nodes": [{"id": "chosen"}]}, + **evidence, + } + ), + ) + self.assertEqual( + result["prompts"]["positive"], self.side("chosen trigger", "execution") + ) + self.assertEqual(result["prompts"]["negative"], self.side("", "execution")) + self.assertEqual( + result.get("metadata", {}).get("workflow_graph", {}).get("value"), + {"nodes": [{"id": "chosen"}]}, + ) + self.assertNotIn("ignored", json.dumps(result)) + + async def test_video_present_malformed_role_markers_block_other_carriers(self): + for marker in ( + {"prompt": None}, + {"prompt": "not-json"}, + {"parameters": None}, + {EXECUTION_KEY: "malformed"}, + ): + for comment, description in ( + ( + json.dumps({"workflow": {"nodes": [{"id": "ignored"}]}}), + json.dumps(marker), + ), + (json.dumps(marker), json.dumps({"prompt": graph("must not win")})), + ): + _, result = await self.video_inspect(comment, description) + self.assertEqual(result["prompts"]["positive"]["status"], "unresolved") + self.assertIsNone(result["prompts"]["positive"]["text"]) + self.assertEqual( + result.get("metadata", {}).get("workflow_graph", {}).get("status"), + "absent", + ) + self.assertNotIn("must not win", json.dumps(result)) + + async def test_video_workflow_carrier_fallback_never_merges_tags(self): + path = self.output_dir / "clip.mp4" + path.write_bytes(b"fixture") + self._link_image(path, "Saved workflow association") + _, result = await self.video_inspect( + '{"workflow":{"nodes":[{"id":"first"}]}}', + '{"workflow":{"nodes":[{"id":"second"}]},"unrelated":"unselected"}', + ) + self.assertEqual(result["prompts"]["positive"]["source"], "saved-association") + self.assertEqual( + result.get("metadata", {}).get("workflow_graph", {}).get("value"), + {"nodes": [{"id": "first"}]}, + ) + self.assertEqual( + result["metadata"]["raw"], {"workflow": '{"nodes": [{"id": "first"}]}'} + ) + self.assertNotIn("second", json.dumps(result)) + self.assertNotIn("unselected", json.dumps(result)) + + async def test_video_object_graph_decimal_and_unsafe_int_projection_is_exact(self): + raw = json.dumps(graph("video", "")) + raw = raw.replace( + '"positive": ["1", 0]', + '"seed":9007199254740993,"cfg":6.5000000000000000001,"positive": ["1", 0]', + ) + for prompt in (raw, json.dumps(raw)): + carrier = ( + '{"prompt":' + + prompt + + ',"workflow":{"nodes":[],"cfg":6.5000000000000000001,' + '"seed":9007199254740993}}' + ) + _, result = await self.video_inspect(carrier) + self.assertEqual(result["prompts"]["positive"], self.side("video")) + inputs = ( + result.get("metadata", {}) + .get("prompt_graph", {}) + .get("value", {}) + .get("3", {}) + .get("inputs", {}) + ) + self.assertEqual(inputs.get("cfg"), "6.5000000000000000001") + self.assertEqual(inputs["seed"], "9007199254740993") + self.assertEqual(inputs["positive"], ["1", 0]) + self.assertEqual( + result["metadata"]["workflow_graph"]["value"]["cfg"], + "6.5000000000000000001", + ) + self.assertNotIn("video_carrier_text", result) + self.assertNotIn("carrier_text", json.dumps(result)) + + async def test_video_original_string_and_reserialized_object_formats_are_distinct( + self, + ): + raw = json.dumps(graph()) + for record, expected in ( + (json.dumps(raw), "original-text"), + (raw, "reserialized-json"), + ): + _, result = await self.video_inspect( + '{"prompt":' + record + ',"workflow":' + record + "}" + ) + self.assertEqual( + result.get("metadata", {}).get("record_formats"), + {"prompt": expected, "workflow": expected}, + ) + self.assertEqual(result["metadata"]["raw"]["prompt"], raw) + + async def test_video_null_role_marker_raises_before_successful_read(self): + for key in ("prompt", "parameters", EXECUTION_KEY): + _, result = await self.video_inspect(json.dumps({key: None})) + self.assertEqual(result["prompts"]["positive"]["status"], "unresolved") + self.assertEqual(result.get("metadata", {}).get("status"), "unreadable") + + async def test_video_null_workflow_keeps_valid_legacy_roles(self): + _, result = await self.video_inspect( + json.dumps({"prompt": graph(), "workflow": None}) + ) + self.assertEqual(result["prompts"]["positive"], self.side("portrait")) + self.assertEqual( + result.get("metadata", {}).get("workflow_graph"), + {"status": "malformed", "value": None}, + ) + self.assertEqual(result["metadata"]["record_formats"]["workflow"], "invalid") + self.assertNotIn("workflow", result["metadata"]["raw"]) + + async def test_video_strict_projection_failure_preserves_legacy_roles(self): + source = json.dumps(graph()) + for suffix, expected in ( + (',"duplicate":1,"duplicate":2', "malformed"), + (',"nonfinite":NaN', "malformed"), + (',"depth":' + "[" * 64 + "0" + "]" * 64, "limited"), + ): + _, result = await self.video_inspect( + '{"prompt":' + source + ',"workflow":{"nodes":[]}' + suffix + "}" + ) + self.assertEqual(result["prompts"]["positive"], self.side("portrait")) + self.assertEqual( + result.get("metadata", {}).get("prompt_graph", {}).get("status"), + expected, + ) + self.assertIsNone(result["metadata"]["prompt_graph"]["value"]) + self.assertEqual(result["metadata"]["workflow_graph"]["status"], expected) + + async def test_video_numeric_role_text_never_becomes_sendable(self): + source = graph(positive=123) + _, result = await self.video_inspect(json.dumps({"prompt": source})) + self.assertEqual(result["prompts"]["positive"]["status"], "unresolved") + self.assertIsNone(result["prompts"]["positive"]["text"]) + self.assertEqual( + result.get("metadata", {}).get("prompt_graph", {}).get("status"), "parsed" + ) + + async def scoped_descriptor(self, root_index=0): + response = await self.client.get("/prompt_manager/images/output?mode=workspace") + entries = (await response.json())["images"] + return next(entry for entry in entries if entry["root_index"] == root_index) + + async def assert_scope_skips_access(self, scope, root="0", status=409): + from pathlib import Path + + original_stat = Path.stat + + def stat(candidate, *args, **kwargs): + if candidate.name == "image.png": + raise AssertionError("candidate stat before scope guard") + return original_stat(candidate, *args, **kwargs) + + params = {"root_scope": scope} + if root is not None: + params["root"] = root + with ( + patch.object(Path, "stat", stat), + patch( + "utils.prompt_inspector.subprocess.Popen", + side_effect=AssertionError("probe"), + ), + patch.object( + self.api.db, + "get_image_prompt_info", + side_effect=AssertionError("saved lookup"), + ), + patch.object( + self.api, "_file_response", side_effect=AssertionError("file response") + ), + patch( + "py.api.images.read_inspection_file", + side_effect=AssertionError("metadata read"), + ), + ): + response = await self.client.get( + "/prompt_manager/images/inspect/image.png", params=params + ) + self.assertEqual(response.status, status) + result = await response.json() + self.assertFalse(result["success"]) + if status == 409: + self.assertEqual(result["code"], "source-changed") + self.assertEqual(response.headers.get("Cache-Control"), "no-store") + self.assertNotIn(str(self.comfy_dir), json.dumps(result)) + + async def test_root_scope_equal_label_reorder_or_replacement_never_reads_other_file( + self, + ): + from py.config import GalleryConfig + + roots = [self.comfy_dir / name / "output" for name in ("a", "b", "c")] + for index, root in enumerate(roots): + png(root / "image.png", prompt=graph(str(index))) + with patch.object( + GalleryConfig, + "path_anchors", + classmethod(lambda cls: [str(root.parent) for root in roots]), + ): + self.api._get_all_output_dirs = lambda: roots[:2] + descriptor = await self.scoped_descriptor() + other = await self.scoped_descriptor(1) + self.assertEqual(descriptor["root_dir"], other["root_dir"]) + for changed in ([roots[1], roots[0]], [roots[2], roots[1]]): + self.api._get_all_output_dirs = lambda: changed + await self.assert_scope_skips_access( + descriptor.get("root_scope", "0" * 64) + ) + + async def test_root_scope_symlink_retarget_requires_refresh(self): + first, second = self.comfy_dir / "a", self.comfy_dir / "b" + png(first / "image.png", prompt=graph("first")) + png(second / "image.png", prompt=graph("second")) + link = self.comfy_dir / "linked" + self._make_symlink(first, link) + self.api._get_all_output_dirs = lambda: [link] + descriptor = await self.scoped_descriptor() + link.unlink() + self._make_symlink(second, link) + await self.assert_scope_skips_access(descriptor.get("root_scope", "0" * 64)) + + async def test_root_scope_rotated_key_and_removed_index_skip_reader_and_db(self): + png(self.output_dir / "image.png", prompt=graph()) + descriptor = await self.scoped_descriptor() + import py.api.images as images_module + + with patch.object(images_module, "_WORKSPACE_CURSOR_SECRET", b"new-key"): + await self.assert_scope_skips_access(descriptor.get("root_scope", "0" * 64)) + self.api._get_all_output_dirs = lambda: [] + await self.assert_scope_skips_access(descriptor.get("root_scope", "0" * 64)) + + async def test_root_scope_malformed_request_skips_reader(self): + png(self.output_dir / "image.png", prompt=graph()) + for token in ("", "z" * 64, "a" * 63, "a" * 65, "a" * 4097): + await self.assert_scope_skips_access(token, status=400) + for root in (None, "-1", "x", "0.0", "+0"): + await self.assert_scope_skips_access("a" * 64, root=root, status=400) + + async def test_root_scope_echo_preserves_legacy_image_and_roles(self): + png(self.output_dir / "image.png", prompt=graph()) + descriptor = await self.scoped_descriptor() + token = descriptor.get("root_scope", "0" * 64) + _, original = await self.inspect() + response = await self.client.get( + "/prompt_manager/images/inspect/image.png", + params={"root": "0", "root_scope": token}, + ) + result = await response.json() + self.assertEqual(response.status, 200) + self.assertEqual(result.get("root_scope"), token) + self.assertEqual( + result["image"], + {"root_index": 0, "path": "image.png", "filename": "image.png"}, + ) + self.assertEqual(result["prompts"], original["prompts"]) + outside = png(self.comfy_dir / "outside.png", prompt=graph("private")) + self._make_symlink(outside, self.output_dir / "escape.png") + for path in ("..%2Foutside.png", "escape.png"): + denied = await self.client.get( + f"/prompt_manager/images/inspect/{path}", + params={"root": "0", "root_scope": token}, + ) + self.assertEqual(denied.status, 403) + + async def test_root_scope_source_change_returns_refresh_instead_of_other_root(self): + png(self.output_dir / "image.png", prompt=graph("old source")) + other = self.comfy_dir / "input" + png(other / "image.png", prompt=graph("different source")) + self.api._get_all_output_dirs = lambda: [self.output_dir, other] + descriptor = await self.scoped_descriptor() + self.api._get_all_output_dirs = lambda: [other, self.output_dir] + response = await self.client.get( + "/prompt_manager/images/inspect/image.png", + params={"root": 0, "root_scope": descriptor.get("root_scope", "0" * 64)}, + ) + self.assertEqual(response.status, 409) + result = await response.json() + self.assertEqual(result["code"], "source-changed") + self.assertNotIn("different source", json.dumps(result)) + + async def test_video_unrepresentable_decimal_preserves_file_roles_and_raw(self): + carrier = json.dumps({"prompt": graph()}).replace( + '"positive": ["1", 0]', + '"cfg":1e1000000000000000000000000,"positive": ["1", 0]', + ) + response, result = await self.video_inspect(carrier) + self.assertEqual(response.status, 200) + self.assertEqual(result["prompts"]["positive"], self.side("portrait")) + self.assertEqual( + result["metadata"]["prompt_graph"], {"status": "malformed", "value": None} + ) + self.assertEqual( + result["metadata"]["record_formats"]["prompt"], "reserialized-json" + ) + self.assertIn("Infinity", result["metadata"]["raw"]["prompt"]) + + async def test_root_scope_guard_and_evidence_use_one_current_roots_snapshot(self): + png(self.output_dir / "image.png", prompt=graph()) + descriptor = await self.scoped_descriptor() + with patch.object( + self.api, "_get_all_output_dirs", side_effect=[[self.output_dir]] + ) as roots: + response = await self.client.get( + "/prompt_manager/images/inspect/image.png", + params={"root": 0, "root_scope": descriptor["root_scope"]}, + ) + self.assertEqual(response.status, 200) + self.assertEqual( + (await response.json())["root_scope"], descriptor["root_scope"] + ) + roots.assert_called_once_with() + + async def test_exact_saved_association_ambiguity_preserves_physical_file_evidence( + self, + ): + for readable in (False, True): + with self.subTest(readable=readable): + filename = "readable.png" if readable else "empty.png" + metadata = ( + {"prompt": json.dumps(graph("Physical portrait", ""))} + if readable + else {} + ) + path = png(self.output_dir / filename, **metadata) + before = path.read_bytes() + results, lookups = [], [] + for count, expected in ((0, "none"), (1, "linked"), (2, "ambiguous")): + if count: + positive = self._save_prompt( + f"Saved owner {readable} {count}", + category=f"Category {count}", + tags=[f"owner-{count}"], + ) + self.api.db.link_image_to_prompt(positive, str(path)) + with patch.object( + self.api.db, + "get_image_prompt_info", + wraps=self.api.db.get_image_prompt_info, + ) as lookup: + response, result = await self.inspect(filename) + self.assertEqual(response.status, 200) + self.assertEqual(result["library"]["status"], expected) + self.assertEqual(lookup.call_count, 1) + lookups.append(lookup) + results.append(result) + self.assertEqual(result["metadata"]["raw"], metadata) + self.assertEqual(path.read_bytes(), before) + if count == 1: + self.assertEqual(result["library"]["positive_id"], positive) + self.assertEqual( + result["library"]["metadata_scope"], "positive" + ) + self.assertEqual(result["library"]["category"], "Category 1") + self.assertEqual(result["library"]["tags"], ["owner-1"]) + if not readable: + self.assertEqual( + result["prompts"]["positive"]["source"], + "saved-association", + ) + if readable or count != 1: + self.assertEqual(result["prompts"], results[0]["prompts"]) + self.assertEqual( + results[2]["library"], + { + "status": "ambiguous", + "scope": "saved-library", + "positive_id": None, + "pair_id": None, + "title": None, + "category": None, + "tags": [], + "metadata_scope": None, + }, + ) + for field in ( + "image", + "file", + "metadata", + "prompts", + "resolution", + "root_scope", + ): + self.assertEqual(results[2][field], results[0][field]) + self.assertNotIn("Saved owner", json.dumps(results[2])) + self.assertNotIn("association_status", json.dumps(results[2])) + for lookup in lookups: + lookup.assert_called_once_with( + str(path), False, report_ambiguity=True + ) diff --git a/tests/test_api_workspace_images.py b/tests/test_api_workspace_images.py index 2c70e02..425c9df 100644 --- a/tests/test_api_workspace_images.py +++ b/tests/test_api_workspace_images.py @@ -255,6 +255,51 @@ class TestWorkspaceImages(ImageAPITestCase): response = await self.client.get("/prompt_manager/gallery/subfolders") self.assertNotIn("roots", await response.json()) + async def test_root_scope_workspace_descriptors_guard_original_and_thumbnail_urls( + self, + ): + from urllib.parse import parse_qs, quote, urlsplit + + roots = [self.output_dir, self.comfy_dir / "input"] + self.api._get_all_output_dirs = lambda: roots + relative = "nested/猫 % #.png" + for index, root in enumerate(roots): + self.media(relative, root=root) + self.media("thumbnails/nested/猫 % #_thumb.png", root=root) + make_png(root / relative, color=(index, 0, 0)) + make_png(root / "thumbnails/nested/猫 % #_thumb.png", color=(index, 0, 0)) + _, result = await self.get(limit=10) + for entry in result["images"]: + self.assertRegex(entry.get("root_scope", ""), r"^[0-9a-f]{64}$") + self.assertIn(quote(relative, safe="/"), entry["url"]) + for url in (entry["url"], entry["thumbnail_url"]): + query = parse_qs(urlsplit(url).query) + self.assertEqual(query["root_scope"], [entry["root_scope"]]) + self.assertEqual(query["root"], [str(entry["root_index"])]) + response = await self.client.get(url) + self.assertEqual(response.status, 200) + expected = roots[entry["root_index"]] / ( + "thumbnails/nested/猫 % #_thumb.png" + if url == entry["thumbnail_url"] + else relative + ) + self.assertEqual(await response.read(), expected.read_bytes()) + self.assertIn("v", parse_qs(urlsplit(entry["thumbnail_url"]).query)) + response = await self.client.get( + f"/prompt_manager/images/inspect/{quote(relative, safe='/')}", + params={"root": entry["root_index"], "root_scope": entry["root_scope"]}, + ) + data = await response.json() + self.assertEqual(data["root_scope"], entry["root_scope"]) + self.assertEqual( + data["image"], + { + "root_index": entry["root_index"], + "path": relative, + "filename": "猫 % #.png", + }, + ) + class TestWorkspaceCursorEncoding(unittest.TestCase): def test_ntfs_unicode_basename_cursor_is_bounded_and_round_trips(self): diff --git a/tests/test_database.py b/tests/test_database.py index a73fcee..648faf4 100644 --- a/tests/test_database.py +++ b/tests/test_database.py @@ -960,6 +960,70 @@ class TestImageQueries(DatabaseTestCase): self.assertIsNone(info["workflow_data"]) self.assertEqual(info["prompt_metadata"], {"k": 1}) + def test_image_prompt_info_reports_ambiguity_only_when_requested(self): + stored = os.path.join("out", "exact.png") + self.assertIsNone(self.db.get_image_prompt_info(stored, False)) + self.assertIsNone( + self.db.get_image_prompt_info(stored, False, report_ambiguity=True) + ) + + first = self._save("First owner", category="First", tags=["first"]) + self.db.link_image_to_prompt(first, stored, self.METADATA) + original = self.db.get_image_prompt_info(stored, False) + self.assertEqual(original["prompt_id"], first) + self.assertEqual(original["category"], "First") + self.assertEqual(original["tags"], ["first"]) + self.assertEqual( + self.db.get_image_prompt_info(stored, False, report_ambiguity=True), + original, + ) + + second = self._save("Second owner", category="Second", tags=["second"]) + self.db.link_image_to_prompt(second, stored, self.METADATA) + self.assertIsNone(self.db.get_image_prompt_info(stored)) + self.assertIsNone(self.db.get_image_prompt_info(stored, False)) + statements = [] + with self.db.model.get_connection() as conn: + conn.set_trace_callback(statements.append) + try: + result = self.db.get_image_prompt_info( + stored, False, report_ambiguity=True + ) + finally: + conn.set_trace_callback(None) + self.assertEqual(result, {"association_status": "ambiguous"}) + self.assertEqual( + len( + [sql for sql in statements if sql.lstrip().upper().startswith("SELECT")] + ), + 1, + ) + self.assertIsNone( + self.db.get_image_prompt_info("missing.png", False, report_ambiguity=True) + ) + + def test_image_prompt_info_ambiguity_opt_in_keeps_exact_path_precedence(self): + first = self._save("Exact owner") + second = self._save("Same basename") + exact = os.path.join("out", "one", "same.png") + other = os.path.join("out", "two", "same.png") + self.db.link_image_to_prompt(first, exact) + self.db.link_image_to_prompt(second, other) + original = self.db.get_image_prompt_info(exact) + self.assertEqual(original["prompt_id"], first) + self.assertEqual( + self.db.get_image_prompt_info(exact, report_ambiguity=True), original + ) + absent = os.path.join("elsewhere", "same.png") + self.assertIsNone(self.db.get_image_prompt_info(absent)) + self.assertIsNone( + self.db.get_image_prompt_info(absent, False, report_ambiguity=True) + ) + self.assertEqual( + self.db.get_image_prompt_info(absent, report_ambiguity=True), + {"association_status": "ambiguous"}, + ) + class TestMaintenanceOperations(DatabaseTestCase): def test_statistics_count_everything(self): diff --git a/tests/test_inspection_response.py b/tests/test_inspection_response.py new file mode 100644 index 0000000..0488abb --- /dev/null +++ b/tests/test_inspection_response.py @@ -0,0 +1,465 @@ +"""Wire projections preserve exact bounded evidence independently of role parsing.""" + +import io +import json +import tempfile +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + +from utils.inspection_response import encode_inspection_response +from utils.prompt_inspector import ( + InspectionRead, + MAX_METADATA_BYTES, + MetadataReadError, + inspect_prompt_roles, + read_inspection_file, + read_inspection_metadata, +) +from utils.pair_execution import EXECUTION_KEY + +CAP = 8 * 1024 * 1024 +IMAGE = {"root_index": 0, "path": "image.png", "filename": "image.png"} +PROMPTS = { + "positive": {"status": "resolved", "text": "portrait", "source": "graph-inferred"}, + "negative": {"status": "resolved", "text": "", "source": "graph-inferred"}, +} + + +def inspection(metadata, *, formats=None, carrier=None): + return InspectionRead( + metadata=metadata, + record_formats=( + formats + if formats is not None + else {key: "original-text" for key in metadata} + ), + video_carrier_text=carrier, + format="PNG", + width=4, + height=4, + ) + + +def encode(read, *, prompts=None, file=None, library=None): + return encode_inspection_response( + image=IMAGE, + root_scope="a" * 64, + file=file + or { + "observation": "current-file", + "source_label": "output", + "media_type": "image", + "extension": ".png", + "size": 100, + "modified_time": 100, + }, + prompts=PROMPTS if prompts is None else prompts, + resolution={"state": "resolved", "sampler_id": "3", "reason": ""}, + inspection=read, + library=library + or { + "status": "none", + "scope": "saved-library", + "positive_id": None, + "pair_id": None, + "title": None, + "category": None, + "tags": [], + "metadata_scope": None, + }, + ) + + +def graph_text(count=3): + graph = { + "1": {"class_type": "CLIPTextEncode", "inputs": {"text": "portrait"}}, + "2": {"class_type": "CLIPTextEncode", "inputs": {"text": ""}}, + "3": { + "class_type": "KSampler", + "inputs": { + "positive": ["1", 0], + "negative": ["2", 0], + "seed": 9007199254740993, + "cfg": "EXACT_CFG", + }, + }, + } + for index in range(4, count + 1): + graph[str(index)] = { + "class_type": "Primitive", + "inputs": {"value": 1, "labels": [True, False, "ordinary"]}, + } + return json.dumps(graph).replace('"EXACT_CFG"', "6.5000000000000000001") + + +class TestInspectionResponse(unittest.TestCase): + def test_normalized_numbers_preserve_exact_literals(self): + text = graph_text().replace( + '"cfg": 6.5000000000000000001', + '"cfg": 6.5000000000000000001, "huge": 1e1000000000', + ) + result = json.loads(encode(inspection({"prompt": text}))) + inputs = result["metadata"]["prompt_graph"]["value"]["3"]["inputs"] + self.assertEqual(inputs["seed"], "9007199254740993") + self.assertEqual(inputs["cfg"], "6.5000000000000000001") + self.assertEqual(inputs["huge"].lower(), "1e+1000000000") + self.assertEqual(inputs["positive"], ["1", 0]) + self.assertEqual(result["metadata"]["raw"]["prompt"], text) + self.assertEqual(result["prompts"], PROMPTS) + + def test_normalization_preserves_150_node_graph_with_more_than_500_json_values( + self, + ): + text = graph_text(150) + self.assertGreater(len(text.split(":")), 500) + for read in ( + inspection({"prompt": text}), + inspection( + {"prompt": json.dumps(json.loads(text))}, + formats={"prompt": "reserialized-json"}, + carrier='{"prompt":' + text + ',"unrelated":{"value":1}}', + ), + ): + result = json.loads(encode(read)) + graph = result["metadata"]["prompt_graph"] + self.assertEqual(graph["status"], "parsed") + self.assertEqual(len(graph["value"]), 150) + self.assertEqual( + graph["value"]["3"]["inputs"]["cfg"], "6.5000000000000000001" + ) + self.assertEqual(graph["value"]["3"]["inputs"]["seed"], "9007199254740993") + self.assertEqual(graph["value"]["3"]["inputs"]["positive"], ["1", 0]) + self.assertIs(graph["value"]["4"]["inputs"]["labels"][0], True) + self.assertEqual(result["metadata"]["raw"], read.metadata) + self.assertEqual(result["prompts"], PROMPTS) + + def test_graph_node_limit_counts_nodes_not_json_fields(self): + for count, status in ((500, "parsed"), (501, "limited")): + prompt = graph_text(count) + workflow = json.dumps( + { + "nodes": [ + {"id": i, "type": "Primitive", "widgets_values": [1, True]} + for i in range(count) + ] + } + ) + for read in ( + inspection({"prompt": prompt, "workflow": workflow}), + inspection( + {"prompt": json.dumps(json.loads(prompt)), "workflow": workflow}, + formats={ + "prompt": "reserialized-json", + "workflow": "reserialized-json", + }, + carrier='{"other":[],"prompt":' + + prompt + + ',"workflow":' + + workflow + + "}", + ), + ): + result = json.loads(encode(read)) + for key in ("prompt_graph", "workflow_graph"): + self.assertEqual(result["metadata"][key]["status"], status) + if status == "limited": + self.assertIsNone(result["metadata"][key]["value"]) + self.assertEqual(result["metadata"]["raw"], read.metadata) + self.assertEqual(result["prompts"], PROMPTS) + + def test_strict_projection_depth_is_separate_from_semantic_link_depth(self): + for depth in (64, 65): + # API map root + node + inputs + nested array containers. + text = ( + '{"1":{"class_type":"Primitive","inputs":{"value":' + + "[" * (depth - 3) + + "0" + + "]" * (depth - 3) + + "}}}" + ) + result = json.loads(encode(inspection({"prompt": text}))) + self.assertEqual( + result["metadata"]["prompt_graph"]["status"], + "parsed" if depth == 64 else "limited", + ) + carrier = '{"prompt":' + text + "}" + result = json.loads( + encode( + inspection( + {"prompt": text}, + formats={"prompt": "reserialized-json"}, + carrier=carrier, + ) + ) + ) + self.assertEqual(result["metadata"]["prompt_graph"]["status"], "limited") + text = '{"1":{"inputs":{"value":' + "[" * 60 + "0" + "]" * 60 + "}}}" + result = json.loads( + encode( + inspection( + {"prompt": text}, + formats={"prompt": "reserialized-json"}, + carrier='{"prompt":' + text + "}", + ) + ) + ) + self.assertEqual(result["metadata"]["prompt_graph"]["status"], "parsed") + chain = { + str(i): { + "class_type": "Reroute", + "inputs": {"value": [str((i + 1) % 150), 0]}, + } + for i in range(150) + } + result = json.loads(encode(inspection({"prompt": json.dumps(chain)}))) + self.assertEqual(result["metadata"]["prompt_graph"]["status"], "parsed") + self.assertEqual(len(result["metadata"]["prompt_graph"]["value"]), 150) + + def test_wide_bounded_values_do_not_consume_semantic_visit_budget(self): + text = json.dumps( + { + "1": { + "inputs": { + "values": list(range(20000)), + "fields": {str(i): True for i in range(1000)}, + } + } + } + ) + self.assertLess(len(text.encode()), MAX_METADATA_BYTES) + body = encode(inspection({"prompt": text})) + graph = json.loads(body)["metadata"]["prompt_graph"] + self.assertEqual(graph["status"], "parsed") + self.assertEqual(len(graph["value"]["1"]["inputs"]["values"]), 20000) + self.assertEqual(len(graph["value"]["1"]["inputs"]["fields"]), 1000) + self.assertLess(len(body), CAP) + + def test_strict_malformed_records_preserve_original_raw_and_roles(self): + for text in ( + '{"1":{},"1":{}}', + '{"1":{"value":NaN}}', + '{"1":{"value":Infinity}}', + "{broken", + "[]", + "42", + "[" * 2000, + ): + result = json.loads(encode(inspection({"prompt": text, "workflow": text}))) + self.assertIn( + result["metadata"]["prompt_graph"]["status"], ("malformed", "limited") + ) + self.assertIsNone(result["metadata"]["prompt_graph"]["value"]) + self.assertEqual( + result["metadata"]["raw"], {"prompt": text, "workflow": text} + ) + self.assertEqual(result["prompts"], PROMPTS) + + def test_video_missing_exact_source_is_limited_not_rounded(self): + rounded = json.dumps(json.loads(graph_text())) + result = json.loads( + encode( + inspection({"prompt": rounded}, formats={"prompt": "reserialized-json"}) + ) + ) + self.assertEqual( + result["metadata"]["prompt_graph"], {"status": "limited", "value": None} + ) + self.assertEqual(result["metadata"]["raw"]["prompt"], rounded) + self.assertEqual(result["prompts"], PROMPTS) + + def test_response_escaping_omits_graphs_then_entire_raw_atomically(self): + prompts = { + role: { + "status": "resolved", + "text": "\x01" * 100000, + "source": "graph-inferred", + } + for role in ("positive", "negative") + } + source = json.loads(graph_text()) + source["1"]["inputs"]["text"] = prompts["positive"]["text"] + source["2"]["inputs"]["text"] = prompts["negative"]["text"] + raw = json.dumps(source) + for count, raw_retained in ((450000, True), (550000, False)): + metadata = {"prompt": raw, "parameters": "\x01" * count} + self.assertLess( + sum(len(value.encode()) for value in metadata.values()), + MAX_METADATA_BYTES, + ) + body = encode(inspection(metadata), prompts=prompts) + result = json.loads(body) + self.assertLessEqual(len(body), CAP) + self.assertEqual(result["image"], IMAGE) + self.assertEqual(result["prompts"], prompts) + self.assertEqual( + result["metadata"]["prompt_graph"], {"status": "limited", "value": None} + ) + self.assertEqual( + result["metadata"]["workflow_graph"], + {"status": "absent", "value": None}, + ) + self.assertEqual( + result["metadata"]["record_formats"], + {key: "original-text" for key in metadata}, + ) + if raw_retained: + self.assertEqual(result["metadata"]["status"], "available") + self.assertIsNone(result["metadata"]["omitted_reason"]) + self.assertEqual(result["metadata"]["raw"], metadata) + self.assertEqual( + result["metadata"]["parameters"], metadata["parameters"] + ) + else: + self.assertEqual(result["metadata"]["raw"], {}) + self.assertIsNone(result["metadata"]["parameters"]) + self.assertEqual(result["metadata"]["status"], "limited") + self.assertEqual( + result["metadata"]["omitted_reason"], "response-size-limit" + ) + + def test_response_byte_boundary_is_inclusive(self): + prompts = { + role: { + "status": "resolved", + "text": "\x01" * 100000, + "source": "graph-inferred", + } + for role in ("positive", "negative") + } + text = graph_text().replace( + '"cfg": 6.5000000000000000001', '"cfg":6.5000000000000000001,"label":"猫😀"' + ) + read = inspection({"prompt": text, "parameters": "\x01" * 500000}) + base_file = { + "observation": "current-file", + "source_label": "output", + "media_type": "image", + "extension": ".png", + } + initial_size = len(encode(read, prompts=prompts, file=base_file)) + self.assertLess(initial_size, CAP) + for target in (CAP - 1, CAP, CAP + 1): + extra = target - initial_size + file = {**base_file, "source_label": "output" + ("x" if extra % 2 else "")} + parameters = read.metadata["parameters"] + "x" * (extra // 2) + current = inspection({"prompt": text, "parameters": parameters}) + self.assertLess( + sum(len(value.encode()) for value in current.metadata.values()), + MAX_METADATA_BYTES, + ) + body = encode(current, prompts=prompts, file=file) + result = json.loads(body) + if target <= CAP: + self.assertEqual(len(body), target) + self.assertEqual(result["metadata"]["prompt_graph"]["status"], "parsed") + else: + self.assertLessEqual(len(body), CAP) + self.assertEqual( + result["metadata"]["prompt_graph"]["status"], "limited" + ) + self.assertEqual(result["prompts"], prompts) + self.assertEqual(result["image"], IMAGE) + self.assertEqual(result["metadata"]["raw"], current.metadata) + + def test_optional_display_fields_are_validated_and_library_is_bounded(self): + file = { + "size": -1, + "width": True, + "height": float("inf"), + "modified_time": float("nan"), + "format": 12, + "observation": "current-file", + } + library = { + "status": "linked", + "positive_id": 2**53, + "pair_id": True, + "tags": ["valid", 3], + "category": {}, + "title": [], + } + result = json.loads(encode(None, file=file, library=library)) + for key in ("size", "width", "height", "modified_time", "format"): + self.assertNotIn(key, result["file"]) + for key in ("positive_id", "pair_id", "category", "title"): + self.assertNotIn(key, result["library"]) + self.assertEqual(result["library"]["tags"], ["valid"]) + result = json.loads( + encode(inspection({}), library={"status": "linked", "tags": ["猫" * 12000]}) + ) + self.assertEqual(result["library"]["status"], "limited") + self.assertEqual(result["library"]["tags"], []) + self.assertLess( + len(json.dumps(result["library"], ensure_ascii=False).encode()), 32 * 1024 + ) + + def test_absent_unreadable_invalid_workflow_and_formats_remain_distinct(self): + for read, status in ( + (None, "unreadable"), + (inspection({}), "absent"), + (inspection({}, formats={"workflow": "invalid"}), "available"), + ): + result = json.loads(encode(read)) + self.assertEqual(result["metadata"]["status"], status) + self.assertEqual(result["metadata"]["raw"], {}) + self.assertIsNone(result["metadata"]["parameters"]) + self.assertEqual(result["metadata"]["workflow_graph"]["status"], "malformed") + + def test_video_null_role_marker_raises_before_successful_read(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "clip.mp4" + path.write_bytes(b"fixture") + for field in ("prompt", "parameters", EXECUTION_KEY): + for reader in (read_inspection_file, read_inspection_metadata): + process = MagicMock() + process.stdout = io.BytesIO( + json.dumps( + {"format": {"tags": {"comment": json.dumps({field: None})}}} + ).encode() + ) + process.poll.return_value = 0 + process.returncode = 0 + with ( + patch( + "utils.prompt_inspector.ffprobe_path", + return_value="/tool/ffprobe", + ), + patch( + "utils.prompt_inspector.subprocess.Popen", + return_value=process, + ), + ): + with self.assertRaises(MetadataReadError): + reader(path) + self.assertTrue(process.stdout.closed) + + def test_original_role_projection_ignores_workflow_without_losing_saved_warning( + self, + ): + self.assertEqual( + inspect_prompt_roles({"workflow": "{}"}, {"prompt_id": 1})["positive"][ + "source" + ], + "saved-association", + ) + + def test_unrepresentable_decimal_exponent_preserves_roles_and_raw(self): + text = graph_text().replace( + "6.5000000000000000001", "1e1000000000000000000000000" + ) + for read in ( + inspection({"prompt": text}), + inspection( + {"prompt": json.dumps(json.loads(text))}, + formats={"prompt": "reserialized-json"}, + carrier='{"prompt":' + text + "}", + ), + ): + result = json.loads(encode(read)) + self.assertEqual( + result["metadata"]["prompt_graph"], + {"status": "malformed", "value": None}, + ) + self.assertEqual(result["metadata"]["raw"], read.metadata) + self.assertEqual(result["prompts"], PROMPTS) diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json index 4bfb204..ceeb6ab 100644 --- a/tools/vendor/frontend-coverage-baseline.json +++ b/tools/vendor/frontend-coverage-baseline.json @@ -13,7 +13,7 @@ }, "comfy-metadata.js": { "lines": 97, - "branches": 90, + "branches": 91, "functions": 98, "statements": 97 }, @@ -168,10 +168,10 @@ "statements": 98 }, "workspace/images.mjs": { - "lines": 95, + "lines": 97, "branches": 87, "functions": 98, - "statements": 95 + "statements": 97 }, "prompt-bridge-adapter.js": { "lines": 98, @@ -192,10 +192,10 @@ "statements": 98 }, "workspace/prompt-inspector.mjs": { - "lines": 97, - "branches": 92, + "lines": 98, + "branches": 98, "functions": 98, - "statements": 97 + "statements": 98 }, "workspace/prompt-sender.mjs": { "lines": 98, @@ -226,5 +226,35 @@ "branches": 98, "functions": 98, "statements": 98 + }, + "workspace/file-inspector.mjs": { + "lines": 95, + "branches": 92, + "functions": 88, + "statements": 95 + }, + "workspace/inspection-state.mjs": { + "lines": 98, + "branches": 93, + "functions": 98, + "statements": 98 + }, + "workspace/media-viewer.mjs": { + "lines": 98, + "branches": 93, + "functions": 98, + "statements": 98 + }, + "workspace/prompt-blocks.mjs": { + "lines": 88, + "branches": 88, + "functions": 98, + "statements": 88 + }, + "workspace/viewer-state.mjs": { + "lines": 98, + "branches": 95, + "functions": 98, + "statements": 98 } } diff --git a/utils/inspection_response.py b/utils/inspection_response.py new file mode 100644 index 0000000..b7f5162 --- /dev/null +++ b/utils/inspection_response.py @@ -0,0 +1,279 @@ +"""Exact inspection-only graph projection and bounded JSON wire encoding.""" + +import json +import math +from decimal import Decimal, InvalidOperation +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .prompt_inspector import InspectionRead + +MAX_RESPONSE_BYTES = 8 * 1024 * 1024 +MAX_LIBRARY_BYTES = 32 * 1024 +_MAX_SAFE_INTEGER = 2**53 - 1 +_RECORD_KEYS = frozenset( + {"prompt", "workflow", "parameters", "comfyassets_prompt_pairs"} +) +_FORMATS = frozenset({"original-text", "reserialized-json", "invalid"}) + + +class _ProjectionLimit(ValueError): + """A present graph cannot be projected within inspection limits.""" + + +def _pairs(items: list[tuple]) -> dict: + result = {} + for key, value in items: + if key in result: + raise ValueError("Duplicate JSON key") + result[key] = value + return result + + +def _nonfinite(value: str) -> None: + raise ValueError("Nonfinite JSON number") + + +def _normalize( + value: object, depth: int = 1, *, retain: bool = True, carrier: bool = False +) -> object: + """Walk bounded source once; unrelated carrier fields are only validated.""" + if isinstance(value, (dict, list)): + if depth > 64: + raise _ProjectionLimit() + if isinstance(value, dict): + result = {} if retain else None + for key, child in value.items(): + keep = retain and (not carrier or key in ("prompt", "workflow")) + normalized = _normalize(child, depth + 1, retain=keep) + if keep: + result[key] = normalized + else: + result = [] if retain else None + for child in value: + normalized = _normalize(child, depth + 1, retain=retain) + if retain: + result.append(normalized) + return result + if isinstance(value, Decimal): + if not value.is_finite(): + raise ValueError("Nonfinite JSON number") + # str keeps large exponents compact and never rounds through a float. + return str(value) if retain else None + if type(value) is int and abs(value) > _MAX_SAFE_INTEGER: + return str(value) if retain else None + return value if retain else None + + +def _strict_projection(text: str, *, carrier: bool = False) -> object: + try: + tree = json.loads( + text, + parse_float=Decimal, + object_pairs_hook=_pairs, + parse_constant=_nonfinite, + ) + if carrier and not isinstance(tree, dict): + raise ValueError("Malformed carrier") + return _normalize(tree, carrier=carrier) + except RecursionError: + raise _ProjectionLimit() from None + except InvalidOperation: + raise ValueError("JSON decimal exceeds supported range") from None + + +def _graph(value: object, *, workflow: bool = False) -> dict: + if not isinstance(value, dict): + return {"status": "malformed", "value": None} + nodes = value.get("nodes") if workflow else value + if isinstance(nodes, (dict, list)) and len(nodes) > 500: + return {"status": "limited", "value": None} + return {"status": "parsed", "value": value} + + +def _metadata(inspection: "InspectionRead | None") -> dict: + raw = ( + {} + if inspection is None + else { + key: value + for key, value in inspection.metadata.items() + if key in _RECORD_KEYS and isinstance(value, str) + } + ) + formats = ( + {} + if inspection is None + else { + key: value + for key, value in inspection.record_formats.items() + if key in _RECORD_KEYS and value in _FORMATS + } + ) + result = { + "status": ( + "unreadable" + if inspection is None + else ("available" if raw or formats else "absent") + ), + "omitted_reason": None, + "raw": raw, + "record_formats": formats, + "parameters": raw.get("parameters"), + } + carrier, failed = None, None + if inspection is not None and inspection.video_carrier_text is not None: + try: + carrier = _strict_projection(inspection.video_carrier_text, carrier=True) + except _ProjectionLimit: + failed = "limited" + except (ValueError, TypeError, OverflowError): + failed = "malformed" + for key, field in (("prompt", "prompt_graph"), ("workflow", "workflow_graph")): + graph = {"status": "absent", "value": None} + if key in raw or key in formats: + if failed: + graph["status"] = failed + elif formats.get(key) == "invalid": + graph["status"] = "malformed" + else: + try: + if carrier is not None: + value = carrier.get(key) + if isinstance(value, str): + value = _strict_projection(value) + elif formats.get(key) == "reserialized-json": + raise _ProjectionLimit() + else: + value = _strict_projection(raw[key]) + graph = _graph(value, workflow=key == "workflow") + except _ProjectionLimit: + graph["status"] = "limited" + except (ValueError, TypeError, KeyError, OverflowError): + graph["status"] = "malformed" + result[field] = graph + return result + + +def _bytes(value: dict) -> bytes: + # JSON preserves isolated surrogate evidence through escaped code units. + return json.dumps( + value, ensure_ascii=False, allow_nan=False, separators=(",", ":") + ).encode("utf-8", "backslashreplace") + + +def _finite(value: object, *, positive: bool = False) -> bool: + try: + return ( + type(value) in (int, float) + and math.isfinite(value) + and (value > 0 if positive else value >= 0) + ) + except OverflowError: + return False + + +def _file_projection(file: dict, inspection: "InspectionRead | None") -> dict: + source = dict(file) + if inspection is not None: + source.update( + format=inspection.format, width=inspection.width, height=inspection.height + ) + result = {"observation": "current-file"} + for key in ("source_label", "extension", "format"): + value = source.get(key) + if value is None and key == "format": + result[key] = None + elif isinstance(value, str): + result[key] = value + if source.get("media_type") in ("image", "video"): + result["media_type"] = source["media_type"] + for key in ("size", "modified_time", "width", "height"): + if key not in source: + continue + value = source[key] + if value is None or _finite(value, positive=key in ("width", "height")): + if key not in ("width", "height") or value is None or type(value) is int: + result[key] = value + return result + + +def _library_projection(library: dict) -> dict: + status = library.get("status") + result = { + "status": ( + status + if status in ("linked", "none", "ambiguous", "unavailable", "limited") + else "unavailable" + ), + "scope": "saved-library", + } + for key in ("positive_id", "pair_id"): + value = library.get(key) + if value is None or type(value) is int and 0 < value <= _MAX_SAFE_INTEGER: + result[key] = value + for key in ("title", "category"): + 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 + tags = library.get("tags") + result["tags"] = ( + [tag for tag in tags if isinstance(tag, str)] if isinstance(tags, list) else [] + ) + if len(_bytes(result)) > MAX_LIBRARY_BYTES: + return { + "status": "limited", + "scope": "saved-library", + "positive_id": None, + "pair_id": None, + "title": None, + "category": None, + "tags": [], + "metadata_scope": None, + } + return result + + +def encode_inspection_response( + *, + image: dict, + root_scope: str, + file: dict, + prompts: dict, + resolution: dict, + inspection: "InspectionRead | None", + library: dict, +) -> bytes: + """Serialize exactly once per budget step, preserving identity and roles.""" + metadata = _metadata(inspection) + response = { + "success": True, + "inspection_version": 1, + "root_scope": root_scope, + "image": image, + "file": _file_projection(file, inspection), + "prompts": prompts, + "resolution": resolution, + "metadata": metadata, + "library": _library_projection(library), + } + body = _bytes(response) + if len(body) > MAX_RESPONSE_BYTES: + for field in ("prompt_graph", "workflow_graph"): + if metadata[field]["status"] != "absent": + metadata[field] = {"status": "limited", "value": None} + body = _bytes(response) + if len(body) > MAX_RESPONSE_BYTES: + metadata.update( + raw={}, + parameters=None, + status="limited", + omitted_reason="response-size-limit", + ) + body = _bytes(response) + if len(body) > MAX_RESPONSE_BYTES: + raise ValueError("Inspection response exceeds limits") + return body diff --git a/utils/prompt_inspector.py b/utils/prompt_inspector.py index 03d6c10..e71f447 100644 --- a/utils/prompt_inspector.py +++ b/utils/prompt_inspector.py @@ -6,6 +6,7 @@ import struct import subprocess # nosec B404 import threading import zlib +from dataclasses import dataclass from pathlib import Path from typing import Any @@ -13,12 +14,13 @@ from PIL import Image from .pair_execution import EXECUTION_KEY from .prompt_pair_resolver import resolve_prompt_pair -from .video_metadata import ffprobe_path, is_video_path, metadata_from_tags +from .video_metadata import ffprobe_path, is_video_path MAX_METADATA_BYTES = 2 * 1024 * 1024 MAX_HEADER_BYTES = 8 * 1024 * 1024 MAX_PNG_CHUNKS = 10_000 -_METADATA_KEYS = frozenset({"prompt", "parameters", EXECUTION_KEY}) +_ROLE_METADATA_KEYS = frozenset({"prompt", "parameters", EXECUTION_KEY}) +_METADATA_KEYS = _ROLE_METADATA_KEYS | {"workflow"} _SIGNATURE = b"\x89PNG\r\n\x1a\n" @@ -26,6 +28,25 @@ class MetadataReadError(ValueError): """Prompt evidence is unreadable, incomplete, or exceeds inspection limits.""" +@dataclass(frozen=True) +class InspectionRead: + """One bounded read, with its transient exact video source kept private.""" + + metadata: dict[str, str] + record_formats: dict[str, str] + video_carrier_text: str | None + format: str | None + width: int | None + height: int | None + + +@dataclass(frozen=True) +class _VideoInspection: + metadata: dict[str, str | None] + record_formats: dict[str, str] + carrier_text: str | None + + def _inflate(data: bytes) -> bytes: inflater = zlib.decompressobj() result = inflater.decompress(data, MAX_METADATA_BYTES + 1) @@ -60,7 +81,7 @@ def _png_text(kind: bytes, data: bytes) -> tuple[str, str]: return key, body.decode(encoding) -def _png_metadata(stream: Any, size: int) -> dict: +def _png_metadata(stream: Any, size: int) -> tuple[dict, int, int]: """CRC-check text and structural chunks, seeking past compressed pixels. PNG text chunks may follow IDAT. Pillow's ``.text`` loads pixels to find @@ -89,10 +110,11 @@ def _png_metadata(stream: Any, size: int) -> dict: if saw_header or not all(struct.unpack(">II", data[:8])): raise MetadataReadError("Malformed PNG header") saw_header = True + width, height = struct.unpack(">II", data[:8]) elif kind == b"IEND": if length or not saw_pixels: raise MetadataReadError("Incomplete PNG image") - return found + return found, width, height else: key, value = _png_text(kind, data) expanded_size += len(value.encode("utf-8")) @@ -108,7 +130,7 @@ def _png_metadata(stream: Any, size: int) -> dict: raise MetadataReadError("PNG chunk count exceeds inspection limits") -def _video_metadata(path: Path) -> dict: +def _video_metadata(path: Path) -> _VideoInspection: probe = ffprobe_path() if not probe: raise MetadataReadError("Video metadata reader is unavailable") @@ -161,6 +183,7 @@ def _video_metadata(path: Path) -> dict: # The scan normalizer drops null fields and unknown keys. Inspection must # retain explicit prompt/execution markers so malformed evidence cannot # silently fall back to an unrelated saved recipe or another comment. + candidates = [] for key in ("comment", "description"): raw = tags.get(key) if not isinstance(raw, str): @@ -169,44 +192,73 @@ def _video_metadata(path: Path) -> dict: envelope = json.loads(raw) except ValueError: continue - if not isinstance(envelope, dict) or not _METADATA_KEYS.intersection(envelope): + if not isinstance(envelope, dict): continue - metadata = metadata_from_tags({key: raw}) + candidates.append((raw, envelope)) + selected = next( + (item for item in candidates if _ROLE_METADATA_KEYS.intersection(item[1])), + None, + ) + if selected is None: + selected = next((item for item in candidates if "workflow" in item[1]), None) + if selected is not None: + raw, envelope = selected + metadata, formats = {}, {} for field in _METADATA_KEYS.intersection(envelope): value = envelope[field] + if field == "workflow" and not isinstance(value, (str, dict)): + formats[field] = "invalid" + continue metadata[field] = ( value if value is None or isinstance(value, str) else json.dumps(value) ) - return metadata - return {} + formats[field] = ( + "original-text" if isinstance(value, str) else "reserialized-json" + ) + return _VideoInspection(metadata, formats, raw) + return _VideoInspection({}, {}, None) -def read_inspection_metadata(path: Path) -> dict: - """Return bounded, header-only metadata or raise a response-safe error.""" +def read_inspection_file(path: Path) -> InspectionRead: + """Read bounded metadata and header facts without decoding media pixels.""" try: + formats, carrier = {}, None + format_name = width = height = None if is_video_path(path): - metadata = _video_metadata(path) + video = _video_metadata(path) + metadata, formats, carrier = ( + video.metadata, + video.record_formats, + video.carrier_text, + ) else: with path.open("rb") as stream: signature = stream.read(8) if signature == _SIGNATURE: - return _png_metadata(stream, path.stat().st_size) - stream.seek(0) - header = stream.read(MAX_HEADER_BYTES) - with Image.open(io.BytesIO(header)) as image: - metadata = dict(image.info) + metadata, width, height = _png_metadata(stream, path.stat().st_size) + format_name = "PNG" + else: + stream.seek(0) + header = stream.read(MAX_HEADER_BYTES) + with Image.open(io.BytesIO(header)) as image: + metadata = dict(image.info) + format_name, (width, height) = image.format, image.size result, size = {}, 0 for key in _METADATA_KEYS: if key not in metadata: continue value = metadata[key] + if key == "workflow" and not isinstance(value, str): + formats[key] = "invalid" + continue if not isinstance(value, str): raise MetadataReadError("Malformed prompt metadata") - size += len(value.encode("utf-8")) + size += len(value.encode("utf-8", "surrogatepass")) if size > MAX_METADATA_BYTES: raise MetadataReadError("Metadata exceeds inspection limits") result[key] = value - return result + formats.setdefault(key, "original-text") + return InspectionRead(result, formats, carrier, format_name, width, height) except MetadataReadError: raise except ( @@ -221,12 +273,25 @@ def read_inspection_metadata(path: Path) -> dict: raise MetadataReadError("Media metadata could not be read") from None +def read_inspection_metadata(path: Path) -> dict[str, str]: + """Compatible metadata-only projection of the single inspection read.""" + return read_inspection_file(path).metadata + + def _side(status: str, text: str | None = None, source: str | None = None) -> dict: return {"status": status, "text": text, "source": source} -def inspect_prompt_roles(metadata: dict, saved: dict | None = None) -> dict: +def inspect_prompt_evidence(metadata: dict, saved: dict | None = None) -> dict: """Keep uncertainty visible; a saved association never replaces file evidence.""" + metadata = { + key: value for key, value in metadata.items() if key in _ROLE_METADATA_KEYS + } + resolution = { + "state": "unresolved", + "sampler_id": None, + "reason": "Prompt evidence is malformed", + } if not metadata: result = {role: _side("absent") for role in ("positive", "negative")} if saved: @@ -240,20 +305,35 @@ def inspect_prompt_roles(metadata: dict, saved: dict | None = None) -> dict: "verified against this file." ), } - return result + return { + "prompts": result, + "resolution": { + "state": "absent", + "sampler_id": None, + "reason": "No parsed prompt metadata", + }, + } canonical = dict(metadata) if "prompt" not in canonical and "parameters" not in canonical: - return {role: _side("unresolved") for role in ("positive", "negative")} + return { + "prompts": {role: _side("unresolved") for role in ("positive", "negative")}, + "resolution": resolution, + } if isinstance(canonical.get("prompt"), str): try: canonical["prompt"] = json.loads(canonical["prompt"]) except (ValueError, RecursionError): - return {role: _side("unresolved") for role in ("positive", "negative")} - resolution = resolve_prompt_pair(canonical) - state = resolution["state"] + return { + "prompts": { + role: _side("unresolved") for role in ("positive", "negative") + }, + "resolution": resolution, + } + resolved = resolve_prompt_pair(canonical) + state = resolved["state"] result = {} for role in ("positive", "negative"): - side = resolution[role] + side = resolved[role] if ( state != "ambiguous" and isinstance(side, dict) @@ -264,4 +344,16 @@ def inspect_prompt_roles(metadata: dict, saved: dict | None = None) -> dict: ) else: result[role] = _side(state if state != "resolved" else "unresolved") - return result + return { + "prompts": result, + "resolution": { + "state": state, + "sampler_id": resolved.get("sampler_id"), + "reason": resolved.get("reason", ""), + }, + } + + +def inspect_prompt_roles(metadata: dict, saved: dict | None = None) -> dict: + """Keep the established role-only helper contract.""" + return inspect_prompt_evidence(metadata, saved)["prompts"] diff --git a/web/js/api-client.js b/web/js/api-client.js index fe8b887..e4d4aa4 100644 --- a/web/js/api-client.js +++ b/web/js/api-client.js @@ -15,10 +15,11 @@ "use strict"; class ApiError extends Error { - constructor(status, message) { + constructor(status, message, code = null) { super(message); this.name = "ApiError"; this.status = status; + this.code = typeof code === "string" && /^[a-z0-9-]{1,64}$/.test(code) ? code : null; } } @@ -62,11 +63,11 @@ const serverMessage = data && typeof data === "object" ? data.error || data.message : undefined; if (!response.ok) { - throw new ApiError(response.status, serverMessage || response.statusText || `HTTP ${response.status}`); + throw new ApiError(response.status, serverMessage || response.statusText || `HTTP ${response.status}`, data?.code); } if (!parsed) throw new ApiError(response.status, "Invalid JSON response"); if (data && typeof data === "object" && data.success === false) { - throw new ApiError(response.status, serverMessage || "Request failed"); + throw new ApiError(response.status, serverMessage || "Request failed", data.code); } return data; } diff --git a/web/js/comfy-metadata.js b/web/js/comfy-metadata.js index 234f66a..c0a4cc2 100644 --- a/web/js/comfy-metadata.js +++ b/web/js/comfy-metadata.js @@ -59,7 +59,7 @@ * their workflow widget text back. Otherwise the API graph is what actually ran * (expanded dynamic prompts, linked text) and always wins. */ - function createContext(graph, workflow) { + function createContext(graph, workflow, conservative = false) { const widgets = new Map(); for (const n of workflowNodes(workflow)) { const text = widgetsOf(n)[0]; @@ -82,7 +82,7 @@ ids.forEach((id) => widgetText.set(id, widgets.get(id))); } } - return { graph, widgetText }; + return { graph, widgetText, conservative }; } /** @@ -92,8 +92,9 @@ */ function enter(ctx, link, seen) { if (!isLink(link) || seen.size >= MAX_VISITS) return []; + if (ctx.conservative && (!Number.isSafeInteger(link[1]) || link[1] < 0 || seen.size >= 64)) return []; const id = String(link[0]); - const node = ctx.graph[id]; + const node = !ctx.conservative || Object.hasOwn(ctx.graph, id) ? ctx.graph[id] : undefined; const key = `${id}:${link[1]}`; if (!node || !node.inputs || seen.has(key)) return []; seen.add(key); @@ -109,6 +110,12 @@ if (!isLink(value)) return value; const [, node] = enter(ctx, value, seen); if (!node) return undefined; + if (ctx.conservative) { + if (node[WORKFLOW_PASSTHROUGH]) return node[WORKFLOW_PASSTHROUGH] === "value" && value[1] === 0 ? resolveScalar(ctx, node.inputs.value, name, seen) : undefined; + const kind = ["seed", "noise_seed", "steps", "cfg"].includes(name) ? "number" : "string"; + return value[1] === 0 && SCALAR_OUTPUTS[node.class_type] === kind && Object.hasOwn(node.inputs, "value") ? + resolveScalar(ctx, node.inputs.value, name, seen) : undefined; + } if (name && Object.prototype.hasOwnProperty.call(node.inputs, name)) { return resolveScalar(ctx, node.inputs[name], name, seen); } @@ -185,6 +192,13 @@ function traceModelName(ctx, link, seen = new Set()) { const [, node] = enter(ctx, link, seen); if (!node) return undefined; + if (ctx.conservative) { + if (link[1] !== 0) return undefined; + if (node[WORKFLOW_PASSTHROUGH]) return traceModelName(ctx, node.inputs.value, seen); + const key = Object.hasOwn(MODEL_OUTPUTS, node.class_type) ? MODEL_OUTPUTS[node.class_type] : undefined; + if (key) return resolveScalar(ctx, node.inputs[key], key, seen); + return MODEL_PASSTHROUGHS.has(node.class_type) ? traceModelName(ctx, node.inputs.model, seen) : undefined; + } const key = MODEL_NAME_KEYS.find((k) => typeof node.inputs[k] === "string"); return key ? node.inputs[key] : traceModelName(ctx, node.inputs.model, seen); } @@ -278,12 +292,56 @@ return out; } + function factWorkflowGraph(workflow) { + const nodes = workflowNodes(workflow), links = new Map(), setters = new Map(); + for (const link of workflow.links || []) { + if (Array.isArray(link)) links.set(link[0], { from: link[1], slot: link[2] }); + else links.set(link.id, { from: link.origin_id, slot: link.origin_slot }); + } + const source = id => { + const link = links.get(id); + if (!link || !["string", "number"].includes(typeof link.from) || !Number.isSafeInteger(link.slot) || link.slot < 0) return undefined; + return [String(link.from), link.slot]; + }; + for (const node of nodes) { + const name = widgetsOf(node)[0]; + if (node.type === "SetNode" && node.mode !== MODE_MUTED && typeof name === "string") setters.set(name, node.inputs?.length === 1 ? node.inputs[0].link : undefined); + } + const graph = Object.create(null); + for (const node of nodes) { + if (node.mode === MODE_MUTED) continue; + let pass = PASS_THROUGH_TYPES.has(node.type), link, kind = "value"; + if (pass) { + const name = widgetsOf(node)[0]; + if (node.type === "GetNode") link = typeof name === "string" ? setters.get(name) : undefined; + else if (node.inputs?.length === 1) link = node.inputs[0].link; + } + else if (node.mode === MODE_BYPASSED) { + if (!MODEL_PASSTHROUGHS.has(node.type)) continue; + const models = (node.inputs || []).filter(input => input.name === "model"); + pass = true; kind = "model"; link = models.length === 1 ? models[0].link : undefined; + } + if (pass) { + // Retain the actual alias hop so a fact's full original ancestry shares one depth/visit set. + graph[String(node.id)] = { class_type: node.type, inputs: { value: source(link) }, [WORKFLOW_PASSTHROUGH]: kind }; + continue; + } + const inputs = namedWidgets(node); + for (const input of node.inputs || []) { + if (input.link !== null && input.link !== undefined) inputs[input.name] = source(input.link); + } + graph[String(node.id)] = { class_type: node.type, inputs }; + } + return graph; + } + /** * Convert a UI workflow ({nodes, links}) into the API graph shape so images * without a `prompt` chunk are traced the same way. Set/Get pairs, Reroutes and * bypassed nodes are resolved to their real source; muted nodes are dropped. */ - function workflowToGraph(workflow) { + function workflowToGraph(workflow, conservative = false) { + if (conservative) return factWorkflowGraph(workflow); const nodes = workflowNodes(workflow).filter((n) => n && n.id !== undefined && n.id !== null); const byId = new Map(nodes.map((n) => [String(n.id), n])); const links = new Map(); @@ -344,7 +402,179 @@ return merged; } - const api = { extractGenerationParams, PLACEHOLDERS }; + const FACT_FIELDS = ["seed", "steps", "cfg", "sampler", "scheduler", "model"]; + const GENERATION_SAMPLERS = new Set(["KSampler", "KSamplerAdvanced", "SamplerCustom", "SamplerCustomAdvanced"]); + const WORKFLOW_PASSTHROUGH = Symbol("inspection workflow passthrough"); + // Fixed output0 contracts from the pinned core node schemas, not input-name inference. + const SCALAR_OUTPUTS = Object.freeze({ + PrimitiveInt: "number", PrimitiveFloat: "number", + PrimitiveString: "string", PrimitiveStringMultiline: "string", + }); + const MODEL_OUTPUTS = Object.freeze({ + CheckpointLoader: "ckpt_name", CheckpointLoaderSimple: "ckpt_name", + unCLIPCheckpointLoader: "ckpt_name", UNETLoader: "unet_name", + }); + const MODEL_PASSTHROUGHS = new Set(["LoraLoader", "LoraLoaderModelOnly"]); + const CONFIGURATION_OUTPUTS = Object.freeze({ + RandomNoise: { kind: "noise", fields: ["noise_seed"] }, + KSamplerSelect: { kind: "sampler", fields: ["sampler_name"] }, + BasicScheduler: { kind: "sigmas", fields: ["steps", "scheduler"] }, + CFGGuider: { kind: "guider", fields: ["cfg", "model"] }, + BasicGuider: { kind: "guider", fields: ["model"] }, + }); + const object = value => value !== null && typeof value === "object" && !Array.isArray(value); + + function fact(status, source = null, samplerId = null, value = null, reason = null) { + return { status, value, source, evidence: source === "embedded-parameters" ? source : source ? "configuration" : null, reason, sampler_id: samplerId }; + } + + function factSet(status, source = null, reason = null) { + return Object.fromEntries(FACT_FIELDS.map(name => [name, fact(status, source, null, null, reason)])); + } + + function exactFactValue(value, field) { + if (typeof value === "number") { + // The projection carries decimals as strings; a float here cannot prove exactness. + if (!["seed", "steps", "cfg"].includes(field) || !Number.isFinite(value) || !Number.isSafeInteger(value)) return null; + value = String(value); + } + if (typeof value !== "string" || !value.trim()) return null; + if (field === "seed" || field === "steps") { + return /^\d+$/.test(value) && !/[\r\n]/.test(value) && (field === "seed" || /[1-9]/.test(value)) ? value : null; + } + if (field === "cfg") return /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/.test(value) && value.trim() === value ? value : null; + return value === "Unknown" ? null : value; + } + + function scalarFact(ctx, inputs, key, field, source, samplerId, seen = new Set()) { + if (!Object.hasOwn(inputs, key)) return fact("not-recorded", source, samplerId); + const value = exactFactValue(resolveScalar(ctx, inputs[key], key, seen), field); + return fact(value === null ? "unresolved" : "known", source, samplerId, value); + } + + function configurationOutput(ctx, link, kind, seen) { + const [id, node] = enter(ctx, link, seen); + if (node?.[WORKFLOW_PASSTHROUGH]) return node[WORKFLOW_PASSTHROUGH] === "value" && link[1] === 0 ? configurationOutput(ctx, node.inputs.value, kind, seen) : []; + const adapter = node && CONFIGURATION_OUTPUTS[node.class_type]; + return node && link[1] === 0 && adapter?.kind === kind ? [id, node, adapter] : []; + } + + function providerFact(ctx, link, kind, keys, field, source, samplerId) { + const seen = new Set(); + const [, node, adapter] = configurationOutput(ctx, link, kind, seen); + if (!node) return fact("unresolved", source, samplerId); + const key = keys.find(name => adapter.fields.includes(name) && Object.hasOwn(node.inputs, name)); + return key === undefined ? fact("not-recorded", source, samplerId) : scalarFact(ctx, node.inputs, key, field, source, samplerId, seen); + } + + function graphFacts(graph, source, resolution) { + if (!object(graph)) return factSet("unresolved", source); + const entries = Object.entries(graph); + if (entries.length > MAX_VISITS) return factSet("unresolved", source, "projection-limited"); + if (entries.some(([, node]) => !object(node) || !object(node.inputs) || typeof node.class_type !== "string")) return factSet("unresolved", source); + const candidates = entries.filter(([, node]) => GENERATION_SAMPLERS.has(node.class_type)); + const selected = candidates.find(([id]) => typeof resolution?.sampler_id === "string" && id === resolution.sampler_id); + if (!selected && candidates.length !== 1) return factSet(candidates.length > 1 ? "ambiguous" : "unresolved", source); + const [samplerId, sampler] = selected || candidates[0]; + const ctx = createContext(graph, null, true); + const inputs = sampler.inputs; + const advanced = sampler.class_type === "SamplerCustomAdvanced"; + const custom = advanced || sampler.class_type === "SamplerCustom"; + let settings = inputs; + let settingsAdapter; + const settingsPath = new Set(); + if (advanced) { + const [, guider, adapter] = configurationOutput(ctx, inputs.guider, "guider", settingsPath); + settings = guider ? guider.inputs : null; + settingsAdapter = adapter; + } + const fromSettings = (key, field) => { + if (!settings) return fact("unresolved", source, samplerId); + if (settingsAdapter && !settingsAdapter.fields.includes(key)) return fact(Object.hasOwn(settings, key) ? "unresolved" : "not-recorded", source, samplerId); + return scalarFact(ctx, settings, key, field, source, samplerId, new Set(settingsPath)); + }; + const seedKey = Object.hasOwn(inputs, "seed") ? "seed" : "noise_seed"; + const facts = { + seed: advanced ? providerFact(ctx, inputs.noise, "noise", ["noise_seed"], "seed", source, samplerId) : scalarFact(ctx, inputs, seedKey, "seed", source, samplerId), + steps: custom ? providerFact(ctx, inputs.sigmas, "sigmas", ["steps"], "steps", source, samplerId) : scalarFact(ctx, inputs, "steps", "steps", source, samplerId), + cfg: fromSettings("cfg", "cfg"), + sampler: custom ? providerFact(ctx, inputs.sampler, "sampler", ["sampler_name"], "sampler", source, samplerId) : scalarFact(ctx, inputs, "sampler_name", "sampler", source, samplerId), + scheduler: Object.hasOwn(inputs, "scheduler") || !custom ? scalarFact(ctx, inputs, "scheduler", "scheduler", source, samplerId) : providerFact(ctx, inputs.sigmas, "sigmas", ["scheduler"], "scheduler", source, samplerId), + model: fact(settings && !Object.hasOwn(settings, "model") ? "not-recorded" : "unresolved", source, samplerId), + }; + if (settings && Object.hasOwn(settings, "model")) { + const model = exactFactValue(traceModelName(ctx, settings.model, new Set(settingsPath)), "model"); + facts.model = fact(model === null ? "unresolved" : "known", source, samplerId, model); + } + return facts; + } + + function validFactWorkflow(workflow) { + if (!object(workflow) || !Array.isArray(workflow.nodes) || (workflow.links !== undefined && !Array.isArray(workflow.links))) return false; + const ids = new Set(), links = new Set(), setters = new Set(); + for (const node of workflow.nodes) { + if (!object(node) || !["string", "number"].includes(typeof node.id) || typeof node.type !== "string" || + (node.inputs !== undefined && (!Array.isArray(node.inputs) || node.inputs.some(input => !object(input) || typeof input.name !== "string"))) || ids.has(String(node.id))) return false; + ids.add(String(node.id)); + if (node.type === "SetNode") { + const name = widgetsOf(node)[0]; + if (setters.has(name)) return false; + setters.add(name); + } + } + for (const link of workflow.links || []) { + const id = Array.isArray(link) ? link[0] : link?.id; + if (id === undefined || links.has(id)) return false; + links.add(id); + } + return true; + } + + function parameterFacts(parameters) { + const source = "embedded-parameters"; + const matches = [...parameters.matchAll(/^Steps:\s*\d+\s*,[^\n]*$/gm)]; + if (matches.length !== 1 || parameters.slice(matches[0].index + matches[0][0].length).trim()) return factSet("unresolved", source); + const body = parameters.slice(0, matches[0].index).replace(/[\r\n]+$/, ""); + if (!body.split("\nNegative prompt:")[0].trim() || body.split("\nNegative prompt:").length > 2) return factSet("unresolved", source); + const settings = new Map(); + for (const part of matches[0][0].split(",")) { + const match = part.match(/^\s*([^:]+):\s*(.*?)\s*$/); + if (!match) return factSet("unresolved", source); + const [, key, value] = match; + settings.set(key, settings.has(key) ? null : value); + } + const keys = { seed: "Seed", steps: "Steps", cfg: "CFG scale", sampler: "Sampler", scheduler: "Schedule type", model: "Model" }; + return Object.fromEntries(FACT_FIELDS.map(field => { + const key = keys[field]; + if (!settings.has(key)) return [field, fact("not-recorded", source)]; + let value = exactFactValue(settings.get(key), field); + if (field === "scheduler" && value !== null && !/^(automatic|normal|karras|exponential|sgm[ _]uniform|simple|ddim[ _]uniform|beta|uniform|linear[ _]quadratic|kl[ _]optimal)$/i.test(value)) value = null; + return [field, fact(value === null ? "unresolved" : "known", source, null, value)]; + })); + } + + /** Consume only the exact, bounded inspection projection; never reparse raw copies. */ + function extractGenerationFacts({ prompt_graph, workflow_graph, parameters, recordFormats = {}, metadataStatus, resolution } = {}) { + for (const [record, source] of [[prompt_graph, "api-graph"], [workflow_graph, "workflow"]]) { + if (!record || record.status === "absent") continue; + if (record.status === "limited") return factSet("unresolved", source, "projection-limited"); + if (record.status !== "parsed") return factSet("unresolved", source); + if (source === "workflow") { + if (record.value?.nodes?.length > MAX_VISITS) return factSet("unresolved", source, "projection-limited"); + if (!validFactWorkflow(record.value)) return factSet("unresolved", source); + return graphFacts(workflowToGraph(record.value, true), source, null); + } + return graphFacts(record.value, source, resolution); + } + if (metadataStatus === "limited") return factSet("unresolved", null, "projection-limited"); + if (metadataStatus === "unreadable") return factSet("unresolved", null, "metadata-unreadable"); + if (parameters !== undefined && parameters !== null || Object.hasOwn(recordFormats, "parameters")) { + return recordFormats.parameters === "original-text" && typeof parameters === "string" ? parameterFacts(parameters) : factSet("unresolved"); + } + return factSet(metadataStatus === "available" || metadataStatus === "absent" ? "not-recorded" : "unresolved"); + } + + const api = { extractGenerationParams, extractGenerationFacts, PLACEHOLDERS }; if (typeof module !== "undefined" && module.exports) module.exports = api; else root.ComfyMetadata = api; })(typeof window !== "undefined" ? window : globalThis); diff --git a/web/js/workspace/file-inspector.mjs b/web/js/workspace/file-inspector.mjs new file mode 100644 index 0000000..b3edb3f --- /dev/null +++ b/web/js/workspace/file-inspector.mjs @@ -0,0 +1,240 @@ +import { createScope, createButton } from '../shared/toolkit/core.js'; +import { mountPromptBlocks, promptRoles } from './prompt-blocks.mjs'; +import { createInspectionController } from './inspection-state.mjs'; +import { createSendButton } from './prompt-sender.mjs'; +import { createWorkspaceIcon } from './icons.mjs'; + +const RAW_KEYS = ['prompt', 'workflow', 'parameters', 'comfyassets_prompt_pairs']; +const RAW_LIMIT = 'Raw metadata exceeds the response limit. Download the original file to inspect it externally.'; +const CONTAINER_WORKFLOW = 'Original workflow JSON text is unavailable for this container record. Download metadata or the original file.'; +const REPRESENTATIONS = { + 'original-text': 'Original embedded text', + 'reserialized-json': 'Reserialized container JSON; numeric spelling/precision may differ', + invalid: 'Invalid container record; text is unavailable', +}; +const FACTS = [['seed', 'Seed'], ['steps', 'Steps'], ['cfg', 'CFG'], ['sampler', 'Sampler'], ['scheduler', 'Scheduler'], ['model', 'Model']]; +const FACT_SOURCES = {'api-graph': 'Embedded API configuration', workflow: 'Saved workflow configuration', 'embedded-parameters': 'Embedded generation parameters'}; + +function safeFilename(value) { + return String(value || 'file').replace(/[\\/\x00-\x1f\x7f<>:"|?*]/g, '_').replace(/^\.+|[. ]+$/g, '') || 'file'; +} + +/** Export only the bounded public evidence, never normalized graphs or container carriers. */ +export function inspectionDownloads(record) { + const name = safeFilename(record?.download?.filename || record?.identity?.path?.split('/').at(-1)); + const stem = name.replace(/\.[^.]+$/, '') || 'file'; + const unavailable = (suffix, reason) => ({available:false, reason, filename:`${stem}.${suffix}.json`, bodyText:null, mime:'application/json'}); + const metadata = record?.metadata; + const reason = metadata?.status === 'limited' ? RAW_LIMIT : 'Full metadata is unavailable for this inspection response.'; + const result = {metadata:unavailable('metadata', reason), workflow:unavailable('workflow', reason)}; + if (!metadata || metadata.status === 'limited') return result; + const raw = {}, record_formats = {}, representations = {}; + for (const key of RAW_KEYS) { + if (Object.hasOwn(REPRESENTATIONS, metadata.record_formats?.[key])) { + record_formats[key] = metadata.record_formats[key]; + representations[key] = REPRESENTATIONS[record_formats[key]]; + if (record_formats[key] !== 'invalid' && typeof metadata.raw?.[key] === 'string') raw[key] = metadata.raw[key]; + } + } + const envelope = {inspection_version:1, identity:record.identity, file:record.file, prompts:record.prompts, + generation:record.generation, library:record.library, + metadata:{status:metadata.status, omitted_reason:metadata.omitted_reason, raw, record_formats, representations, + scope:'Supported embedded records only; one selected container carrier for video. Other metadata and private carrier text are excluded.'}}; + result.metadata = {...result.metadata, available:true, reason:null, bodyText:JSON.stringify(envelope, null, 2)}; + if (record_formats.workflow === 'reserialized-json') result.workflow.reason = CONTAINER_WORKFLOW; + else if (record_formats.workflow === 'original-text' && metadata.workflow_graph?.status === 'parsed' && typeof raw.workflow === 'string') { + result.workflow = {...result.workflow, available:true, reason:null, bodyText:raw.workflow}; + } else result.workflow.reason = 'A valid original workflow JSON record is not available in this file.'; + return result; +} + +function el(tag, text, className) { + const node = document.createElement(tag); + if (text !== undefined) node.textContent = text; + if (className) node.className = className; + return node; +} +function childScope(parent) { + const scope = createScope(), dispose = () => scope.dispose(); + parent.signal.addEventListener('abort', dispose, {once:true}); + scope.cleanup(() => parent.signal.removeEventListener('abort', dispose)); + if (parent.signal.aborted) scope.dispose(); + return scope; +} +function disclosure(label) { + const node = el('details'); node.append(el('summary', label)); return node; +} +function definitionList(entries, className) { + const list = el('dl', undefined, className); + for (const [label, value] of entries) { + const row = el('div'); row.append(el('dt', label), el('dd', value ?? 'Not available')); list.append(row); + } + return list; +} +function factText(fact) { + if (fact?.status === 'known' && typeof fact.value === 'string') return fact.value; + if (fact?.status === 'ambiguous') return 'Ambiguous'; + if (fact?.status === 'not-recorded') return 'Not recorded'; + return fact?.reason === 'projection-limited' ? 'Unavailable within inspection limits' : 'Unresolved'; +} +function formatBytes(value) { + return value === null || value === undefined ? null : value >= 1048576 ? `${(value / 1048576).toFixed(1)} MB` : `${Math.max(1, Math.round(value / 1024))} KB`; +} + +export function mountFileInspector(container, {sender, parentScope, inline = false, onPreview, onRetry, onRefresh, onInteraction = () => {}}) { + if (parentScope.signal.aborted) return {setState() {}, dispose() {}}; + const scope = childScope(parentScope); + let contentScope; + container.classList.add('file-inspector'); + scope.listen(container, 'pointerdown', onInteraction); + scope.listen(container, 'keydown', onInteraction); + scope.listen(container, 'click', onInteraction); + scope.listen(container, 'wheel', onInteraction, {passive:true}); + scope.cleanup(() => {contentScope?.dispose(); container.replaceChildren(); container.classList.remove('file-inspector');}); + + function setState(state) { + if (scope.signal.aborted) return; + contentScope?.dispose(); contentScope = childScope(scope); + container.replaceChildren(); + if (state.loading || state.error) { + const status = el('p', state.loading ? 'Loading file details…' : state.refreshRequired ? state.message : 'Could not read prompts from this file.', 'file-status muted'); + status.setAttribute('role', state.error ? 'alert' : 'status'); container.append(status); + if (state.error && state.refreshRequired) { + if (onRefresh) { + const refresh = createButton('Refresh image collection'); + contentScope.listen(refresh, 'click', onRefresh); container.append(refresh); + } else container.append(el('p', 'Reopen this file from a refreshed image collection.', 'muted')); + } else if (state.error && onRetry) { + const retry = createButton('Retry prompt details'); contentScope.listen(retry, 'click', onRetry); container.append(retry); + } + return; + } + const record = state.record; + if (!record) return; + const {file, library, metadata} = record; + const filename = record.identity.path.split('/').at(-1); + if (inline && record.download) { + const cover = createButton('', 'file-cover'); cover.setAttribute('aria-label', 'Open full preview'); + const video = file?.media_type === 'video'; + const media = el(video ? 'video' : 'img'); media.src = record.download.url; + if (video) { + media.muted = true; media.playsInline = true; media.preload = 'metadata'; + contentScope.cleanup(() => {media.pause(); media.removeAttribute('src'); media.load();}); + } else {media.alt = filename; media.decoding = 'async'; contentScope.cleanup(() => media.removeAttribute('src'));} + cover.append(media); if (onPreview) contentScope.listen(cover, 'click', onPreview); + else {cover.setAttribute('aria-disabled', 'true'); cover.title = 'Full preview is unavailable in this view.';} + container.append(cover); + } + const inner = el('div', undefined, 'file-inspector-inner'); container.append(inner); + const header = el('div', undefined, 'file-title'); + header.append(el('h2', library?.status === 'linked' && library.title ? library.title : filename)); + const extension = file?.extension || filename.split('.').at(-1); + header.append(el('span', file?.format || `${extension.toUpperCase()} extension`, 'file-format file-chip')); + inner.append(header, el('p', filename, 'file-name muted')); + const chips = el('div', undefined, 'file-chips'); + const sources = new Set(Object.values(record.prompts).filter(role => role?.status === 'resolved').map(role => role.source)); + const evidence = sources.has('execution') ? 'Execution prompt evidence' : sources.has('graph-inferred') ? 'Workflow prompt evidence' : sources.has('embedded-parameters') ? 'Embedded parameter evidence' : null; + if (evidence) chips.append(el('span', evidence, 'file-chip file-evidence')); + if (library?.status === 'linked' && library.category) { + const label = library.metadata_scope === 'pair' ? 'Recipe category' : 'Positive prompt category'; + chips.append(el('span', `${label}: ${library.category}`, 'file-chip')); + } + if (chips.childNodes.length) inner.append(chips); + const prompts = el('div'); inner.append(prompts); + const blocks = mountPromptBlocks(prompts, {sender, parentScope:contentScope, includePairAction:false}); + blocks.setState({prompts:record.prompts}); contentScope.cleanup(blocks.dispose); + if (library?.status === 'linked') { + const vocabulary = el('div', undefined, 'file-vocabulary'); + vocabulary.append(el('p', library.metadata_scope === 'pair' ? 'Recipe tags' : 'Positive prompt tags', 'muted')); + const tags = el('div', undefined, 'file-chips'); + for (const tag of library.tags) tags.append(el('span', tag, 'file-chip')); + vocabulary.append(library.tags.length ? tags : el('p', 'No saved tags', 'muted')); inner.append(vocabulary); + } else if (!library) inner.append(el('p', 'Saved vocabulary is unavailable for this inspection response.', 'muted')); + const facts = definitionList(FACTS.map(([key, label]) => [label, factText(record.generation[key])]), 'generation-facts'); + inner.append(facts); + if (FACTS.some(([key]) => record.generation[key]?.reason === 'projection-limited')) { + const explanation = el('p', 'Configuration exceeds inspection limits; download the original file to inspect it locally.', 'generation-limit muted'); + explanation.id = `file-fact-limit-${++factReasonId}`; + FACTS.forEach(([key], index) => { + if (record.generation[key]?.reason === 'projection-limited') facts.children[index].querySelector('dd').setAttribute('aria-describedby', explanation.id); + }); + inner.append(explanation); + } + const provenance = disclosure('File & provenance'); + provenance.append(el('p', 'Current file at this source; historical bytes are not verified.', 'muted')); + if (file) provenance.append(definitionList([ + ['Source', file.source_label], ['Path', record.identity.path], ['Size', formatBytes(file.size)], + ['Dimensions', file.width && file.height ? `${file.width} × ${file.height}` : null], + ['Format', file.format || `${file.extension.toUpperCase()} extension`], ['Media type', file.media_type], + ['Modified', file.modified_time !== null ? new Date(file.modified_time * 1000).toLocaleString() : null], + ], 'file-facts')); + else provenance.append(el('p', 'Current file observations are unavailable for this inspection response.', 'muted')); + for (const [key, label] of FACTS) { + const fact = record.generation[key]; + if (fact?.evidence) provenance.append(el('p', `${label}: ${fact.evidence === 'configuration' ? 'Configuration evidence' : 'Embedded parameter evidence'} · ${FACT_SOURCES[fact.source] || 'Source not recorded'}`, 'muted')); + } + provenance.append(el('p', record.historyTarget ? 'Saved association; not verified against current file' : library?.status === 'ambiguous' ? 'Saved association is ambiguous; no unique saved positive prompt is available.' : 'No unique saved positive prompt association is available.', 'muted')); + if (record.download) { + const original = el('a', 'Download original', 'workspace-link'); original.href = record.download.url; + original.download = safeFilename(record.download.filename); provenance.append(original); + } + inner.append(provenance); + const raw = disclosure('Workflow & raw metadata'); + raw.append(el('p', 'Supported embedded records only, not all EXIF or container metadata. Video records come from one selected container carrier; alternate carriers are not merged.', 'muted')); + if (!metadata) raw.append(el('p', 'Full metadata is unavailable for this inspection response.', 'muted')); + else { + if (metadata.status !== 'available') raw.append(el('p', {limited:RAW_LIMIT, absent:'No supported embedded metadata was recorded in this file.', unreadable:'Embedded metadata could not be read.'}[metadata.status], 'muted')); + for (const key of RAW_KEYS) { + const format = metadata.record_formats[key]; if (!format) continue; + raw.append(el('p', key, 'raw-record-name'), el('p', REPRESENTATIONS[format], 'muted')); + if (Object.hasOwn(metadata.raw, key)) { + const text = el('pre'); text.tabIndex = 0; text.append(el('code', metadata.raw[key])); raw.append(text); + } + } + } + const downloads = inspectionDownloads(record); + for (const [key, label] of [['metadata', 'Download metadata JSON'], ['workflow', 'Download workflow JSON']]) { + const item = downloads[key], action = createButton(label); + const group = el('div', undefined, 'file-download'); group.append(action); + if (!item.available) { + action.setAttribute('aria-disabled', 'true'); + const explanation = el('p', item.reason, 'muted'); + // IDs need only be unique within the current document, including its two inspectors. + explanation.id = `file-download-${++downloadReasonId}`; action.setAttribute('aria-describedby', explanation.id); group.append(explanation); + } + contentScope.listen(action, 'click', () => { + if (!item.available || contentScope.signal.aborted) return; + const url = URL.createObjectURL(new Blob([item.bodyText], {type:item.mime})); + let timer, released = false; + const release = () => {if (released) return; released = true; clearTimeout(timer); URL.revokeObjectURL(url);}; + contentScope.cleanup(release); + const anchor = el('a'); anchor.href = url; anchor.download = item.filename; + container.append(anchor); anchor.click(); anchor.remove(); timer = setTimeout(release, 1000); + }); + raw.append(group); + } + inner.append(raw); + const footer = el('footer', undefined, 'file-inspector-footer'), roles = promptRoles(record.prompts); + if (Object.hasOwn(roles, 'positive') && Object.hasOwn(roles, 'negative')) { + const pair = createSendButton('Send prompt pair'); pair.dataset.sendRole = 'pair'; + contentScope.listen(pair, 'click', () => sender.open(roles, pair)); footer.append(pair); + } + if (record.historyTarget) { + const history = el('a', 'Prompt history', 'workspace-link'); history.href = record.historyTarget; + history.prepend(createWorkspaceIcon('branch')); footer.append(history); + } + if (footer.childNodes.length) container.append(footer); + } + return {setState, dispose:() => scope.dispose()}; +} +let downloadReasonId = 0, factReasonId = 0; + +/** Compatible standalone controller; Images owns a single shared controller instead. */ +export function mountPromptInspector(container, {api, image, sender, parentScope}) { + if (parentScope.signal.aborted) return {dispose() {}}; + const scope = childScope(parentScope), controller = createInspectionController({api}); + const view = mountFileInspector(container, {sender, parentScope:scope, onRetry:() => controller.retry()}); + scope.cleanup(controller.subscribe(view.setState)); scope.cleanup(() => controller.dispose()); scope.cleanup(view.dispose); + controller.select(image); + return {dispose:() => scope.dispose()}; +} diff --git a/web/js/workspace/images-state.mjs b/web/js/workspace/images-state.mjs index 63f543a..25cfdfe 100644 --- a/web/js/workspace/images-state.mjs +++ b/web/js/workspace/images-state.mjs @@ -114,7 +114,7 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re } } - async function reset(filters = {}) { + async function reset(filters = {}, {load = true} = {}) { if (disposed) return 0; const nextQuery = queryFor(filters, batchSize); controller?.abort(); @@ -123,7 +123,7 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re seen = new Set(); publish({ items: Object.freeze([]), total: 0, cursor: null, hasMore: true, loading: false, error: null, loaded: 0 }); - return loadMore(); + return load ? loadMore() : 0; } return { diff --git a/web/js/workspace/images.css b/web/js/workspace/images.css index 89001c7..7c8d45a 100644 --- a/web/js/workspace/images.css +++ b/web/js/workspace/images.css @@ -43,18 +43,37 @@ .image-details .workspace-link { display: flex; font-size: 11px; } .image-continuation { text-align: center; padding-block: 24px; font-size: 12px; } .image-continuation [role="alert"] { color: var(--ca-error-text); } -.image-preview { background: var(--ca-surface); color: var(--ca-text); border: 1px solid var(--ca-border); border-radius: 10px; inline-size: min(1200px, calc(100% - 32px)); max-inline-size: none; padding: 0; } -.image-preview::backdrop { background: #000b; } -.image-preview header { padding: 14px 18px; display: flex; align-items: center; justify-content: space-between; gap: 16px; } -.image-preview h2 { font-size: 15px; margin: 0; overflow-wrap: anywhere; } -.image-preview-stage { position: relative; block-size: min(75dvh, 850px); background: var(--ca-field); } -.image-preview-stage > video { display: block; inline-size: 100%; block-size: 100%; object-fit: contain; } -.image-preview-stage .viewer-container { inline-size: 100% !important; block-size: 100% !important; } +.image-preview { box-sizing: border-box; background: var(--ca-surface); color: var(--ca-text); border: 1px solid var(--ca-border); border-radius: 9px; inline-size: min(1500px, 96%); block-size: min(940px, 94dvh); max-block-size: 94%; padding: 18px; overflow: hidden; overscroll-behavior: contain; } +.image-preview[open] { display: grid; grid-template-rows: auto minmax(0, 1fr); gap: 20px; } +.image-preview::backdrop { background: #080b12b8; overflow: hidden; overscroll-behavior: contain; } +.image-preview-header { display: flex; align-items: center; justify-content: space-between; gap: 18px; min-inline-size: 0; } +.image-preview-header > div { min-inline-size: 0; } +.image-preview-header h2 { font-size: 18px; font-weight: 600; line-height: 1.5; margin: 0; overflow-wrap: anywhere; } +.image-preview-position { font-size: 12px; line-height: 1.5; margin: 0; overflow-wrap: anywhere; } +.ca-tools .image-preview-header > button { align-self: start; min-inline-size: 32px; padding: 4px 10px; font-size: 20px; line-height: 1.5; } +.image-preview-body { display: grid; grid-template-columns: minmax(0, 1fr) 340px; gap: 20px; min-block-size: 0; min-inline-size: 0; } +.image-preview-left { display: grid; grid-template-rows: minmax(0, 1fr) auto; min-inline-size: 0; min-block-size: 0; background: var(--ca-field); border: 1px solid var(--ca-line); border-radius: 6px; overflow: hidden; } +.image-preview-stage { position: relative; min-inline-size: 0; min-block-size: 0; } +.image-preview-media { position: absolute; inset: 0; } +.image-preview-media > :where(img, video) { display: block; inline-size: 100%; block-size: 100%; object-fit: contain; } +.image-preview-media > img[hidden] { display: none; } +.image-preview-media > .viewer-container { position: absolute; inset: 0; inline-size: 100% !important; block-size: 100% !important; background: var(--ca-field); } +.image-preview-toolbar { border-block-start: 1px solid var(--ca-line); padding: 10px; } +.image-preview-controls { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 6px; } +.ca-tools .image-preview-controls :where(button, a) { display: inline-flex; align-items: center; justify-content: center; min-block-size: 32px; padding: 4px 10px; background: var(--ca-control); border: 1px solid var(--ca-border); border-radius: 4px; color: var(--ca-text); font-size: 12px; line-height: 1.5; text-decoration: none; } +.image-preview-controls [aria-disabled="true"] { opacity: .55; cursor: not-allowed; } +.image-preview-reason { text-align: center; font-size: 11px; line-height: 1.6; margin: 8px 0 0; } +.image-preview-failure { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; flex-direction: column; padding: 20px; background: var(--ca-field); text-align: center; font-size: 12px; } +.image-preview-failure[hidden] { display: none; } +.image-preview-details { min-inline-size: 0; min-block-size: 0; overflow: auto; overscroll-behavior: contain; padding: 0 6px 18px; } +.image-preview :where(button, a, .image-preview-stage):focus-visible { outline: 2px solid var(--ca-focus); outline-offset: -2px; } @media (max-width: 1250px) { .image-workspace { grid-template-columns: minmax(0, 1fr) 275px; gap: 15px; } + .image-preview-body { grid-template-columns: minmax(0, 1fr) 300px; } } @media (max-width: 950px) { .image-workspace { grid-template-columns: minmax(0, 1fr) 280px; } + .image-preview-body { grid-template-columns: minmax(0, 1fr) 270px; } } @media (max-width: 700px) { .image-workspace { grid-template-columns: minmax(0, 1fr); } @@ -65,6 +84,12 @@ .image-filters .ca-field:first-child { flex-basis: 100%; } .image-view-controls { gap: 8px; } .image-view-controls > .workspace-link { margin-inline-start: 0; } + .image-preview { block-size: 94dvh; overflow: auto; } + .image-preview[open] { grid-template-rows: auto auto; } + .image-preview-body { grid-template-columns: minmax(0, 1fr); } + .image-preview-left { block-size: 48dvh; min-block-size: 300px; } + .image-preview-details { overflow: visible; } + .ca-tools .image-preview-controls :where(button, a) { font-size: 10px; } } @container images-page (inline-size < 600px) { .image-workspace { grid-template-columns: minmax(0, 1fr); } @@ -91,10 +116,7 @@ .prompt-actions .workspace-icon { inline-size: 14px; block-size: 14px; } .prompt-copy-status { font-size: 11px; margin-block-end: 0; } .prompt-copy-status:empty { display: none; } -.image-preview-body { display: grid; grid-template-columns: minmax(0, 1fr) minmax(270px, 340px); } -.image-preview-details { padding: 0 18px 18px; max-block-size: 75dvh; overflow: auto; min-inline-size: 0; } .image-preview-details .prompt-inspector { margin-block-start: 0; } -.image-preview > header { border-block-end: 1px solid var(--ca-line); } .prompt-send-button { display: inline-flex; align-items: center; justify-content: center; gap: 7px; color: var(--ca-focus); } .prompt-send-button svg { inline-size: 16px; block-size: 16px; flex-shrink: 0; } .prompt-sender { background: var(--ca-surface); color: var(--ca-text); border: 1px solid var(--ca-line); border-radius: 12px; padding: 22px; inline-size: min(760px, calc(100% - 32px)); max-inline-size: none; max-block-size: calc(100dvh - 32px); overflow: auto; } @@ -113,9 +135,9 @@ .prompt-sender-composition { padding: 12px; background: var(--ca-field); border-radius: 6px; } .prompt-sender-actions { justify-content: flex-end; margin-block-start: 18px; } @media (max-width: 780px) { - .image-preview-body { grid-template-columns: minmax(0, 1fr); } - .image-preview-stage { block-size: 52dvh; } - .image-preview-details { max-block-size: none; } - .image-preview { max-block-size: calc(100dvh - 24px); overflow: auto; } .prompt-sender-options { grid-template-columns: minmax(0, 1fr); } } +@media (pointer: coarse) { + .ca-tools .image-preview :is(button, a) { min-inline-size: 40px; min-block-size: 40px; } + .ca-tools .image-preview .prompt-actions button { min-inline-size: 36px; min-block-size: 36px; } +} diff --git a/web/js/workspace/images.mjs b/web/js/workspace/images.mjs index 8e38d40..b4799fd 100644 --- a/web/js/workspace/images.mjs +++ b/web/js/workspace/images.mjs @@ -1,9 +1,11 @@ import { createScope, createButton, createField, createSelect } from '../shared/toolkit/core.js'; import { createImageStream, imageKey } from './images-state.mjs'; import { layoutImages, visibleImages, workspaceImageMetrics } from './image-layout.mjs'; -import { mountPromptInspector } from './prompt-inspector.mjs'; +import { mountFileInspector } from './file-inspector.mjs'; +import { createInspectionController } from './inspection-state.mjs'; import { createPromptSender } from './prompt-sender.mjs'; import { createWorkspaceIcon } from './icons.mjs'; +import { createMediaViewer } from './media-viewer.mjs'; function el(tag, text, className) { const element = document.createElement(tag); @@ -26,13 +28,16 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage if (parentScope.signal.aborted) { scope.dispose(); return {dispose}; } const { resolveUrl } = window.ApiClient; const sender = createPromptSender({scope}); - let detailInspector, previewInspector; - scope.cleanup(() => { detailInspector?.dispose(); previewInspector?.dispose(); }); + let detailInspector; + const inspection = createInspectionController({api}); + scope.cleanup(inspection.subscribe(next => detailInspector?.setState(next))); + scope.cleanup(() => inspection.dispose()); + scope.cleanup(() => detailInspector?.dispose()); const visit = embedded ? null : lastVisit; let density = visit?.density || 'comfortable'; let metrics = workspaceImageMetrics(window.innerWidth, density); - let layout = visit?.layout || 'grid', selection = visit?.selection || null, frame = 0, loadFrame = 0, restoreFrame = 0, observer, viewer; - let state, geometry = { positions: [], height: 0, maxHeight: 0 }, galleryWidth = 0, optionsRequest; + let layout = visit?.layout || 'grid', selection = visit?.selection || null, frame = 0, loadFrame = 0, restoreFrame = 0, observer; + let state, geometry = { positions: [], height: 0, maxHeight: 0 }, galleryWidth = 0, optionsRequest, recovering = false; const rendered = new Map(); const itemIndexes = new Map(); const filters = { root: 'all', q: '', subfolder: '', media: 'all', sort: 'newest', ...visit?.filters }; @@ -63,7 +68,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage if (!embedded) main.append(filtersBar, optionsStatus); const controls = el('div', undefined, 'image-view-controls'); const toolbar = el('div', undefined, 'image-toolbar'); - toolbar.append(el('h2', embedded ? 'Linked images' : 'All images')); + const collectionHeading = el('h2', embedded ? 'Linked images' : 'All images');collectionHeading.tabIndex = -1; + toolbar.append(collectionHeading); const count = el('p', 'Loading images…', 'muted'); const layouts = el('div', undefined, 'image-layouts'); layouts.setAttribute('role', 'group'); layouts.setAttribute('aria-label', 'Image layout'); @@ -87,14 +93,13 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage try { await api.post('/prompt_manager/gallery/rescan', {}, {signal:scope.signal}); if (scope.signal.aborted) return; - await loadOptions(); - if (!scope.signal.aborted) reset(); + if (await loadOptions() && !scope.signal.aborted) reset(); } catch { if (scope.signal.aborted) return; optionsStatus.textContent = 'Images could not be refreshed. Try Refresh again.'; optionsStatus.hidden = false; } finally { - refresh.disabled = false; + refresh.disabled = recovering; } }); const currentGallery = el('a', 'Current gallery', 'workspace-link'); @@ -128,29 +133,13 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage details.append(el('h2', 'Image details'), el('p', 'Select an image to inspect it.')); body.append(collection, details); main.append(body); - const dialog = el('dialog', undefined, 'image-preview'); - dialog.setAttribute('aria-label', 'Image preview'); - const dialogHeader = el('header'); - const previewTitle = el('h2'); - const close = createButton('Close preview'); - dialogHeader.append(previewTitle, close); - const stage = el('div', undefined, 'image-preview-stage'); - const previewBody = el('div', undefined, 'image-preview-body'); - const previewDetails = el('aside', undefined, 'image-preview-details'); - previewDetails.setAttribute('aria-label', 'Preview prompt details'); - previewBody.append(stage, previewDetails); - dialog.append(dialogHeader, previewBody); - main.append(dialog); - scope.listen(close, 'click', () => dialog.close()); - scope.listen(dialog, 'close', () => { destroyPreview(); scheduleLoad(); }); - function destroyPreview() { - previewInspector?.dispose(); previewInspector = null; - previewDetails.replaceChildren(); - viewer?.destroy(); viewer = null; - for (const video of stage.querySelectorAll('video')) releaseVideo(video); - stage.replaceChildren(); - } - scope.cleanup(() => { dialog.close(); destroyPreview(); dialog.remove(); }); + const mediaViewer = createMediaViewer({container:main,parentScope:scope,inspectionController:inspection,sender, + onSelect:image => {if (selection !== imageKey(image)) inspect(image);else inspection.select(image);}, + onRefresh:refreshInspectionCollection,onClose:scheduleLoad,onRestoreFocus:image => { + const index = state?.items.findIndex(entry => imageKey(entry) === imageKey(image)); + if (index >= 0) focusImage(index);else collectionHeading.focus({preventScroll:true}); + }}); + scope.cleanup(() => mediaViewer.dispose()); function mediaElement(image, lazy = false) { const video = image.is_video && !image.thumbnail_url; @@ -167,51 +156,18 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage detailInspector?.dispose(); selection = imageKey(image); for (const video of details.querySelectorAll('video')) releaseVideo(video); - const thumbnail = mediaElement(image); - const metadata = el('dl', undefined, 'image-facts'); - const fields = [['Source', image.root_dir], ['Path', image.path], ['Size', formatBytes(image.size)], - ['Dimensions', image.width && image.height ? `${image.width} × ${image.height}` : 'Not available'], - ['Modified', new Date(image.modified_time * 1000).toLocaleString()]]; - for (const [label, value] of fields) { - const row = el('div'); row.append(el('dt', label), el('dd', value)); metadata.append(row); - } - const preview = createButton('Open full preview', 'ca-primary'); - // The view delegates these controls so changing selection retains no old handlers. - preview.dataset.previewKey = selection; - const download = el('a', 'Download original', 'workspace-link'); - download.href = resolveUrl(image.url); download.download = image.filename; - const prompts = el('div'); - const legacy = el('p', 'Open Current gallery for tagging.', 'muted'); - legacy.hidden = embedded; - details.replaceChildren(thumbnail, el('h2', image.filename), preview, download, prompts, metadata, legacy); - detailInspector = mountPromptInspector(prompts, {api, image, sender, parentScope:scope}); + detailInspector = mountFileInspector(details, {sender, parentScope:scope, inline:true, + onPreview:() => previewImage(image), onRetry:() => inspection.retry(), onRefresh:refreshInspectionCollection}); + detailInspector.setState(inspection.state); + inspection.select(image); for (const [key, card] of rendered) { card.classList.toggle('selected', key === selection); card.querySelector('.image-open').setAttribute('aria-pressed', String(key === selection)); } } - scope.listen(details, 'click', event => { - const key = event.target.closest('[data-preview-key]')?.dataset.previewKey; - if (key) previewImage(state.items.find(image => imageKey(image) === key)); - }); function previewImage(image) { if (!image) return; - if (selection !== imageKey(image)) inspect(image); - destroyPreview(); - previewTitle.textContent = image.filename; - previewInspector = mountPromptInspector(previewDetails, {api, image, sender, parentScope:scope}); - if (!dialog.open) dialog.showModal(); - if (image.is_video) { - const video = el('video'); video.controls = true; video.src = resolveUrl(image.url); video.preload = 'metadata'; - stage.append(video); - } else if (window.Viewer) { - const original = el('img'); original.src = resolveUrl(image.url); original.alt = image.filename; - original.hidden = true; stage.append(original); - viewer = new window.Viewer(original, { inline: true, container: stage, navbar: false, title: false, - transition: false, keyboard: false, viewed() { viewer?.resize(); } }); - } else { - stage.append(el('p', 'The image viewer could not load. Refresh the page to try again.')); - } + mediaViewer.open({items:state.items,selectedKey:imageKey(image),trigger:document.activeElement}); } function cardFor(image) { const key = imageKey(image); @@ -368,12 +324,12 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage state = next; count.textContent = embedded ? `${next.loaded.toLocaleString()} loaded · ${next.total.toLocaleString()} linked images` : `${next.loaded.toLocaleString()} of ${next.total.toLocaleString()} images`; if (changed) updateLayout(); - message.textContent = next.error ? 'Images could not be loaded. Retry or refresh the collection.' : next.loading ? 'Loading images…' : ''; + message.textContent = recovering ? 'Images paused until sources and folders are available.' : next.error ? 'Images could not be loaded. Retry or refresh the collection.' : next.loading ? 'Loading images…' : ''; if (next.error) message.setAttribute('role', 'alert'); else message.setAttribute('role', 'status'); retry.hidden = !next.error; more.hidden = !next.hasMore || !!next.error; - more.setAttribute('aria-disabled', String(next.loading)); - end.textContent = !next.loading && !next.error && !next.hasMore ? (next.loaded ? 'You’re all caught up.' : embedded ? 'No linked media available for this selection.' : 'No matching images or media. Try changing the filters.') : ''; + more.setAttribute('aria-disabled', String(next.loading || recovering)); + end.textContent = !recovering && !next.loading && !next.error && !next.hasMore ? (next.loaded ? 'You’re all caught up.' : embedded ? 'No linked media available for this selection.' : 'No matching images or media. Try changing the filters.') : ''; if (!selection && next.items.length) inspect(next.items[0]); scheduleLoad(); } @@ -382,15 +338,15 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage stream.dispose(); for (const video of body.querySelectorAll('video')) releaseVideo(video); }); - scope.listen(more, 'click', () => stream.loadMore()); - scope.listen(retry, 'click', () => stream.loadMore()); + scope.listen(more, 'click', () => { if (!recovering) stream.loadMore(); }); + scope.listen(retry, 'click', () => { if (!recovering) stream.loadMore(); }); function maybeLoad() { - if (!observer || scope.signal.aborted || !state?.hasMore || state.loading || state.error || dialog.open) return; + if (!observer || recovering || scope.signal.aborted || !state?.hasMore || state.loading || state.error || main.querySelector('.image-preview[open]')) return; const bounds = continuation.getBoundingClientRect(); if (bounds.top <= window.innerHeight + 250 && bounds.bottom >= 0) stream.loadMore(); } function scheduleLoad() { - if (!loadFrame) loadFrame = requestAnimationFrame(() => { loadFrame = 0; maybeLoad(); }); + if (!scope.signal.aborted && !loadFrame) loadFrame = requestAnimationFrame(() => { loadFrame = 0; maybeLoad(); }); } if (typeof IntersectionObserver === 'function') { observer = new IntersectionObserver(entries => { @@ -399,11 +355,31 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage observer.observe(continuation); } function reset() { - detailInspector?.dispose(); detailInspector = null; + mediaViewer.stopSlideshow(); + main.querySelector('.image-preview[open]')?.close(); + if (!recovering) { + inspection.clear(); + detailInspector?.dispose(); detailInspector = null; + details.replaceChildren(el('h2', 'Image details'), el('p', 'Select an image to inspect it.')); + } selection = null; for (const video of details.querySelectorAll('video')) releaseVideo(video); - details.replaceChildren(el('h2', 'Image details'), el('p', 'Select an image to inspect it.')); - stream.reset(filters); + stream.reset(filters, {load:!recovering}); + } + async function refreshInspectionCollection() { + if (scope.signal.aborted) return; + // Numeric roots may now name another source; retire the old stream first. + recovering = true; + filters.root = 'all';filters.subfolder = '';filters.q = search.value.trim(); + source.replaceChildren(new Option('All sources', 'all'));folder.replaceChildren(new Option('All folders', '')); + for (const control of [search,source,folder,media,sort,refresh,more,retry]) control.disabled = true; + optionsStatus.hidden = true; + reset(); + const discovery = loadOptions(), request = optionsRequest; + if (!await discovery || scope.signal.aborted || request !== optionsRequest) return; + recovering = false; + for (const control of [search,source,folder,media,sort,refresh,more,retry]) control.disabled = false; + reset(); } async function loadOptions() { optionsRequest?.abort(); @@ -412,22 +388,25 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage const params = new URLSearchParams({mode:'workspace', include_ancestors:'true'}); if (filters.root !== 'all') params.set('root', filters.root); const data = await api.get(`/prompt_manager/gallery/subfolders?${params}`, {signal:request.signal}); - if (scope.signal.aborted || request !== optionsRequest) return; + if (scope.signal.aborted || request !== optionsRequest) return false; source.replaceChildren(new Option('All sources', 'all'), ...data.roots.map(root => new Option(root.label, String(root.index)))); source.value = filters.root; folder.replaceChildren(new Option('All folders', ''), ...data.subfolders.map(path => new Option(path, path))); folder.value = filters.subfolder; optionsStatus.hidden = true; + return true; } catch { - if (scope.signal.aborted || request !== optionsRequest) return; - optionsStatus.textContent = 'Sources and folders could not be loaded. Refresh to retry.'; + if (scope.signal.aborted || request !== optionsRequest) return false; + optionsStatus.textContent = recovering ? 'Sources and folders could not be loaded. Refresh image collection to retry.' : 'Sources and folders could not be loaded. Refresh to retry.'; optionsStatus.hidden = false; + return false; } } scope.cleanup(() => optionsRequest?.abort()); - scope.listen(search, 'input', scope.debounce(() => { filters.q = search.value.trim(); reset(); }, 200)); + scope.listen(search, 'input', scope.debounce(() => { if (!recovering) { filters.q = search.value.trim(); reset(); } }, 200)); for (const [input, key] of [[source, 'root'], [folder, 'subfolder'], [media, 'media'], [sort, 'sort']]) { scope.listen(input, 'change', () => { + if (recovering) return; filters[key] = input.value; if (key === 'root') { filters.subfolder = ''; folder.value = ''; loadOptions(); } reset(); diff --git a/web/js/workspace/inspection-state.mjs b/web/js/workspace/inspection-state.mjs new file mode 100644 index 0000000..6efd2af --- /dev/null +++ b/web/js/workspace/inspection-state.mjs @@ -0,0 +1,249 @@ +import { imageKey } from './images-state.mjs'; +import { promptRoles } from './prompt-blocks.mjs'; + +const SOURCE_CHANGED = 'The source changed. Refresh the image collection before inspecting this file.'; +const RECORD_KEYS = ['prompt', 'workflow', 'parameters', 'comfyassets_prompt_pairs']; +const FACT_FIELDS = ['seed', 'steps', 'cfg', 'sampler', 'scheduler', 'model']; +const object = value => value !== null && typeof value === 'object' && !Array.isArray(value); +const validScope = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value) && value.length === 64; + +export class InspectionSourceChangedError extends Error { + constructor() { + super(SOURCE_CHANGED); + this.name = 'InspectionSourceChangedError'; + this.refreshRequired = true; + } +} + +function descriptorScope(image) { + if (!Object.hasOwn(image, 'root_scope')) return null; + if (!validScope(image.root_scope)) throw new TypeError('Invalid inspection source scope'); + return image.root_scope; +} + +function validateIdentity(image) { + if (!object(image) || !Number.isSafeInteger(image.root_index) || image.root_index < 0 || + typeof image.path !== 'string' || !image.path || image.path.includes('\\') || /^[a-z]:/i.test(image.path) || /[\x00-\x1f\x7f]/.test(image.path) || + image.path.split('/').some(part => !part || part === '.' || part === '..')) throw new TypeError('Invalid inspection identity'); + return descriptorScope(image); +} + +export function inspectPath(image) { + const scope = validateIdentity(image); + const query = new URLSearchParams({ root: String(image.root_index) }); + if (scope !== null) query.set('root_scope', scope); + return `/prompt_manager/images/inspect/${image.path.split('/').map(encodeURIComponent).join('/')}?${query}`; +} + +function downloadFor(image, required) { + try { + if (typeof image.url !== 'string' || !image.url) throw new TypeError('Missing original media URL'); + const location = globalThis.location || globalThis.window?.location; + const reference = new URL(location?.href || 'http://localhost/'); + const resolveUrl = globalThis.window?.ApiClient?.resolveUrl || globalThis.ApiClient?.resolveUrl || (url => url); + const value = resolveUrl(image.url); + const url = new URL(value, reference); + const sameOrigin = 'Origin' in globalThis ? + globalThis.Origin.from(url).isSameOrigin(globalThis.Origin.from(reference)) : + url.origin !== 'null' && url.origin === reference.origin; + const expected = new URL(resolveUrl(`/prompt_manager/images/serve/${image.path.split('/').map(encodeURIComponent).join('/')}`), reference); + const scope = descriptorScope(image); + if (!sameOrigin || !['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.hash || + url.pathname !== expected.pathname || url.searchParams.getAll('root').length !== 1 || url.searchParams.get('root') !== String(image.root_index) || + url.searchParams.getAll('root_scope').length > 1 || (scope !== null && url.searchParams.get('root_scope') !== scope) || + (url.searchParams.has('root_scope') && !validScope(url.searchParams.get('root_scope')))) throw new TypeError('Invalid original media identity'); + return { url: value, filename: image.path.split('/').at(-1) }; + } catch (error) { + if (required) throw new TypeError('Invalid original media identity', { cause: error }); + return null; + } +} + +export function originalMediaDownload(image) { + validateIdentity(image); + return downloadFor(image, true); +} + +function promptsFor(value) { + if (!object(value)) throw new TypeError('Invalid inspection prompts'); + const confirmed = promptRoles(value); + for (const role of ['positive', 'negative']) { + const record = value[role]; + if (record === undefined) continue; + if (!object(record) || typeof record.status !== 'string' || + (record.text !== undefined && record.text !== null && typeof record.text !== 'string') || + (record.status === 'resolved' && !Object.hasOwn(confirmed, role))) throw new TypeError('Invalid inspection role'); + } + return value; +} + +function fileFor(value) { + if (!object(value) || value.observation !== 'current-file' || typeof value.source_label !== 'string' || + !['image', 'video'].includes(value.media_type) || typeof value.extension !== 'string' || + (value.format !== null && typeof value.format !== 'string')) throw new TypeError('Invalid inspection file'); + const file = { observation: value.observation, source_label: value.source_label, media_type: value.media_type, extension: value.extension, format: value.format }; + for (const key of ['size', 'width', 'height', 'modified_time']) { + const item = value[key] ?? null; + if (item !== null && (typeof item !== 'number' || !Number.isFinite(item) || + (['width', 'height'].includes(key) ? !Number.isSafeInteger(item) || item <= 0 : item < 0))) throw new TypeError('Invalid inspection file observation'); + file[key] = item; + } + return file; +} + +function graphFor(value) { + if (!object(value) || !['parsed', 'absent', 'malformed', 'limited'].includes(value.status) || + (value.status === 'parsed' ? !object(value.value) : value.value !== null)) throw new TypeError('Invalid inspection graph'); + return { status: value.status, value: value.value }; +} + +function metadataFor(value) { + if (!object(value) || !['available', 'absent', 'unreadable', 'limited'].includes(value.status) || + ![null, 'response-size-limit'].includes(value.omitted_reason) || !object(value.raw) || !object(value.record_formats) || + (value.parameters !== null && typeof value.parameters !== 'string')) throw new TypeError('Invalid inspection metadata'); + const raw = {}, formats = {}; + for (const [key, format] of Object.entries(value.record_formats)) { + if (!RECORD_KEYS.includes(key) || !['original-text', 'reserialized-json', 'invalid'].includes(format)) throw new TypeError('Invalid inspection record format'); + formats[key] = format; + } + for (const [key, text] of Object.entries(value.raw)) { + if (!RECORD_KEYS.includes(key) || typeof text !== 'string' || !Object.hasOwn(formats, key) || formats[key] === 'invalid') throw new TypeError('Invalid inspection raw record'); + raw[key] = text; + } + if (value.parameters !== (raw.parameters ?? null)) throw new TypeError('Invalid inspection parameters projection'); + const prompt = graphFor(value.prompt_graph), workflow = graphFor(value.workflow_graph); + const wholeRawOmitted = value.status === 'limited'; + if (wholeRawOmitted && (Object.keys(raw).length || value.parameters !== null)) throw new TypeError('Invalid atomic raw omission'); + if (!wholeRawOmitted && Object.entries(formats).some(([key, format]) => format !== 'invalid' && !Object.hasOwn(raw, key))) throw new TypeError('Missing represented raw record'); + const invalidGraphOmitted = [['prompt', prompt], ['workflow', workflow]].some(([key, graph]) => formats[key] === 'invalid' && graph.status === 'limited'); + for (const [key, graph] of [['prompt', prompt], ['workflow', workflow]]) { + const hasRecord = Object.hasOwn(formats, key); + if ((formats[key] === 'invalid' && !['malformed', 'limited'].includes(graph.status)) || + hasRecord !== (graph.status !== 'absent')) throw new TypeError('Invalid graph representation provenance'); + if (hasRecord && (wholeRawOmitted || invalidGraphOmitted) && graph.status !== 'limited') throw new TypeError('Invalid atomic graph omission'); + } + if ((value.status === 'limited') !== (value.omitted_reason === 'response-size-limit')) throw new TypeError('Invalid metadata omission status'); + if (['absent', 'unreadable'].includes(value.status) && (Object.keys(raw).length || Object.keys(formats).length || prompt.status !== 'absent' || workflow.status !== 'absent')) throw new TypeError('Invalid unavailable metadata'); + return { status: value.status, omitted_reason: value.omitted_reason, raw, record_formats: formats, prompt_graph: prompt, workflow_graph: workflow, parameters: value.parameters }; +} + +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 }; + 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'); + library[key] = item; + } + return library; +} + +function unavailableFacts() { + return Object.fromEntries(FACT_FIELDS.map(key => [key, { status: 'unresolved', value: null, source: null, evidence: null, reason: null, sampler_id: null }])); +} + +export function toInspectionRecord(result, image, extractFacts = globalThis.ComfyMetadata?.extractGenerationFacts || globalThis.window?.ComfyMetadata?.extractGenerationFacts) { + const scope = validateIdentity(image); + if (scope !== null && (!object(result) || result.root_scope !== scope)) throw new InspectionSourceChangedError(); + if (!object(result) || result.success === false) throw new TypeError('Invalid inspection response'); + const legacy = result.inspection_version === undefined; + if (!legacy && result.inspection_version !== 1) throw new TypeError('Unsupported inspection version'); + if (!legacy && result.success !== true) throw new TypeError('Invalid inspection success status'); + if ((!legacy || result.image !== undefined) && (!object(result.image) || result.image.root_index !== image.root_index || result.image.path !== image.path || result.image.filename !== image.path.split('/').at(-1))) throw new TypeError('Mismatched inspection identity'); + const record = { + identity: { kind: 'rooted-file', key: imageKey(image), root_index: image.root_index, path: image.path, root_scope: scope }, + file: null, prompts: promptsFor(result.prompts), library: null, metadata: null, + generation: unavailableFacts(), historyTarget: null, download: downloadFor(image, !legacy || scope !== null), + }; + if (legacy) return record; + const resolution = result.resolution; + if (!object(resolution) || !['resolved', 'absent', 'ambiguous', 'unresolved'].includes(resolution.state) || + (resolution.sampler_id !== null && (typeof resolution.sampler_id !== 'string' || !resolution.sampler_id)) || typeof resolution.reason !== 'string') throw new TypeError('Invalid inspection resolution'); + record.file = fileFor(result.file); + record.metadata = metadataFor(result.metadata); + record.library = libraryFor(result.library); + if (typeof extractFacts !== 'function') throw new TypeError('Inspection facts parser unavailable'); + record.generation = extractFacts({ prompt_graph: record.metadata.prompt_graph, workflow_graph: record.metadata.workflow_graph, + parameters: record.metadata.parameters, recordFormats: record.metadata.record_formats, metadataStatus: record.metadata.status, resolution }); + if (record.library.status === 'linked' && record.library.positive_id !== null) record.historyTarget = `#/library/${record.library.positive_id}`; + return record; +} + +export function createInspectionLoader(api, onChange, { extractFacts } = {}) { + let request = null, disposed = false; + return { + async load(image) { + if (disposed) return; + request?.abort(); + const current = request = new AbortController(); + onChange({ loading: true }); + try { + const result = await api.get(inspectPath(image), { signal: current.signal }); + if (disposed || request !== current) return; + const record = toInspectionRecord(result, image, extractFacts); + onChange({ prompts: record.prompts, record }); + } catch (error) { + if (disposed || request !== current) return; + if (error?.status === 409 && error.code === 'source-changed') error = new InspectionSourceChangedError(); + const refreshRequired = error instanceof InspectionSourceChangedError; + onChange({ error: true, refreshRequired, message: refreshRequired ? SOURCE_CHANGED : 'Could not read details from this file.' }); + } + }, + dispose() { disposed = true; request?.abort(); request = null; }, + }; +} + +const emptyState = () => ({ loading: false, error: false, record: null, prompts: undefined, message: '', refreshRequired: false }); + +export function createInspectionController({ api, extractFacts }) { + let state = Object.freeze(emptyState()), current = null, pending = null, disposed = false; + let attempted, hasAttempt = false; + const listeners = new Set(); + function publish(update) { + if (disposed) return; + state = Object.freeze({ ...emptyState(), ...update }); + for (const listener of listeners) listener(state); + } + const makeLoader = () => createInspectionLoader(api, publish, { extractFacts }); + let loader = makeLoader(); + function read(image) { + pending = loader.load(image); + return pending; + } + return { + get state() { return state; }, + select(image) { + if (disposed) return; + let selected = null; + try { + const scope = validateIdentity(image), key = imageKey(image); + if (current?.key === key && current.scope === scope) return pending; + selected = { key, scope }; + } catch { + // The loader publishes validation errors after invalidating the previous source. + } + loader.dispose(); loader = makeLoader(); current = selected; + attempted = object(image) ? { ...image } : image; hasAttempt = true; + return read(attempted); + }, + retry() { + if (disposed || !hasAttempt || state.refreshRequired) return; + return read(attempted); + }, + clear() { + if (disposed) return; + loader.dispose(); loader = makeLoader(); current = null; pending = null; attempted = undefined; hasAttempt = false; + publish({}); + }, + subscribe(listener) { + if (disposed) return () => {}; + listeners.add(listener); listener(state); + return () => listeners.delete(listener); + }, + dispose() { + if (disposed) return; + disposed = true; loader.dispose(); listeners.clear(); current = null; pending = null; attempted = undefined; hasAttempt = false; state = Object.freeze(emptyState()); + }, + }; +} diff --git a/web/js/workspace/inspector.css b/web/js/workspace/inspector.css new file mode 100644 index 0000000..c360a7a --- /dev/null +++ b/web/js/workspace/inspector.css @@ -0,0 +1,47 @@ +.image-details.file-inspector { padding: 0; } +.image-preview-slideshow-reason { text-align: center; font-size: 11px; line-height: 1.6; margin: 8px 0 0; } +.file-inspector { min-inline-size: 0; overflow-wrap: anywhere; } +.file-inspector-inner { padding: 17px; } +.image-preview-details .file-inspector-inner { padding: 0; } +.image-preview-details .file-inspector-footer { padding-inline: 0; } +.ca-tools .file-cover { display: block; padding: 0; border: 0; border-radius: 6px 6px 0 0; inline-size: 100%; block-size: 150px; background: var(--ca-field); } +.file-cover :where(img, video) { display: block; inline-size: 100%; block-size: 100%; object-fit: cover; border-radius: inherit; } +.file-title { display: flex; align-items: start; justify-content: space-between; gap: 8px; } +.file-inspector .file-title h2 { font-size: 14px; line-height: 1.5; margin: 0; } +.file-name { font-size: 12px; margin-block: 12px; } +.file-chips { display: flex; flex-wrap: wrap; gap: 5px; } +.file-chip { display: inline-block; padding: 3px 7px; border: 1px solid var(--ca-line); border-radius: 4px; background: var(--ca-field); font-size: 10px; } +.file-evidence { color: var(--ca-focus); } +.file-format { flex-shrink: 0; max-inline-size: 50%; } +.file-inspector .prompt-inspector { margin-block-start: 15px; } +.file-inspector .prompt-block { padding-block: 15px 0; margin-block-start: 15px; } +.file-inspector .prompt-text { margin-block: 8px 0; } +.file-inspector .prompt-negative { margin-block-end: 12px; } +.file-vocabulary > p { margin-block: 0 6px; font-size: 10px; } +.file-vocabulary .file-chip { font-size: 12px; } +.generation-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-block: 16px; font-size: 11px; } +.generation-limit { font-size: 10px; line-height: 1.65; margin-block: 12px; } +.generation-facts dt, .file-facts dt { color: var(--ca-muted); font-size: 10px; } +.generation-facts dd, .file-facts dd { margin: 3px 0 0; } +.file-inspector details { border-block-start: 1px solid var(--ca-line); margin-block-start: 16px; padding-block-start: 13px; font-size: 12px; } +.file-inspector summary { cursor: pointer; min-block-size: 24px; } +.file-inspector :where(summary, button, a):focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 2px; } +.file-inspector details p { font-size: 10px; line-height: 1.65; } +.file-facts { font-size: 11px; } +.file-facts > div { margin-block: 10px; } +.file-inspector details pre { white-space: pre-wrap; overflow-wrap: anywhere; max-block-size: 350px; overflow: auto; padding: 10px; background: var(--ca-field); border: 1px solid var(--ca-line); border-radius: 4px; font-size: 11px; line-height: 1.6; } +.raw-record-name { font-weight: 600; } +.file-download { margin-block: 12px; } +.ca-tools .file-download button { font-size: 11px; min-block-size: 30px; } +.file-inspector [aria-disabled="true"] { opacity: .6; cursor: not-allowed; } +.file-inspector-footer { display: flex; flex-wrap: wrap; gap: 8px; border-block-start: 1px solid var(--ca-line); padding: 13px 17px; } +.ca-tools .file-inspector-footer :where(button, a) { font-size: 11px; min-block-size: 30px; } +.ca-tools .image-preview-details .file-inspector-footer button { white-space: normal; } +.file-inspector-footer .workspace-link { display: inline-flex; gap: 7px; border: 1px solid var(--ca-line); border-radius: 4px; padding: 5px 8px; } +.file-inspector-footer .workspace-icon { inline-size: 14px; block-size: 14px; } +.file-status { padding: 17px; } +@media (max-width: 700px) { .ca-tools .file-cover { block-size: 200px; } } +@media (pointer: coarse) { + .ca-tools .file-inspector :is(button, a, summary) { min-inline-size: 40px; min-block-size: 40px; } + .ca-tools .file-inspector .prompt-actions button { inline-size: 36px; block-size: 36px; min-inline-size: 36px; min-block-size: 36px; } +} diff --git a/web/js/workspace/media-viewer.mjs b/web/js/workspace/media-viewer.mjs new file mode 100644 index 0000000..f68d072 --- /dev/null +++ b/web/js/workspace/media-viewer.mjs @@ -0,0 +1,212 @@ +import {createScope, createButton} from '../shared/toolkit/core.js'; +import {createViewerSession} from './viewer-state.mjs'; +import {originalMediaDownload} from './inspection-state.mjs'; +import {mountFileInspector} from './file-inspector.mjs'; + +let viewerId = 0; +export function readViewerPreferences(getStorage = () => window.localStorage, normalize = globalThis.GallerySettings.normalizeGallerySettings) { + let stored; + try { stored = JSON.parse(getStorage().getItem('gallerySettings')); } catch { stored = null; } + const {videoAutoplay,videoMute,videoLoop} = normalize(stored); + return {videoAutoplay,videoMute,videoLoop}; +} +function element(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; +} + +export function createMediaViewer({container,parentScope,inspectionController,sender,onSelect,onRefresh,onRestoreFocus,onClose}) { + const scope = createScope(); + let active = null, retryVersion = 0; + const dispose = () => scope.dispose(); + const stopSlideshow = () => active?.stop(); + if (parentScope.signal.aborted) { scope.dispose(); return {open() {},dispose,stopSlideshow}; } + parentScope.signal.addEventListener('abort',dispose,{once:true}); + scope.cleanup(() => parentScope.signal.removeEventListener('abort',dispose)); + scope.cleanup(() => active?.close(false)); + + function open(options) { + if (scope.signal.aborted) return; + active?.close(false); + const {items,selectedKey,trigger = document.activeElement} = options; + let mounted = false, initial, entry, currentKey, viewer = null, mediaScope = null, closed = false; + let originalDownload = null, inspectionState = inspectionController.state; + const session = createViewerSession({items,initialKey:selectedKey,onChange:(state,selected) => { + if (!mounted) { initial = [state,selected]; return; } + update(state,selected); + }}); + const local = createScope(); + const dialog = element('dialog','image-preview'); + dialog.setAttribute('aria-label','Image preview'); + const header = element('header','image-preview-header'); + const heading = element('div'), title = element('h2'); + const position = element('p','image-preview-position muted'); + position.setAttribute('role','status'); + heading.append(title,position); + const close = createButton('×');close.setAttribute('aria-label','Close preview');close.autofocus = true; + header.append(heading,close); + const body = element('div','image-preview-body'), left = element('div','image-preview-left'); + const stage = element('section','image-preview-stage');stage.tabIndex = 0;stage.setAttribute('aria-label','Media preview'); + const media = element('div','image-preview-media'), failure = element('div','image-preview-failure'); + const error = element('p',undefined,'Media could not be loaded. Retry this file.'); + const retry = createButton('Retry media');failure.append(error,retry);failure.hidden = true;stage.append(media,failure); + const toolbar = element('div','image-preview-toolbar'), controls = element('div','image-preview-controls'); + controls.setAttribute('role','group');controls.setAttribute('aria-label','Media controls'); + const reason = element('p','image-preview-reason muted');reason.id = `media-viewer-reason-${++viewerId}`; + function button(label,text,action) { + const node = createButton(text);node.setAttribute('aria-label',label); + local.listen(node,'click',() => { if (node.getAttribute('aria-disabled') !== 'true') action(); }); + controls.append(node);return node; + } + const previous = button('Previous media','← Previous',() => session.navigate(-1)); + const transforms = [button('Zoom out','−',() => viewer.zoom(-0.2)),button('Zoom in','+',() => viewer.zoom(0.2)), + button('Fit image','Fit',() => viewer.reset()),button('Rotate image 90 degrees','Rotate',() => viewer.rotate(90)), + button('Flip image horizontally','Flip',() => viewer.scaleX(viewer.imageData.scaleX === 1 ? -1 : 1))]; + const slideshow = button('Play slideshow','Play',() => session.state.requested ? session.stop() : session.play()); + const slideshowReason = element('p','image-preview-slideshow-reason muted');slideshowReason.id = `media-viewer-slideshow-${viewerId}`; + const download = element('a','image-preview-download','Download');download.setAttribute('aria-label','Download original');download.setAttribute('download',''); + local.listen(download,'click',event => {session.stop();if (download.getAttribute('aria-disabled') === 'true') event.preventDefault();}); + controls.append(download); + const next = button('Next media','Next →',() => session.navigate(1)); + toolbar.append(controls,reason,slideshowReason);left.append(stage,toolbar); + const details = element('aside','image-preview-details');details.setAttribute('aria-label','Preview prompt details'); + body.append(left,details);dialog.append(header,body);container.append(dialog); + const localSender = {open(roles,trigger) {session.stop();sender.open(roles,trigger);}}; + const inspector = mountFileInspector(details,{sender:localSender,parentScope:local,onInteraction:() => session.stop(),onRetry:() => inspectionController.retry(), + onRefresh:onRefresh ? () => { closeViewer();onRefresh(); } : undefined}); + function closeViewer(restore = true) { + if (closed) return; + closed = true;active = null;local.dispose(); + if (restore && !scope.signal.aborted) { + if (trigger?.isConnected) trigger.focus({preventScroll:true}); + else onRestoreFocus?.(entry); + } + if (!scope.signal.aborted) onClose?.(); + } + active = {close:closeViewer,stop:() => session.stop()}; + local.cleanup(() => { + session.dispose();mediaScope?.dispose();inspector.dispose(); + if (dialog.open) dialog.close();dialog.remove(); + }); + local.listen(close,'click',() => closeViewer());local.listen(dialog,'close',() => closeViewer()); + // Finish focus restoration before a later key event can select another control. + local.listen(dialog,'cancel',event => {event.preventDefault();closeViewer();}); + local.listen(document,'visibilitychange',() => {if (document.hidden) session.stop();}); + local.listen(window,'pagehide',() => closeViewer(false)); + local.listen(retry,'click',() => {if (!inspectionState.refreshRequired) rebuild(entry);}); + local.listen(dialog,'keydown',event => { + if (!['ArrowLeft','ArrowRight'].includes(event.key) || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey || + (!stage.contains(event.target) && !controls.contains(event.target)) || + event.target.closest('video,input,textarea,select,pre,summary,[contenteditable]') || + !window.getSelection()?.isCollapsed || [...document.querySelectorAll('dialog[open]')].at(-1) !== dialog) return; + event.preventDefault();session.navigate(event.key === 'ArrowLeft' ? -1 : 1); + }); + function rebuild(selected) { + mediaScope?.dispose();mediaScope = createScope();viewer = null;media.replaceChildren(); + failure.hidden = true; + const mediaLocal = mediaScope, key = session.state.key; + const current = () => !local.signal.aborted && !mediaLocal.signal.aborted && session.state.key === key; + let original; + try { original = originalMediaDownload(selected); } + catch { session.failed(key);return; } + const url = new URL(original.url,window.location.href); + url.searchParams.set('_pm_viewer',`${Date.now()}-${++retryVersion}`); + const node = element(selected.is_video ? 'video' : 'img'); + mediaLocal.listen(node,'error',() => {if (current()) session.failed(key);}); + if (selected.is_video) { + node.controls = true;node.playsInline = true;node.preload = 'metadata'; + const preferences = readViewerPreferences(); + node.muted = preferences.videoMute;node.loop = preferences.videoLoop; + mediaLocal.listen(node,'loadedmetadata',() => {if (current()) session.ready(key);}); + mediaLocal.cleanup(() => {node.pause();node.removeAttribute('src');node.load();}); + if (preferences.videoAutoplay) mediaLocal.listen(node,'loadedmetadata',() => { + if (current()) node.play().catch(() => {}); + },{once:true}); + } else { + node.alt = selected.filename;node.decoding = 'async'; + node.hidden = !!window.Viewer; + if (!window.Viewer) mediaLocal.listen(node,'load',() => {if (current()) session.ready(key);}); + mediaLocal.cleanup(() => node.removeAttribute('src')); + } + node.src = url.href;media.append(node); + if (!selected.is_video && window.Viewer) { + let fitFrame = 0, fitWidth, fitHeight; + const scheduleFit = () => { + if (!current() || fitFrame) return; + fitFrame = requestAnimationFrame(() => { + fitFrame = 0; + if (!current() || !instance.viewed) return; + const width = media.offsetWidth, height = media.offsetHeight; + if (width === fitWidth && height === fitHeight) return; + fitWidth = width;fitHeight = height;instance.resize(); + }); + }; + const instance = new window.Viewer(node,{inline:true,container:media,toolbar:false,navbar:false,title:false,button:false, + keyboard:false,fullscreen:false,transition:false,viewed() { + if (!current()) return; + session.ready(key);scheduleFit(); + }}); + const observer = new ResizeObserver(scheduleFit);observer.observe(stage); + viewer = instance;mediaLocal.cleanup(() => {observer.disconnect();cancelAnimationFrame(fitFrame);instance.destroy();}); + } + updateControls(session.state); + } + function updateControls(state) { + const unavailable = inspectionState.refreshRequired ? inspectionState.message : state.phase === 'error' ? 'Media could not be loaded. Retry this file.' : entry.is_video ? + 'Image transforms are unavailable for video.' : !viewer ? + 'Image transforms are unavailable because Viewer.js could not load.' : !state.mediaReady ? 'Image transforms are available when the image is ready.' : ''; + reason.textContent = unavailable;reason.hidden = !unavailable; + for (const node of transforms) { + node.setAttribute('aria-disabled',String(!!unavailable)); + if (unavailable) node.setAttribute('aria-describedby',reason.id);else node.removeAttribute('aria-describedby'); + } + for (const node of [previous,next]) { + node.setAttribute('aria-disabled',String(state.total === 1)); + if (state.total === 1) {node.setAttribute('aria-describedby',position.id);} + } + const action = state.phase === 'paused-video' ? 'Stop' : state.requested ? 'Pause' : 'Play'; + slideshow.textContent = action;slideshow.setAttribute('aria-label',`${action} slideshow`); + const cannotPlay = !state.requested && (inspectionState.refreshRequired || state.phase === 'error' || state.total < 2 || entry.is_video); + slideshow.setAttribute('aria-disabled',String(!!cannotPlay)); + slideshowReason.textContent = !cannotPlay ? '' : inspectionState.refreshRequired ? inspectionState.message : state.phase === 'error' ? + 'Media could not be loaded. Retry this file.' : state.total < 2 ? 'Slideshow requires at least two loaded media entries.' : 'Start slideshow on an image.'; + slideshowReason.hidden = !cannotPlay; + if (cannotPlay) slideshow.setAttribute('aria-describedby',slideshowReason.id);else slideshow.removeAttribute('aria-describedby'); + const canDownload = originalDownload && !inspectionState.refreshRequired; + download.setAttribute('aria-disabled',String(!canDownload)); + if (canDownload) { + download.href = originalDownload.url;download.download = originalDownload.filename; + download.removeAttribute('tabindex');download.removeAttribute('aria-describedby'); + } else { + download.removeAttribute('href');download.tabIndex = 0;download.setAttribute('aria-describedby',reason.id); + } + failure.hidden = state.phase !== 'error' || inspectionState.refreshRequired; + } + function update(state,selected) { + if (closed || state.phase === 'disposed') return; + entry = selected; + const status = {'waiting-image':'Slideshow waiting for image.','playing-image':'Slideshow playing.', + 'paused-video':'Slideshow paused for video. Next or Previous continues it on an image; Stop ends slideshow.',error:'Media could not be loaded.'}[state.phase]; + const label = `${state.index + 1} of ${state.total} loaded: ${entry.filename}${status ? ` — ${status}` : ''}`; + if (position.textContent !== label) position.textContent = label; + if (currentKey !== state.key) { + currentKey = state.key;title.textContent = entry.filename; + try { originalDownload = originalMediaDownload(entry); } catch { originalDownload = null; } + onSelect?.(entry); + rebuild(entry); + } + updateControls(session.state); + } + position.id = `media-viewer-position-${viewerId}`; + dialog.showModal();mounted = true;update(...initial); + local.cleanup(inspectionController.subscribe(state => { + inspectionState = state;updateControls(session.state); + if (state.error) session.stop(); + inspector.setState(state); + if (state.record?.identity.key === currentKey && state.record.library?.title) title.textContent = `${state.record.library.title} · ${entry.filename}`; + })); + } + return {open,dispose,stopSlideshow}; +} diff --git a/web/js/workspace/prompt-blocks.mjs b/web/js/workspace/prompt-blocks.mjs new file mode 100644 index 0000000..113a791 --- /dev/null +++ b/web/js/workspace/prompt-blocks.mjs @@ -0,0 +1,115 @@ +import { createScope, createButton } from '../shared/toolkit/core.js'; +import { createSendButton } from './prompt-sender.mjs'; +import { createWorkspaceIcon } from './icons.mjs'; + +export function promptRoles(prompts) { + return Object.fromEntries(['positive', 'negative'].filter(role => prompts?.[role]?.status === 'resolved' && typeof prompts[role].text === 'string').map(role => [role, prompts[role].text])); +} + +const sources = { + library: 'Saved library prompt', + execution: 'Recorded at execution', + 'graph-inferred': 'Inferred from the saved workflow', + 'embedded-parameters': 'Embedded generation parameters', + 'saved-association': 'Saved library association', +}; +const statuses = { + absent: 'Not recorded in this file.', + ambiguous: 'Multiple possible prompts. Source could not be determined.', + unresolved: 'Prompt could not be resolved.', +}; +function el(tag, text, className) { + const node = document.createElement(tag); + if (text !== undefined) node.textContent = text; + if (className) node.className = className; + return node; +} + +function childScope(parentScope) { + const scope = createScope(); + const dispose = () => scope.dispose(); + parentScope.signal.addEventListener('abort', dispose, {once:true}); + scope.cleanup(() => parentScope.signal.removeEventListener('abort', dispose)); + if (parentScope.signal.aborted) scope.dispose(); + return scope; +} + +export function mountPromptBlocks(container, {prompts, sender, parentScope, roles:visibleRoles = ['positive', 'negative'], onRetry, includePairAction = true}) { + if (parentScope.signal.aborted) return {update() {}, setState() {}, dispose() {}}; + const scope = childScope(parentScope); + container.classList.add('prompt-inspector'); + scope.listen(container, 'click', async event => { + const send = event.target.closest('[data-send-role]'); + if (send) { + const role = send.dataset.sendRole; + sender.open(role === 'pair' ? roles : {[role]:roles[role]}, send); + } + const copy = event.target.closest('[data-copy-role]'); + if (copy) { + const role = copy.dataset.copyRole; + const status = copy.closest('section').querySelector('.prompt-copy-status'); + try { + await navigator.clipboard.writeText(roles[role]); + if (!scope.signal.aborted) status.textContent = 'Copied.'; + } catch { + if (!scope.signal.aborted) status.textContent = 'Copy unavailable. Select the prompt text to copy it.'; + } + } + if (event.target.closest('[data-retry-prompts]')) onRetry?.(); + }); + let roles = {}; + function render(state) { + if (scope.signal.aborted) return; + roles = {}; + container.replaceChildren(); + if (state.loading || state.error) { + const status = el('p', state.loading ? 'Loading prompts…' : 'Could not read prompts from this file.', 'muted'); + status.setAttribute('role', state.error ? 'alert' : 'status'); + container.append(status); + if (state.error && onRetry) { + const retry = createButton('Retry prompt details'); + retry.dataset.retryPrompts = ''; + container.append(retry); + } + return; + } + roles = Object.fromEntries(Object.entries(promptRoles(state.prompts)).filter(([role]) => visibleRoles.includes(role))); + for (const role of visibleRoles) { + const title = `${role[0].toUpperCase() + role.slice(1)} prompt`; + const record = state.prompts?.[role]; + const block = el('section', undefined, `prompt-block prompt-${role}`); + block.setAttribute('aria-label', title); + const heading = el('div', undefined, 'prompt-block-heading'); + heading.append(el('h3', title)); + block.append(heading); + if (Object.hasOwn(roles, role)) { + block.append(el('p', sources[record.source] || 'Source not recorded', 'prompt-source muted')); + if (roles[role] === '') block.append(el('p', `Empty ${role} prompt`, 'prompt-empty')); + else { + const text = el('pre', roles[role], 'prompt-text'); + text.tabIndex = 0; + block.append(text); + } + const actions = el('div', undefined, 'prompt-actions'); + const send = createSendButton(`Send ${role} prompt`); + send.dataset.sendRole = role; + const copy = createButton('', 'ca-icon-button'); + copy.setAttribute('aria-label', `Copy ${role} prompt`); + copy.title = `Copy ${role} prompt`; + copy.append(createWorkspaceIcon('copy')); + copy.dataset.copyRole = role; + actions.append(copy, send); + heading.append(actions); + const status = el('p', '', 'prompt-copy-status muted'); status.setAttribute('role', 'status'); + block.append(status); + } else block.append(el('p', typeof record?.message === 'string' ? record.message : statuses[record?.status] || statuses.unresolved, 'muted')); + container.append(block); + } + if (includePairAction && Object.hasOwn(roles, 'positive') && Object.hasOwn(roles, 'negative')) { + const pair = createSendButton('Send prompt pair'); pair.dataset.sendRole = 'pair'; + container.append(pair); + } + } + render({prompts}); + return {update:prompts => render({prompts}), setState:render, dispose:() => scope.dispose()}; +} diff --git a/web/js/workspace/prompt-inspector.mjs b/web/js/workspace/prompt-inspector.mjs index 0fe3a84..432f342 100644 --- a/web/js/workspace/prompt-inspector.mjs +++ b/web/js/workspace/prompt-inspector.mjs @@ -1,147 +1,3 @@ -import { createScope, createButton } from '../shared/toolkit/core.js'; -import { createSendButton } from './prompt-sender.mjs'; -import { createWorkspaceIcon } from './icons.mjs'; - -export function inspectPath(image) { - return `/prompt_manager/images/inspect/${image.path.split('/').map(encodeURIComponent).join('/')}?root=${image.root_index}`; -} - -export function promptRoles(prompts) { - return Object.fromEntries(['positive', 'negative'].filter(role => prompts?.[role]?.status === 'resolved' && typeof prompts[role].text === 'string').map(role => [role, prompts[role].text])); -} - -export function createInspectionLoader(api, onChange) { - let request, disposed = false; - return { - async load(image) { - if (disposed) return; - request?.abort(); - const current = request = new AbortController(); - onChange({ loading: true }); - try { - const result = await api.get(inspectPath(image), {signal:current.signal}); - if (!disposed && request === current) onChange({prompts:result.prompts}); - } catch { - if (!disposed && request === current) onChange({error:true}); - } - }, - dispose() { disposed = true; request?.abort(); }, - }; -} - -const sources = { - library: 'Saved library prompt', - execution: 'Recorded at execution', - 'graph-inferred': 'Inferred from the saved workflow', - 'embedded-parameters': 'Embedded generation parameters', - 'saved-association': 'Saved library association', -}; -const statuses = { - absent: 'Not recorded in this file.', - ambiguous: 'Multiple possible prompts. Source could not be determined.', - unresolved: 'Prompt could not be resolved.', -}; -function el(tag, text, className) { - const node = document.createElement(tag); - if (text !== undefined) node.textContent = text; - if (className) node.className = className; - return node; -} - -function childScope(parentScope) { - const scope = createScope(); - const dispose = () => scope.dispose(); - parentScope.signal.addEventListener('abort', dispose, {once:true}); - scope.cleanup(() => parentScope.signal.removeEventListener('abort', dispose)); - if (parentScope.signal.aborted) scope.dispose(); - return scope; -} - -export function mountPromptBlocks(container, {prompts, sender, parentScope, roles:visibleRoles = ['positive', 'negative'], onRetry}) { - const scope = childScope(parentScope); - container.classList.add('prompt-inspector'); - scope.listen(container, 'click', async event => { - const send = event.target.closest('[data-send-role]'); - if (send) { - const role = send.dataset.sendRole; - sender.open(role === 'pair' ? roles : {[role]:roles[role]}, send); - } - const copy = event.target.closest('[data-copy-role]'); - if (copy) { - const role = copy.dataset.copyRole; - const status = copy.closest('section').querySelector('.prompt-copy-status'); - try { - await navigator.clipboard.writeText(roles[role]); - if (!scope.signal.aborted) status.textContent = 'Copied.'; - } catch { - if (!scope.signal.aborted) status.textContent = 'Copy unavailable. Select the prompt text to copy it.'; - } - } - if (event.target.closest('[data-retry-prompts]')) onRetry?.(); - }); - let roles = {}; - function render(state) { - if (scope.signal.aborted) return; - roles = {}; - container.replaceChildren(); - if (state.loading || state.error) { - const status = el('p', state.loading ? 'Loading prompts…' : 'Could not read prompts from this file.', 'muted'); - status.setAttribute('role', state.error ? 'alert' : 'status'); - container.append(status); - if (state.error && onRetry) { - const retry = createButton('Retry prompt details'); - retry.dataset.retryPrompts = ''; - container.append(retry); - } - return; - } - roles = Object.fromEntries(Object.entries(promptRoles(state.prompts)).filter(([role]) => visibleRoles.includes(role))); - for (const role of visibleRoles) { - const title = `${role[0].toUpperCase() + role.slice(1)} prompt`; - const record = state.prompts?.[role]; - const block = el('section', undefined, `prompt-block prompt-${role}`); - block.setAttribute('aria-label', title); - const heading = el('div', undefined, 'prompt-block-heading'); - heading.append(el('h3', title)); - block.append(heading); - if (Object.hasOwn(roles, role)) { - block.append(el('p', sources[record.source] || 'Source not recorded', 'prompt-source muted')); - if (roles[role] === '') block.append(el('p', `Empty ${role} prompt`, 'prompt-empty')); - else { - const text = el('pre', roles[role], 'prompt-text'); - text.tabIndex = 0; - block.append(text); - } - const actions = el('div', undefined, 'prompt-actions'); - const send = createSendButton(`Send ${role} prompt`); - send.dataset.sendRole = role; - const copy = createButton('', 'ca-icon-button'); - copy.setAttribute('aria-label', `Copy ${role} prompt`); - copy.title = `Copy ${role} prompt`; - copy.append(createWorkspaceIcon('copy')); - copy.dataset.copyRole = role; - actions.append(copy, send); - heading.append(actions); - const status = el('p', '', 'prompt-copy-status muted'); status.setAttribute('role', 'status'); - block.append(status); - } else block.append(el('p', typeof record?.message === 'string' ? record.message : statuses[record?.status] || statuses.unresolved, 'muted')); - container.append(block); - } - if (Object.hasOwn(roles, 'positive') && Object.hasOwn(roles, 'negative')) { - const pair = createSendButton('Send prompt pair'); pair.dataset.sendRole = 'pair'; - container.append(pair); - } - } - render({prompts}); - return {update:prompts => render({prompts}), setState:render, dispose:() => scope.dispose()}; -} - -export function mountPromptInspector(container, {api, image, sender, parentScope}) { - const scope = childScope(parentScope); - let loader; - const blocks = mountPromptBlocks(container, {sender, parentScope:scope, onRetry:() => loader.load(image)}); - loader = createInspectionLoader(api, blocks.setState); - scope.cleanup(() => loader.dispose()); - if (!scope.signal.aborted) loader.load(image); - return {dispose:() => scope.dispose()}; -} +export { promptRoles, mountPromptBlocks } from './prompt-blocks.mjs'; +export { inspectPath, createInspectionLoader } from './inspection-state.mjs'; +export { mountPromptInspector } from './file-inspector.mjs'; diff --git a/web/js/workspace/viewer-state.mjs b/web/js/workspace/viewer-state.mjs new file mode 100644 index 0000000..7a26a16 --- /dev/null +++ b/web/js/workspace/viewer-state.mjs @@ -0,0 +1,73 @@ +import {imageKey} from './images-state.mjs'; +import {inspectPath} from './inspection-state.mjs'; + +/** A viewer visits only the descriptors loaded by its opening query. */ +export function createViewerSession({items, initialKey, onChange = () => {}, setTimer = setTimeout, clearTimer = clearTimeout}) { + if (!Array.isArray(items) || !items.length) throw new TypeError('A loaded media collection is required'); + const keys = new Set(); + const loaded = Object.freeze(items.map(item => { + inspectPath(item); + const key = imageKey(item); + if (keys.has(key)) throw new TypeError('Duplicate viewer identity'); + keys.add(key); + return Object.freeze({...item}); + })); + const index = loaded.findIndex(item => imageKey(item) === initialKey); + if (index < 0) throw new TypeError('Selected media is not loaded'); + let state = Object.freeze({key:initialKey,index,total:loaded.length,phase:'stopped',requested:false,mediaReady:false}); + let timer = null, deadline = 0; + function cancel() { + deadline++; + if (timer !== null) clearTimer(timer); + timer = null; + } + function publish(update) { + state = Object.freeze({...state,...update}); + onChange(state, loaded[state.index]); + } + function phase(ready = state.mediaReady) { + return !state.requested ? 'stopped' : loaded[state.index].is_video ? 'paused-video' : ready ? 'playing-image' : 'waiting-image'; + } + function schedule() { + if (state.phase !== 'playing-image' || timer !== null) return; + const key = state.key, token = ++deadline; + timer = setTimer(() => { + if (token !== deadline || state.phase !== 'playing-image' || !state.requested || state.key !== key) return; + timer = null;navigate(1); + },3500); + } + function navigate(direction) { + if (state.phase === 'disposed') return; + if (direction !== -1 && direction !== 1) throw new TypeError('Viewer direction must be -1 or 1'); + if (loaded.length === 1) return; + cancel(); + const next = (state.index + direction + loaded.length) % loaded.length; + publish({index:next,key:imageKey(loaded[next]),phase:state.requested ? loaded[next].is_video ? 'paused-video' : 'waiting-image' : 'stopped',mediaReady:false}); + } + publish({}); + return { + get state() { return state; }, + navigate, + play() { + if (state.phase === 'disposed' || state.phase === 'error' || state.requested || loaded.length < 2 || loaded[state.index].is_video) return; + publish({requested:true,phase:state.mediaReady ? 'playing-image' : 'waiting-image'}); + schedule(); + }, + stop() { + if (state.phase === 'disposed') return; + cancel();publish({requested:false,phase:state.phase === 'error' ? 'error' : 'stopped'}); + }, + ready(key) { + if (state.phase === 'disposed' || key !== state.key || state.mediaReady) return; + publish({mediaReady:true,phase:phase(true)});schedule(); + }, + failed(key) { + if (state.phase === 'disposed' || key !== state.key) return; + cancel();publish({mediaReady:false,phase:'error',requested:false}); + }, + dispose() { + if (state.phase === 'disposed') return; + cancel();publish({phase:'disposed',requested:false,mediaReady:false}); + }, + }; +} diff --git a/web/workspace.html b/web/workspace.html index 0057c5a..8ff41bd 100644 --- a/web/workspace.html +++ b/web/workspace.html @@ -10,7 +10,10 @@ + + +