feat(workspace): complete shared inspector and collection viewer (#224)
* feat(inspector): expose bounded physical file evidence * fix(inspector): distinguish ambiguous saved associations * feat(inspector): derive conservative generation facts * fix(inspector): guard fact ancestry and inspection state * feat(inspector): share complete file details and exports * fix(inspector): preserve fact states and touch targets * feat(viewer): navigate the loaded media collection Refs #216 * fix(viewer): refit after current stage geometry changes Ref #216. Observe the current media stage after readiness and wrapped title changes without rebuilding focused controls or resetting transforms at unchanged dimensions. * feat(viewer): add lifecycle-safe slideshow and media preferences * fix(viewer): resume gallery continuation after close (#216) * fix(images): guard source recovery and wrap viewer actions Clear stale root and folder intent, cancel old pages, and wait for current source discovery before loading a refreshed collection. Preserve safe retry through failed, superseded, and disposed discovery. Wrap narrow viewer footer actions and wait for measured gallery viewport settling in responsive checks. Refs #216; addresses PR #224 review. * test(viewer): await gallery layout after viewport changes Require actual inner, root client, and document widths to agree after each responsive-case viewport and CSS zoom transition. Keep the existing exact frame, overflow, focus, and coarse target assertions unchanged. Observed PR #224 CI initial mobile resize at537px before pending card relayout settled to356px. Refs #216.
This commit is contained in:
@@ -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
|
||||
|
||||
+26
@@ -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
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+197
-32
@@ -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:
|
||||
|
||||
@@ -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='<svg onload="window.executed=true"><image href="https://invalid.test/never"/></svg></pre>'+'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());}
|
||||
});
|
||||
@@ -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},
|
||||
}}}));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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('<script type="module" src="js/workspace/index.mjs"></script>', '');
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
@@ -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();}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'.
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 <svg> </pre>'},record_formats:{workflow:'original-text',prompt:'original-text',parameters:'original-text'},workflow_graph:{status:'parsed',value:{seed:'9007199254740993'}},prompt_graph:{status:'malformed',value:null},parameters:'literal <svg> </pre>'}};
|
||||
}
|
||||
|
||||
test('metadata export retains literal raw and scoped evidence',async()=>{
|
||||
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');
|
||||
});
|
||||
@@ -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)];
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
+36
-6
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
+119
-27
@@ -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"]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+235
-5
@@ -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);
|
||||
|
||||
@@ -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()};
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
+36
-14
@@ -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; }
|
||||
}
|
||||
|
||||
+63
-84
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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};
|
||||
}
|
||||
@@ -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()};
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -10,7 +10,10 @@
|
||||
<link rel="stylesheet" href="lib/viewerjs/viewer.min.css">
|
||||
<link rel="stylesheet" href="js/workspace/images.css">
|
||||
<link rel="stylesheet" href="js/workspace/library.css">
|
||||
<link rel="stylesheet" href="js/workspace/inspector.css">
|
||||
<script src="lib/viewerjs/viewer.min.js" defer></script>
|
||||
<script src="js/comfy-metadata.js" defer></script>
|
||||
<script src="js/gallery-settings.js" defer></script>
|
||||
<script type="module" src="js/workspace/index.mjs"></script>
|
||||
</head>
|
||||
<body class="ca-tools">
|
||||
|
||||
Reference in New Issue
Block a user