Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e471a47d1f | ||
|
|
deeacea700 | ||
|
|
94d50d15f2 | ||
|
|
06c26e0a41 | ||
|
|
ba4e958804 | ||
|
|
a574f57cd8 |
@@ -20,34 +20,24 @@ jobs:
|
||||
frontend-coverage:
|
||||
name: frontend coverage (chromium and touch)
|
||||
runs-on: ubuntu-latest
|
||||
# Layout comparisons depend on the fonts each browser falls back to, so browser jobs run in the
|
||||
# same pinned Playwright image as local runs (tag = @playwright/test in tools/vendor/package-lock.json;
|
||||
# tests/vendor/browser-image.test.mjs checks it). Browsers and fonts come from the image.
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble
|
||||
options: --user 1001
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
# The image's own Python 3.12 and Node 24: setup-python/setup-node install the runner's toolcache
|
||||
# builds, whose entry points name host paths that do not exist in the container. The image has no
|
||||
# ensurepip and the job has no root, so a checksum-pinned pip wheel bootstraps a user-space venv.
|
||||
- name: Python environment
|
||||
env:
|
||||
PIP_WHEEL: https://files.pythonhosted.org/packages/f3/6e/1736e5b4ae2b778ef2f81c47d797de9f891d4d8acb047a24ca37a60294dd/pip-26.2.1-py3-none-any.whl
|
||||
PIP_SHA256: 71138adf1f4ca900cdb7d289c21b7494329f2332b6d85f0e1c42108c0384ed3e
|
||||
run: |
|
||||
venv="$RUNNER_TEMP/venv" wheel="$RUNNER_TEMP/pip-26.2.1-py3-none-any.whl"
|
||||
python3 -m venv --without-pip "$venv"
|
||||
curl -fsSL --retry 3 -o "$wheel" "$PIP_WHEEL"
|
||||
echo "$PIP_SHA256 $wheel" | sha256sum -c -
|
||||
"$venv/bin/python" "$wheel/pip" install --no-index --no-cache-dir "$wheel"
|
||||
"$venv/bin/python" -m pip install --no-cache-dir -r requirements-dev.txt
|
||||
echo "$venv/bin" >> "$GITHUB_PATH"
|
||||
- run: python --version && node --version && node --version | grep -q '^v24\.'
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: pip
|
||||
cache-dependency-path: requirements-dev.txt
|
||||
- run: python -m pip install -r requirements-dev.txt
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: npm
|
||||
cache-dependency-path: tools/vendor/package-lock.json
|
||||
- run: npm ci --prefix tools/vendor --ignore-scripts
|
||||
- run: npm run --prefix tools/vendor check
|
||||
- run: npm run --prefix tools/vendor test
|
||||
- run: npx --prefix tools/vendor playwright install --with-deps chromium
|
||||
- name: Frontend unit, viewer, workflow, and coverage checks
|
||||
run: npm run --prefix tools/vendor test:coverage
|
||||
- name: Upload frontend coverage and Chromium traces
|
||||
@@ -64,12 +54,6 @@ jobs:
|
||||
browser-workflows:
|
||||
name: page workflows (${{ matrix.browser }})
|
||||
runs-on: ubuntu-latest
|
||||
# Layout comparisons depend on the fonts each browser falls back to, so browser jobs run in the
|
||||
# same pinned Playwright image as local runs (tag = @playwright/test in tools/vendor/package-lock.json;
|
||||
# tests/vendor/browser-image.test.mjs checks it). Browsers and fonts come from the image.
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble
|
||||
options: --user 1001
|
||||
timeout-minutes: 25
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -77,23 +61,19 @@ jobs:
|
||||
browser: [firefox, webkit]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
# The image's own Python 3.12 and Node 24: setup-python/setup-node install the runner's toolcache
|
||||
# builds, whose entry points name host paths that do not exist in the container. The image has no
|
||||
# ensurepip and the job has no root, so a checksum-pinned pip wheel bootstraps a user-space venv.
|
||||
- name: Python environment
|
||||
env:
|
||||
PIP_WHEEL: https://files.pythonhosted.org/packages/f3/6e/1736e5b4ae2b778ef2f81c47d797de9f891d4d8acb047a24ca37a60294dd/pip-26.2.1-py3-none-any.whl
|
||||
PIP_SHA256: 71138adf1f4ca900cdb7d289c21b7494329f2332b6d85f0e1c42108c0384ed3e
|
||||
run: |
|
||||
venv="$RUNNER_TEMP/venv" wheel="$RUNNER_TEMP/pip-26.2.1-py3-none-any.whl"
|
||||
python3 -m venv --without-pip "$venv"
|
||||
curl -fsSL --retry 3 -o "$wheel" "$PIP_WHEEL"
|
||||
echo "$PIP_SHA256 $wheel" | sha256sum -c -
|
||||
"$venv/bin/python" "$wheel/pip" install --no-index --no-cache-dir "$wheel"
|
||||
"$venv/bin/python" -m pip install --no-cache-dir -r requirements-dev.txt
|
||||
echo "$venv/bin" >> "$GITHUB_PATH"
|
||||
- run: python --version && node --version && node --version | grep -q '^v24\.'
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: pip
|
||||
cache-dependency-path: requirements-dev.txt
|
||||
- run: python -m pip install -r requirements-dev.txt
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: npm
|
||||
cache-dependency-path: tools/vendor/package-lock.json
|
||||
- run: npm ci --prefix tools/vendor --ignore-scripts
|
||||
- run: npx --prefix tools/vendor playwright install --with-deps ${{ matrix.browser }}
|
||||
- name: Page workflows in ${{ matrix.browser }}
|
||||
run: npm run --prefix tools/vendor test:workflows -- --project ${{ matrix.browser }}
|
||||
- name: Upload browser traces
|
||||
|
||||
+3
-124
@@ -42,18 +42,8 @@ npm run --prefix tools/vendor test:workflows -- --project firefox --project webk
|
||||
`PM_TEST_PYTHON` selects the Python executable; `PM_TEST_PORT` selects the local
|
||||
port (default 8765). Set these environment variables when Python is outside PATH
|
||||
or the port is occupied. Test servers are never reused. On unsupported Linux
|
||||
distributions, browser system dependencies may need manual installation. For a
|
||||
quick browser run, use `test:workflows -- --project chromium`.
|
||||
|
||||
CI runs the browser jobs inside the pinned image
|
||||
`mcr.microsoft.com/playwright:v<@playwright/test version>-noble`
|
||||
(`tests/vendor/browser-image.test.mjs` keeps the tag in step with the lockfile).
|
||||
Concept comparisons measure geometry, and geometry follows the fonts a browser
|
||||
falls back to: the concept names Inter, which neither the image nor a stock
|
||||
runner ships. On a plain `ubuntu-latest` runner the fallback is DejaVu Sans,
|
||||
whose wider metrics make the concept's own Tags table overflow a 390px viewport,
|
||||
so the 390px comparisons are only meaningful in the pinned image. To reproduce CI,
|
||||
run the suites in that image.
|
||||
distributions, browser system dependencies may need manual installation; CI uses
|
||||
Ubuntu. For a quick browser run, use `test:workflows -- --project chromium`.
|
||||
|
||||
## Managed real ComfyUI
|
||||
|
||||
@@ -227,55 +217,7 @@ workflow graph and its facts become limited). The serialized
|
||||
response stays under 8 MiB by omitting, in order, the workflow graph, then the prompt
|
||||
graph, then all raw records atomically. Retry does not raise these limits. Ingestion
|
||||
(scan, live monitor, admin import) and queue-time run counting keep the default
|
||||
profile, including its 500-node gate. Only `INSPECTION_PROFILE` reads the node
|
||||
semantics table `web/js/inspection-semantics.json` (at most 1 MiB and eight nesting
|
||||
levels, loaded on first use). With it, `SamplerCustomAdvanced` follows its `guider`
|
||||
link (through Reroutes) to `CFGGuider` (positive and negative) or `BasicGuider`
|
||||
(positive only; the negative is `absent`, "Guider has no negative input"). Any
|
||||
other guider, such as `DualCFGGuider`, is `unsupported-node`. Conditioning is
|
||||
traced through the table's pass-throughs (FluxGuidance, ReferenceLatent, the
|
||||
ConditioningSet* nodes, ControlNet apply nodes, InpaintModelConditioning and
|
||||
WanImageToVideo by output slot). `ConditioningZeroOut` is an explicit empty text
|
||||
with source `zeroed-conditioning`. `easy ifElse` with a literal boolean follows that
|
||||
branch only; with a linked boolean both branches are traced, equal texts resolve and
|
||||
different texts make the sampler `ambiguous` with one candidate per choice (at most
|
||||
32, else `work-limit`). ConditioningCombine/Concat/Average resolve only when every
|
||||
part has the same text, otherwise `unresolved` with `composite` naming the node.
|
||||
Results that depend on a forced choice are never memoized. A `FaceDetailer`
|
||||
(`detailer-stage`) is a sampler stage: the samplers reached through its `image`
|
||||
ancestry are compared with it. Equal texts resolve with the detailer as the sampler;
|
||||
different texts are `ambiguous` with `multistage-differs` and one candidate per
|
||||
stage; an unreadable stage makes the chain `unresolved`. Unanchored, a sampler
|
||||
whose output only feeds detailers (or previews) is not a candidate of its own.
|
||||
A sampler's latent ancestry is walked through every link input of non-sampler
|
||||
nodes within the same visit and depth budgets: with no earlier sampler it is a
|
||||
single stage whatever the origin (VAEEncode, EmptyLatentBatch, EmptyFlux2LatentImage,
|
||||
EmptyHunyuanLatentVideo, SetLatentNoiseMask, InpaintModelConditioning or
|
||||
WanImageToVideo slot 2). An earlier sampler (img2img from a decode, hires fix) stays
|
||||
`unresolved` "Multistage sampler ancestry is ambiguous" until stage texts are
|
||||
compared; an unlisted node with sampler-shaped inputs (positive, negative, guider,
|
||||
latent_image, a pipe, video embeds, custom-sampler parts, or a `model` link together
|
||||
with conditioning, neg_conditioning, samples or latent) is `unsupported-node`
|
||||
naming it, here and in a detailer's image ancestry; a latent that is not a link or
|
||||
reaches its own sampler is `malformed`; running out of budget is `work-limit` or
|
||||
`depth-limit`, never single stage. Each sampler's walk runs once per resolution;
|
||||
selector branch runs share it and other candidates are charged its visit cost.
|
||||
Text encoders: `single` encoders (the Qwen image encoders, `TextEncodeQwenImage21`
|
||||
slot 0 `prompt` and slot 1 `negative_prompt`, `KikoTextEncode`) read one string
|
||||
input unchanged. `parts-equal` encoders (CLIPTextEncodeSDXL, Flux, SD3) resolve when
|
||||
every part is identical or exactly one part is non-empty (exact `""`, whitespace is
|
||||
text); otherwise `composite`. String sources reproduce the pinned KJNodes code from
|
||||
literal settings only: `StringConstant` is its `string`; `StringConstantMultiline`
|
||||
with a literal true `strip_newlines` is `replace("\n", "").strip()`;
|
||||
`JoinStringMulti` keeps `string_1` (even empty), appends `string_2`..`string_N`
|
||||
(`N` = `inputcount`) after the literal delimiter unless missing or exactly `""`, and
|
||||
ignores later inputs. A linked setting is `unsupported-node`; a list output
|
||||
(`return_list` true or linked) and `PromptSearchList` are `runtime-value` ("Text is
|
||||
decided at run time"), never one list item. Unverified text nodes (LoRAfromText,
|
||||
RegexReplace, XYZPrompt, ImpactWildcardProcessor) stay `unsupported-node`. Every
|
||||
intermediate text is length-checked and charged to the scanned-character budget. If
|
||||
the table cannot be loaded, graph inspection is `unresolved` ("Inspection node
|
||||
semantics are unavailable") and ingestion is unaffected, because it never imports the table.
|
||||
profile, including its 500-node gate.
|
||||
|
||||
`tools/inspection_corpus.py --root DIR --out JSONL [--workers N] [--baseline JSONL]
|
||||
[--summary TXT] [--code TREE]` measures a media folder read-only against the #240
|
||||
@@ -693,66 +635,3 @@ Both token functions (`library_edit_token`, `category_impact_token`) share
|
||||
outputs.
|
||||
|
||||
Deploying this backend needs a ComfyUI restart (Python and route changes).
|
||||
|
||||
## Native vocabulary UI (#236)
|
||||
|
||||
`#/tags` ("Tags & categories", page title "Tags") is a native workspace route; it no
|
||||
longer hands off to the legacy tag manager. That page stays reachable from Overview's
|
||||
"Open existing tag manager" card for tag search and sort, the untagged entry and
|
||||
multi-tag AND/OR browsing; bulk prompt actions (delete, tag, category) stay on the
|
||||
legacy admin page. The screen consumes only the `/prompt_manager/vocabulary/*` routes
|
||||
above and adds no route, so PR B needs no ComfyUI restart: a browser refresh is enough.
|
||||
|
||||
Screen: a `+ Create tag` heading action, a Tag vocabulary table (columns Tag, Prompts,
|
||||
Images shown as "—" with hidden "Not available yet", and Actions with Browse and
|
||||
Rename / merge), and a Categories panel whose category rows end with the Manage
|
||||
category row (Review). Lists load 100 rows at a time, append when scrolled into view
|
||||
and keep `Load more tags` / `Load more categories` while more remain. Prompts counts
|
||||
are the Library Browse totals.
|
||||
|
||||
Dialogs (`vocabulary-dialog.mjs`): **Create tag**, **Rename or merge tag** (renaming to
|
||||
an existing name merges; it carries a **Delete tag…** confirmation that names the
|
||||
links removed) and **Manage categories** (Category select, **Preview**, then **Apply**).
|
||||
Names with commas are rejected on these native routes only.
|
||||
|
||||
Guarded states, each exercised against the real server in
|
||||
`tests/browser/workspace-vocabulary.spec.mjs`: stale epoch (`storage-changed`) and
|
||||
`vocabulary-conflict` write nothing and re-read; an unknown outcome keeps the dialog
|
||||
open, re-reads and never resends a delete; an unconfirmed Delete offers Retry that only
|
||||
re-reads the impact; a failed list read shows Retry with "Nothing was changed."; a
|
||||
failed Load more moves keyboard focus to its Retry; a write that lands after its dialog
|
||||
was force-closed or left with Back still re-reads the lists and drops the cached Library
|
||||
shelf; Manage categories reads up to 1,000 categories and says so when capped.
|
||||
**Browse** on a tag or category row opens Library with exactly that Tags or Category
|
||||
filter (the concept puts the name in the search text instead); the Prompts count equals
|
||||
the number of cards Browse shows.
|
||||
|
||||
Test files: `tests/js/workspace-vocabulary-state.test.js` (strict parsing, stale and
|
||||
abort handling, late writes after dispose, sentences), `tests/js/workspace-state.test.js`
|
||||
(route/title), `tests/browser/workspace-vocabulary.spec.mjs` (screen, dialogs, Browse,
|
||||
mobile, the concept geometry/token/control comparison and its mutation check),
|
||||
`tests/browser/vocabulary-canonical-comparison.mjs` (inventory mapping),
|
||||
`tests/browser/vocabulary-visual.spec.mjs` (paired capture artifacts) and the
|
||||
`focused node tag suggestions include a newly created tag` case in
|
||||
`tests/browser/focused-node.spec.mjs`. The fixture is
|
||||
`tests/browser/workspace_vocabulary_fixture.py`. The screen is not host-facing, so no
|
||||
`tests/comfyui` case was added.
|
||||
|
||||
```sh
|
||||
node --test tests/js/workspace-vocabulary-state.test.js tests/js/workspace-state.test.js
|
||||
PM_TEST_PORT=8766 npm run --prefix tools/vendor test:workflows -- \
|
||||
workspace-vocabulary.spec.mjs vocabulary-visual.spec.mjs focused-node.spec.mjs \
|
||||
--project chromium --project touch --project firefox --project webkit
|
||||
```
|
||||
|
||||
Recorded deviations from the concept (maintainer acceptance): no Auto-tag images
|
||||
button and an Images "—" cell until the image-annotation slice; bounded lists with
|
||||
Load more; title "Tags"; Manage categories adds a Category select and Preview before
|
||||
Apply; "Apply" instead of "Apply in prototype"; Delete tag… inside Rename or merge;
|
||||
Browse sets the exact filter; singular "1 positive prompt"; the production list appends
|
||||
fixture-only tags after the concept's sample tags. Paired concept/production captures
|
||||
for both themes and widths are attached as test artifacts; no PNG baseline is added
|
||||
until acceptance. Not compared: hover states and the dialog focus ring (the page-level
|
||||
keyboard ring and link ring offsets are asserted). No coverage floor was lowered;
|
||||
`vocabulary-state.mjs`, `vocabulary.mjs` and `vocabulary-dialog.mjs` have additive
|
||||
floors in `frontend-coverage-baseline.json`.
|
||||
|
||||
+16
-52
@@ -27,22 +27,10 @@ except ImportError:
|
||||
try:
|
||||
from ...database.operations import PromptDatabase
|
||||
from ...database.pair_ingestion import ingest_resolved_pair
|
||||
from ...utils.generation_params import (
|
||||
PLACEHOLDERS,
|
||||
GenerationParamsError,
|
||||
extract_generation_params,
|
||||
parse_js_json,
|
||||
)
|
||||
from ...utils.prompt_pair_resolver import resolve_prompt_pair
|
||||
except ImportError:
|
||||
from database.operations import PromptDatabase
|
||||
from database.pair_ingestion import ingest_resolved_pair
|
||||
from utils.generation_params import (
|
||||
PLACEHOLDERS,
|
||||
GenerationParamsError,
|
||||
extract_generation_params,
|
||||
parse_js_json,
|
||||
)
|
||||
from utils.prompt_pair_resolver import resolve_prompt_pair
|
||||
|
||||
|
||||
@@ -1523,21 +1511,6 @@ class AdminRoutesMixin:
|
||||
counts["pair_links_added"] += outcome.get("links_added", 0)
|
||||
return counts
|
||||
|
||||
@staticmethod
|
||||
def _graph_prompt_text(metadata):
|
||||
"""The positive prompt the browser parser traces from the graphs, or None."""
|
||||
records = {}
|
||||
for key in ("prompt", "workflow"):
|
||||
value = metadata.get(key)
|
||||
records[key] = parse_js_json(value) if isinstance(value, str) else value
|
||||
try:
|
||||
positive = extract_generation_params(records)["positivePrompt"]
|
||||
except GenerationParamsError:
|
||||
return None
|
||||
if not isinstance(positive, str) or positive == PLACEHOLDERS["positivePrompt"]:
|
||||
return None
|
||||
return positive.strip() or None
|
||||
|
||||
def _ingest_scanned_file(self, media_file, metadata, detailed=False):
|
||||
"""Enrich exact pairs; retain legacy positive-only import for absent pairs."""
|
||||
if not metadata:
|
||||
@@ -1558,32 +1531,23 @@ class AdminRoutesMixin:
|
||||
self.db, str(media_file), canonical, resolution
|
||||
)
|
||||
return result if detailed else result["status"]
|
||||
if canonical.get("prompt") and resolution["state"] in (
|
||||
"ambiguous",
|
||||
"unresolved",
|
||||
):
|
||||
# The resolver could not decide: link the prompt the inspector and the
|
||||
# legacy pages show for this file (the browser parser, in Python).
|
||||
# Graphs without a recognized sampler keep the legacy extractor below.
|
||||
prompt_text = self._graph_prompt_text(metadata)
|
||||
if not prompt_text:
|
||||
return resolution["state"]
|
||||
elif resolution["state"] == "ambiguous":
|
||||
if resolution["state"] == "ambiguous":
|
||||
return "ambiguous"
|
||||
elif resolution["state"] == "unresolved" and not isinstance(
|
||||
canonical.get("parameters"), str
|
||||
):
|
||||
return resolution["state"]
|
||||
else:
|
||||
parsed = self._parse_comfyui_prompt(metadata)
|
||||
if not (parsed.get("prompt") or parsed.get("parameters")):
|
||||
return None
|
||||
prompt_text = self._extract_readable_prompt(parsed)
|
||||
if prompt_text is not None and not isinstance(prompt_text, str):
|
||||
prompt_text = str(prompt_text)
|
||||
if not (prompt_text and prompt_text.strip()):
|
||||
return "found"
|
||||
prompt_text = prompt_text.strip()
|
||||
if resolution["state"] == "unresolved":
|
||||
if canonical.get("prompt") or not isinstance(
|
||||
canonical.get("parameters"), str
|
||||
):
|
||||
return resolution["state"]
|
||||
parsed = self._parse_comfyui_prompt(metadata)
|
||||
if not (parsed.get("prompt") or parsed.get("parameters")):
|
||||
return None
|
||||
|
||||
prompt_text = self._extract_readable_prompt(parsed)
|
||||
if prompt_text is not None and not isinstance(prompt_text, str):
|
||||
prompt_text = str(prompt_text)
|
||||
if not (prompt_text and prompt_text.strip()):
|
||||
return "found"
|
||||
prompt_text = prompt_text.strip()
|
||||
|
||||
prompt_hash = generate_prompt_hash(prompt_text)
|
||||
existing = self.db.get_prompt_by_hash(prompt_hash)
|
||||
|
||||
@@ -9,11 +9,6 @@ try:
|
||||
MetadataReadError,
|
||||
inspect_prompt_evidence,
|
||||
read_inspection_file,
|
||||
unreadable_evidence,
|
||||
)
|
||||
from ...utils.prompt_pair_resolver import (
|
||||
INSPECTION_PROFILE,
|
||||
PREVIEW_INSPECTION_PROFILE,
|
||||
)
|
||||
from ...utils.video_metadata import is_video_path
|
||||
except ImportError:
|
||||
@@ -21,11 +16,6 @@ except ImportError:
|
||||
MetadataReadError,
|
||||
inspect_prompt_evidence,
|
||||
read_inspection_file,
|
||||
unreadable_evidence,
|
||||
)
|
||||
from utils.prompt_pair_resolver import (
|
||||
INSPECTION_PROFILE,
|
||||
PREVIEW_INSPECTION_PROFILE,
|
||||
)
|
||||
from utils.video_metadata import is_video_path
|
||||
|
||||
@@ -70,28 +60,6 @@ def _current_file_facts(path: Path) -> os.stat_result:
|
||||
return observed
|
||||
|
||||
|
||||
def _in_host_temp(path: Path) -> bool:
|
||||
"""Whether a file lies in ComfyUI's temp folder, where previews are written.
|
||||
|
||||
An unknown temp folder reads as no: the file is then inspected as saved
|
||||
output.
|
||||
"""
|
||||
try:
|
||||
import folder_paths
|
||||
|
||||
temp = folder_paths.get_temp_directory()
|
||||
except (ImportError, AttributeError, OSError):
|
||||
return False
|
||||
if type(temp) is not str or not temp:
|
||||
return False
|
||||
try:
|
||||
folder = os.path.normcase(os.path.realpath(temp))
|
||||
target = os.path.normcase(os.path.realpath(path))
|
||||
return os.path.commonpath([folder, target]) == folder
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def inspect_current_file(
|
||||
path: Path, *, source_label: str, saved: dict | None, library: dict
|
||||
) -> dict:
|
||||
@@ -105,18 +73,24 @@ def inspect_current_file(
|
||||
if any(getattr(before, key) != getattr(after, key) for key in fields):
|
||||
raise CurrentFileChanged
|
||||
if inspection is None:
|
||||
evidence = unreadable_evidence()
|
||||
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",
|
||||
"reason_code": None,
|
||||
"node": None,
|
||||
},
|
||||
}
|
||||
else:
|
||||
role_saved = (
|
||||
None if saved and saved.get("association_status") == "ambiguous" else saved
|
||||
)
|
||||
# A preview may be the output of a stage that reaches only previews.
|
||||
profile = (
|
||||
PREVIEW_INSPECTION_PROFILE if _in_host_temp(path) else INSPECTION_PROFILE
|
||||
)
|
||||
evidence = inspect_prompt_evidence(
|
||||
inspection.metadata, role_saved, profile=profile
|
||||
)
|
||||
evidence = inspect_prompt_evidence(inspection.metadata, role_saved)
|
||||
return {
|
||||
"file": {
|
||||
"observation": "current-file",
|
||||
|
||||
@@ -130,19 +130,6 @@ test(`${variant} completes saved tags with keyboard and pointer and appends comm
|
||||
await expect(tags).toHaveValue('A brand new tag');
|
||||
});
|
||||
|
||||
test(`${variant} focused node tag suggestions include a newly created tag`, async ({page,request}) => {
|
||||
const name=`created_${variant.toLowerCase()}_vocabulary`;
|
||||
const listing=await request.get('/prompt_manager/vocabulary/tags?limit=1');await expect(listing).toBeOK();
|
||||
const created=await request.post('/prompt_manager/vocabulary/tags',{data:{expected_epoch:(await listing.json()).library_epoch,name}});
|
||||
await expect(created).toBeOK();expect((await created.json()).tag).toMatchObject({name,prompt_count:0});
|
||||
await page.getByText('Metadata · category / tags',{exact:true}).click();
|
||||
const tags=page.getByLabel('Tags',{exact:true});
|
||||
await tags.fill(`created_${variant.toLowerCase()}`);
|
||||
await expect(page.getByRole('option',{name,exact:true})).toBeVisible();
|
||||
await tags.press('ArrowDown');await tags.press('Enter');
|
||||
await expect(tags).toHaveValue(`${name}, `);
|
||||
});
|
||||
|
||||
test(`${variant} metadata autocomplete ignores stale responses, recovers from errors and cleans up`, async ({page}) => {
|
||||
let release;
|
||||
let mode='delayed';
|
||||
|
||||
@@ -26,10 +26,7 @@ test('inspection facts survive actual API to browser transport', async ({ page,
|
||||
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);
|
||||
// Differing branches show what the legacy parser shows: the first sampler's seed.
|
||||
expect(Object.values(result.ambiguous.generation).map(fact => [fact.status, fact.value])).toEqual([
|
||||
['known', '9007199254740993'], ['known', '28'], ['known', '6.5'], ['known', 'dpmpp_2m'], ['known', 'karras'], ['known', 'fixture-model.safetensors']]);
|
||||
expect(Object.values(result.ambiguous.generation).every(fact => fact.sampler_id === null)).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);
|
||||
|
||||
@@ -232,17 +232,6 @@ test('unverified saved associations explain uncertainty and cannot be sent',asyn
|
||||
await expect(details(page).getByRole('button',{name:/^Send /})).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('unresolved roles name the blocking node as text and offer no prompt actions',async({page})=>{
|
||||
const line='Prompt uses a node the inspector does not support (<img src=x onerror="window.injected=1">).';
|
||||
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:null,message:line},negative:{status:'unresolved',text:null,source:null,message:line},
|
||||
}}}));
|
||||
await page.goto(workspace);await select(page,'0:uncertain.png');
|
||||
for (const name of ['Positive','Negative']) await expect(role(details(page),name)).toContainText(line);
|
||||
await expect(details(page).locator('img[onerror]')).toHaveCount(0);
|
||||
expect(await page.evaluate(()=>window.injected)).toBeUndefined();
|
||||
await expect(details(page).getByRole('button',{name:/^(Send|Copy) /})).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('prompt role headings contain compact real actions and honest source evidence',async({page})=>{
|
||||
await page.goto(workspace);await select(page,'0:shared/same.png');
|
||||
|
||||
@@ -24,7 +24,6 @@ from tests.browser.workspace_history_fixture import (
|
||||
from tests.browser.workspace_library_editor_fixture import (
|
||||
register_library_editor_commands,
|
||||
)
|
||||
from tests.browser.workspace_vocabulary_fixture import register_vocabulary_commands
|
||||
|
||||
CANONICAL_ROOT = Path(__file__).resolve().parents[2]
|
||||
CANONICAL_ASSETS = {
|
||||
@@ -105,7 +104,6 @@ def create_app(root: Path) -> web.Application:
|
||||
api.add_routes(routes)
|
||||
history_commands = register_workspace_history_commands(routes, api, root)
|
||||
library_editor_commands = register_library_editor_commands(routes, api, root)
|
||||
register_vocabulary_commands(routes, api, root)
|
||||
|
||||
async def canonical_asset(request: web.Request) -> web.Response:
|
||||
filename, content_type = CANONICAL_ASSETS[request.path]
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
// Concept-vs-production inventory for the native Tags & categories screen (concept renderTags, app.js:719-729).
|
||||
// pageInventoryOf runs in the page; dialogs reuse the editor's inventoryOf. The concept is never rewritten:
|
||||
// mapVocabularyConcept / mapVocabularyDialog apply only the recorded deviations, each named with its ruling.
|
||||
export {inventoryOf, diffInventory} from './library-editor-canonical-comparison.mjs';
|
||||
|
||||
export function pageInventoryOf(main, production) {
|
||||
const css=(node,...keys)=>{const style=getComputedStyle(node);return Object.fromEntries(keys.map(key=>[key,style[key]]));};
|
||||
const px=value=>Math.round(value);
|
||||
const visible=node=>node.getClientRects().length>0;
|
||||
const font=node=>css(node,'fontFamily','fontSize','fontWeight','lineHeight','letterSpacing','color','marginTop','marginBottom');
|
||||
const edges=node=>css(node,'paddingTop','paddingRight','paddingBottom','paddingLeft','borderTopWidth','borderTopStyle','borderTopColor',
|
||||
'borderBottomWidth','borderBottomStyle','borderBottomColor','borderTopLeftRadius','backgroundColor','color');
|
||||
const buttonOf=node=>({text:node.textContent.trim(),type:node.type,primary:node.classList.contains('ca-primary'),danger:node.classList.contains('danger'),
|
||||
...css(node,'minHeight','fontSize','fontWeight','color','backgroundColor','borderTopColor','borderTopLeftRadius','paddingLeft','paddingRight'),
|
||||
height:px(node.getBoundingClientRect().height)});
|
||||
// Visible text of a cell, without visually hidden helper text (recorded separately).
|
||||
const shownText=node=>[...node.childNodes].filter(child=>!(child.classList?.contains('ca-visually-hidden'))).map(child=>child.textContent).join('').trim();
|
||||
|
||||
// Rendered line count of a cell's text, so a wrapped short name is reported even when the row height is unchanged.
|
||||
const lines=node=>{const range=document.createRange();range.selectNodeContents(node);return new Set([...range.getClientRects()].map(rect=>px(rect.top))).size;};
|
||||
const heading=main.querySelector(production?':scope > .workspace-heading':':scope > .heading');
|
||||
const copy=production?heading.querySelector('.workspace-heading-copy'):heading.firstElementChild;
|
||||
const h1=copy.querySelector('h1'),subtitle=copy.querySelector('p'),actions=heading.querySelector('.heading-actions');
|
||||
const grid=main.querySelector(':scope > .two-col'),gridBox=grid.getBoundingClientRect();
|
||||
const surfaces=[...grid.querySelectorAll(':scope > section.surface')];
|
||||
const [tagSurface,categorySurface]=surfaces;
|
||||
const wrap=tagSurface.querySelector('.table-wrap'),table=wrap.querySelector('table'),caption=table.querySelector('caption');
|
||||
const rows=[...table.querySelectorAll('tbody tr')];
|
||||
const firstCell=rows[0]?.querySelector('td');
|
||||
const operations=[...categorySurface.querySelectorAll('.operation')].filter(visible);
|
||||
return {
|
||||
heading:{...css(heading,'display','flexDirection','justifyContent','alignItems','columnGap','rowGap','marginBottom'),
|
||||
h1:{text:h1.textContent,tabIndex:h1.tabIndex,...font(h1)},
|
||||
subtitle:{text:subtitle.textContent,...font(subtitle)},
|
||||
actions:{...css(actions,'display','columnGap','flexWrap'),fullWidth:px(actions.getBoundingClientRect().width)===px(heading.getBoundingClientRect().width),
|
||||
buttons:[...actions.querySelectorAll('button')].map(buttonOf)},
|
||||
height:px(heading.getBoundingClientRect().height)},
|
||||
twoCol:{columns:getComputedStyle(grid).gridTemplateColumns.split(' ').length,...css(grid,'display','columnGap','rowGap'),
|
||||
stacked:surfaces.length>1&&surfaces[1].getBoundingClientRect().top>=surfaces[0].getBoundingClientRect().bottom,
|
||||
width:px(gridBox.width)},
|
||||
surfaces:surfaces.map(surface=>({...edges(surface),...css(surface,'marginTop'),width:px(surface.getBoundingClientRect().width),
|
||||
h2:{text:surface.querySelector('h2').textContent,...font(surface.querySelector('h2')),offset:px(surface.querySelector('h2').getBoundingClientRect().top-surface.getBoundingClientRect().top)},
|
||||
paragraphs:[...surface.querySelectorAll(':scope > p')].filter(visible).map(node=>({text:node.textContent,...font(node),
|
||||
offset:px(node.getBoundingClientRect().top-surface.getBoundingClientRect().top)}))})),
|
||||
table:{wrap:{...css(wrap,'overflowX','marginLeft','marginRight'),offset:px(wrap.getBoundingClientRect().top-tagSurface.getBoundingClientRect().top)},
|
||||
...css(table,'borderCollapse','fontSize','color'),fullWidth:px(table.getBoundingClientRect().width)>=px(wrap.clientWidth),
|
||||
caption:{text:caption.textContent,...font(caption),...css(caption,'textAlign','captionSide')},
|
||||
headers:[...table.querySelectorAll('thead th')].map(cell=>({text:cell.textContent,scope:cell.getAttribute('scope'),...font(cell),...edges(cell),...css(cell,'textAlign','verticalAlign')})),
|
||||
firstCell:firstCell?{...font(firstCell),...edges(firstCell),...css(firstCell,'textAlign','verticalAlign')}:null,
|
||||
firstRowHeight:rows[0]?px(rows[0].getBoundingClientRect().height):null,
|
||||
rowActions:rows[0]?{...css(rows[0].querySelector('.row-actions'),'display','justifyContent','columnGap','flexWrap','alignItems'),
|
||||
buttons:[...rows[0].querySelectorAll('.row-actions button')].map(buttonOf)}:null,
|
||||
rows:rows.map(row=>{const cells=[...row.querySelectorAll('td')];return {cells:cells.length,height:px(row.getBoundingClientRect().height),tagLines:lines(cells[0]),tag:cells[0].textContent,prompts:cells[1].textContent,
|
||||
images:shownText(cells[2]),imagesHidden:cells[2].querySelector('.ca-visually-hidden')?.textContent??null,
|
||||
actions:[...cells[3].querySelectorAll('.row-actions button')].map(node=>node.textContent.trim())};})},
|
||||
operations:operations.map(row=>{const h3=row.querySelector('h3'),p=row.querySelector('p'),rowActions=row.querySelector('.row-actions');return {
|
||||
...css(row,'display','flexDirection','justifyContent','alignItems','columnGap','paddingTop','paddingBottom','borderTopWidth','borderTopStyle','borderTopColor'),
|
||||
height:px(row.getBoundingClientRect().height),h3:{text:h3.textContent,...font(h3)},p:{text:p.textContent,...font(p)},
|
||||
actions:{...css(rowActions,'flexShrink','flexWrap','columnGap'),buttons:[...rowActions.querySelectorAll('button')].map(buttonOf)}};}),
|
||||
noPageScroll:document.documentElement.scrollWidth<=innerWidth+1,
|
||||
};
|
||||
}
|
||||
|
||||
// Recorded deviations only (plan "Mock inventory" + decisions D1-D15 + ledger rulings). Everything else must be equal.
|
||||
export function mapVocabularyConcept(reference) {
|
||||
return {...reference,
|
||||
heading:{...reference.heading,actions:{...reference.heading.actions,
|
||||
// D2: Auto-tag images is omitted until the image-annotation slice owns image tags.
|
||||
buttons:reference.heading.actions.buttons.filter(item=>item.text!=='Auto-tag images')}},
|
||||
table:{...reference.table,rows:reference.table.rows.map(row=>({...row,
|
||||
// D3: no image-tag owner yet, so the Images cell shows "—" with a visually hidden "Not available yet".
|
||||
images:'—',imagesHidden:'Not available yet'}))},
|
||||
operations:reference.operations.map(row=>({...row,p:{...row.p,
|
||||
// Ledger ruling: "1 positive prompts" -> singular, consistent with the shared plural helper (#238/#243).
|
||||
text:row.p.text.replace(/^1 positive prompts$/,'1 positive prompt')}})),
|
||||
};
|
||||
}
|
||||
|
||||
// Concept dialogs are simpleInput (app.js:1138-1143). donors are concept inventories for controls the concept
|
||||
// shows elsewhere: a `.field select` and a `p.small` (Filters dialog, app.js:1351) and a danger dialog action
|
||||
// (Archive… in the editor, app.js:976), so added controls are held to concept tokens, never to production values.
|
||||
export function mapVocabularyDialog(mode,reference,{filters,editor,categories}) {
|
||||
const fields=reference.fields.map(item=>({...item,
|
||||
// Inventory rows 27-29: the name is `required` (blank Apply is blocked natively).
|
||||
required:true}));
|
||||
const buttons=reference.actions.buttons.map(item=>item.text==='Apply in prototype'
|
||||
// Inventory row 26: "Apply in prototype" is Apply; it submits the form so `required` is enforced natively.
|
||||
?{...item,text:'Apply',type:'submit'}:item);
|
||||
if(mode==='rename') {
|
||||
// D14 (and ledger ruling "Delete inside Rename/merge"): danger `Delete tag…` first in the actions, concept danger tokens.
|
||||
const danger=editor.actions.buttons.find(item=>item.danger);
|
||||
return {...reference,fields,actions:{...reference.actions,buttons:[{...danger,text:'Delete tag…',type:'button'},...buttons]}};
|
||||
}
|
||||
if(mode==='manage') {
|
||||
const select=filters.fields.find(item=>item.tag==='select');
|
||||
return {...reference,
|
||||
fields:[
|
||||
// D9: a `Category` source select first, listing the stored categories (Manage loads every page, ruling: up to 1,000).
|
||||
{...select,label:'Category',required:true,options:categories.map(value=>[value,value])},
|
||||
// D9 / Task 8: the proposed name offers a category datalist (no trailing commas: one value).
|
||||
...fields.map(item=>({...item,listed:true,commaOptions:false}))],
|
||||
// D9: the preview sentence is a `p.small`, empty and hidden until Preview.
|
||||
small:[{...filters.small[0],text:''}],
|
||||
// D9: the primary is `Preview` until a preview is shown, then `Apply`.
|
||||
actions:{...reference.actions,buttons:buttons.map(item=>item.text==='Apply'?{...item,text:'Preview'}:item)}};
|
||||
}
|
||||
return {...reference,fields,actions:{...reference.actions,buttons}};
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||||
|
||||
// Artifact-only captures of Tags & categories states that have no concept counterpart: the Manage categories
|
||||
// preview, loading and 1,000-category cap notes, tag conflict and restore notes, the Delete tag confirmation (D14),
|
||||
// empty lists (D12), a failed read with Retry, and Load more (D5). The page and the three concept dialogs are captured
|
||||
// as concept/production pairs by workspace-vocabulary.spec.mjs. There are deliberately no pixel assertions or
|
||||
// baselines: the maintainer accepts these captures visually before any baseline is added.
|
||||
test.use({locale:'en-US',timezoneId:'UTC',reducedMotion:'reduce'});
|
||||
const route='/prompt_manager/workspace#/tags';
|
||||
const main=page=>page.locator('#workspace-main');
|
||||
const rows=page=>main(page).locator('tbody tr');
|
||||
const dialog=page=>page.locator('dialog.vocabulary-dialog');
|
||||
const button=(scope,name)=>scope.getByRole('button',{name,exact:true});
|
||||
const note=page=>dialog(page).locator('.warning-note');
|
||||
const tagSurface='#workspace-main .two-col > section.surface:first-child';
|
||||
const categoryOperation=(page,name)=>main(page).locator('.operation').filter({has:page.getByRole('heading',{level:3,name,exact:true})});
|
||||
|
||||
async function seed(request,data={}) {const response=await request.post('/__test__/vocabulary/reset',{data});await expect(response).toBeOK();return response.json();}
|
||||
async function open(page,theme,count) {
|
||||
await page.goto('about:blank');await page.goto(route);
|
||||
await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption(theme);
|
||||
if(count!==undefined)await expect(rows(page)).toHaveCount(count);
|
||||
}
|
||||
async function openDialog(page,trigger) {await trigger.click();await expect(dialog(page)).toBeVisible();return dialog(page);}
|
||||
const empty=(manifest,kind)=>({success:true,library_epoch:manifest.epoch,total:0,has_more:false,next_cursor:null,[kind]:[]});
|
||||
|
||||
for(const [width,height] of [[1600,1100],[390,844]])for(const theme of ['dark','light'])test(`vocabulary state captures for maintainer acceptance ${width} ${theme}`,async({page,request},testInfo)=>{
|
||||
await page.setViewportSize({width,height});
|
||||
const shot=(name,selector='dialog.vocabulary-dialog')=>captureCanonicalState(page,testInfo,`production-${name}-${width}-${theme}`,selector);
|
||||
let manifest=await seed(request);await open(page,theme,manifest.tags.length);
|
||||
|
||||
// Manage categories: preview shown (D9).
|
||||
let box=await openDialog(page,categoryOperation(page,'Manage category').getByRole('button',{name:'Review',exact:true}));
|
||||
await expect(button(box,'Preview')).toBeEnabled();
|
||||
await box.getByLabel('Proposed new category name',{exact:true}).fill('Scenery');await button(box,'Preview').click();
|
||||
await expect(box.locator('p.small')).toHaveText('“Landscapes” is used by 3 positive prompts. Apply renames it to “Scenery”.');
|
||||
await shot('manage-preview');
|
||||
await page.keyboard.press('Escape');await expect(box).toHaveCount(0);
|
||||
|
||||
// Rename or merge: conflict note, then restore note (the server answers 409; nothing is written).
|
||||
for(const [name,code,text] of [['rename-conflict','vocabulary-conflict',/This tag changed since this dialog opened/],
|
||||
['rename-restored','storage-changed',/Prompt storage was restored since this dialog opened/]]) {
|
||||
await page.route('**/prompt_manager/vocabulary/tags/*',item=>item.request().method()==='PATCH'
|
||||
?item.fulfill({status:409,json:{success:false,code,error:'Changed.',library_epoch:manifest.epoch}}):item.continue(),{times:1});
|
||||
box=await openDialog(page,rows(page).first().getByRole('button',{name:'Rename / merge',exact:true}));
|
||||
await box.getByLabel('New name; existing names merge',{exact:true}).fill('cinematic_light');await button(box,'Apply').click();
|
||||
await expect(note(page)).toHaveText(text);await shot(name);
|
||||
await page.keyboard.press('Escape');await expect(box).toHaveCount(0);
|
||||
await expect(rows(page)).toHaveCount(manifest.tags.length);
|
||||
}
|
||||
|
||||
// Delete tag confirmation (D14) with the real impact sentence.
|
||||
box=await openDialog(page,rows(page).first().getByRole('button',{name:'Rename / merge',exact:true}));
|
||||
await button(box,'Delete tag…').click();await expect(button(box,'Delete')).toBeEnabled();
|
||||
await expect(box.locator('p.small')).toBeVisible();await shot('delete-confirmation');
|
||||
await page.keyboard.press('Escape');await expect(box).toHaveCount(0);
|
||||
|
||||
// Manage categories while further category pages load, and at the 1,000-category cap (ledger ruling).
|
||||
manifest=await seed(request,{extra_categories:150});
|
||||
let release;const held=new Promise(resolve=>{release=resolve;});
|
||||
await page.route('**/prompt_manager/vocabulary/categories?*',async item=>{if(new URL(item.request().url()).searchParams.has('cursor'))await held;await item.continue();});
|
||||
await open(page,theme,manifest.tags.length);
|
||||
box=await openDialog(page,categoryOperation(page,'Manage category').getByRole('button',{name:'Review',exact:true}));
|
||||
await expect(note(page)).toHaveText('Loading categories…');await shot('manage-loading');
|
||||
release();await expect(note(page)).toBeHidden();await page.keyboard.press('Escape');await expect(box).toHaveCount(0);
|
||||
await page.unroute('**/prompt_manager/vocabulary/categories?*');
|
||||
await page.route('**/prompt_manager/vocabulary/categories?*',item=>{
|
||||
const cursor=new URL(item.request().url()).searchParams.get('cursor');const index=cursor?Number(cursor.slice(1)):0;
|
||||
const categories=Array.from({length:100},(_,offset)=>({value:`many_${String(index*100+offset).padStart(5,'0')}`,prompt_count:1}));
|
||||
return item.fulfill({json:{success:true,library_epoch:manifest.epoch,total:5000,has_more:true,next_cursor:`c${index+1}`,categories}});
|
||||
});
|
||||
await open(page,theme);await expect(categoryOperation(page,'many_00000')).toHaveCount(1);
|
||||
const review=categoryOperation(page,'Manage category').getByRole('button',{name:'Review',exact:true});
|
||||
// Every mocked page has more, so scrolling to Review would keep appending; click it in place.
|
||||
await review.evaluate(node=>node.click());await expect(dialog(page)).toBeVisible();
|
||||
await expect(note(page)).toHaveText('Only the first 1,000 categories are listed.');await shot('manage-capped');
|
||||
await page.keyboard.press('Escape');await page.unroute('**/prompt_manager/vocabulary/categories?*');
|
||||
|
||||
// Empty lists (D12).
|
||||
await page.route('**/prompt_manager/vocabulary/tags?*',item=>item.fulfill({json:empty(manifest,'tags')}));
|
||||
await page.route('**/prompt_manager/vocabulary/categories?*',item=>item.fulfill({json:empty(manifest,'categories')}));
|
||||
await open(page,theme);
|
||||
await expect(main(page).getByText('No tags yet.',{exact:true})).toBeVisible();await expect(main(page).getByText('No categories yet.',{exact:true})).toBeVisible();
|
||||
await shot('empty','#workspace-main');
|
||||
await page.unroute('**/prompt_manager/vocabulary/tags?*');await page.unroute('**/prompt_manager/vocabulary/categories?*');
|
||||
|
||||
// A failed read shows the inline error with Retry and no zero counts (D12).
|
||||
await page.route('**/prompt_manager/vocabulary/tags?*',item=>item.fulfill({status:500,json:{success:false,error:'Database is unavailable'}}));
|
||||
await open(page,theme);await expect(main(page).getByRole('alert')).toHaveText('Tags could not be loaded. Nothing was changed.');
|
||||
await shot('read-error',tagSurface);
|
||||
await page.unroute('**/prompt_manager/vocabulary/tags?*');
|
||||
|
||||
// Load more while more tags remain (D5): the end of the first page.
|
||||
manifest=await seed(request,{extra_tags:211});await open(page,theme,100);
|
||||
const more=button(main(page),'Load more tags');await expect(more).toBeVisible();
|
||||
// A full-page clip of the end of the first page: scrolling there would append the next page before the capture.
|
||||
const end=await more.evaluate(node=>{const box=node.closest('.surface').getBoundingClientRect();return box.bottom+scrollY;});
|
||||
const path=testInfo.outputPath(`production-load-more-${width}-${theme}.png`);
|
||||
await page.screenshot({path,fullPage:true,animations:'disabled',clip:{x:0,y:Math.max(0,end-420),width,height:440}});
|
||||
await testInfo.attach(`production-load-more-${width}-${theme}`,{path,contentType:'image/png'});
|
||||
await expect(rows(page)).toHaveCount(100);
|
||||
});
|
||||
@@ -458,46 +458,6 @@ test('frozen Effective pair uses the shared acknowledged sender and Escape close
|
||||
} finally {await host.evaluate(()=>window.historyHost.dispose());await host.close();}
|
||||
});
|
||||
|
||||
test('the inline current file names its prompts apart from the frozen run fields',async({page})=>{
|
||||
// The current file resolves its own prompts, as a real host's saved PNG does, so the dialog holds two prompt texts per role.
|
||||
await page.route(url=>url.pathname.endsWith('/inspection'),async route=>{const response=await route.fetch();const body=await response.json();
|
||||
body.prompts={positive:{status:'resolved',text:'current file positive',source:'graph-inferred'},negative:{status:'resolved',text:'current file negative',source:'graph-inferred'}};
|
||||
await route.fulfill({response,json:body});});
|
||||
await open(page,page.fixture.detail_id);await marker(page,page.fixture.runs.shared).click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
|
||||
await dialog(page).getByRole('button',{name:'Raw',exact:true}).click();
|
||||
const frozen=dialog(page).getByRole('region',{name:'Frozen run prompt texts',exact:true});
|
||||
const before=await exact(frozen.getByRole('region',{name:'Positive prompt',exact:true}).locator('pre'));
|
||||
await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();
|
||||
await expect(page.getByRole('dialog',{name:'Image preview',exact:true})).toBeVisible();await page.keyboard.press('Escape');await expect(dialog(page)).toBeVisible();
|
||||
const current=dialog(page).getByRole('region',{name:'Current reported file',exact:true});await expect(current).toBeVisible();
|
||||
await expect(current.getByRole('region',{name:'Current file positive prompt',exact:true}).locator('pre')).toHaveText('current file positive');
|
||||
await expect(current.getByRole('region',{name:'Current file negative prompt',exact:true}).locator('pre')).toHaveText('current file negative');
|
||||
await expect(current.getByRole('button',{name:'Copy current file positive prompt',exact:true})).toBeVisible();
|
||||
for(const role of ['Positive','Negative'])await expect(dialog(page).getByRole('region',{name:`${role} prompt`,exact:true})).toHaveCount(1);
|
||||
await expect(current.getByRole('region',{name:/^(Positive|Negative) prompt$/})).toHaveCount(0);
|
||||
expect(await exact(dialog(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('pre'))).toBe(before);
|
||||
});
|
||||
|
||||
test('the recorded run dialog with a resolved current file has no duplicate button or region names',async({page})=>{
|
||||
await page.route(url=>url.pathname.endsWith('/inspection'),async route=>{const response=await route.fetch();const body=await response.json();
|
||||
body.prompts={positive:{status:'resolved',text:'current file positive',source:'graph-inferred'},negative:{status:'resolved',text:'current file negative',source:'graph-inferred'}};
|
||||
await route.fulfill({response,json:body});});
|
||||
await open(page,page.fixture.detail_id);await marker(page,page.fixture.runs.shared).click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
|
||||
await dialog(page).getByRole('button',{name:'Raw',exact:true}).click();
|
||||
await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();
|
||||
await expect(page.getByRole('dialog',{name:'Image preview',exact:true})).toBeVisible();await page.keyboard.press('Escape');
|
||||
await expect(dialog(page).getByRole('region',{name:'Current reported file',exact:true})).toBeVisible();
|
||||
await expect(dialog(page).getByRole('region',{name:'Current file positive prompt',exact:true})).toBeVisible();
|
||||
// Receipt rows repeat per-row actions by design; every other control and region in the dialog must be uniquely named.
|
||||
const snapshot=await dialog(page).ariaSnapshot();const counts=new Map();
|
||||
for(const line of snapshot.split('\n')){const match=/^\s*- (button|region|link) "((?:[^"\\]|\\.)*)"/.exec(line);if(!match)continue;
|
||||
const key=`${match[1]} ${match[2]}`;counts.set(key,(counts.get(key)||0)+1);}
|
||||
expect(counts.size).toBeGreaterThan(10);
|
||||
const receiptNames=await dialog(page).locator('[data-receipt]').first().evaluate(row=>[...row.querySelectorAll('button,a,[role="region"]')].map(node=>node.getAttribute('aria-label')||node.textContent.trim()));
|
||||
const repeated=[...counts].filter(([key,n])=>n>1&&!receiptNames.some(name=>key.endsWith(` ${name}`)));
|
||||
expect(repeated).toEqual([]);
|
||||
});
|
||||
|
||||
test('pagehide tears down dialogs and persisted reentry revalidates before enabling recorded actions',async({page})=>{
|
||||
await open(page,page.fixture.detail_id);await marker(page,page.fixture.runs.shared).click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
|
||||
await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();await expect(page.getByRole('dialog',{name:'Image preview',exact:true})).toBeVisible();
|
||||
|
||||
@@ -1,793 +0,0 @@
|
||||
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
|
||||
import {writeFile} from 'node:fs/promises';
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||||
import {pageInventoryOf,inventoryOf,diffInventory,mapVocabularyConcept,mapVocabularyDialog} from './vocabulary-canonical-comparison.mjs';
|
||||
|
||||
const route='/prompt_manager/workspace#/tags';
|
||||
// Prompts per concept tag (app.js:71-165): first appearance order, Library Browse counts.
|
||||
const CONCEPT_COUNTS=[['cinematic',2],['blue_hour',1],['architecture',1],['minimal',2],['warm_light',1],['editorial',1],['forest',1],['mist',1],
|
||||
['peaceful',2],['neon',1],['night',1],['space',1],['concept_art',1],['coast',1],['soft_light',1]];
|
||||
const CATEGORIES=[['Landscapes',3],['Architecture',1],['Environments',1],['Illustration',1]];
|
||||
const main=page=>page.locator('#workspace-main');
|
||||
const surfaces=page=>main(page).locator('.two-col > section.surface');
|
||||
const table=page=>main(page).getByRole('table',{name:'Reusable tags',exact:true});
|
||||
const rows=page=>table(page).locator('tbody tr');
|
||||
const tagRow=(page,name)=>rows(page).filter({has:page.locator('td:first-child').getByText(name,{exact:true})});
|
||||
const categoryRow=(page,name)=>surfaces(page).nth(1).locator('.operation').filter({has:page.getByRole('heading',{level:3,name,exact:true})});
|
||||
const loadMore=page=>main(page).getByRole('button',{name:'Load more tags',exact:true});
|
||||
|
||||
async function seed(request,data={}) {
|
||||
const response=await request.post('/__test__/vocabulary/reset',{data});await expect(response).toBeOK();return response.json();
|
||||
}
|
||||
async function expectLibrary(page,{tags='',category='',count}) {
|
||||
await expect(page).toHaveURL(/#\/library$/);
|
||||
await expect(main(page).getByRole('heading',{level:1})).toHaveText('Prompt library');
|
||||
await expect(main(page).getByRole('heading',{level:1})).toBeFocused();
|
||||
await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('');
|
||||
await expect(page.getByRole('textbox',{name:'Tags',exact:true})).toHaveValue(tags);
|
||||
const select=page.getByRole('combobox',{name:'Category',exact:true});
|
||||
await expect(select).toHaveValue(category);
|
||||
if(!category) await expect(select.locator('option:checked')).toHaveText('All categories');
|
||||
await expect(page.locator('.library-caption')).toHaveText(`${count} of ${count} positive prompts · Images are grouped by positive prompt`);
|
||||
await expect(page.locator('article[data-prompt-id]')).toHaveCount(count);
|
||||
}
|
||||
|
||||
let manifest;
|
||||
test.beforeEach(async({page,request,browserName})=>{
|
||||
await startCoverage(page,browserName);manifest=await seed(request);
|
||||
page.vocabularyErrors=[];page.on('pageerror',error=>page.vocabularyErrors.push(error.message));
|
||||
});
|
||||
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.vocabularyErrors).toEqual([]);});
|
||||
|
||||
test('tags route renders the concept composition without a legacy handoff',async({page})=>{
|
||||
await page.goto(route);
|
||||
await expect(main(page).getByRole('heading',{level:1})).toHaveText('Tags & categories');
|
||||
await expect(main(page).locator('.workspace-heading-copy > p')).toHaveText('Organize once. Reuse everywhere.');
|
||||
const actions=main(page).locator('.workspace-heading .heading-actions button');
|
||||
await expect(actions).toHaveText(['+ Create tag']);
|
||||
await expect(actions.first()).not.toHaveClass(/ca-primary/);
|
||||
await expect(main(page).getByRole('button',{name:'Auto-tag images'})).toHaveCount(0);
|
||||
await expect(surfaces(page)).toHaveCount(2);
|
||||
await expect(surfaces(page).locator('h2')).toHaveText(['Tag vocabulary','Categories']);
|
||||
await expect(surfaces(page).locator(':scope > p:visible')).toHaveText(['Category/tag autocomplete uses this same vocabulary across nodes and dialogs.',
|
||||
'Categories provide a broad home. Tags describe content; proposed collections group projects without changing either.']);
|
||||
await expect(table(page).locator('caption')).toHaveText('Reusable tags');
|
||||
await expect(table(page).locator('thead th')).toHaveText(['Tag','Prompts','Images','Actions']);
|
||||
expect(await table(page).locator('thead th').evaluateAll(cells=>cells.map(cell=>cell.getAttribute('scope')))).toEqual(['col','col','col','col']);
|
||||
const expected=[...CONCEPT_COUNTS,[manifest.pair_only_tag,1],[manifest.archived_only_tag,0],[manifest.negative_only_tag,0],[manifest.unused_tag,0]];
|
||||
await expect(rows(page)).toHaveCount(expected.length);
|
||||
expect(manifest.tags).toEqual(expected.map(([name])=>name));
|
||||
await expect(rows(page).locator('td:nth-child(1)')).toHaveText(expected.map(([name])=>name));
|
||||
await expect(rows(page).locator('td:nth-child(2)')).toHaveText(expected.map(([,count])=>String(count)));
|
||||
const images=await rows(page).locator('td:nth-child(3)').evaluateAll(cells=>cells.map(cell=>[cell.querySelector('[aria-hidden="true"]')?.textContent,cell.querySelector('.ca-visually-hidden')?.textContent]));
|
||||
expect(images).toEqual(expected.map(()=>['—','Not available yet']));
|
||||
await expect(rows(page).first().locator('td:nth-child(3)')).toHaveAccessibleName('Not available yet');
|
||||
const labels=await rows(page).evaluateAll(nodes=>nodes.map(row=>[...row.querySelectorAll('td:nth-child(4) .row-actions button')].map(button=>button.textContent)));
|
||||
expect(labels).toEqual(expected.map(()=>['Browse','Rename / merge']));
|
||||
const operations=surfaces(page).nth(1).locator('.operation');
|
||||
await expect(operations.locator('h3')).toHaveText([...CATEGORIES.map(([name])=>name),'Manage category']);
|
||||
await expect(operations.locator('p')).toHaveText([...CATEGORIES.map(([,count])=>`${count} positive prompt${count===1?'':'s'}`),'Rename or consolidate a category with a preview of affected recipes.']);
|
||||
await expect(operations.locator('.row-actions button')).toHaveText(['Browse','Browse','Browse','Browse','Review']);
|
||||
await expect(main(page).locator('.handoff')).toHaveCount(0);
|
||||
await expect(main(page).locator('a[href*="admin"]')).toHaveCount(0);
|
||||
await expect(main(page).getByRole('button',{name:/^Retry/})).toHaveCount(0);
|
||||
await expect(loadMore(page)).toBeHidden();
|
||||
});
|
||||
|
||||
test('prompt counts equal Library Browse results',async({page})=>{
|
||||
for(const name of ['cinematic',manifest.pair_only_tag,manifest.archived_only_tag]) {
|
||||
await page.goto(route);
|
||||
const row=tagRow(page,name);await expect(row).toHaveCount(1);
|
||||
const count=Number(await row.locator('td:nth-child(2)').textContent());
|
||||
await row.getByRole('button',{name:'Browse',exact:true}).click();
|
||||
await expectLibrary(page,{tags:name,count});
|
||||
}
|
||||
});
|
||||
|
||||
test('category Browse sets only the category constraint',async({page})=>{
|
||||
await page.goto('/prompt_manager/workspace#/library');
|
||||
const search=page.getByRole('searchbox',{name:'Search prompts',exact:true});
|
||||
await search.fill('observatory');await expect(page.locator('article[data-prompt-id]')).toHaveCount(1);
|
||||
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Tags & categories',exact:true}).click();
|
||||
const row=categoryRow(page,'Landscapes');await expect(row.locator('p')).toHaveText('3 positive prompts');
|
||||
await row.getByRole('button',{name:'Browse',exact:true}).click();
|
||||
await expectLibrary(page,{category:'Landscapes',count:3});
|
||||
});
|
||||
|
||||
test('long vocabularies append and Load more is keyboard reachable',async({page,request})=>{
|
||||
manifest=await seed(request,{extra_tags:211});expect(manifest.tags).toHaveLength(230);
|
||||
await page.goto(route);
|
||||
await expect(rows(page)).toHaveCount(100);
|
||||
await expect(loadMore(page)).toBeVisible();
|
||||
await rows(page).last().getByRole('button',{name:'Rename / merge',exact:true}).evaluate(button=>button.focus({preventScroll:true}));
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(loadMore(page)).toBeFocused();
|
||||
// Focus scrolled the end of the list into view, which appends the next page.
|
||||
await expect(rows(page)).toHaveCount(200);
|
||||
await expect(loadMore(page)).toBeFocused();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(rows(page)).toHaveCount(230);
|
||||
await expect(loadMore(page)).toHaveCount(0);
|
||||
await expect(rows(page).nth(200).getByRole('button',{name:'Browse',exact:true})).toBeFocused();
|
||||
await expect(rows(page).locator('td:nth-child(1)')).toHaveText(manifest.tags);
|
||||
// Reload at the top so restored scroll does not append before the check.
|
||||
await page.evaluate(()=>window.scrollTo(0,0));
|
||||
await page.reload();
|
||||
await expect(rows(page)).toHaveCount(100);
|
||||
const scroll=top=>page.evaluate(y=>new Promise(resolve=>{window.scrollTo(0,y ?? document.documentElement.scrollHeight);requestAnimationFrame(()=>requestAnimationFrame(resolve));}),top);
|
||||
for(const count of [200,230]) {
|
||||
// Each new arrival of the end of the list appends one page.
|
||||
await scroll(0);await scroll(null);
|
||||
await expect(rows(page)).toHaveCount(count);
|
||||
}
|
||||
await expect(loadMore(page)).toHaveCount(0);
|
||||
await expect(rows(page).locator('td:nth-child(1)')).toHaveText(manifest.tags);
|
||||
});
|
||||
|
||||
test('read failure shows inline error with Retry and no zero counts',async({page})=>{
|
||||
let failures=0;
|
||||
await page.route('**/prompt_manager/vocabulary/tags?*',route=>{
|
||||
if(failures++) return route.continue();
|
||||
return route.fulfill({status:500,json:{success:false,error:'Database is unavailable'}});
|
||||
});
|
||||
await page.goto(route);
|
||||
const alert=surfaces(page).first().getByRole('alert');
|
||||
await expect(alert).toHaveText('Tags could not be loaded. Nothing was changed.');
|
||||
await expect(table(page)).toHaveCount(0);
|
||||
await expect(surfaces(page).first().locator('td')).toHaveCount(0);
|
||||
await expect(surfaces(page).first().locator('.empty')).toBeHidden();
|
||||
await expect(loadMore(page)).toHaveCount(0);
|
||||
await expect(categoryRow(page,'Landscapes').locator('p')).toHaveText('3 positive prompts');
|
||||
const retry=surfaces(page).first().getByRole('button',{name:'Retry',exact:true});
|
||||
await retry.click();
|
||||
await expect(rows(page)).toHaveCount(manifest.tags.length);
|
||||
await expect(alert).toHaveCount(0);await expect(retry).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('breadcrumb and document title are Tags',async({page})=>{
|
||||
await page.goto(route);
|
||||
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Tags');
|
||||
await expect(page).toHaveTitle('Tags · Prompt Manager');
|
||||
await expect(page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Tags & categories',exact:true})).toHaveAttribute('aria-current','page');
|
||||
await page.reload();
|
||||
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Tags');
|
||||
await page.goto('/prompt_manager/workspace#/library');
|
||||
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Prompt library');
|
||||
await expect(page).toHaveTitle('Prompt library · Prompt Manager');
|
||||
});
|
||||
|
||||
test('route disposal aborts requests and removes observers',async({page})=>{
|
||||
await page.addInitScript(()=>{
|
||||
window.__vocabularySignals=[];window.__vocabularyObservers=[];
|
||||
const fetch=window.fetch;
|
||||
window.fetch=(input,init={})=>{if(String(input).includes('/vocabulary/') && init.signal) window.__vocabularySignals.push(init.signal);return fetch(input,init);};
|
||||
const Observer=window.IntersectionObserver;
|
||||
window.IntersectionObserver=class extends Observer {
|
||||
constructor(...args) {super(...args);this.record={targets:[],disconnected:false};window.__vocabularyObservers.push(this.record);}
|
||||
observe(target) {this.record.targets.push(target.className);return super.observe(target);}
|
||||
disconnect() {this.record.disconnected=true;return super.disconnect();}
|
||||
};
|
||||
});
|
||||
const held=[];
|
||||
await page.route('**/prompt_manager/vocabulary/**',route=>{held.push(route);});
|
||||
await page.goto(route);
|
||||
await expect.poll(()=>page.evaluate(()=>window.__vocabularySignals.length)).toBe(2);
|
||||
expect(await page.evaluate(()=>window.__vocabularyObservers.filter(item=>item.targets.some(name=>name.includes('vocabulary-continuation'))).length)).toBe(2);
|
||||
await expect(main(page).getByText('Loading…',{exact:true})).toHaveCount(2);
|
||||
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Prompt library',exact:true}).click();
|
||||
await expect(main(page).getByRole('heading',{level:1})).toHaveText('Prompt library');
|
||||
expect(await page.evaluate(()=>window.__vocabularySignals.map(signal=>signal.aborted))).toEqual([true,true]);
|
||||
expect(await page.evaluate(()=>window.__vocabularyObservers.filter(item=>item.targets.some(name=>name.includes('vocabulary-continuation'))).map(item=>item.disconnected))).toEqual([true,true]);
|
||||
await expect(main(page).locator('.two-col')).toHaveCount(0);
|
||||
expect(await page.evaluate(()=>document.getElementById('workspace-main').classList.contains('vocabulary-main'))).toBe(false);
|
||||
await page.unroute('**/prompt_manager/vocabulary/**');
|
||||
for(const item of held) await item.abort().catch(()=>{});
|
||||
});
|
||||
|
||||
test('390px layout stacks without horizontal page scroll; table scrolls locally',async({page,request})=>{
|
||||
manifest=await seed(request,{extra_tags:3});
|
||||
await page.setViewportSize({width:390,height:844});
|
||||
await page.goto(route);
|
||||
await expect(rows(page)).toHaveCount(manifest.tags.length);
|
||||
expect(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1);
|
||||
const layout=await main(page).evaluate(node=>{
|
||||
const grid=node.querySelector('.two-col'),[left,right]=grid.querySelectorAll(':scope > .surface'),wrap=node.querySelector('.table-wrap');
|
||||
const heading=node.querySelector('.workspace-heading'),operation=node.querySelector('.operation');
|
||||
return {tracks:getComputedStyle(grid).gridTemplateColumns.split(' ').length,stacked:right.getBoundingClientRect().top>=left.getBoundingClientRect().bottom,
|
||||
overflow:getComputedStyle(wrap).overflowX,heading:getComputedStyle(heading).flexDirection,operation:getComputedStyle(operation).flexDirection,
|
||||
actions:node.querySelector('.workspace-heading .heading-actions').getBoundingClientRect().width===heading.getBoundingClientRect().width};
|
||||
});
|
||||
expect(layout).toEqual({tracks:1,stacked:true,overflow:'auto',heading:'column',operation:'column',actions:true});
|
||||
});
|
||||
|
||||
async function expectEveryRowActionVisible(page,request,css) {
|
||||
const name='an_extraordinarily_long_descriptive_vocabulary_x';expect(name.length).toBeGreaterThanOrEqual(40);expect(name.length).toBeLessThanOrEqual(50);
|
||||
await expect(await request.post('/prompt_manager/vocabulary/tags',{data:{expected_epoch:manifest.epoch,name}})).toBeOK();
|
||||
await page.setViewportSize({width:390,height:844});
|
||||
await page.goto(route);await expect(rows(page)).toHaveCount(manifest.tags.length+1);await expect(tagRow(page,name)).toHaveCount(1);
|
||||
if(css)await page.addStyleTag({content:css});
|
||||
const report=await table(page).evaluate(node=>{
|
||||
const wrap=node.closest('.table-wrap'),box=wrap.getBoundingClientRect();
|
||||
const clipped=[...node.querySelectorAll('tbody tr')].flatMap(row=>[...row.querySelectorAll('.row-actions button')]
|
||||
.filter(item=>{const rect=item.getBoundingClientRect();return rect.left<box.left-0.5||rect.right>box.right+0.5;})
|
||||
.map(item=>`${row.cells[0].textContent}: ${item.textContent}`));
|
||||
return {overflow:wrap.scrollWidth<=wrap.clientWidth+1,clipped};
|
||||
});
|
||||
expect(report).toEqual({overflow:true,clipped:[]});
|
||||
expect(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1);
|
||||
}
|
||||
test('long tag names wrap at 390px so every row action stays fully visible',async({page,request})=>expectEveryRowActionVisible(page,request));
|
||||
// The fit must not depend on font metrics: CI's stock DejaVu Sans fallback is about 9% wider than the pinned image's
|
||||
// fallback, which pushed Rename / merge out of view. Wider letter spacing stands in for any wider fallback font.
|
||||
test('row actions stay fully visible at 390px with wider font metrics',async({page,request})=>
|
||||
expectEveryRowActionVisible(page,request,'.vocabulary-main table{letter-spacing:1px}'));
|
||||
|
||||
// Dialogs: the editor's modal shell (concept simpleInput, app.js:1138-1143) with truthful outcomes.
|
||||
const dialog=page=>page.locator('dialog.vocabulary-dialog');
|
||||
const button=(scope,name)=>scope.getByRole('button',{name,exact:true});
|
||||
const field=(scope,label)=>scope.getByLabel(label,{exact:true});
|
||||
const note=scope=>scope.locator('.warning-note');
|
||||
const toast=page=>page.locator('#workspace-toast');
|
||||
const actionNames=scope=>scope.locator('.dialog-actions button').evaluateAll(nodes=>nodes.map(node=>node.textContent.trim()));
|
||||
const snapshot=async request=>{const response=await request.get('/__test__/vocabulary/snapshot');await expect(response).toBeOK();return response.json();};
|
||||
const backup=async request=>{const response=await request.get('/__test__/vocabulary/backup');await expect(response).toBeOK();return response.body();};
|
||||
const restore=async(request,buffer)=>expect(await request.post('/prompt_manager/restore',{multipart:{database_file:{name:'vocabulary.db',mimeType:'application/octet-stream',buffer}}})).toBeOK();
|
||||
const tagNames=rows=>rows.tags.map(([,name])=>name);
|
||||
function trackWrites(page) {
|
||||
const writes=[];page.on('request',item=>{if(item.method()!=='GET')writes.push({method:item.method(),url:new URL(item.url()).pathname,body:item.postDataJSON()});});return writes;
|
||||
}
|
||||
async function openPage(page) {await page.goto(route);await expect(rows(page)).toHaveCount(manifest.tags.length);}
|
||||
async function openDialog(page,trigger,title) {
|
||||
await trigger.click();const box=dialog(page);await expect(box).toBeVisible();
|
||||
expect(await box.evaluate(node=>node.matches(':modal'))).toBe(true);
|
||||
await expect(box.getByRole('heading',{level:2})).toHaveText(title);
|
||||
await expect(button(box,'Close dialog')).toBeFocused();
|
||||
return box;
|
||||
}
|
||||
const openRename=(page,name)=>openDialog(page,tagRow(page,name).getByRole('button',{name:'Rename / merge',exact:true}),'Rename or merge tag');
|
||||
const openManage=page=>openDialog(page,categoryRow(page,'Manage category').getByRole('button',{name:'Review',exact:true}),'Manage categories');
|
||||
|
||||
test('create tag adds an unused tag that every editor offers',async({page,request})=>{
|
||||
const writes=trackWrites(page);await openPage(page);const before=await snapshot(request);
|
||||
const box=await openDialog(page,main(page).getByRole('button',{name:'+ Create tag',exact:true}),'Create tag');
|
||||
await expect(box).toHaveAttribute('aria-labelledby',await box.getByRole('heading',{level:2}).getAttribute('id'));
|
||||
await expect(box.locator('.dialog-header > button')).toHaveText('×');
|
||||
await expect(box.locator('.field label')).toHaveText(['Tag name']);
|
||||
const input=field(box,'Tag name');await expect(input).toHaveValue('');await expect(input).toHaveAttribute('required','');
|
||||
expect(await actionNames(box)).toEqual(['Cancel','Apply']);
|
||||
await expect(button(box,'Apply')).toHaveClass(/ca-primary/);await expect(button(box,'Cancel')).not.toHaveClass(/ca-primary/);
|
||||
await button(box,'Apply').click();
|
||||
await expect(box).toBeVisible();expect(await input.evaluate(node=>node.validity.valueMissing)).toBe(true);expect(writes).toEqual([]);
|
||||
await input.fill('sunset');await button(box,'Apply').click();
|
||||
await expect(box).toHaveCount(0);
|
||||
await expect(toast(page)).toHaveText('Tag created.');
|
||||
const row=tagRow(page,'sunset');await expect(row.locator('td:nth-child(2)')).toHaveText('0');
|
||||
await expect(row.getByRole('button',{name:'Rename / merge',exact:true})).toBeFocused();
|
||||
expect(writes).toEqual([{method:'POST',url:'/prompt_manager/vocabulary/tags',body:{expected_epoch:manifest.epoch,name:'sunset'}}]);
|
||||
const after=await snapshot(request);
|
||||
expect(tagNames(after)).toEqual([...manifest.tags,'sunset']);
|
||||
expect({...after,tags:before.tags}).toEqual(before);
|
||||
await page.goto('/prompt_manager/workspace#/library');
|
||||
await page.getByRole('button',{name:'+ New prompt',exact:true}).click();
|
||||
await expect(page.locator('dialog.library-editor datalist#tag-options option[value="sunset,"]')).toHaveCount(1);
|
||||
await page.keyboard.press('Escape');
|
||||
await page.goto(route);await expect(tagRow(page,'sunset')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('rename and merge persist and collapse duplicate links',async({page,request})=>{
|
||||
const writes=trackWrites(page);await openPage(page);
|
||||
let box=await openRename(page,'blue_hour');
|
||||
await expect(box.locator('.field label')).toHaveText(['New name; existing names merge']);
|
||||
const input=field(box,'New name; existing names merge');
|
||||
await expect(input).toHaveValue('blue_hour');await expect(input).toHaveAttribute('required','');
|
||||
expect(await actionNames(box)).toEqual(['Delete tag…','Cancel','Apply']);
|
||||
await expect(button(box,'Delete tag…')).toHaveClass(/danger/);await expect(button(box,'Apply')).toHaveClass(/ca-primary/);
|
||||
await input.fill('golden_hour');await button(box,'Apply').click();
|
||||
await expect(box).toHaveCount(0);await expect(toast(page)).toHaveText('Tag renamed.');
|
||||
await expect(tagRow(page,'blue_hour')).toHaveCount(0);
|
||||
await expect(tagRow(page,'golden_hour').locator('td:nth-child(2)')).toHaveText('1');
|
||||
await expect(tagRow(page,'golden_hour').getByRole('button',{name:'Rename / merge',exact:true})).toBeFocused();
|
||||
expect(writes.at(-1)).toMatchObject({method:'PATCH',body:{expected_epoch:manifest.epoch,name:'blue_hour',new_name:'golden_hour'}});
|
||||
expect(writes.at(-1).url).toMatch(/^\/prompt_manager\/vocabulary\/tags\/\d+$/);
|
||||
// Alpine observatory carries both: merging must leave one link, not two.
|
||||
box=await openRename(page,'architecture');
|
||||
await field(box,'New name; existing names merge').fill('cinematic');await button(box,'Apply').click();
|
||||
await expect(box).toHaveCount(0);await expect(toast(page)).toHaveText('Tag merged without duplicate links.');
|
||||
await expect(tagRow(page,'architecture')).toHaveCount(0);
|
||||
const target=tagRow(page,'cinematic');await expect(target.locator('td:nth-child(2)')).toHaveText('2');
|
||||
await expect(target.getByRole('button',{name:'Rename / merge',exact:true})).toBeFocused();
|
||||
const after=await snapshot(request);
|
||||
expect(tagNames(after)).not.toContain('architecture');
|
||||
const cinematic=after.tags.find(([,name])=>name==='cinematic')[0];
|
||||
expect(after.prompt_tags.filter(([,tag])=>tag===cinematic)).toHaveLength(2);
|
||||
await page.reload();await expect(tagRow(page,'golden_hour')).toHaveCount(1);await expect(tagRow(page,'architecture')).toHaveCount(0);
|
||||
await tagRow(page,'cinematic').getByRole('button',{name:'Browse',exact:true}).click();
|
||||
await expectLibrary(page,{tags:'cinematic',count:2});
|
||||
});
|
||||
|
||||
test('manage categories previews real impact before applying',async({page,request})=>{
|
||||
const writes=trackWrites(page);await openPage(page);
|
||||
let box=await openManage(page);
|
||||
await expect(box.locator('.field label')).toHaveText(['Category','Proposed new category name']);
|
||||
const source=field(box,'Category'),target=field(box,'Proposed new category name');
|
||||
await expect(source.locator('option')).toHaveText(CATEGORIES.map(([name])=>name));
|
||||
await expect(source).toHaveValue('Landscapes');
|
||||
await expect(target).toHaveValue('');await expect(target).toHaveAttribute('required','');await expect(target).toHaveAttribute('list','vocabulary-category-options');
|
||||
// Its own id: the recipe editor's `datalist#categories` is a different list.
|
||||
await expect(page.locator('datalist#categories')).toHaveCount(0);
|
||||
expect(await box.locator('datalist#vocabulary-category-options option').evaluateAll(nodes=>nodes.map(node=>node.value))).toEqual(CATEGORIES.map(([name])=>name));
|
||||
expect(await actionNames(box)).toEqual(['Cancel','Preview']);
|
||||
await expect(button(box,'Preview')).toHaveClass(/ca-primary/);
|
||||
const impact=box.locator('p.small');await expect(impact).toBeHidden();
|
||||
await target.fill('Scenery');await button(box,'Preview').click();
|
||||
await expect(impact).toHaveText('“Landscapes” is used by 3 positive prompts. Apply renames it to “Scenery”.');
|
||||
expect(await actionNames(box)).toEqual(['Cancel','Apply']);
|
||||
expect(writes.map(write=>write.url)).toEqual(['/prompt_manager/vocabulary/categories/preview']);
|
||||
await target.fill('Sceneries');
|
||||
await expect(impact).toBeHidden();expect(await actionNames(box)).toEqual(['Cancel','Preview']);
|
||||
await target.fill('Scenery');await button(box,'Preview').click();await expect(impact).toBeVisible();
|
||||
await button(box,'Apply').click();
|
||||
await expect(box).toHaveCount(0);await expect(toast(page)).toHaveText('Category renamed.');
|
||||
await expect(categoryRow(page,'Landscapes')).toHaveCount(0);
|
||||
await expect(categoryRow(page,'Scenery').locator('p')).toHaveText('3 positive prompts');
|
||||
await expect(categoryRow(page,'Scenery').getByRole('button',{name:'Browse',exact:true})).toBeFocused();
|
||||
expect(writes.at(-1)).toMatchObject({url:'/prompt_manager/vocabulary/categories/apply',body:{expected_epoch:manifest.epoch,source:'Landscapes',target:'Scenery',impact_token:expect.stringMatching(/^[0-9a-f]{64}$/)}});
|
||||
|
||||
box=await openManage(page);
|
||||
await field(box,'Category').selectOption('Illustration');await field(box,'Proposed new category name').fill('Architecture');
|
||||
await button(box,'Preview').click();
|
||||
await expect(box.locator('p.small')).toHaveText('“Illustration” is used by 1 positive prompt. Apply consolidates it into the existing “Architecture”.');
|
||||
await field(box,'Category').selectOption('Environments');await expect(box.locator('p.small')).toBeHidden();
|
||||
await field(box,'Category').selectOption('Illustration');await button(box,'Preview').click();
|
||||
await button(box,'Apply').click();
|
||||
await expect(box).toHaveCount(0);await expect(toast(page)).toHaveText('Categories consolidated.');
|
||||
await expect(categoryRow(page,'Illustration')).toHaveCount(0);
|
||||
await expect(categoryRow(page,'Architecture').locator('p')).toHaveText('2 positive prompts');
|
||||
await page.reload();await expect(categoryRow(page,'Scenery')).toHaveCount(1);await expect(categoryRow(page,'Illustration')).toHaveCount(0);
|
||||
const after=await snapshot(request);
|
||||
expect(after.prompts.filter(row=>row[2]==='Scenery')).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('an edit during a pending preview needs a new Preview; Apply never sends the old values',async({page,request})=>{
|
||||
const writes=trackWrites(page);await openPage(page);const before=await snapshot(request);
|
||||
let release;const held=new Promise(resolve=>{release=resolve;});
|
||||
await page.route('**/prompt_manager/vocabulary/categories/preview',async route=>{await held;await route.continue();},{times:1});
|
||||
const box=await openManage(page);
|
||||
const source=field(box,'Category'),target=field(box,'Proposed new category name'),impact=box.locator('p.small');
|
||||
await target.fill('Scenery');await button(box,'Preview').click();
|
||||
await expect(button(box,'Preview')).toBeDisabled();
|
||||
await target.fill('Vistas');await source.selectOption('Illustration');
|
||||
release();
|
||||
await expect.poll(()=>writes.length).toBe(1);
|
||||
expect(writes[0]).toMatchObject({url:'/prompt_manager/vocabulary/categories/preview',body:{source:'Landscapes',target:'Scenery'}});
|
||||
// The late "Landscapes → Scenery" preview no longer describes the form: no impact, no Apply.
|
||||
await expect(button(box,'Preview')).toBeEnabled();
|
||||
await expect(impact).toBeHidden();expect(await actionNames(box)).toEqual(['Cancel','Preview']);
|
||||
await expect(target).toHaveValue('Vistas');await expect(source).toHaveValue('Illustration');
|
||||
await button(box,'Preview').click();
|
||||
await expect(impact).toHaveText('“Illustration” is used by 1 positive prompt. Apply renames it to “Vistas”.');
|
||||
expect(writes.at(-1)).toMatchObject({url:'/prompt_manager/vocabulary/categories/preview',body:{source:'Illustration',target:'Vistas'}});
|
||||
expect(writes.filter(write=>write.url.endsWith('/apply'))).toEqual([]);
|
||||
expect(await snapshot(request)).toEqual(before);
|
||||
});
|
||||
|
||||
test('delete tag confirms real impact and removes only the vocabulary name',async({page,request})=>{
|
||||
const writes=trackWrites(page);await openPage(page);const before=await snapshot(request);
|
||||
let box=await openRename(page,'cinematic');
|
||||
await button(box,'Delete tag…').click();
|
||||
await expect(box.getByRole('heading',{level:2})).toHaveText('Delete tag');
|
||||
await expect(button(box,'Close dialog')).toBeFocused();
|
||||
await expect(box.locator('p.small')).toHaveText('Deleting “cinematic” removes the name and its links from 2 positive prompts. The prompts, negative variants, images and history themselves are kept.');
|
||||
expect(await actionNames(box)).toEqual(['Cancel','Delete']);
|
||||
await expect(button(box,'Delete')).toHaveClass(/danger/);await expect(box.locator('.field')).toHaveCount(0);
|
||||
await button(box,'Cancel').click();await expect(box).toHaveCount(0);
|
||||
await expect(tagRow(page,'cinematic').getByRole('button',{name:'Rename / merge',exact:true})).toBeFocused();
|
||||
expect(writes).toEqual([]);expect(await snapshot(request)).toEqual(before);
|
||||
|
||||
box=await openRename(page,'cinematic');await button(box,'Delete tag…').click();
|
||||
await expect(box.locator('p.small')).toBeVisible();await button(box,'Delete').click();
|
||||
await expect(box).toHaveCount(0);await expect(toast(page)).toHaveText('Tag deleted.');
|
||||
await expect(tagRow(page,'cinematic')).toHaveCount(0);
|
||||
await expect(main(page).getByRole('heading',{level:1})).toBeFocused();
|
||||
expect(writes).toEqual([{method:'POST',url:expect.stringMatching(/^\/prompt_manager\/vocabulary\/tags\/\d+\/delete$/),body:{expected_epoch:manifest.epoch,name:'cinematic'}}]);
|
||||
const after=await snapshot(request);
|
||||
const cinematic=before.tags.find(([,name])=>name==='cinematic')[0];
|
||||
expect(after.tags).toEqual(before.tags.filter(([id])=>id!==cinematic));
|
||||
expect(after.prompt_tags).toEqual(before.prompt_tags.filter(([,tag])=>tag!==cinematic));
|
||||
for(const table of ['prompt_pair_tags','prompt_pairs','generated_images','image_prompt_pairs']) expect(after[table]).toEqual(before[table]);
|
||||
expect(after.prompts.map(([id,text,category])=>[id,text,category])).toEqual(before.prompts.map(([id,text,category])=>[id,text,category]));
|
||||
await page.goto('/prompt_manager/workspace#/library');
|
||||
await expect(page.locator('article[data-prompt-id]')).toHaveCount(6);
|
||||
await expect(page.locator('.library-chip').filter({hasText:/^cinematic$/})).toHaveCount(0);
|
||||
await expect(page.locator('.library-chip').filter({hasText:/^blue_hour$/})).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('cancel and escape write nothing and restore focus',async({page,request})=>{
|
||||
const writes=trackWrites(page);await openPage(page);const before=await snapshot(request);
|
||||
const create=main(page).getByRole('button',{name:'+ Create tag',exact:true});
|
||||
let box=await openDialog(page,create,'Create tag');
|
||||
await field(box,'Tag name').fill('never_saved');await page.keyboard.press('Escape');
|
||||
await expect(box).toHaveCount(0);await expect(create).toBeFocused();
|
||||
box=await openRename(page,'mist');await field(box,'New name; existing names merge').fill('never_renamed');
|
||||
await button(box,'Close dialog').click();await expect(box).toHaveCount(0);
|
||||
await expect(tagRow(page,'mist').getByRole('button',{name:'Rename / merge',exact:true})).toBeFocused();
|
||||
box=await openRename(page,'mist');await button(box,'Delete tag…').click();await expect(box.locator('p.small')).toBeVisible();
|
||||
await page.keyboard.press('Escape');await expect(box).toHaveCount(0);
|
||||
await expect(tagRow(page,'mist').getByRole('button',{name:'Rename / merge',exact:true})).toBeFocused();
|
||||
box=await openManage(page);await field(box,'Proposed new category name').fill('Scenery');await button(box,'Preview').click();
|
||||
await expect(box.locator('p.small')).toBeVisible();await button(box,'Cancel').click();
|
||||
await expect(box).toHaveCount(0);await expect(categoryRow(page,'Manage category').getByRole('button',{name:'Review',exact:true})).toBeFocused();
|
||||
expect(writes.map(write=>write.url)).toEqual(['/prompt_manager/vocabulary/categories/preview']);
|
||||
expect(await snapshot(request)).toEqual(before);
|
||||
await expect(toast(page)).toHaveText('');
|
||||
});
|
||||
|
||||
test('conflict and restore states keep the dialog and write nothing',async({page,request})=>{
|
||||
const saved=await backup(request);const writes=trackWrites(page);await openPage(page);
|
||||
let box=await openRename(page,'mist');
|
||||
await expect(await request.put('/prompt_manager/tags/mist',{data:{new_name:'fog'}})).toBeOK();
|
||||
await field(box,'New name; existing names merge').fill('haze');await button(box,'Apply').click();
|
||||
await expect(note(box)).toHaveText('This tag changed since this dialog opened. Nothing was changed. The list was re-read; review it before trying again.');
|
||||
await expect(box).toBeVisible();await expect(button(box,'Apply')).toBeDisabled();
|
||||
await expect(tagRow(page,'fog')).toHaveCount(1);await expect(tagRow(page,'mist')).toHaveCount(0);
|
||||
expect(tagNames(await snapshot(request))).not.toContain('haze');
|
||||
await button(box,'Cancel').click();await expect(box).toHaveCount(0);
|
||||
|
||||
box=await openManage(page);await field(box,'Proposed new category name').fill('Scenery');await button(box,'Preview').click();
|
||||
await expect(box.locator('p.small')).toHaveText('“Landscapes” is used by 3 positive prompts. Apply renames it to “Scenery”.');
|
||||
const geometry=manifest.recipes[1].positive_id;
|
||||
const current=await(await request.get(`/prompt_manager/library/${geometry}`)).json();
|
||||
await expect(await request.patch(`/prompt_manager/library/${geometry}`,{data:{expected_epoch:current.library_epoch,if_match:current.positive.edit_token,metadata:{category:'Landscapes'}}})).toBeOK();
|
||||
await button(box,'Apply').click();
|
||||
await expect(note(box)).toHaveText('This category changed since this dialog opened. Nothing was changed. Preview again before applying.');
|
||||
expect(await actionNames(box)).toEqual(['Cancel','Preview']);await expect(box.locator('p.small')).toBeHidden();
|
||||
expect((await snapshot(request)).prompts.some(row=>row[2]==='Scenery')).toBe(false);
|
||||
await button(box,'Preview').click();
|
||||
await expect(box.locator('p.small')).toHaveText('“Landscapes” is used by 4 positive prompts. Apply renames it to “Scenery”.');
|
||||
await expect(note(box)).toBeHidden();
|
||||
await button(box,'Cancel').click();
|
||||
|
||||
const before=await snapshot(request);
|
||||
box=await openDialog(page,main(page).getByRole('button',{name:'+ Create tag',exact:true}),'Create tag');
|
||||
await restore(request,saved);
|
||||
await field(box,'Tag name').fill('sunrise');await button(box,'Apply').click();
|
||||
await expect(note(box)).toHaveText('Prompt storage was restored since this dialog opened. Nothing was changed.');
|
||||
await expect(button(box,'Apply')).toBeDisabled();
|
||||
expect(await actionNames(box)).toEqual(['Reload','Cancel','Apply']);
|
||||
const restored=await snapshot(request);expect(tagNames(restored)).not.toContain('sunrise');expect(restored.tags).not.toEqual(before.tags);
|
||||
await button(box,'Reload').click();await expect(box).toHaveCount(0);
|
||||
await expect(main(page).getByRole('heading',{level:1})).toBeFocused();
|
||||
await expect(tagRow(page,'mist')).toHaveCount(1);await expect(tagRow(page,'fog')).toHaveCount(0);
|
||||
expect(writes.filter(write=>write.url==='/prompt_manager/vocabulary/tags')).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('lost response is outcome-unknown',async({page,request})=>{
|
||||
const writes=trackWrites(page);await openPage(page);
|
||||
await page.route('**/prompt_manager/vocabulary/tags/*',async route=>{
|
||||
if(route.request().method()!=='PATCH')return route.continue();
|
||||
await route.fetch();await route.abort('failed');
|
||||
});
|
||||
const box=await openRename(page,'forest');
|
||||
await field(box,'New name; existing names merge').fill('woodland');await button(box,'Apply').click();
|
||||
await expect(note(box)).toHaveText('The result could not be confirmed. The lists were re-read; check them before applying again.');
|
||||
await expect(box).toBeVisible();
|
||||
await expect(tagRow(page,'woodland')).toHaveCount(1);await expect(tagRow(page,'forest')).toHaveCount(0);
|
||||
expect(tagNames(await snapshot(request))).toContain('woodland');
|
||||
// Never resent on its own.
|
||||
await page.evaluate(()=>new Promise(resolve=>setTimeout(resolve,300)));
|
||||
expect(writes.filter(write=>write.method==='PATCH')).toHaveLength(1);
|
||||
await expect(button(box,'Apply')).toBeEnabled();
|
||||
await button(box,'Cancel').click();await expect(box).toHaveCount(0);
|
||||
await expect(main(page).getByRole('heading',{level:1})).toBeFocused();
|
||||
});
|
||||
|
||||
const nav=(page,name)=>page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name,exact:true});
|
||||
const chips=(page,name)=>main(page).locator('article[data-prompt-id] .library-chip').filter({hasText:new RegExp(`^${name}$`)});
|
||||
/** Visit the Library so its shelf is cached, then open Rename on blue_hour and start a rename whose PATCH is held. */
|
||||
async function heldRename(page) {
|
||||
await page.goto('/prompt_manager/workspace#/library');await expect(chips(page,'blue_hour')).toHaveCount(1);
|
||||
await nav(page,'Overview').click();await nav(page,'Tags & categories').click();
|
||||
await expect(rows(page)).toHaveCount(manifest.tags.length);
|
||||
let held;const arrived=new Promise(resolve=>{held=resolve;});
|
||||
await page.route('**/prompt_manager/vocabulary/tags/*',route=>route.request().method()==='PATCH'?held(route):route.continue());
|
||||
const box=await openRename(page,'blue_hour');
|
||||
await field(box,'New name; existing names merge').fill('golden_hour');await button(box,'Apply').click();
|
||||
return {box,patch:await arrived};
|
||||
}
|
||||
async function releaseAndExpectFreshLibrary(page,patch) {
|
||||
const landed=page.waitForResponse(response=>response.request().method()==='PATCH');
|
||||
await patch.continue();expect((await landed).ok()).toBe(true);
|
||||
await nav(page,'Prompt library').click();
|
||||
await expect(main(page).getByRole('heading',{level:1})).toHaveText('Prompt library');
|
||||
await expect(chips(page,'golden_hour')).toHaveCount(1);await expect(chips(page,'blue_hour')).toHaveCount(0);
|
||||
}
|
||||
|
||||
test('a write that lands after a forced dialog close still refreshes the lists and the cached Library',async({page,request})=>{
|
||||
const {box,patch}=await heldRename(page);
|
||||
// Chromium force-closes a modal on a repeated Escape; close() is the same event in every engine.
|
||||
await box.evaluate(node=>node.close());
|
||||
await expect(box).toHaveCount(0);
|
||||
await expect(tagRow(page,'blue_hour').getByRole('button',{name:'Rename / merge',exact:true})).toBeFocused();
|
||||
const landed=page.waitForResponse(response=>response.request().method()==='PATCH');
|
||||
await patch.continue();await landed;
|
||||
await expect(tagRow(page,'golden_hour')).toHaveCount(1);await expect(tagRow(page,'blue_hour')).toHaveCount(0);
|
||||
expect(tagNames(await snapshot(request))).toContain('golden_hour');
|
||||
await expect(toast(page)).not.toHaveText('Tag renamed.');
|
||||
await nav(page,'Prompt library').click();
|
||||
await expect(chips(page,'golden_hour')).toHaveCount(1);await expect(chips(page,'blue_hour')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('Back during a write removes the dialog and the landed write refreshes the cached Library',async({page})=>{
|
||||
const {box,patch}=await heldRename(page);
|
||||
await page.goBack();
|
||||
await expect(main(page).getByRole('heading',{level:1})).toHaveText('Overview');
|
||||
await expect(box).toHaveCount(0);
|
||||
expect(await page.evaluate(()=>document.querySelectorAll('dialog').length)).toBe(0);
|
||||
// Nothing stays inert: the page takes keyboard focus again.
|
||||
await nav(page,'Prompt library').focus();await expect(nav(page,'Prompt library')).toBeFocused();
|
||||
await releaseAndExpectFreshLibrary(page,patch);
|
||||
});
|
||||
|
||||
test('a failed Load more read moves keyboard focus to Retry',async({page,request})=>{
|
||||
manifest=await seed(request,{extra_tags:211});
|
||||
await page.route('**/prompt_manager/vocabulary/tags?*',route=>{
|
||||
if(new URL(route.request().url()).searchParams.has('cursor')) return route.fulfill({status:500,json:{success:false,error:'Database is unavailable'}});
|
||||
return route.continue();
|
||||
});
|
||||
await page.goto(route);await expect(rows(page)).toHaveCount(100);
|
||||
await loadMore(page).evaluate(button=>button.focus({preventScroll:true}));
|
||||
await page.keyboard.press('Enter');
|
||||
const retry=surfaces(page).first().getByRole('button',{name:'Retry',exact:true});
|
||||
await expect(retry).toBeVisible();await expect(retry).toBeFocused();
|
||||
await expect(rows(page)).toHaveCount(100);
|
||||
});
|
||||
|
||||
test('an unconfirmed delete disables Delete and re-reads the impact before anything else',async({page,request})=>{
|
||||
const writes=trackWrites(page);await openPage(page);
|
||||
await page.route('**/prompt_manager/vocabulary/tags/*/delete',async route=>{await route.fetch();await route.abort('failed');});
|
||||
const box=await openRename(page,'forest');await button(box,'Delete tag…').click();
|
||||
const impact=box.locator('p.small');await expect(impact).toBeVisible();
|
||||
await button(box,'Delete').click();
|
||||
await expect(note(box)).toHaveText('The result could not be confirmed. The lists were re-read; check them before applying again.');
|
||||
await expect(impact).toBeHidden();await expect(button(box,'Delete')).toBeDisabled();
|
||||
expect(await actionNames(box)).toEqual(['Retry','Cancel','Delete']);
|
||||
await expect(tagRow(page,'forest')).toHaveCount(0);expect(tagNames(await snapshot(request))).not.toContain('forest');
|
||||
await page.evaluate(()=>new Promise(resolve=>setTimeout(resolve,300)));
|
||||
expect(writes.filter(write=>write.method==='POST')).toHaveLength(1);
|
||||
// Retry re-reads the impact and resends nothing: the tag is gone, so it is a changed tag.
|
||||
await button(box,'Retry').click();
|
||||
await expect(note(box)).toHaveText('This tag changed since this dialog opened. Nothing was changed. The list was re-read; review it before trying again.');
|
||||
await expect(button(box,'Delete')).toBeDisabled();await expect(impact).toBeHidden();
|
||||
expect(writes.filter(write=>write.method==='POST')).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('delete confirmation read failure, missing tag and restore keep Delete disabled',async({page,request})=>{
|
||||
const saved=await backup(request);const writes=trackWrites(page);await openPage(page);
|
||||
let failures=1;
|
||||
await page.route('**/prompt_manager/vocabulary/tags/*/impact',route=>failures-->0?route.fulfill({status:500,json:{success:false,error:'Database is unavailable'}}):route.continue());
|
||||
let box=await openRename(page,'mist');await button(box,'Delete tag…').click();
|
||||
await expect(note(box)).toHaveText('Linked prompts could not be read. Nothing was deleted.');
|
||||
await expect(button(box,'Delete')).toBeDisabled();expect(await actionNames(box)).toEqual(['Retry','Cancel','Delete']);
|
||||
await button(box,'Retry').click();
|
||||
await expect(box.locator('p.small')).toHaveText('Deleting “mist” removes the name and its links from 1 positive prompt. The prompts, negative variants, images and history themselves are kept.');
|
||||
await expect(note(box)).toBeHidden();await expect(button(box,'Delete')).toBeEnabled();
|
||||
await button(box,'Cancel').click();
|
||||
|
||||
// Deleted elsewhere: the impact read finds no tag, which is a changed tag.
|
||||
box=await openRename(page,'forest');
|
||||
await expect(await request.delete('/prompt_manager/tags/forest')).toBeOK();
|
||||
await button(box,'Delete tag…').click();
|
||||
await expect(note(box)).toHaveText('This tag changed since this dialog opened. Nothing was changed. The list was re-read; review it before trying again.');
|
||||
await expect(button(box,'Delete')).toBeDisabled();await expect(tagRow(page,'forest')).toHaveCount(0);
|
||||
await button(box,'Cancel').click();
|
||||
// Renamed elsewhere after the impact read: the guarded delete is refused and writes nothing.
|
||||
box=await openRename(page,'peaceful');await button(box,'Delete tag…').click();await expect(button(box,'Delete')).toBeEnabled();
|
||||
await expect(await request.put('/prompt_manager/tags/peaceful',{data:{new_name:'calm'}})).toBeOK();
|
||||
await button(box,'Delete').click();
|
||||
await expect(note(box)).toHaveText('This tag changed since this dialog opened. Nothing was changed. The list was re-read; review it before trying again.');
|
||||
await expect(button(box,'Delete')).toBeDisabled();await expect(tagRow(page,'calm').locator('td:nth-child(2)')).toHaveText('2');
|
||||
await button(box,'Cancel').click();
|
||||
|
||||
box=await openRename(page,'mist');await restore(request,saved);
|
||||
await button(box,'Delete tag…').click();
|
||||
await expect(note(box)).toHaveText('Prompt storage was restored since this dialog opened. Nothing was changed.');
|
||||
await expect(button(box,'Delete')).toBeDisabled();expect(await actionNames(box)).toEqual(['Reload','Cancel','Delete']);
|
||||
await button(box,'Reload').click();await expect(box).toHaveCount(0);
|
||||
await expect(main(page).getByRole('heading',{level:1})).toBeFocused();await expect(tagRow(page,'forest')).toHaveCount(1);
|
||||
expect(writes.map(write=>write.url)).toEqual([expect.stringMatching(/^\/prompt_manager\/vocabulary\/tags\/\d+\/delete$/)]);
|
||||
});
|
||||
|
||||
test('manage categories lists every category page without Load more',async({page,request})=>{
|
||||
manifest=await seed(request,{extra_categories:150});
|
||||
let release;const held=new Promise(resolve=>{release=resolve;});
|
||||
await page.route('**/prompt_manager/vocabulary/categories?*',async route=>{
|
||||
if(new URL(route.request().url()).searchParams.has('cursor')) await held;
|
||||
return route.continue();
|
||||
});
|
||||
await page.goto(route);await expect(categoryRow(page,'Landscapes')).toHaveCount(1);
|
||||
const box=await openManage(page);
|
||||
await expect(note(box)).toHaveText('Loading categories…');await expect(button(box,'Preview')).toBeDisabled();
|
||||
release();
|
||||
await expect(note(box)).toBeHidden();await expect(button(box,'Preview')).toBeEnabled();
|
||||
const source=field(box,'Category');
|
||||
await expect(source.locator('option')).toHaveCount(154);
|
||||
await expect(source.locator('option').last()).toHaveText('bulk_category_0149');
|
||||
expect(await box.locator('datalist#vocabulary-category-options option').count()).toBe(154);
|
||||
await source.selectOption('bulk_category_0149');await field(box,'Proposed new category name').fill('Bulk renamed');
|
||||
await button(box,'Preview').click();
|
||||
await expect(box.locator('p.small')).toHaveText('“bulk_category_0149” is used by 1 positive prompt. Apply renames it to “Bulk renamed”.');
|
||||
await expect(main(page).getByRole('button',{name:'Load more categories',exact:true})).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('manage categories stops at 1,000 categories and says so',async({page})=>{
|
||||
await page.route('**/prompt_manager/vocabulary/categories?*',route=>{
|
||||
const cursor=new URL(route.request().url()).searchParams.get('cursor');const index=cursor?Number(cursor.slice(1)):0;
|
||||
const categories=Array.from({length:100},(_,item)=>({value:`many_${String(index*100+item).padStart(5,'0')}`,prompt_count:1}));
|
||||
return route.fulfill({json:{success:true,library_epoch:manifest.epoch,total:5000,has_more:true,next_cursor:`c${index+1}`,categories}});
|
||||
});
|
||||
await page.goto(route);await expect(categoryRow(page,'many_00000')).toHaveCount(1);
|
||||
// Every mocked page has more, so scrolling to Review would keep appending under the pointer.
|
||||
const review=categoryRow(page,'Manage category').getByRole('button',{name:'Review',exact:true});
|
||||
const box=await openDialog(page,{click:()=>review.evaluate(node=>node.click())},'Manage categories');
|
||||
await expect(note(box)).toHaveText('Only the first 1,000 categories are listed.');
|
||||
await expect(field(box,'Category').locator('option')).toHaveCount(1000);
|
||||
await expect(field(box,'Category').locator('option').last()).toHaveText('many_00999');
|
||||
await expect(button(box,'Preview')).toBeEnabled();
|
||||
});
|
||||
|
||||
test('a failed category read in Manage categories offers Retry',async({page,request})=>{
|
||||
manifest=await seed(request,{extra_categories:150});
|
||||
let failures=1;
|
||||
await page.route('**/prompt_manager/vocabulary/categories?*',route=>{
|
||||
if(new URL(route.request().url()).searchParams.has('cursor') && failures-->0) return route.fulfill({status:500,json:{success:false,error:'Database is unavailable'}});
|
||||
return route.continue();
|
||||
});
|
||||
await page.goto(route);await expect(categoryRow(page,'Landscapes')).toHaveCount(1);
|
||||
const box=await openManage(page);
|
||||
await expect(note(box)).toHaveText('Categories could not be loaded. Nothing was changed.');
|
||||
await expect(button(box,'Preview')).toBeDisabled();expect(await actionNames(box)).toEqual(['Retry','Cancel','Preview']);
|
||||
await button(box,'Retry').click();
|
||||
await expect(field(box,'Category').locator('option')).toHaveCount(154);
|
||||
await expect(note(box)).toBeHidden();await expect(button(box,'Preview')).toBeEnabled();
|
||||
});
|
||||
|
||||
// Concept comparison (Task 9): the immutable concept at /__test__/canonical/workspace against production with the
|
||||
// concept fixture, same browser, both themes, both widths, normal and reduced motion. Only recorded deviations are mapped.
|
||||
const concept='/__test__/canonical/workspace';
|
||||
const SIZES=[[1600,1100],[390,844]];
|
||||
const MOTIONS=['no-preference','reduce'];
|
||||
async function openConceptTags(page,theme,motion) {
|
||||
await page.emulateMedia({reducedMotion:motion});
|
||||
await page.goto('about:blank');await page.goto(`${concept}#tags`);
|
||||
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
await expect(page.locator('#main h1')).toHaveText('Tags & categories');
|
||||
}
|
||||
async function openProductionTags(page,theme,motion) {
|
||||
await page.emulateMedia({reducedMotion:motion});
|
||||
await page.goto('about:blank');await page.goto(route);
|
||||
await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption(theme);
|
||||
await expect(rows(page)).toHaveCount(manifest.tags.length);await expect(categoryRow(page,'Landscapes')).toHaveCount(1);
|
||||
}
|
||||
const pageOf=(page,production)=>page.locator(production?'#workspace-main':'#main').evaluate(pageInventoryOf,production);
|
||||
/** Breadcrumb, document title (D7: "Tags", only the concept suffix differs) and the keyboard focus ring on a row action. */
|
||||
async function chromeOf(page,production) {
|
||||
const scope=page.locator(production?'#workspace-main':'#main');
|
||||
await scope.locator('tbody tr').first().getByRole('button',{name:'Rename / merge',exact:true}).focus();
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
const browse=scope.locator('tbody tr').first().getByRole('button',{name:'Browse',exact:true});await expect(browse).toBeFocused();
|
||||
const ring=await browse.evaluate(node=>{const style=getComputedStyle(node);return {visible:node.matches(':focus-visible'),
|
||||
outlineStyle:style.outlineStyle,outlineWidth:style.outlineWidth,outlineColor:style.outlineColor,outlineOffset:style.outlineOffset};});
|
||||
await page.keyboard.press('Tab');
|
||||
const brandLink=page.locator('a.brand').first();await brandLink.focus();
|
||||
const linkRing=await brandLink.evaluate(node=>{const style=getComputedStyle(node);return {visible:node.matches(':focus-visible'),
|
||||
outlineStyle:style.outlineStyle,outlineWidth:style.outlineWidth,outlineOffset:style.outlineOffset};});
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(page).toHaveTitle(/^Tags( · Prompt Manager( concept)?)?$/);
|
||||
return {breadcrumb:(await page.locator(production?'#workspace-breadcrumb':'#breadcrumb').textContent()).trim(),
|
||||
title:(await page.title()).replace(/ · Prompt Manager( concept)?$/,''),ring,linkRing};
|
||||
}
|
||||
/** Concept rows are the concept's own sample tags; production appends the fixture-only tags after them (D4 creation order). */
|
||||
function comparePage(reference,actual) {
|
||||
const expected=mapVocabularyConcept(reference);
|
||||
const shared=expected.table.rows.length;
|
||||
return {diff:diffInventory(expected,{...actual,table:{...actual.table,rows:actual.table.rows.slice(0,shared)}}),
|
||||
extra:actual.table.rows.slice(shared).map(row=>row.tag)};
|
||||
}
|
||||
const fixtureOnly=()=>[manifest.pair_only_tag,manifest.archived_only_tag,manifest.negative_only_tag,manifest.unused_tag];
|
||||
|
||||
for(const [width,height] of SIZES)for(const theme of ['dark','light'])test(`tags page geometry, tokens and controls match the concept ${width}x${height} ${theme}`,async({page},testInfo)=>{
|
||||
await page.setViewportSize({width,height});const result={};
|
||||
for(const motion of MOTIONS) {
|
||||
await openConceptTags(page,theme,motion);const reference=await pageOf(page,false);
|
||||
if(motion==='no-preference')await captureCanonicalState(page,testInfo,`concept-tags-${width}-${theme}`,'#main');
|
||||
const referenceChrome=await chromeOf(page,false);
|
||||
await openProductionTags(page,theme,motion);const actual=await pageOf(page,true);
|
||||
if(motion==='no-preference')await captureCanonicalState(page,testInfo,`production-tags-${width}-${theme}`,'#workspace-main');
|
||||
result[motion]={reference,actual,...comparePage(reference,actual),referenceChrome,actualChrome:await chromeOf(page,true)};
|
||||
}
|
||||
const path=testInfo.outputPath('tags-geometry.json');await writeFile(path,JSON.stringify(result,null,2));
|
||||
await testInfo.attach('tags-geometry',{path,contentType:'application/json'});
|
||||
for(const [motion,{reference,diff,extra,referenceChrome,actualChrome}] of Object.entries(result)) {
|
||||
expect.soft(referenceChrome,`${motion} concept chrome`).toMatchObject({breadcrumb:'Tags',title:'Tags',ring:{visible:true},linkRing:{outlineOffset:'3px'}});
|
||||
expect.soft(actualChrome,`${motion} breadcrumb, title and focus ring`).toEqual(referenceChrome);
|
||||
expect.soft(reference.heading.actions.buttons.map(item=>item.text),`${motion} concept heading actions`).toEqual(['+ Create tag','Auto-tag images']);
|
||||
expect.soft(reference.table.rows.map(row=>[row.tag,Number(row.prompts)]),`${motion} concept rows`).toEqual(CONCEPT_COUNTS);
|
||||
expect.soft(diff,`${motion} differences after the recorded deviations`).toEqual([]);
|
||||
expect.soft(extra,`${motion} fixture-only rows`).toEqual(fixtureOnly());
|
||||
}
|
||||
const {actual}=result['no-preference'];
|
||||
expect(actual.noPageScroll).toBe(true);
|
||||
expect(actual.twoCol.columns).toBe(width<=700?1:2);
|
||||
});
|
||||
|
||||
const conceptDialogs={
|
||||
create:async page=>page.locator('#main').getByRole('button',{name:'+ Create tag',exact:true}).click(),
|
||||
rename:async page=>page.locator('#main tbody tr').first().getByRole('button',{name:'Rename / merge',exact:true}).click(),
|
||||
manage:async page=>page.locator('#main').getByRole('button',{name:'Review',exact:true}).click(),
|
||||
};
|
||||
const productionDialogs={
|
||||
create:page=>openDialog(page,main(page).getByRole('button',{name:'+ Create tag',exact:true}),'Create tag'),
|
||||
rename:page=>openRename(page,'cinematic'),
|
||||
manage:async page=>{const box=await openManage(page);await expect(button(box,'Preview')).toBeEnabled();return box;},
|
||||
};
|
||||
async function conceptDonors(page,theme) {
|
||||
await page.goto('about:blank');await page.goto(`${concept}#images`);await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
await page.locator('#main').getByRole('button',{name:'Filters',exact:true}).click();await expect(page.locator('#modal')).toBeVisible();
|
||||
const filters=await page.locator('#modal').evaluate(inventoryOf);
|
||||
await page.goto('about:blank');await page.goto(`${concept}#prompt/alpine`);await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();await expect(page.locator('#modal')).toBeVisible();
|
||||
const editor=await page.locator('#modal').evaluate(inventoryOf);
|
||||
return {filters,editor,categories:CATEGORIES.map(([name])=>name)};
|
||||
}
|
||||
|
||||
for(const [width,height] of SIZES)for(const theme of ['dark','light'])test(`dialog inventories match the concept ${width}x${height} ${theme}`,async({page},testInfo)=>{
|
||||
await page.setViewportSize({width,height});const result={};
|
||||
const donors=await conceptDonors(page,theme);
|
||||
for(const mode of Object.keys(conceptDialogs)) {
|
||||
await openConceptTags(page,theme,'no-preference');await conceptDialogs[mode](page);
|
||||
const modal=page.locator('#modal');await expect(modal).toBeVisible();
|
||||
result[mode]={reference:await modal.evaluate(inventoryOf),referenceValue:await modal.locator('input').inputValue()};
|
||||
await captureCanonicalState(page,testInfo,`concept-dialog-${mode}-${width}-${theme}`,'#modal');
|
||||
}
|
||||
await openProductionTags(page,theme,'no-preference');
|
||||
for(const mode of Object.keys(productionDialogs)) {
|
||||
const box=await productionDialogs[mode](page);
|
||||
result[mode].actual=await box.evaluate(inventoryOf);result[mode].actualValue=await box.locator('input').inputValue();
|
||||
await captureCanonicalState(page,testInfo,`production-dialog-${mode}-${width}-${theme}`,'dialog.vocabulary-dialog');
|
||||
await page.keyboard.press('Escape');await expect(box).toHaveCount(0);
|
||||
}
|
||||
const path=testInfo.outputPath('vocabulary-dialog-geometry.json');await writeFile(path,JSON.stringify({donors,result},null,2));
|
||||
await testInfo.attach('vocabulary-dialog-geometry',{path,contentType:'application/json'});
|
||||
for(const [mode,{reference,actual,referenceValue,actualValue}] of Object.entries(result)) {
|
||||
expect.soft(reference.actions.buttons.map(item=>item.text),`${mode} concept actions`).toEqual(['Cancel','Apply in prototype']);
|
||||
expect.soft(diffInventory(mapVocabularyDialog(mode,reference,donors),actual),`${mode} differences after the recorded deviations`).toEqual([]);
|
||||
expect.soft(actualValue,`${mode} prefilled value`).toBe(referenceValue);
|
||||
}
|
||||
});
|
||||
|
||||
test('mutation check: the comparison reports a changed surface padding and a changed Browse label',async({page})=>{
|
||||
await page.setViewportSize({width:1600,height:1100});
|
||||
await openConceptTags(page,'dark','no-preference');const reference=await pageOf(page,false);
|
||||
await openProductionTags(page,'dark','no-preference');
|
||||
expect(comparePage(reference,await pageOf(page,true)).diff).toEqual([]);
|
||||
await page.addStyleTag({content:'.vocabulary-main .surface{padding:21px!important}'});
|
||||
await rows(page).first().getByRole('button',{name:'Browse',exact:true}).evaluate(node=>{node.textContent='Open';});
|
||||
const paths=comparePage(reference,await pageOf(page,true)).diff.map(item=>item.path);
|
||||
expect(paths).toContain('surfaces.0.paddingTop');
|
||||
expect(paths).toContain('surfaces.1.paddingLeft');
|
||||
expect(paths).toContain('table.rows.0.actions.0');
|
||||
expect(paths).toContain('table.rowActions.buttons.0.text');
|
||||
// The dialog comparison is not vacuous either: a removed Cancel and a recolored field are reported.
|
||||
const donors=await conceptDonors(page,'dark');
|
||||
await openConceptTags(page,'dark','no-preference');await conceptDialogs.create(page);
|
||||
const dialogReference=mapVocabularyDialog('create',await page.locator('#modal').evaluate(inventoryOf),donors);
|
||||
await openProductionTags(page,'dark','no-preference');const box=await productionDialogs.create(page);
|
||||
expect(diffInventory(dialogReference,await box.evaluate(inventoryOf))).toEqual([]);
|
||||
await page.addStyleTag({content:'dialog.vocabulary-dialog .field input{color:rgb(4,5,6)!important}'});
|
||||
await button(box,'Cancel').evaluate(node=>node.remove());
|
||||
const dialogPaths=diffInventory(dialogReference,await box.evaluate(inventoryOf)).map(item=>item.path);
|
||||
expect(dialogPaths).toContain('actions.buttons.length');
|
||||
expect(dialogPaths).toContain('fields.0.control.color');
|
||||
await page.keyboard.press('Escape');
|
||||
});
|
||||
@@ -8,7 +8,7 @@ const routes = [
|
||||
['overview', 'Overview', null],
|
||||
['images', 'Images', '/prompt_manager/gallery'],
|
||||
['library', 'Prompt library', '/prompt_manager/admin'],
|
||||
['tags', 'Tags & categories', null],
|
||||
['tags', 'Tags & categories', '/prompt_manager/admin#/tags'],
|
||||
['inspect', 'Inspect a file', '/prompt_manager/gallery.html'],
|
||||
['system', 'System', '/prompt_manager/admin'],
|
||||
];
|
||||
@@ -68,7 +68,6 @@ test('all shell destinations deep-link, reload and expose working existing appli
|
||||
await expect(main.getByRole('heading', { level: 1, name: label, exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('navigation', { name: 'Workspace', exact: true })
|
||||
.getByRole('link', { name: label, exact: true })).toHaveAttribute('aria-current', 'page');
|
||||
if (route === 'tags') await expect(main.locator('.handoff')).toHaveCount(0);
|
||||
if (legacy) {
|
||||
await expect(main.locator(`a[href="${legacy}"]`)).toBeVisible();
|
||||
await expect(await request.get(legacy.split('#')[0])).toBeOK();
|
||||
|
||||
@@ -89,9 +89,7 @@ def seed_workspace_inspector(
|
||||
],
|
||||
"links": [],
|
||||
}
|
||||
# Two branches that differ only in seed: facts merge per field (spec section 10).
|
||||
branch = {**complete["3"], "inputs": {**complete["3"]["inputs"], "seed": 7}}
|
||||
ambiguous = {**complete, "5": branch}
|
||||
ambiguous = {**complete, "5": complete["3"]}
|
||||
examples = (
|
||||
("complete.png", {"prompt": complete, "workflow": workflow}),
|
||||
("empty.png", {}),
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
"""Owned, deterministic vocabulary data for the native Tags & categories workflows.
|
||||
|
||||
The six recipes are the approved concept's (docs/concepts/workspace/app.js:71-165)
|
||||
in concept order, so tag creation order and category first use match the mock.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from py.api import PromptManagerAPI
|
||||
|
||||
|
||||
CONCEPT_RECIPES = (
|
||||
{
|
||||
"title": "Alpine observatory",
|
||||
"category": "Landscapes",
|
||||
"tags": ["cinematic", "blue_hour", "architecture"],
|
||||
"positive": "A remote alpine observatory at blue hour, warm light through "
|
||||
"glass, dramatic mountain peaks, cinematic composition, atmospheric mist, "
|
||||
"fine detail",
|
||||
"negative": "blurry, low quality, low resolution",
|
||||
},
|
||||
{
|
||||
"title": "Quiet geometry",
|
||||
"category": "Architecture",
|
||||
"tags": ["minimal", "warm_light", "editorial"],
|
||||
"positive": "Monolithic sandstone pavilion in a vast desert, sweeping dunes, "
|
||||
"warm terracotta, late afternoon sun, architectural photography, quiet "
|
||||
"geometry",
|
||||
"negative": "plastic, harsh shadows, noise, watermark",
|
||||
},
|
||||
{
|
||||
"title": "Somewhere in the pines",
|
||||
"category": "Landscapes",
|
||||
"tags": ["forest", "mist", "peaceful"],
|
||||
"positive": "A quiet cedar cabin above a still forest lake, towering "
|
||||
"evergreens, soft morning mist, muted jade and moss, a small amber window, "
|
||||
"cinematic still",
|
||||
"negative": "oversaturated, blurry, low quality, text",
|
||||
},
|
||||
{
|
||||
"title": "After the rain",
|
||||
"category": "Environments",
|
||||
"tags": ["neon", "night", "cinematic"],
|
||||
"positive": "Rain-soaked city street at night, blue neon and rose "
|
||||
"reflections, deep perspective, cinematic science fiction, solitary "
|
||||
"architecture, detailed atmosphere",
|
||||
"negative": "blurry, low resolution, watermark, oversaturated",
|
||||
},
|
||||
{
|
||||
"title": "The long way home",
|
||||
"category": "Illustration",
|
||||
"tags": ["space", "minimal", "concept_art"],
|
||||
"positive": "An isolated lunar research outpost, a luminous planet above the "
|
||||
"horizon, sweeping pale terrain, quiet science fiction, finely detailed "
|
||||
"concept art",
|
||||
"negative": "blurry, low quality, watermark",
|
||||
},
|
||||
{
|
||||
"title": "Between sea and sky",
|
||||
"category": "Landscapes",
|
||||
"tags": ["coast", "soft_light", "peaceful"],
|
||||
"positive": "A solitary lighthouse on a rugged coastal cliff, sea mist, "
|
||||
"glints of morning light on turquoise water, atmospheric perspective, fine "
|
||||
"art landscape",
|
||||
"negative": "oversaturated, flat lighting, watermark, text",
|
||||
},
|
||||
)
|
||||
PAIR_ONLY_TAG = "pair_only_detail"
|
||||
ARCHIVED_ONLY_TAG = "archived_pair_detail"
|
||||
NEGATIVE_ONLY_TAG = "legacy_negative_detail"
|
||||
UNUSED_TAG = "unused_vocabulary"
|
||||
MAX_EXTRA_TAGS = 300
|
||||
MAX_EXTRA_CATEGORIES = 200
|
||||
_TABLES = (
|
||||
"image_prompt_pairs",
|
||||
"prompt_pair_tags",
|
||||
"prompt_pairs",
|
||||
"generated_images",
|
||||
"prompt_tags",
|
||||
"tags",
|
||||
"prompt_library_state",
|
||||
"prompts",
|
||||
)
|
||||
# Every owner row a vocabulary command could touch, in a stable order, for
|
||||
# before/after comparisons; prompt text, pairs and images must never change.
|
||||
_SNAPSHOT = {
|
||||
"tags": "SELECT id, name FROM tags ORDER BY id",
|
||||
"prompt_tags": "SELECT prompt_id, tag_id FROM prompt_tags ORDER BY 1, 2",
|
||||
"prompt_pair_tags": "SELECT pair_id, tag_id FROM prompt_pair_tags ORDER BY 1, 2",
|
||||
"prompts": "SELECT id, text, category, tags, updated_at FROM prompts ORDER BY id",
|
||||
"prompt_pairs": "SELECT id, positive_prompt_id, negative_prompt_id, category,"
|
||||
" archived, updated_at FROM prompt_pairs ORDER BY id",
|
||||
"generated_images": "SELECT id, prompt_id, image_path FROM generated_images"
|
||||
" ORDER BY id",
|
||||
"image_prompt_pairs": "SELECT id, image_id, pair_id FROM image_prompt_pairs"
|
||||
" ORDER BY id",
|
||||
}
|
||||
|
||||
|
||||
def seed_vocabulary(
|
||||
api: PromptManagerAPI, root: Path, extra_tags: int = 0, extra_categories: int = 0
|
||||
) -> dict:
|
||||
"""Replace this server's disposable rows with the concept vocabulary."""
|
||||
db = api.db
|
||||
with db.model.get_connection() as conn:
|
||||
for table in _TABLES:
|
||||
conn.execute(f"DELETE FROM {table}")
|
||||
for name in ("output", "input"):
|
||||
shutil.rmtree(root / name)
|
||||
(root / name).mkdir()
|
||||
epoch = db.get_execution_epoch()
|
||||
recipes = []
|
||||
for recipe in CONCEPT_RECIPES:
|
||||
receipt = db.create_library_recipe(
|
||||
recipe["positive"],
|
||||
recipe["negative"],
|
||||
expected_epoch=epoch,
|
||||
parent_metadata={
|
||||
"title": recipe["title"],
|
||||
"category": recipe["category"],
|
||||
"tags": list(recipe["tags"]),
|
||||
},
|
||||
)
|
||||
recipes.append(
|
||||
{"positive_id": receipt["positive"]["id"], "pair_id": receipt["pair"]["id"]}
|
||||
)
|
||||
first = CONCEPT_RECIPES[0]["positive"]
|
||||
# A tag carried only by an active saved pair counts its Library parent.
|
||||
db.save_prompt_pair(first, "grain, banding", tags=[PAIR_ONLY_TAG])
|
||||
# A tag carried only by an archived pair is hidden from Library Browse.
|
||||
archived = db.save_prompt_pair(
|
||||
CONCEPT_RECIPES[1]["positive"], "lens flare", tags=[ARCHIVED_ONLY_TAG]
|
||||
)
|
||||
db.archive_prompt_pair(archived, True)
|
||||
# A legacy tag on a negative-only component is never a Library parent.
|
||||
with db.model.get_connection() as conn:
|
||||
negative_id = conn.execute(
|
||||
"SELECT negative_prompt_id FROM prompt_pairs WHERE id=?",
|
||||
(recipes[2]["pair_id"],),
|
||||
).fetchone()[0]
|
||||
db.update_prompt_metadata(negative_id, tags=[NEGATIVE_ONLY_TAG])
|
||||
db.create_vocabulary_tag(UNUSED_TAG, expected_epoch=epoch)
|
||||
for number in range(extra_tags):
|
||||
db.create_vocabulary_tag(f"bulk_tag_{number:04}", expected_epoch=epoch)
|
||||
# Each extra category is carried by one minimal Library recipe, after the concept's.
|
||||
for number in range(extra_categories):
|
||||
db.create_library_recipe(
|
||||
f"Bulk category recipe {number:04}",
|
||||
"",
|
||||
expected_epoch=epoch,
|
||||
parent_metadata={
|
||||
"title": f"Bulk {number:04}",
|
||||
"category": f"bulk_category_{number:04}",
|
||||
},
|
||||
)
|
||||
with db.model.get_connection() as conn:
|
||||
tags = [row[0] for row in conn.execute("SELECT name FROM tags ORDER BY id")]
|
||||
api.invalidate_gallery_cache()
|
||||
return {
|
||||
"epoch": epoch,
|
||||
"recipes": recipes,
|
||||
"archived_pair_id": archived,
|
||||
"negative_only_id": negative_id,
|
||||
"tags": tags,
|
||||
"pair_only_tag": PAIR_ONLY_TAG,
|
||||
"archived_only_tag": ARCHIVED_ONLY_TAG,
|
||||
"negative_only_tag": NEGATIVE_ONLY_TAG,
|
||||
"unused_tag": UNUSED_TAG,
|
||||
}
|
||||
|
||||
|
||||
def vocabulary_snapshot(api: PromptManagerAPI) -> dict:
|
||||
"""Plain rows of every vocabulary owner table."""
|
||||
with api.db.model.get_connection() as conn:
|
||||
return {
|
||||
name: [list(row) for row in conn.execute(query)]
|
||||
for name, query in _SNAPSHOT.items()
|
||||
}
|
||||
|
||||
|
||||
def register_vocabulary_commands(
|
||||
routes: web.RouteTableDef, api: PromptManagerAPI, root: Path
|
||||
) -> None:
|
||||
"""Register only fixed seed, snapshot and backup commands; no paths or SQL input."""
|
||||
|
||||
@routes.post("/__test__/vocabulary/reset")
|
||||
async def reset(request: web.Request) -> web.Response:
|
||||
data = await request.json() if request.can_read_body else {}
|
||||
if not isinstance(data, dict) or set(data) - {"extra_tags", "extra_categories"}:
|
||||
raise web.HTTPBadRequest()
|
||||
extra = data.get("extra_tags", 0)
|
||||
categories = data.get("extra_categories", 0)
|
||||
for value, limit in (
|
||||
(extra, MAX_EXTRA_TAGS),
|
||||
(categories, MAX_EXTRA_CATEGORIES),
|
||||
):
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise web.HTTPBadRequest()
|
||||
if not 0 <= value <= limit:
|
||||
raise web.HTTPBadRequest()
|
||||
if request.query:
|
||||
raise web.HTTPBadRequest()
|
||||
manifest = await api._run_in_executor(
|
||||
seed_vocabulary, api, root, extra, categories
|
||||
)
|
||||
return web.json_response({"success": True, **manifest})
|
||||
|
||||
@routes.get("/__test__/vocabulary/snapshot")
|
||||
async def snapshot(request: web.Request) -> web.Response:
|
||||
if request.query:
|
||||
raise web.HTTPBadRequest()
|
||||
rows = await api._run_in_executor(vocabulary_snapshot, api)
|
||||
return web.json_response(rows)
|
||||
|
||||
@routes.get("/__test__/vocabulary/backup")
|
||||
async def backup(request: web.Request) -> web.Response:
|
||||
"""A database copy for the real restore route, which rotates the epoch."""
|
||||
if request.query:
|
||||
raise web.HTTPBadRequest()
|
||||
path = root / "owned-vocabulary-backup.db"
|
||||
created = await api._run_in_executor(api.db.model.backup_database, str(path))
|
||||
if not created:
|
||||
raise web.HTTPInternalServerError()
|
||||
return web.Response(
|
||||
body=path.read_bytes(), content_type="application/octet-stream"
|
||||
)
|
||||
@@ -243,10 +243,10 @@ test.describe('public chronological history with the actual acknowledged host br
|
||||
const frozen = attempts[0].context;
|
||||
for (const mode of ['Raw', 'Effective']) {
|
||||
await run.getByRole('button', { name: mode, exact: true }).click();
|
||||
for (const [role, value] of [['Positive', frozen.positive[mode.toLowerCase()]], ['Negative', frozen.negative[mode.toLowerCase()]]]) expect(await run.getByRole('region', { name: 'Frozen run prompt texts', exact: true }).getByRole('region', { name: `${role} prompt`, exact: true }).locator('pre').evaluate(node => node.textContent)).toBe(value);
|
||||
for (const [role, value] of [['Positive', frozen.positive[mode.toLowerCase()]], ['Negative', frozen.negative[mode.toLowerCase()]]]) expect(await run.getByRole('region', { name: `${role} prompt`, exact: true }).locator('pre').evaluate(node => node.textContent)).toBe(value);
|
||||
await page.evaluate(async id => {const { app } = await import('/scripts/app.js');const node = app.graph.getNodeById(id);node.widgets.find(w => w.name === 'text').value = 'Before send';window.chronologyEffects = { queue: 0, load: 0 };window.chronologyOriginals = {};for (const [method, key] of [['queuePrompt', 'queue'], ['loadGraphData', 'load']]) {const original = app[method];window.chronologyOriginals[method] = original;app[method] = function(...args) {window.chronologyEffects[key]++;return original.apply(this, args);};}}, destination);
|
||||
try {
|
||||
await run.getByRole('region', { name: 'Frozen run prompt texts', exact: true }).getByRole('button', { name: 'Send prompt pair', exact: true }).click();const sender = workspace.getByRole('dialog', { name: 'Send to ComfyUI', exact: true });
|
||||
await run.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);await hosts.selectOption({ index: 1 });
|
||||
await sender.getByRole('combobox', { name: 'Destination node', exact: true }).selectOption({ index: 1 });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.');
|
||||
|
||||
Vendored
+1
-1
@@ -15,7 +15,7 @@ Browser tests use the committed file; FFmpeg is not required to run them.
|
||||
|
||||
# Inspection parity fixtures
|
||||
|
||||
`inspection_parity/` holds small synthetic PNGs (`f01b.png` ... `f24.png`, each
|
||||
`inspection_parity/` holds small synthetic PNGs (`f01b.png` ... `f17.png`, each
|
||||
under 32 KiB) and `vectors.json`, used by the #240 inspection parity tests. Every
|
||||
graph, prompt text, model name and filename prefix in them is original synthetic
|
||||
content written for this repository. Nothing comes from user output. They are
|
||||
|
||||
-809
File diff suppressed because one or more lines are too long
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 1.4 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 1.3 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 988 B |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 989 B |
+73
-427
@@ -6,9 +6,9 @@
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "ambiguous",
|
||||
"reason": "Multiple sampler branches",
|
||||
"reason_code": null,
|
||||
"state": "unresolved",
|
||||
"reason": "No supported sampler pair found (saw FaceDetailer, SamplerCustomAdvanced)",
|
||||
"reason_code": "no-sampler",
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
@@ -19,24 +19,24 @@
|
||||
"value": null
|
||||
},
|
||||
"steps": {
|
||||
"status": "known",
|
||||
"value": "20"
|
||||
"status": "ambiguous",
|
||||
"value": null
|
||||
},
|
||||
"cfg": {
|
||||
"status": "unresolved",
|
||||
"status": "ambiguous",
|
||||
"value": null
|
||||
},
|
||||
"sampler": {
|
||||
"status": "known",
|
||||
"value": "euler"
|
||||
"status": "ambiguous",
|
||||
"value": null
|
||||
},
|
||||
"scheduler": {
|
||||
"status": "ambiguous",
|
||||
"value": null
|
||||
},
|
||||
"model": {
|
||||
"status": "known",
|
||||
"value": "flux-2-klein-9b-fp8.safetensors"
|
||||
"status": "ambiguous",
|
||||
"value": null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,12 +48,12 @@
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "absent",
|
||||
"reason": "Guider has no negative input",
|
||||
"reason_code": null,
|
||||
"state": "unresolved",
|
||||
"reason": "No supported sampler pair found (saw FaceDetailer, SamplerCustomAdvanced)",
|
||||
"reason_code": "no-sampler",
|
||||
"selection": null,
|
||||
"positive": "a red fox in snow, detailed fur, Scene 0: unique shot description soft winter light,",
|
||||
"negative": "<absent>",
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"candidate_count": 0,
|
||||
"facts": {
|
||||
"seed": {
|
||||
@@ -61,8 +61,8 @@
|
||||
"value": "1000"
|
||||
},
|
||||
"steps": {
|
||||
"status": "known",
|
||||
"value": "20"
|
||||
"status": "unresolved",
|
||||
"value": null
|
||||
},
|
||||
"cfg": {
|
||||
"status": "not-recorded",
|
||||
@@ -73,8 +73,8 @@
|
||||
"value": "euler"
|
||||
},
|
||||
"scheduler": {
|
||||
"status": "known",
|
||||
"value": "Flux2Scheduler"
|
||||
"status": "unresolved",
|
||||
"value": null
|
||||
},
|
||||
"model": {
|
||||
"status": "known",
|
||||
@@ -258,12 +258,12 @@
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "resolved",
|
||||
"reason": "",
|
||||
"reason_code": null,
|
||||
"state": "unresolved",
|
||||
"reason": "Unsupported node semantics or output slot",
|
||||
"reason_code": "unsupported-node",
|
||||
"selection": null,
|
||||
"positive": "a red fox in snow, detailed fur",
|
||||
"negative": "blurry, lowres",
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"candidate_count": 0,
|
||||
"facts": {
|
||||
"seed": {
|
||||
@@ -300,12 +300,12 @@
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "resolved",
|
||||
"reason": "",
|
||||
"reason_code": null,
|
||||
"state": "unresolved",
|
||||
"reason": "Unsupported node semantics or output slot",
|
||||
"reason_code": "unsupported-node",
|
||||
"selection": null,
|
||||
"positive": "a red fox in snow, detailed fur",
|
||||
"negative": "blurry, lowres",
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"candidate_count": 0,
|
||||
"facts": {
|
||||
"seed": {
|
||||
@@ -342,12 +342,12 @@
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "resolved",
|
||||
"reason": "",
|
||||
"reason_code": null,
|
||||
"state": "unresolved",
|
||||
"reason": "Unsupported node semantics or output slot",
|
||||
"reason_code": "unsupported-node",
|
||||
"selection": null,
|
||||
"positive": "a red fox in snow, detailed fur",
|
||||
"negative": "blurry, lowres",
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"candidate_count": 0,
|
||||
"facts": {
|
||||
"seed": {
|
||||
@@ -385,8 +385,8 @@
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "unresolved",
|
||||
"reason": "Combined conditioning has different texts",
|
||||
"reason_code": "composite",
|
||||
"reason": "Unsupported node semantics or output slot",
|
||||
"reason_code": "unsupported-node",
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
@@ -426,12 +426,12 @@
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "resolved",
|
||||
"reason": "",
|
||||
"reason_code": null,
|
||||
"state": "unresolved",
|
||||
"reason": "Unsupported node semantics or output slot",
|
||||
"reason_code": "unsupported-node",
|
||||
"selection": null,
|
||||
"positive": "a red fox in snow, detailed fur",
|
||||
"negative": "blurry, lowres",
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"candidate_count": 0,
|
||||
"facts": {
|
||||
"seed": {
|
||||
@@ -510,12 +510,12 @@
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "resolved",
|
||||
"reason": "",
|
||||
"reason_code": null,
|
||||
"state": "unresolved",
|
||||
"reason": "No supported sampler pair found (saw SamplerCustomAdvanced, CFGGuider)",
|
||||
"reason_code": "no-sampler",
|
||||
"selection": null,
|
||||
"positive": "a red fox in snow, detailed fur",
|
||||
"negative": "blurry, lowres",
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"candidate_count": 0,
|
||||
"facts": {
|
||||
"seed": {
|
||||
@@ -603,28 +603,28 @@
|
||||
"candidate_count": 0,
|
||||
"facts": {
|
||||
"seed": {
|
||||
"status": "known",
|
||||
"value": "42"
|
||||
"status": "ambiguous",
|
||||
"value": null
|
||||
},
|
||||
"steps": {
|
||||
"status": "known",
|
||||
"value": "25"
|
||||
"status": "ambiguous",
|
||||
"value": null
|
||||
},
|
||||
"cfg": {
|
||||
"status": "known",
|
||||
"value": "6.5"
|
||||
"status": "ambiguous",
|
||||
"value": null
|
||||
},
|
||||
"sampler": {
|
||||
"status": "known",
|
||||
"value": "euler"
|
||||
"status": "ambiguous",
|
||||
"value": null
|
||||
},
|
||||
"scheduler": {
|
||||
"status": "known",
|
||||
"value": "normal"
|
||||
"status": "ambiguous",
|
||||
"value": null
|
||||
},
|
||||
"model": {
|
||||
"status": "known",
|
||||
"value": "sdxl_base.safetensors"
|
||||
"status": "ambiguous",
|
||||
"value": null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -636,12 +636,12 @@
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "resolved",
|
||||
"reason": "",
|
||||
"reason_code": null,
|
||||
"state": "unresolved",
|
||||
"reason": "Unsupported latent ancestry",
|
||||
"reason_code": "unsupported-node",
|
||||
"selection": null,
|
||||
"positive": "a red fox in snow, detailed fur",
|
||||
"negative": "blurry, lowres",
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"candidate_count": 0,
|
||||
"facts": {
|
||||
"seed": {
|
||||
@@ -678,12 +678,12 @@
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "resolved",
|
||||
"reason": "",
|
||||
"reason_code": null,
|
||||
"state": "unresolved",
|
||||
"reason": "Unsupported latent ancestry",
|
||||
"reason_code": "unsupported-node",
|
||||
"selection": null,
|
||||
"positive": "a red fox in snow, detailed fur",
|
||||
"negative": "blurry, lowres",
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"candidate_count": 0,
|
||||
"facts": {
|
||||
"seed": {
|
||||
@@ -888,12 +888,12 @@
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "absent",
|
||||
"reason": "Guider has no negative input",
|
||||
"reason_code": null,
|
||||
"state": "unresolved",
|
||||
"reason": "No supported sampler pair found (saw SamplerCustomAdvanced)",
|
||||
"reason_code": "no-sampler",
|
||||
"selection": null,
|
||||
"positive": "a red fox in snow, detailed fur",
|
||||
"negative": "<absent>",
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"candidate_count": 0,
|
||||
"facts": {
|
||||
"seed": {
|
||||
@@ -923,258 +923,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "f19-inspection",
|
||||
"fixture": "f19",
|
||||
"profile": "inspection",
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "ambiguous",
|
||||
"reason": "Sampler stages have different prompts",
|
||||
"reason_code": "multistage-differs",
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"candidate_count": 0,
|
||||
"facts": {
|
||||
"seed": {
|
||||
"status": "ambiguous",
|
||||
"value": null
|
||||
},
|
||||
"steps": {
|
||||
"status": "ambiguous",
|
||||
"value": null
|
||||
},
|
||||
"cfg": {
|
||||
"status": "ambiguous",
|
||||
"value": null
|
||||
},
|
||||
"sampler": {
|
||||
"status": "known",
|
||||
"value": "euler"
|
||||
},
|
||||
"scheduler": {
|
||||
"status": "known",
|
||||
"value": "normal"
|
||||
},
|
||||
"model": {
|
||||
"status": "known",
|
||||
"value": "sdxl_base.safetensors"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "f20-inspection",
|
||||
"fixture": "f20",
|
||||
"profile": "inspection",
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "resolved",
|
||||
"reason": "",
|
||||
"reason_code": null,
|
||||
"selection": null,
|
||||
"positive": "a red fox in snow, detailed fur",
|
||||
"negative": "blurry, lowres",
|
||||
"candidate_count": 0,
|
||||
"facts": {
|
||||
"seed": {
|
||||
"status": "known",
|
||||
"value": "42"
|
||||
},
|
||||
"steps": {
|
||||
"status": "known",
|
||||
"value": "25"
|
||||
},
|
||||
"cfg": {
|
||||
"status": "known",
|
||||
"value": "6.5"
|
||||
},
|
||||
"sampler": {
|
||||
"status": "known",
|
||||
"value": "euler"
|
||||
},
|
||||
"scheduler": {
|
||||
"status": "known",
|
||||
"value": "normal"
|
||||
},
|
||||
"model": {
|
||||
"status": "known",
|
||||
"value": "sdxl_base.safetensors"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "f21-inspection",
|
||||
"fixture": "f21",
|
||||
"profile": "inspection",
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "unresolved",
|
||||
"reason": "Text is decided at run time",
|
||||
"reason_code": "runtime-value",
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"candidate_count": 0,
|
||||
"facts": {
|
||||
"seed": {
|
||||
"status": "known",
|
||||
"value": "42"
|
||||
},
|
||||
"steps": {
|
||||
"status": "known",
|
||||
"value": "25"
|
||||
},
|
||||
"cfg": {
|
||||
"status": "known",
|
||||
"value": "6.5"
|
||||
},
|
||||
"sampler": {
|
||||
"status": "known",
|
||||
"value": "euler"
|
||||
},
|
||||
"scheduler": {
|
||||
"status": "known",
|
||||
"value": "normal"
|
||||
},
|
||||
"model": {
|
||||
"status": "known",
|
||||
"value": "sdxl_base.safetensors"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "f22-inspection",
|
||||
"fixture": "f22",
|
||||
"profile": "inspection",
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "unresolved",
|
||||
"reason": "Text is decided at run time",
|
||||
"reason_code": "runtime-value",
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"candidate_count": 0,
|
||||
"facts": {
|
||||
"seed": {
|
||||
"status": "known",
|
||||
"value": "42"
|
||||
},
|
||||
"steps": {
|
||||
"status": "known",
|
||||
"value": "25"
|
||||
},
|
||||
"cfg": {
|
||||
"status": "known",
|
||||
"value": "6.5"
|
||||
},
|
||||
"sampler": {
|
||||
"status": "known",
|
||||
"value": "euler"
|
||||
},
|
||||
"scheduler": {
|
||||
"status": "known",
|
||||
"value": "normal"
|
||||
},
|
||||
"model": {
|
||||
"status": "known",
|
||||
"value": "sdxl_base.safetensors"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "f23-inspection",
|
||||
"fixture": "f23",
|
||||
"profile": "inspection",
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "resolved",
|
||||
"reason": "",
|
||||
"reason_code": null,
|
||||
"selection": null,
|
||||
"positive": "a red fox in snow, detailed fur",
|
||||
"negative": "",
|
||||
"candidate_count": 0,
|
||||
"facts": {
|
||||
"seed": {
|
||||
"status": "known",
|
||||
"value": "42"
|
||||
},
|
||||
"steps": {
|
||||
"status": "known",
|
||||
"value": "25"
|
||||
},
|
||||
"cfg": {
|
||||
"status": "known",
|
||||
"value": "6.5"
|
||||
},
|
||||
"sampler": {
|
||||
"status": "known",
|
||||
"value": "euler"
|
||||
},
|
||||
"scheduler": {
|
||||
"status": "known",
|
||||
"value": "normal"
|
||||
},
|
||||
"model": {
|
||||
"status": "known",
|
||||
"value": "sdxl_base.safetensors"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "f24-inspection",
|
||||
"fixture": "f24",
|
||||
"profile": "inspection",
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "resolved",
|
||||
"reason": "",
|
||||
"reason_code": null,
|
||||
"selection": null,
|
||||
"positive": "a red fox in snow, detailed fur",
|
||||
"negative": "blurry, lowres",
|
||||
"candidate_count": 0,
|
||||
"facts": {
|
||||
"seed": {
|
||||
"status": "known",
|
||||
"value": "42"
|
||||
},
|
||||
"steps": {
|
||||
"status": "known",
|
||||
"value": "25"
|
||||
},
|
||||
"cfg": {
|
||||
"status": "known",
|
||||
"value": "6.5"
|
||||
},
|
||||
"sampler": {
|
||||
"status": "known",
|
||||
"value": "euler"
|
||||
},
|
||||
"scheduler": {
|
||||
"status": "known",
|
||||
"value": "normal"
|
||||
},
|
||||
"model": {
|
||||
"status": "known",
|
||||
"value": "sdxl_base.safetensors"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "f01-default",
|
||||
"fixture": "f01",
|
||||
@@ -1548,107 +1296,5 @@
|
||||
"candidate_count": 0,
|
||||
"facts": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "f19-default",
|
||||
"fixture": "f19",
|
||||
"profile": "default",
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "resolved",
|
||||
"reason": "",
|
||||
"reason_code": null,
|
||||
"selection": null,
|
||||
"positive": "a red fox in snow, detailed fur",
|
||||
"negative": "blurry, lowres",
|
||||
"candidate_count": 0,
|
||||
"facts": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "f20-default",
|
||||
"fixture": "f20",
|
||||
"profile": "default",
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "unresolved",
|
||||
"reason": "Unsupported node semantics or output slot",
|
||||
"reason_code": null,
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"candidate_count": 0,
|
||||
"facts": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "f21-default",
|
||||
"fixture": "f21",
|
||||
"profile": "default",
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "unresolved",
|
||||
"reason": "Unsupported node semantics or output slot",
|
||||
"reason_code": null,
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"candidate_count": 0,
|
||||
"facts": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "f22-default",
|
||||
"fixture": "f22",
|
||||
"profile": "default",
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "unresolved",
|
||||
"reason": "Unsupported node semantics or output slot",
|
||||
"reason_code": null,
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"candidate_count": 0,
|
||||
"facts": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "f23-default",
|
||||
"fixture": "f23",
|
||||
"profile": "default",
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "unresolved",
|
||||
"reason": "Unsupported node semantics or output slot",
|
||||
"reason_code": null,
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"candidate_count": 0,
|
||||
"facts": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "f24-default",
|
||||
"fixture": "f24",
|
||||
"profile": "default",
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "unresolved",
|
||||
"reason": "Unsupported node semantics or output slot",
|
||||
"reason_code": null,
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"candidate_count": 0,
|
||||
"facts": null
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,444 +0,0 @@
|
||||
"""Named workflow cases shared by the generation-parameter parser tests.
|
||||
|
||||
Each case is the raw metadata records a file would carry, as text, so the cases
|
||||
exercise ``parse_js_json`` as well as the tracing. The JS parity test runs every
|
||||
case through the legacy browser parser too. Prompt texts and seeds are invented.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
|
||||
def _text(value):
|
||||
return None if value is None else json.dumps(value)
|
||||
|
||||
|
||||
def case(prompt=None, workflow=None):
|
||||
return {"prompt": _text(prompt), "workflow": _text(workflow)}
|
||||
|
||||
|
||||
def encoder(text, clip=("4", 1)):
|
||||
return {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": text, "clip": list(clip)},
|
||||
}
|
||||
|
||||
|
||||
def ksampler(positive, negative, model=("4", 0), **settings):
|
||||
inputs = {
|
||||
"seed": 5,
|
||||
"steps": 20,
|
||||
"cfg": 7,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "normal",
|
||||
"denoise": 1.0,
|
||||
"model": list(model),
|
||||
"positive": list(positive),
|
||||
"negative": list(negative),
|
||||
"latent_image": ["2", 0],
|
||||
}
|
||||
inputs.update(settings)
|
||||
return {"class_type": "KSampler", "inputs": inputs}
|
||||
|
||||
|
||||
CHECKPOINT = {
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"inputs": {"ckpt_name": "example.safetensors"},
|
||||
}
|
||||
LATENT = {
|
||||
"class_type": "EmptyLatentImage",
|
||||
"inputs": {"width": 512, "height": 512, "batch_size": 1},
|
||||
}
|
||||
|
||||
|
||||
def _basic():
|
||||
return {
|
||||
"4": CHECKPOINT,
|
||||
"2": LATENT,
|
||||
"6": encoder("a cat on a windowsill"),
|
||||
"7": encoder("blurry, watermark"),
|
||||
"3": ksampler(("6", 0), ("7", 0)),
|
||||
"9": {"class_type": "SaveImage", "inputs": {"images": ["8", 0]}},
|
||||
}
|
||||
|
||||
|
||||
def _prompt_manager(text, prepend="", append=""):
|
||||
return {
|
||||
"class_type": "PromptManager",
|
||||
"inputs": {
|
||||
"text": text,
|
||||
"category": "",
|
||||
"tags": "",
|
||||
"search_text": "",
|
||||
"prepend_text": prepend,
|
||||
"append_text": append,
|
||||
"clip": ["4", 1],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _shared_prompt_pipeline():
|
||||
"""One PromptManager pair feeds a base pass, an upscale pass and a detailer."""
|
||||
return {
|
||||
"1": CHECKPOINT,
|
||||
"5": ksampler(
|
||||
("25", 0),
|
||||
("26", 0),
|
||||
("1", 0),
|
||||
seed=111,
|
||||
steps=4,
|
||||
cfg=1.0,
|
||||
sampler_name="lcm",
|
||||
latent_image=["20", 0],
|
||||
),
|
||||
"6": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {"samples": ["5", 0], "vae": ["1", 2]},
|
||||
},
|
||||
"11": {
|
||||
"class_type": "VAEEncode",
|
||||
"inputs": {"pixels": ["6", 0], "vae": ["1", 2]},
|
||||
},
|
||||
"12": ksampler(
|
||||
("25", 0),
|
||||
("26", 0),
|
||||
("1", 0),
|
||||
seed=222,
|
||||
steps=6,
|
||||
cfg=1.0,
|
||||
sampler_name="lcm",
|
||||
denoise=0.45,
|
||||
latent_image=["11", 0],
|
||||
),
|
||||
"13": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {"samples": ["12", 0], "vae": ["1", 2]},
|
||||
},
|
||||
"17": {
|
||||
"class_type": "FaceDetailer",
|
||||
"inputs": {
|
||||
"seed": 333,
|
||||
"steps": 4,
|
||||
"cfg": 1.0,
|
||||
"sampler_name": "lcm",
|
||||
"image": ["13", 0],
|
||||
"model": ["1", 0],
|
||||
"clip": ["1", 1],
|
||||
"vae": ["1", 2],
|
||||
"positive": ["25", 0],
|
||||
"negative": ["26", 0],
|
||||
},
|
||||
},
|
||||
"20": {
|
||||
"class_type": "EmptyLatentImage",
|
||||
"inputs": {"width": 640, "height": 1536, "batch_size": 1},
|
||||
},
|
||||
"25": _prompt_manager("a lighthouse at dusk, oil painting"),
|
||||
"26": _prompt_manager("blurry, watermark"),
|
||||
"48": {
|
||||
"class_type": "KikoSaveImage",
|
||||
"inputs": {"filename_prefix": ["49", 0], "images": ["17", 0]},
|
||||
},
|
||||
"49": {
|
||||
"class_type": "KikoProjectFilePath",
|
||||
"inputs": {"project_name": "example"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _custom_sampler(guider):
|
||||
return {
|
||||
"13": CHECKPOINT,
|
||||
"20": encoder("a red fox in snow", clip=("13", 1)),
|
||||
"21": encoder("lowres", clip=("13", 1)),
|
||||
"22": guider,
|
||||
"25": {"class_type": "RandomNoise", "inputs": {"noise_seed": 42}},
|
||||
"16": {"class_type": "KSamplerSelect", "inputs": {"sampler_name": "dpmpp_2m"}},
|
||||
"17": {
|
||||
"class_type": "BasicScheduler",
|
||||
"inputs": {
|
||||
"scheduler": "karras",
|
||||
"steps": 8,
|
||||
"denoise": 1.0,
|
||||
"model": ["13", 0],
|
||||
},
|
||||
},
|
||||
"1300:8": {
|
||||
"class_type": "SamplerCustomAdvanced",
|
||||
"inputs": {
|
||||
"noise": ["25", 0],
|
||||
"guider": ["22", 0],
|
||||
"sampler": ["16", 0],
|
||||
"sigmas": ["17", 0],
|
||||
"latent_image": ["5", 0],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _workflow_only():
|
||||
return {
|
||||
"nodes": [
|
||||
{
|
||||
"id": 4,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"widgets_values": ["example.safetensors"],
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "CLIPTextEncode",
|
||||
"mode": 0,
|
||||
"inputs": [{"name": "clip", "link": 3}],
|
||||
"widgets_values": ["a tidepool at noon"],
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "CLIPTextEncode",
|
||||
"mode": 0,
|
||||
"inputs": [{"name": "clip", "link": 5}],
|
||||
"widgets_values": ["noisy"],
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "Reroute",
|
||||
"mode": 0,
|
||||
"inputs": [{"name": "", "link": 7}],
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "KSampler",
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "model", "link": 1},
|
||||
{"name": "positive", "link": 8},
|
||||
{"name": "negative", "link": 4},
|
||||
{"name": "latent_image", "link": 2},
|
||||
],
|
||||
"widgets_values": [314, "randomize", 25, 6.5, "dpmpp_2m", "karras", 1],
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"type": "CLIPTextEncode",
|
||||
"mode": 2,
|
||||
"inputs": [],
|
||||
"widgets_values": ["muted node text"],
|
||||
},
|
||||
],
|
||||
"links": [
|
||||
[1, 4, 0, 3, 0, "MODEL"],
|
||||
[3, 4, 1, 6, 0, "CLIP"],
|
||||
[5, 4, 1, 7, 0, "CLIP"],
|
||||
[7, 6, 0, 10, 0, "CONDITIONING"],
|
||||
[8, 10, 0, 3, 1, "CONDITIONING"],
|
||||
[4, 7, 0, 3, 2, "CONDITIONING"],
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": 99,
|
||||
"origin_slot": 0,
|
||||
"target_id": 3,
|
||||
"target_slot": 3,
|
||||
"type": "LATENT",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _chain(length):
|
||||
graph = {"4": CHECKPOINT, "1": encoder("deep chain text")}
|
||||
previous = "1"
|
||||
for index in range(length):
|
||||
node_id = str(100 + index)
|
||||
graph[node_id] = {
|
||||
"class_type": "ConditioningPass",
|
||||
"inputs": {"conditioning": [previous, 0]},
|
||||
}
|
||||
previous = node_id
|
||||
graph["3"] = ksampler((previous, 0), ("1", 0))
|
||||
return graph
|
||||
|
||||
|
||||
CASES = {
|
||||
"basic": case(_basic()),
|
||||
"prompt-manager-prepend-append": case(
|
||||
{
|
||||
"4": CHECKPOINT,
|
||||
"6": _prompt_manager("a cat", prepend="masterpiece", append="4k"),
|
||||
"7": _prompt_manager(
|
||||
"masterpiece, a dog", prepend="masterpiece", append="4k"
|
||||
),
|
||||
"3": ksampler(("6", 0), ("7", 0)),
|
||||
}
|
||||
),
|
||||
"linked-text-concat": case(
|
||||
{
|
||||
"4": CHECKPOINT,
|
||||
"10": {"class_type": "PrimitiveString", "inputs": {"value": "first part"}},
|
||||
"11": {
|
||||
"class_type": "StringConcatenate",
|
||||
"inputs": {
|
||||
"string_a": ["10", 0],
|
||||
"string_b": "second part",
|
||||
"delimiter": ", ",
|
||||
"seed": 3,
|
||||
"string_c": " ",
|
||||
},
|
||||
},
|
||||
"6": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": ["11", 0], "clip": ["4", 1]},
|
||||
},
|
||||
"7": encoder(""),
|
||||
"3": ksampler(("6", 0), ("7", 0)),
|
||||
}
|
||||
),
|
||||
"prompt-manager-pair": case(
|
||||
{
|
||||
"4": CHECKPOINT,
|
||||
"8": {
|
||||
"class_type": "PromptManagerPair",
|
||||
"inputs": {
|
||||
"text": "pair positive",
|
||||
"negative_text": "pair negative",
|
||||
"prepend_text": "",
|
||||
"append_text": "",
|
||||
"clip": ["4", 1],
|
||||
},
|
||||
},
|
||||
"3": ksampler(("8", 0), ("8", 1)),
|
||||
}
|
||||
),
|
||||
"conditioning-passthrough": case(
|
||||
{
|
||||
"4": CHECKPOINT,
|
||||
"6": encoder("through a controlnet"),
|
||||
"7": encoder("negative through it"),
|
||||
"12": {
|
||||
"class_type": "ControlNetApplyAdvanced",
|
||||
"inputs": {
|
||||
"positive": ["6", 0],
|
||||
"negative": ["7", 0],
|
||||
"control_net": ["13", 0],
|
||||
},
|
||||
},
|
||||
"3": ksampler(("12", 0), ("12", 1)),
|
||||
}
|
||||
),
|
||||
"first-sampler-in-js-key-order": case(
|
||||
{
|
||||
"4": CHECKPOINT,
|
||||
"6": encoder("from sampler twelve"),
|
||||
"7": encoder("from sampler five"),
|
||||
"x": ksampler(("6", 0), ("6", 0)),
|
||||
"12": ksampler(("6", 0), ("6", 0), seed=12),
|
||||
"5": ksampler(("7", 0), ("7", 0), seed=55),
|
||||
}
|
||||
),
|
||||
"custom-sampler-cfg-guider": case(
|
||||
_custom_sampler(
|
||||
{
|
||||
"class_type": "CFGGuider",
|
||||
"inputs": {
|
||||
"model": ["13", 0],
|
||||
"positive": ["20", 0],
|
||||
"negative": ["21", 0],
|
||||
"cfg": 3.5,
|
||||
},
|
||||
}
|
||||
)
|
||||
),
|
||||
"custom-sampler-basic-guider": case(
|
||||
_custom_sampler(
|
||||
{
|
||||
"class_type": "BasicGuider",
|
||||
"inputs": {"model": ["13", 0], "conditioning": ["20", 0]},
|
||||
}
|
||||
)
|
||||
),
|
||||
"no-sampler-loose-settings": case(
|
||||
{
|
||||
"4": CHECKPOINT,
|
||||
"3": {
|
||||
"class_type": "MyKSamplerVariant",
|
||||
"inputs": {
|
||||
"noise_seed": 9,
|
||||
"steps": 12,
|
||||
"cfg": 2,
|
||||
"sampler_name": "ddim",
|
||||
},
|
||||
},
|
||||
}
|
||||
),
|
||||
"primitive-seed": case(
|
||||
{
|
||||
"4": CHECKPOINT,
|
||||
"6": encoder("seeded"),
|
||||
"30": {
|
||||
"class_type": "Seed (rgthree)",
|
||||
"inputs": {"other": "x", "seed": 77},
|
||||
},
|
||||
"3": ksampler(("6", 0), ("6", 0), seed=["30", 0]),
|
||||
}
|
||||
),
|
||||
"workflow-only": case(workflow=_workflow_only()),
|
||||
"overwritten-prompt-manager-text": case(
|
||||
{
|
||||
"4": CHECKPOINT,
|
||||
"25": _prompt_manager("same saved text"),
|
||||
"26": _prompt_manager("same saved text"),
|
||||
"3": ksampler(("25", 0), ("26", 0)),
|
||||
},
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": 25,
|
||||
"type": "PromptManager",
|
||||
"widgets_values": ["the real positive"],
|
||||
},
|
||||
{
|
||||
"id": 26,
|
||||
"type": "PromptManager",
|
||||
"widgets_values": ["the real negative"],
|
||||
},
|
||||
],
|
||||
"links": [],
|
||||
},
|
||||
),
|
||||
"conditioning-cycle": case(
|
||||
{
|
||||
"4": CHECKPOINT,
|
||||
"20": {
|
||||
"class_type": "ConditioningCombine",
|
||||
"inputs": {"conditioning_1": ["21", 0]},
|
||||
},
|
||||
"21": {
|
||||
"class_type": "ConditioningCombine",
|
||||
"inputs": {"conditioning_1": ["20", 0]},
|
||||
},
|
||||
"3": ksampler(("20", 0), ("21", 0)),
|
||||
}
|
||||
),
|
||||
"chain-within-visit-limit": case(_chain(450)),
|
||||
"chain-beyond-visit-limit": case(_chain(600)),
|
||||
"chain-at-last-visit": case(_chain(499)),
|
||||
"chain-one-past-last-visit": case(_chain(500)),
|
||||
"blank-text-and-zero-seed": case(
|
||||
{
|
||||
"4": CHECKPOINT,
|
||||
"6": encoder(" "),
|
||||
"7": encoder("kept negative"),
|
||||
"3": ksampler(("6", 0), ("7", 0), seed=0),
|
||||
}
|
||||
),
|
||||
"shared-prompt-pipeline": case(_shared_prompt_pipeline()),
|
||||
"null-workflow-node": case(_basic(), {"nodes": [None], "links": []}),
|
||||
"unparsable-records": {"prompt": '{"3": NaN}', "workflow": "not json"},
|
||||
"seed-beyond-double-precision": case(
|
||||
{
|
||||
"4": CHECKPOINT,
|
||||
"6": encoder("x"),
|
||||
"3": ksampler(("6", 0), ("6", 0), cfg=1.0, seed=4974052037430000001),
|
||||
}
|
||||
),
|
||||
"nothing": case(),
|
||||
}
|
||||
+1
-116
@@ -2,7 +2,7 @@
|
||||
|
||||
Every graph here is original synthetic content (CC0): neutral texts, generic model
|
||||
file names and invented filename prefixes. Nothing is copied from user outputs.
|
||||
The shapes reproduce the parity audit's fixture numbering (``f01``..``f24``).
|
||||
The shapes reproduce the parity audit's fixture numbering (``f01``..``f17``).
|
||||
|
||||
``build(name)`` returns ``{"prompt", "workflow", "extra_chunks"}``. ``write_png``
|
||||
writes the two records the way core ``SaveImage`` does (Pillow ``PngInfo.add_text``,
|
||||
@@ -66,12 +66,6 @@ COMMITTED = (
|
||||
"f15",
|
||||
"f16",
|
||||
"f17",
|
||||
"f19",
|
||||
"f20",
|
||||
"f21",
|
||||
"f22",
|
||||
"f23",
|
||||
"f24",
|
||||
)
|
||||
# Built in a temporary directory at test time (too large to commit).
|
||||
TEST_TIME = ("f01", "f04", "f05")
|
||||
@@ -106,10 +100,6 @@ def _enc(text):
|
||||
return {"class_type": "CLIPTextEncode", "inputs": {"text": text, "clip": ["4", 1]}}
|
||||
|
||||
|
||||
def _linked_enc(link):
|
||||
return {"class_type": "CLIPTextEncode", "inputs": {"text": link, "clip": ["4", 1]}}
|
||||
|
||||
|
||||
def _ksampler_graph(positive, negative, sampler="KSampler", latent=None, extra=None):
|
||||
graph = {
|
||||
"4": {
|
||||
@@ -701,105 +691,6 @@ def _f17():
|
||||
return _basic(graph, _ui(graph))
|
||||
|
||||
|
||||
DETAILER_POS = "a red fox in snow, sharp detailed face"
|
||||
|
||||
|
||||
def _f19():
|
||||
"""KSampler -> VAEDecode -> FaceDetailer -> SaveImage; the detailer re-prompts."""
|
||||
graph = _ksampler_graph(_enc(POS), _enc(NEG))
|
||||
graph["10"] = _enc(DETAILER_POS)
|
||||
graph["50"] = {
|
||||
"class_type": "FaceDetailer",
|
||||
"inputs": {
|
||||
"image": ["8", 0],
|
||||
"model": ["4", 0],
|
||||
"clip": ["4", 1],
|
||||
"vae": ["4", 2],
|
||||
"positive": ["10", 0],
|
||||
"negative": ["7", 0],
|
||||
"seed": 7,
|
||||
"steps": 12,
|
||||
"cfg": 4.5,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "normal",
|
||||
"denoise": 0.4,
|
||||
},
|
||||
}
|
||||
graph["9"]["inputs"]["images"] = ["50", 0]
|
||||
return _basic(graph)
|
||||
|
||||
|
||||
def _f20():
|
||||
"""``easy ifElse`` with a literal boolean picks the positive branch."""
|
||||
select = {
|
||||
"class_type": "easy ifElse",
|
||||
"inputs": {"boolean": True, "on_true": ["10", 0], "on_false": ["11", 0]},
|
||||
}
|
||||
extra = {"10": _enc(POS), "11": _enc("a grey wolf in fog")}
|
||||
return _basic(_ksampler_graph(select, _enc(NEG), extra=extra))
|
||||
|
||||
|
||||
def _f21():
|
||||
"""``JoinStringMulti`` with ``return_list: true`` feeds the positive encoder."""
|
||||
join = {
|
||||
"class_type": "JoinStringMulti",
|
||||
"inputs": {
|
||||
"inputcount": 2,
|
||||
"delimiter": ", ",
|
||||
"return_list": True,
|
||||
"string_1": ["11", 0],
|
||||
"string_2": ["12", 0],
|
||||
},
|
||||
}
|
||||
extra = {
|
||||
"10": join,
|
||||
"11": {"class_type": "PrimitiveString", "inputs": {"value": "a red fox"}},
|
||||
"12": {"class_type": "PrimitiveString", "inputs": {"value": "in snow"}},
|
||||
}
|
||||
positive = _linked_enc(["10", 0])
|
||||
return _basic(_ksampler_graph(positive, _enc(NEG), extra=extra))
|
||||
|
||||
|
||||
def _f22():
|
||||
"""``PromptSearchList`` (database search at run time) feeds the positive."""
|
||||
search = {
|
||||
"class_type": "PromptSearchList",
|
||||
"inputs": {
|
||||
"text": "fox",
|
||||
"category": "",
|
||||
"tags": "",
|
||||
"min_rating": 0,
|
||||
"limit": 50,
|
||||
},
|
||||
}
|
||||
positive = _linked_enc(["10", 0])
|
||||
return _basic(_ksampler_graph(positive, _enc(NEG), extra={"10": search}))
|
||||
|
||||
|
||||
def _f24():
|
||||
"""``TextEncodeQwenImage21``: slot 0 is the positive, slot 1 the negative."""
|
||||
qwen = {
|
||||
"class_type": "TextEncodeQwenImage21",
|
||||
"inputs": {
|
||||
"clip": ["4", 1],
|
||||
"prompt": POS,
|
||||
"negative_prompt": NEG,
|
||||
"vae": ["4", 2],
|
||||
"resolution": 1024,
|
||||
},
|
||||
}
|
||||
graph = _ksampler_graph(qwen, None)
|
||||
del graph["7"]
|
||||
graph["3"]["inputs"]["negative"] = ["6", 1]
|
||||
return _basic(graph)
|
||||
|
||||
|
||||
def _f23():
|
||||
"""The negative is ``ConditioningZeroOut`` of the positive encoder."""
|
||||
zero = {"class_type": "ConditioningZeroOut", "inputs": {"conditioning": ["6", 0]}}
|
||||
return _basic(_ksampler_graph(_enc(POS), zero))
|
||||
|
||||
|
||||
_BUILDERS = {
|
||||
"f01": _f01,
|
||||
"f01b": _f01b,
|
||||
@@ -823,12 +714,6 @@ _BUILDERS = {
|
||||
"f15": _f15,
|
||||
"f16": _f16,
|
||||
"f17": _f17,
|
||||
"f19": _f19,
|
||||
"f20": _f20,
|
||||
"f21": _f21,
|
||||
"f22": _f22,
|
||||
"f23": _f23,
|
||||
"f24": _f24,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -164,232 +164,6 @@ test("facts require model output ancestry with supported loaders and passthrough
|
||||
assert.equal(factsFor(numericModel).model.status, "unresolved", "model names require string evidence");
|
||||
});
|
||||
|
||||
// D19: a FaceDetailer re-samples the KSampler's decoded image with its own settings.
|
||||
function detailerFactGraph(detailer = {}) {
|
||||
const graph = factGraph({ seed: "42", steps: 25, cfg: "6.5", sampler_name: "euler", scheduler: "normal" });
|
||||
graph.decode = { class_type: "VAEDecode", inputs: { samples: ["s", 0], vae: ["model", 2] } };
|
||||
graph.d = { class_type: "FaceDetailer", inputs: { image: ["decode", 0], model: ["model", 0], seed: "7", steps: 12, cfg: "6.5", sampler_name: "euler", scheduler: "normal", denoise: "0.4", ...detailer } };
|
||||
return graph;
|
||||
}
|
||||
const statusValues = facts => Object.fromEntries(factFields.map(field => [field, [facts[field].status, facts[field].value]]));
|
||||
|
||||
test("facts use resolution.sampler_id for detailer-stage nodes", () => {
|
||||
// Resolved (the stages' prompts agree): the final stage the server named supplies every fact.
|
||||
const resolved = factsFor(detailerFactGraph(), { resolution: { state: "resolved", sampler_id: "d" } });
|
||||
assert.deepEqual(statusValues(resolved), {
|
||||
seed: ["known", "7"], steps: ["known", "12"], cfg: ["known", "6.5"],
|
||||
sampler: ["known", "euler"], scheduler: ["known", "normal"], model: ["known", "model.safetensors"],
|
||||
});
|
||||
assert.ok(factFields.every(field => resolved[field].sampler_id === "d" && resolved[field].evidence === "configuration"));
|
||||
// The upstream KSampler is also accepted when it is the one resolved.
|
||||
assert.equal(factsFor(detailerFactGraph(), { resolution: { state: "resolved", sampler_id: "s" } }).seed.value, "42");
|
||||
// Ambiguous stages: merged per field, never the detailer's or the upstream value alone.
|
||||
const ambiguous = factsFor(detailerFactGraph(), { resolution: { state: "ambiguous", sampler_id: "d", reason_code: "multistage-differs" } });
|
||||
assert.deepEqual(statusValues(ambiguous), {
|
||||
seed: ["ambiguous", null], steps: ["ambiguous", null], cfg: ["known", "6.5"],
|
||||
sampler: ["known", "euler"], scheduler: ["known", "normal"], model: ["known", "model.safetensors"],
|
||||
});
|
||||
assert.ok(factFields.every(field => ambiguous[field].sampler_id === null), "a merged fact names no single sampler");
|
||||
// A field one stage cannot read and the others agree on stays unresolved; not-recorded is not agreement.
|
||||
const unreadable = factsFor(detailerFactGraph({ cfg: ["missing", 0] }), { resolution: { state: "ambiguous", sampler_id: "d" } });
|
||||
assert.deepEqual([unreadable.cfg.status, unreadable.cfg.value], ["unresolved", null]);
|
||||
const unrecorded = detailerFactGraph(); delete unrecorded.d.inputs.scheduler;
|
||||
assert.equal(factsFor(unrecorded, { resolution: { state: "ambiguous", sampler_id: "d" } }).scheduler.status, "unresolved");
|
||||
// Two detailer stages over one KSampler: three stages merge.
|
||||
const chained = detailerFactGraph({ seed: "42" });
|
||||
chained.d2 = { class_type: "FaceDetailer", inputs: { image: ["d", 0], model: ["model", 0], seed: "42", steps: 25, cfg: "3", sampler_name: "euler", scheduler: "normal" } };
|
||||
assert.deepEqual(statusValues(factsFor(chained, { resolution: { state: "ambiguous", sampler_id: "d2" } })), {
|
||||
seed: ["known", "42"], steps: ["ambiguous", null], cfg: ["ambiguous", null],
|
||||
sampler: ["known", "euler"], scheduler: ["known", "normal"], model: ["known", "model.safetensors"],
|
||||
});
|
||||
// Without a resolution, two samplers stay ambiguous (today's rules, now over the samplers table).
|
||||
assert.ok(Object.values(factsFor(detailerFactGraph())).every(fact => fact.status === "ambiguous"));
|
||||
// A sampler_id the graph or the samplers table does not hold never falls back to a guess.
|
||||
for (const sampler_id of ["missing", "decode", "model", "__proto__"]) {
|
||||
for (const state of ["resolved", "ambiguous"]) {
|
||||
const facts = factsFor(detailerFactGraph(), { resolution: { state, sampler_id } });
|
||||
assert.ok(Object.values(facts).every(fact => fact.status === "unresolved" && fact.value === null), `${state} ${sampler_id}`);
|
||||
}
|
||||
}
|
||||
// Stage ancestry that cannot be walked is unresolved, not a partial merge.
|
||||
const cyclic = detailerFactGraph(); cyclic.decode.inputs.samples = ["d", 0];
|
||||
const unlinked = detailerFactGraph({ image: "not a link" });
|
||||
for (const graph of [cyclic, unlinked]) {
|
||||
assert.ok(Object.values(factsFor(graph, { resolution: { state: "ambiguous", sampler_id: "d" } })).every(fact => fact.status === "unresolved"));
|
||||
}
|
||||
const wide = detailerFactGraph();
|
||||
let previous = ["s", 0];
|
||||
for (let index = 0; index < 600; index++) { wide[`hop${index}`] = { class_type: "ImageScale", inputs: { image: previous } }; previous = [`hop${index}`, 0]; }
|
||||
wide.decode.inputs.samples = ["s", 0]; wide.d.inputs.image = previous;
|
||||
assert.ok(Object.values(factsFor(wide, { resolution: { state: "ambiguous", sampler_id: "d" } })).every(fact => fact.status === "unresolved"), "the stage walk is bounded by MAX_VISITS");
|
||||
});
|
||||
|
||||
test("ambiguous results without a sampler_id merge facts across every listed stage", () => {
|
||||
const ambiguous = { resolution: { state: "ambiguous", sampler_id: null, reason: "Multiple sampler branches" } };
|
||||
// f01b's shape: an upstream sampler and a detailer whose settings partly differ.
|
||||
assert.deepEqual(statusValues(factsFor(detailerFactGraph(), ambiguous)), {
|
||||
seed: ["ambiguous", null], steps: ["ambiguous", null], cfg: ["known", "6.5"],
|
||||
sampler: ["known", "euler"], scheduler: ["known", "normal"], model: ["known", "model.safetensors"],
|
||||
});
|
||||
assert.ok(Object.values(factsFor(detailerFactGraph(), ambiguous)).every(fact => fact.sampler_id === null));
|
||||
// Equal independent branches agree on every field once the server says ambiguous;
|
||||
// without a resolution they stay ambiguous (today's rules, reversed in PR3).
|
||||
const equal = factGraph(); equal.other = structuredClone(equal.s);
|
||||
assert.ok(Object.values(factsFor(equal, ambiguous)).every(fact => fact.status === "known"));
|
||||
assert.ok(Object.values(factsFor(equal)).every(fact => fact.status === "ambiguous"));
|
||||
// A walk that fails keeps every field unresolved; so does a graph with no listed sampler.
|
||||
const cyclic = detailerFactGraph(); cyclic.decode.inputs.samples = ["d", 0];
|
||||
const broken = detailerFactGraph(); broken.d.inputs.image = ["gone", 0];
|
||||
for (const graph of [cyclic, broken, { model: factGraph().model }]) {
|
||||
assert.ok(Object.values(factsFor(graph, ambiguous)).every(fact => fact.status === "unresolved" && fact.value === null));
|
||||
}
|
||||
const many = factGraph();
|
||||
for (let index = 0; index < 40; index++) many[`s${index}`] = structuredClone(many.s);
|
||||
assert.ok(Object.values(factsFor(many, ambiguous)).every(fact => fact.status === "unresolved"), "more stages than the candidate cap");
|
||||
});
|
||||
|
||||
test("stage walks fail on unlisted sampler-shaped nodes like the resolver", () => {
|
||||
const shaped = [
|
||||
{ positive: ["model", 0] }, { latent_image: ["model", 0] }, { guider: ["model", 0] }, { negative: ["model", 0] },
|
||||
{ pipe: ["model", 0] }, { basic_pipe: ["model", 0] }, { detailer_pipe: ["model", 0] }, { sampler: ["model", 0] },
|
||||
{ sigmas: ["model", 0] }, { noise: ["model", 0] }, { text_embeds: ["model", 0] }, { hyvid_embeds: ["model", 0] },
|
||||
{ model: ["model", 0], conditioning: ["model", 0] }, { model: ["model", 0], neg_conditioning: ["model", 0] },
|
||||
{ model: ["model", 0], samples: ["model", 0] }, { model: ["model", 0], latent: ["model", 0] },
|
||||
];
|
||||
for (const inputs of shaped) {
|
||||
const graph = detailerFactGraph();
|
||||
graph.efficient = { class_type: "MysterySampler", inputs: { ...inputs, image: ["decode", 0] } };
|
||||
graph.d.inputs.image = ["efficient", 0];
|
||||
for (const sampler_id of ["d", null]) {
|
||||
const facts = factsFor(graph, { resolution: { state: "ambiguous", sampler_id } });
|
||||
assert.ok(Object.values(facts).every(fact => fact.status === "unresolved"), `${JSON.stringify(inputs)} ${sampler_id}`);
|
||||
}
|
||||
}
|
||||
// Model patches (model alone, or with clip) and listed table classes are walked through.
|
||||
for (const [class_type, inputs] of [["ModelPatch", { model: ["model", 0] }], ["LoraPatch", { model: ["model", 0], clip: ["model", 1] }], ["ImageScale", { samples: ["model", 0] }], ["CFGGuider", { model: ["model", 0], positive: ["model", 0] }]]) {
|
||||
const graph = detailerFactGraph();
|
||||
graph.hop = { class_type, inputs: { ...inputs, image: ["decode", 0] } };
|
||||
graph.d.inputs.image = ["hop", 0];
|
||||
assert.equal(factsFor(graph, { resolution: { state: "ambiguous", sampler_id: "d" } }).seed.status, "ambiguous", class_type);
|
||||
}
|
||||
});
|
||||
|
||||
test("unresolved and absent results over several stages merge facts per field", () => {
|
||||
// Review I-1: the run's settings are facts even when its prompt is not, e.g. an
|
||||
// upstream prompt behind an unsupported node (LoRAfromText) leaves the server unresolved.
|
||||
const merged = {
|
||||
seed: ["ambiguous", null], steps: ["ambiguous", null], cfg: ["known", "6.5"],
|
||||
sampler: ["known", "euler"], scheduler: ["known", "normal"], model: ["known", "model.safetensors"],
|
||||
};
|
||||
for (const resolution of [
|
||||
{ state: "unresolved", sampler_id: "d", reason_code: "unsupported-node" },
|
||||
{ state: "unresolved", sampler_id: null, reason_code: "malformed" },
|
||||
{ state: "absent", sampler_id: "d" },
|
||||
]) {
|
||||
const facts = factsFor(detailerFactGraph(), { resolution });
|
||||
assert.deepEqual(statusValues(facts), merged, JSON.stringify(resolution));
|
||||
assert.ok(factFields.every(field => facts[field].sampler_id === null));
|
||||
}
|
||||
// A stage missing a field leaves it unresolved; a failed walk leaves everything unresolved.
|
||||
const unrecorded = detailerFactGraph(); delete unrecorded.d.inputs.scheduler;
|
||||
assert.equal(factsFor(unrecorded, { resolution: { state: "unresolved", sampler_id: "d" } }).scheduler.status, "unresolved");
|
||||
const cyclic = detailerFactGraph(); cyclic.decode.inputs.samples = ["d", 0];
|
||||
assert.ok(Object.values(factsFor(cyclic, { resolution: { state: "unresolved", sampler_id: "d" } })).every(fact => fact.status === "unresolved"));
|
||||
// One listed sampler keeps today's rule: its own facts, attributed to it.
|
||||
const single = factsFor(factGraph(), { resolution: { state: "unresolved", sampler_id: "s" } });
|
||||
assert.ok(factFields.every(field => single[field].status === "known" && single[field].sampler_id === "s"));
|
||||
});
|
||||
|
||||
test("a named sampler limits absent and unresolved facts to its own stages", () => {
|
||||
// Codex P2: the server dropped a preview-only detailer and named the saved sampler, so
|
||||
// the detailer's settings never leak into the saved file's facts.
|
||||
const graph = detailerFactGraph();
|
||||
graph.save = { class_type: "SaveImage", inputs: { images: ["decode", 0] } };
|
||||
graph.preview = { class_type: "PreviewImage", inputs: { images: ["d", 0] } };
|
||||
const own = {
|
||||
seed: ["known", "42"], steps: ["known", "25"], cfg: ["known", "6.5"],
|
||||
sampler: ["known", "euler"], scheduler: ["known", "normal"], model: ["known", "model.safetensors"],
|
||||
};
|
||||
for (const state of ["absent", "unresolved"]) assert.deepEqual(statusValues(factsFor(graph, { resolution: { state, sampler_id: "s" } })), own, state);
|
||||
// Without a named sampler every listed sampler still merges.
|
||||
assert.equal(factsFor(graph, { resolution: { state: "absent", sampler_id: null } }).seed.status, "ambiguous");
|
||||
});
|
||||
|
||||
test("stage walks follow only the resolver's links", () => {
|
||||
// Review M-2: `["eff", -1]` is not a link to the server, so it is not walked.
|
||||
for (const slot of [-1, 0.5, "0"]) {
|
||||
const graph = detailerFactGraph();
|
||||
graph.eff = { class_type: "MysterySampler", inputs: { positive: ["model", 0], model: ["model", 0] } };
|
||||
graph.decode.inputs.extra = ["eff", slot];
|
||||
assert.equal(factsFor(graph, { resolution: { state: "ambiguous", sampler_id: "d" } }).seed.status, "ambiguous", String(slot));
|
||||
const shaped = detailerFactGraph();
|
||||
shaped.eff = { class_type: "Unlisted", inputs: { positive: ["model", slot], image: ["decode", 0] } };
|
||||
shaped.d.inputs.image = ["eff", 0];
|
||||
assert.equal(factsFor(shaped, { resolution: { state: "ambiguous", sampler_id: "d" } }).seed.status, "ambiguous", `shaped ${slot}`);
|
||||
}
|
||||
const linked = detailerFactGraph();
|
||||
linked.eff = { class_type: "MysterySampler", inputs: { positive: ["model", 0], model: ["model", 0] } };
|
||||
linked.decode.inputs.extra = ["eff", 0];
|
||||
assert.equal(factsFor(linked, { resolution: { state: "ambiguous", sampler_id: "d" } }).seed.status, "unresolved");
|
||||
});
|
||||
|
||||
test("Flux2Scheduler provides steps and scheduler is its node type", () => {
|
||||
const graph = customFactGraph();
|
||||
graph.sigmas = { class_type: "Flux2Scheduler", inputs: { steps: 4, width: 1024, height: 1024 } };
|
||||
const facts = factsFor(graph);
|
||||
assert.deepEqual([facts.steps.status, facts.steps.value], ["known", "4"]);
|
||||
assert.deepEqual([facts.scheduler.status, facts.scheduler.value, facts.scheduler.evidence], ["known", "Flux2Scheduler", "configuration"]);
|
||||
for (const slot of [1, -1]) {
|
||||
const linked = customFactGraph(); linked.sigmas = graph.sigmas; linked.s.inputs.sigmas = ["sigmas", slot];
|
||||
assert.equal(factsFor(linked).scheduler.status, "unresolved", `output${slot}`);
|
||||
}
|
||||
// Its own inputs cannot replace the node type, and steps keep exact-integer rules.
|
||||
const named = customFactGraph(); named.sigmas = { class_type: "Flux2Scheduler", inputs: { steps: 0, scheduler: "karras" } };
|
||||
assert.equal(factsFor(named).scheduler.value, "Flux2Scheduler");
|
||||
assert.equal(factsFor(named).steps.status, "unresolved");
|
||||
});
|
||||
|
||||
test("ModelSampling passthroughs reach UNETLoader", () => {
|
||||
const graph = customFactGraph();
|
||||
graph.g.inputs.model = ["flux", 0];
|
||||
graph.flux = { class_type: "ModelSamplingFlux", inputs: { model: ["aura", 0], max_shift: "1.15", base_shift: "0.5" } };
|
||||
graph.aura = { class_type: "ModelSamplingAuraFlow", inputs: { model: ["sd3", 0], shift: "1.73" } };
|
||||
graph.sd3 = { class_type: "ModelSamplingSD3", inputs: { model: ["lora", 0], shift: "3" } };
|
||||
graph.lora = { class_type: "LoraLoaderModelOnly", inputs: { model: ["m", 0], lora_name: "style", strength_model: "1" } };
|
||||
assert.deepEqual([factsFor(graph).model.status, factsFor(graph).model.value], ["known", "flux.safetensors"]);
|
||||
for (const node of ["flux", "aura", "sd3"]) {
|
||||
const wrongSlot = structuredClone(graph);
|
||||
const parent = { flux: "g", aura: "flux", sd3: "aura" }[node];
|
||||
wrongSlot[parent].inputs.model = [node, 1];
|
||||
assert.equal(factsFor(wrongSlot).model.status, "unresolved", `${node} output1`);
|
||||
const unlisted = structuredClone(graph); unlisted[node].class_type = "ModelSamplingUnknown";
|
||||
assert.equal(factsFor(unlisted).model.status, "unresolved", `${node} unlisted`);
|
||||
}
|
||||
});
|
||||
|
||||
test("vectors.json pins full facts for every inspection fixture", () => {
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const vectors = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "fixtures", "inspection_parity", "vectors.json"), "utf8"));
|
||||
const inspection = vectors.filter(vector => vector.profile === "inspection");
|
||||
assert.ok(inspection.length > 0);
|
||||
const statuses = new Set(["known", "ambiguous", "unresolved", "not-recorded"]);
|
||||
for (const vector of inspection) {
|
||||
const facts = vector.expect.facts;
|
||||
assert.deepEqual(Object.keys(facts), factFields, vector.name);
|
||||
for (const field of factFields) {
|
||||
assert.ok(statuses.has(facts[field].status), `${vector.name} ${field}`);
|
||||
assert.equal(facts[field].value === null, facts[field].status !== "known", `${vector.name} ${field} value`);
|
||||
}
|
||||
}
|
||||
// f19: KSampler seed 42 / steps 25 / cfg 6.5 then FaceDetailer seed 7 / steps 12 / cfg 4.5, different prompts.
|
||||
const f19 = inspection.find(vector => vector.fixture === "f19");
|
||||
assert.equal(f19.expect.state, "ambiguous");
|
||||
assert.deepEqual(Object.fromEntries(factFields.map(field => [field, f19.expect.facts[field].status])), {
|
||||
seed: "ambiguous", steps: "ambiguous", cfg: "ambiguous", sampler: "known", scheduler: "known", model: "known",
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
// Run with: node --test tests/js/
|
||||
// Legacy parser rules ported to the inspection facts (#240, Task 2.7b). Synthetic texts.
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { extractGenerationFacts } = require("../../web/js/comfy-metadata.js");
|
||||
|
||||
const fields = ["seed", "steps", "cfg", "sampler", "scheduler", "model"];
|
||||
const FOOTER = "Steps: 20, Sampler: Euler, CFG scale: 7, Seed: 5";
|
||||
|
||||
function parameterFacts(parameters) {
|
||||
const facts = extractGenerationFacts({
|
||||
prompt_graph: { status: "absent", value: null },
|
||||
workflow_graph: { status: "absent", value: null },
|
||||
parameters,
|
||||
recordFormats: { parameters: "original-text" },
|
||||
metadataStatus: "available",
|
||||
});
|
||||
return Object.fromEntries(fields.map(field => [field, [facts[field].status, facts[field].value]]));
|
||||
}
|
||||
|
||||
const FOUR_KNOWN = {
|
||||
seed: ["known", "5"], steps: ["known", "20"], cfg: ["known", "7"], sampler: ["known", "Euler"],
|
||||
scheduler: ["not-recorded", null], model: ["not-recorded", null],
|
||||
};
|
||||
const ALL_UNRESOLVED = Object.fromEntries(fields.map(field => [field, ["unresolved", null]]));
|
||||
|
||||
test("R4: text starting with the negative delimiter has no positive, as with a leading newline", () => {
|
||||
for (const text of [`Negative prompt: bad\n${FOOTER}`, `\nNegative prompt: bad\n${FOOTER}`, `Negative prompt: a\nNegative prompt: b\n${FOOTER}`]) {
|
||||
assert.deepEqual(parameterFacts(text), ALL_UNRESOLVED, text);
|
||||
}
|
||||
assert.deepEqual(parameterFacts(`a cat\nNegative prompt: bad\n${FOOTER}`), FOUR_KNOWN);
|
||||
});
|
||||
|
||||
test("R5: template records after the footer and a bare Steps footer", () => {
|
||||
const base = `a cat\nNegative prompt: bad\n${FOOTER}`;
|
||||
for (const trailer of ["\nTemplate: a {cat|dog}", "\nTemplate: a {cat|dog}\nNegative Template: bad", "\nNegative Template: bad\n"]) {
|
||||
assert.deepEqual(parameterFacts(base + trailer), FOUR_KNOWN, trailer);
|
||||
}
|
||||
for (const trailer of ["\nTemplate: a\nTemplate: b", "\nNegative Template: bad\nTemplate: a", "\nTemplate: a\nmore", "\ntrailing"]) {
|
||||
assert.deepEqual(parameterFacts(base + trailer), ALL_UNRESOLVED, trailer);
|
||||
}
|
||||
const bare = parameterFacts("a cat\nNegative prompt: bad\nSteps: 20\nTemplate: a {cat|dog}");
|
||||
assert.deepEqual(bare.steps, ["known", "20"]);
|
||||
assert.ok(["seed", "cfg", "sampler", "scheduler", "model"].every(field => bare[field][0] === "not-recorded"));
|
||||
// A full footer wins over a bare `Steps: N` line in the prompt; two bare ones are not unique.
|
||||
assert.deepEqual(parameterFacts(`a cat\nSteps: 3\nNegative prompt: bad\n${FOOTER}`), FOUR_KNOWN);
|
||||
assert.deepEqual(parameterFacts("a cat\nSteps: 3\nNegative prompt: bad\nSteps: 20"), ALL_UNRESOLVED);
|
||||
});
|
||||
|
||||
test("R10: commas inside quoted settings values do not split them", () => {
|
||||
assert.deepEqual(parameterFacts(`a cat\nNegative prompt: bad\n${FOOTER}, ADetailer prompt: "face, smile"`), FOUR_KNOWN);
|
||||
assert.deepEqual(parameterFacts(`a cat\n${FOOTER}, Note: "say \\"hi, there\\"", Model: base`), { ...FOUR_KNOWN, model: ["known", "base"] });
|
||||
// A quoted value of a fact is its JSON string.
|
||||
assert.deepEqual(parameterFacts(`a cat\n${FOOTER}, Model: "base, v2"`).model, ["known", "base, v2"]);
|
||||
// Unquoted JSON (comfy-image-saver hashes) keeps the historical split, field by field.
|
||||
assert.deepEqual(parameterFacts(`a cat\n${FOOTER}, Hashes: {"model": "abc", "lora:x": "def"}, Model: base`).model, ["known", "base"]);
|
||||
// An unbalanced quote is not a quoted value: the historical split, as A1111 reads it.
|
||||
assert.deepEqual(parameterFacts(`a cat\n${FOOTER}, Note: "open, Model: base`).model, ["known", "base"]);
|
||||
});
|
||||
|
||||
function graphFacts(graph, resolution) {
|
||||
const facts = extractGenerationFacts({
|
||||
prompt_graph: { status: "parsed", value: graph },
|
||||
workflow_graph: { status: "absent", value: null },
|
||||
metadataStatus: "available",
|
||||
resolution,
|
||||
});
|
||||
return Object.fromEntries(fields.map(field => [field, [facts[field].status, facts[field].value]]));
|
||||
}
|
||||
|
||||
function modelGraph(between) {
|
||||
return {
|
||||
ckpt: { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "base.safetensors" } },
|
||||
hop: { class_type: between, inputs: { model: ["ckpt", 0], clip: ["ckpt", 1] } },
|
||||
s: { class_type: "KSampler", inputs: { seed: 5, steps: 20, cfg: 7, sampler_name: "euler", scheduler: "normal", model: ["hop", 0] } },
|
||||
};
|
||||
}
|
||||
|
||||
test("R9: model pass-throughs verified in their sources lead to the loader", () => {
|
||||
for (const name of ["Lora Loader (LoraManager)", "Power Lora Loader (rgthree)", "ModelPatchTorchSettings", "CFGNorm", "PerturbedAttentionGuidance"]) {
|
||||
assert.deepEqual(graphFacts(modelGraph(name)).model, ["known", "base.safetensors"], name);
|
||||
// Only output 0 is the model.
|
||||
const other = modelGraph(name); other.s.inputs.model = ["hop", 1];
|
||||
assert.deepEqual(graphFacts(other).model, ["unresolved", null], name);
|
||||
}
|
||||
assert.deepEqual(graphFacts(modelGraph("ModelMergeSDXL")).model, ["unresolved", null]);
|
||||
});
|
||||
|
||||
test("R6: upscale and SEGS detailers supply stage facts like FaceDetailer", () => {
|
||||
for (const [class_type, image] of [["UltimateSDUpscale", "image"], ["UltimateSDUpscaleNoUpscale", "upscaled_image"], ["DetailerForEach", "image"]]) {
|
||||
const graph = modelGraph("CFGNorm");
|
||||
graph.decode = { class_type: "VAEDecode", inputs: { samples: ["s", 0], vae: ["ckpt", 2] } };
|
||||
graph.d = { class_type, inputs: { [image]: ["decode", 0], model: ["hop", 0], seed: 7, steps: 12, cfg: 7, sampler_name: "euler", scheduler: "normal" } };
|
||||
assert.deepEqual(graphFacts(graph, { state: "resolved", sampler_id: "d" }).seed, ["known", "7"], class_type);
|
||||
const merged = graphFacts(graph, { state: "ambiguous", sampler_id: "d" });
|
||||
assert.deepEqual([merged.seed[0], merged.steps[0], merged.cfg], ["ambiguous", "ambiguous", ["known", "7"]], class_type);
|
||||
}
|
||||
});
|
||||
|
||||
test("R6: upstream-verified samplers supply facts unless an override input is set", () => {
|
||||
for (const [class_type, overrides] of [["ClownsharKSampler_Beta", ["options", "sigmas"]], ["KSampler (Efficient)", ["script"]], ["ttN KSampler_v2", ["adv_xyPlot"]]]) {
|
||||
const graph = modelGraph("CR Apply LoRA Stack");
|
||||
graph.s.class_type = class_type;
|
||||
assert.deepEqual(graphFacts(graph), {
|
||||
seed: ["known", "5"], steps: ["known", "20"], cfg: ["known", "7"], sampler: ["known", "euler"],
|
||||
scheduler: ["known", "normal"], model: ["known", "base.safetensors"],
|
||||
}, class_type);
|
||||
for (const name of overrides) {
|
||||
const overridden = structuredClone(graph);
|
||||
overridden.s.inputs[name] = ["script", 0];
|
||||
overridden.script = { class_type: "SomeScript", inputs: {} };
|
||||
assert.deepEqual(graphFacts(overridden), ALL_UNRESOLVED, `${class_type} ${name}`);
|
||||
for (const unset of [null, ""]) {
|
||||
overridden.s.inputs[name] = unset;
|
||||
assert.equal(graphFacts(overridden).seed[0], "known", `${class_type} ${name} ${unset}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
// Run with: node --test tests/js/
|
||||
// comfy-metadata.js stays a classic script for the legacy pages, so its facts
|
||||
// tables are inline literals. This test keeps them equal to the one semantics
|
||||
// table (web/js/inspection-semantics.json) that Python also loads.
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const metadata = require("../../web/js/comfy-metadata.js");
|
||||
|
||||
const tablePath = path.join(__dirname, "..", "..", "web", "js", "inspection-semantics.json");
|
||||
const semantics = JSON.parse(fs.readFileSync(tablePath, "utf8"));
|
||||
|
||||
function plain(value) {
|
||||
if (value instanceof Set) return [...value];
|
||||
if (Array.isArray(value)) return value.map(plain);
|
||||
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, plain(item)]));
|
||||
return value;
|
||||
}
|
||||
|
||||
test("facts tables equal semantics.facts", () => {
|
||||
assert.equal(typeof metadata._factsTables, "object", "comfy-metadata exposes its facts tables for this test");
|
||||
assert.deepStrictEqual(plain(metadata._factsTables), semantics.facts);
|
||||
});
|
||||
|
||||
test("facts guider providers are guiders in the semantics table", () => {
|
||||
const providers = Object.entries(semantics.facts.configuration_outputs).filter(([, adapter]) => adapter.kind === "guider");
|
||||
assert.ok(providers.length > 0);
|
||||
for (const [name] of providers) assert.ok(Object.hasOwn(semantics.guiders, name), name);
|
||||
});
|
||||
|
||||
test("the facts tables are not part of the legacy page contract", () => {
|
||||
assert.deepStrictEqual(Object.keys(metadata).filter(key => !key.startsWith("_")).sort(), ["PLACEHOLDERS", "extractGenerationFacts", "extractGenerationParams"]);
|
||||
assert.ok(Object.isFrozen(metadata._factsTables));
|
||||
});
|
||||
|
||||
// Task 2.1 checked only `facts`; the facts code also reads the samplers table
|
||||
// (which classes are samplers, their facts shape and a detailer's image input).
|
||||
test("sampler facts table equals semantics.samplers", () => {
|
||||
assert.equal(typeof metadata._samplerTable, "object", "comfy-metadata exposes its sampler table for this test");
|
||||
// Task 2.7b: plus `fact_overrides`; `prompt_overrides` is read only by the resolver.
|
||||
const expected = Object.fromEntries(Object.entries(semantics.samplers).map(([name, entry]) => [name, {
|
||||
facts: entry.facts,
|
||||
...(entry.image_stage === undefined ? {} : { image_stage: entry.image_stage }),
|
||||
...(entry.fact_overrides === undefined ? {} : { fact_overrides: entry.fact_overrides }),
|
||||
}]));
|
||||
assert.deepStrictEqual(plain(metadata._samplerTable), expected);
|
||||
assert.ok(Object.isFrozen(metadata._samplerTable));
|
||||
});
|
||||
|
||||
test("detailer-stage samplers, and only they, name an image stage", () => {
|
||||
for (const [name, entry] of Object.entries(semantics.samplers)) {
|
||||
assert.equal(Object.hasOwn(metadata._samplerTable[name], "image_stage"), entry.strategy === "detailer-stage", name);
|
||||
}
|
||||
});
|
||||
|
||||
// The stage walk treats every table class as known (the resolver's `all_classes`).
|
||||
test("stage walk table classes equal every class in the semantics table", () => {
|
||||
const classes = new Set();
|
||||
for (const category of ["samplers", "guiders", "selectors", "composites", "savers"]) Object.keys(semantics[category]).forEach(name => classes.add(name));
|
||||
for (const category of ["conditioning", "encoders", "strings"]) semantics[category].forEach(entry => classes.add(entry.class));
|
||||
for (const category of ["runtime", "non_file_outputs"]) semantics[category].forEach(name => classes.add(name));
|
||||
assert.deepStrictEqual([...metadata._tableClasses].sort(), [...classes].sort());
|
||||
});
|
||||
|
||||
// Review I-2: one source for the stage-walk input lists (Python reads them there too).
|
||||
test("stage walk inputs equal semantics.stage_inputs", () => {
|
||||
assert.equal(typeof semantics.stage_inputs, "object");
|
||||
assert.equal(typeof metadata._stageInputs, "object", "comfy-metadata exposes its stage inputs for this test");
|
||||
assert.deepStrictEqual(plain(metadata._stageInputs), semantics.stage_inputs);
|
||||
assert.ok(Object.isFrozen(metadata._stageInputs));
|
||||
});
|
||||
|
||||
// Review M-3: JS treats every samplers entry as a candidate; the server only these strategies.
|
||||
test("every samplers entry is a candidate strategy", () => {
|
||||
for (const [name, entry] of Object.entries(semantics.samplers)) {
|
||||
assert.ok(["ksampler", "custom-advanced", "detailer-stage"].includes(entry.strategy), name);
|
||||
}
|
||||
});
|
||||
@@ -3,10 +3,7 @@
|
||||
// (web/metadata.html, web/js/gallery.js) show today on the synthetic inspection
|
||||
// parity fixtures, so the #240 workspace work cannot change them by accident.
|
||||
// Some answers are known to be wrong (f01b shows the preview-only detailer text
|
||||
// as both roles; f16 shows the last of two conflicting prompt chunks; f19 shows the
|
||||
// upstream sampler's prompt and settings, not the detailer's; f23 shows the positive
|
||||
// text as the zeroed negative; f21 joins a list output; f22 shows PromptSearchList's
|
||||
// search text as the prompt). They are
|
||||
// as both roles; f16 shows the last of two conflicting prompt chunks). They are
|
||||
// recorded as-is and intentionally unchanged (spec section 11).
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
@@ -59,12 +56,6 @@ const EXPECTED = {
|
||||
f15: { positivePrompt: "a red fox in snow, detailed fur", negativePrompt: "blurry, lowres", checkpoint: "sdxl_base.safetensors", steps: 25, cfgScale: 6.5, sampler: "euler", seed: 42 },
|
||||
f16: { positivePrompt: "a grey wolf in fog", negativePrompt: "blurry, lowres", checkpoint: "sdxl_base.safetensors", steps: 25, cfgScale: 6.5, sampler: "euler", seed: 42 },
|
||||
f17: { positivePrompt: "No prompt found", negativePrompt: "No negative prompt found", checkpoint: "flux1-dev.safetensors", steps: "Unknown", cfgScale: "Unknown", sampler: "euler", seed: "Unknown" },
|
||||
f19: { positivePrompt: "a red fox in snow, detailed fur", negativePrompt: "blurry, lowres", checkpoint: "sdxl_base.safetensors", steps: 25, cfgScale: 6.5, sampler: "euler", seed: 42 },
|
||||
f20: { positivePrompt: "No prompt found", negativePrompt: "blurry, lowres", checkpoint: "sdxl_base.safetensors", steps: 25, cfgScale: 6.5, sampler: "euler", seed: 42 },
|
||||
f21: { positivePrompt: "a red fox, in snow", negativePrompt: "blurry, lowres", checkpoint: "sdxl_base.safetensors", steps: 25, cfgScale: 6.5, sampler: "euler", seed: 42 },
|
||||
f22: { positivePrompt: "fox", negativePrompt: "blurry, lowres", checkpoint: "sdxl_base.safetensors", steps: 25, cfgScale: 6.5, sampler: "euler", seed: 42 },
|
||||
f23: { positivePrompt: "a red fox in snow, detailed fur", negativePrompt: "a red fox in snow, detailed fur", checkpoint: "sdxl_base.safetensors", steps: 25, cfgScale: 6.5, sampler: "euler", seed: 42 },
|
||||
f24: { positivePrompt: "No prompt found", negativePrompt: "No negative prompt found", checkpoint: "sdxl_base.safetensors", steps: 25, cfgScale: 6.5, sampler: "euler", seed: 42 },
|
||||
};
|
||||
|
||||
test("legacy extractGenerationParams characterization covers every committed fixture", () => {
|
||||
|
||||
@@ -70,76 +70,3 @@ test('frozen fields disclose their exact source and keep null, empty and whitesp
|
||||
container.click(f.walk(container,node=>node.dataset.sendRole==='pair')[0]);assert.equal(sent.length,2);
|
||||
}finally{scope.dispose();f.restore();}
|
||||
});
|
||||
|
||||
test('a zeroed conditioning negative shows as an explicit empty negative with its own source',()=>{
|
||||
const f=dom(),scope=createScope(),container=new f.Element('div'),sent=[];
|
||||
try{
|
||||
module.mountPromptBlocks(container,{parentScope:scope,sender:{open:roles=>sent.push(roles)},prompts:{positive:{status:'resolved',text:'positive',source:'graph-inferred'},negative:{status:'resolved',text:'',source:'zeroed-conditioning'}}});
|
||||
assert.equal(f.walk(container,node=>node.textContent==='Zeroed conditioning (no negative text)').length,1);
|
||||
assert.equal(f.walk(container,node=>node.textContent==='Empty negative prompt').length,1);
|
||||
container.click(f.walk(container,node=>node.dataset.sendRole==='pair')[0]);
|
||||
assert.deepEqual(sent,[{positive:'positive',negative:''}]);
|
||||
}finally{scope.dispose();f.restore();}
|
||||
});
|
||||
|
||||
const unresolved=message=>({status:'unresolved',text:null,source:null,...(message===undefined?{}:{message})});
|
||||
const statusLines=(f,container)=>f.walk(container,node=>node.tagName==='p'&&node.className==='muted').map(node=>node.textContent);
|
||||
test('unsupported node status names the class',()=>{
|
||||
const f=dom(),scope=createScope(),container=new f.Element('div');
|
||||
const line='Prompt uses a node the inspector does not support (DualCFGGuider).';
|
||||
try{
|
||||
module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:unresolved(line),negative:unresolved(line)}});
|
||||
assert.deepEqual(statusLines(f,container),[line,line]);
|
||||
assert.equal(f.walk(container,node=>node.dataset.sendRole!==undefined||node.dataset.copyRole!==undefined).length,0,'no action implies a prompt exists');
|
||||
}finally{scope.dispose();f.restore();}
|
||||
});
|
||||
test('runtime and composite statuses',()=>{
|
||||
const f=dom(),scope=createScope(),container=new f.Element('div');
|
||||
const runtime='Prompt is chosen at run time (PromptSearchList).',composite='Prompt combines several texts (ConditioningCombine).';
|
||||
try{
|
||||
const view=module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:unresolved(runtime),negative:unresolved(runtime)}});
|
||||
assert.deepEqual(statusLines(f,container),[runtime,runtime]);
|
||||
view.update({positive:unresolved(composite),negative:{status:'resolved',text:'kept',source:'execution'}});
|
||||
assert.deepEqual(statusLines(f,container),[composite]);
|
||||
assert.equal(f.walk(container,node=>node.tagName==='pre'&&node.textContent==='kept').length,1);
|
||||
}finally{scope.dispose();f.restore();}
|
||||
});
|
||||
test('limit status',()=>{
|
||||
const f=dom(),scope=createScope(),container=new f.Element('div');
|
||||
const line='Prompt exceeds inspection limits.';
|
||||
try{
|
||||
module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:unresolved(line),negative:unresolved(line)}});
|
||||
assert.deepEqual(statusLines(f,container),[line,line]);
|
||||
}finally{scope.dispose();f.restore();}
|
||||
});
|
||||
test('roles without a usable line keep the status copy, and a candidate is never shown as a prompt',()=>{
|
||||
const f=dom(),scope=createScope(),container=new f.Element('div');
|
||||
try{
|
||||
module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:unresolved(''),negative:{status:'ambiguous',text:'candidate text',source:null}}});
|
||||
assert.deepEqual(statusLines(f,container),['Prompt could not be resolved.','Multiple possible prompts. Source could not be determined.']);
|
||||
assert.equal(f.walk(container,node=>node.textContent==='candidate text').length,0);
|
||||
}finally{scope.dispose();f.restore();}
|
||||
});
|
||||
test('class names in status lines render as text, never markup',()=>{
|
||||
const f=dom(),scope=createScope(),container=new f.Element('div');
|
||||
const line='Prompt uses a node the inspector does not support (<img src=x onerror=alert(1)>).';
|
||||
try{
|
||||
module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:unresolved(line),negative:unresolved(line)}});
|
||||
assert.deepEqual(statusLines(f,container),[line,line]);
|
||||
assert.equal(f.walk(container,node=>node.tagName==='img').length,0);
|
||||
assert.equal(f.walk(container,node=>Object.hasOwn(node,'innerHTML')).length,0);
|
||||
}finally{scope.dispose();f.restore();}
|
||||
});
|
||||
|
||||
test('a context label names nested prompt regions and actions apart from the frozen blocks while the visible heading stays',()=>{
|
||||
const f=dom(),scope=createScope(),container=new f.Element('div');
|
||||
try{
|
||||
module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},contextLabel:'Current file',prompts:{positive:record('p'),negative:record('n')}});
|
||||
const labels=f.walk(container,node=>node.attributes['aria-label']).map(node=>node.attributes['aria-label']);
|
||||
for(const name of ['Current file positive prompt','Current file negative prompt','Copy current file positive prompt','Send current file positive prompt','Copy current file negative prompt','Send current file negative prompt']) assert.ok(labels.includes(name),name);
|
||||
assert.equal(labels.filter(name=>/^(Positive|Negative) prompt$/.test(name)).length,0);
|
||||
assert.deepEqual(f.walk(container,node=>node.tagName==='h3').map(node=>node.textContent),['Positive prompt','Negative prompt']);
|
||||
const plain=new f.Element('div');module.mountPromptBlocks(plain,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:record('p')}});
|
||||
assert.ok(f.walk(plain,node=>node.attributes['aria-label']==='Positive prompt').length);
|
||||
}finally{scope.dispose();f.restore();}
|
||||
});
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
// Runs the legacy browser parser (comfy-metadata.js extractGenerationParams) on
|
||||
// one JSON case per input line and prints one JSON result per line. The Python
|
||||
// port (utils/generation_params.py) must produce the same results.
|
||||
//
|
||||
// Input line: {"prompt": <text or null>, "workflow": <text or null>}
|
||||
// Each present record is parsed with JSON.parse; a record that fails
|
||||
// to parse is treated as absent, as the browser pages do.
|
||||
// Output line: {"ok": true, "result": {...}} or {"ok": false} when the parser throws.
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const require = createRequire(import.meta.url);
|
||||
const { extractGenerationParams } = require(path.join(here, '..', '..', '..', 'web', 'js', 'comfy-metadata.js'));
|
||||
|
||||
function parsed(text) {
|
||||
if (typeof text !== 'string') return undefined;
|
||||
try { return JSON.parse(text); } catch { return undefined; }
|
||||
}
|
||||
|
||||
for (const line of fs.readFileSync(0, 'utf8').split('\n')) {
|
||||
if (!line.trim()) continue;
|
||||
const item = JSON.parse(line), data = {};
|
||||
const prompt = parsed(item.prompt), workflow = parsed(item.workflow);
|
||||
if (prompt !== undefined) data.prompt = prompt;
|
||||
if (workflow !== undefined) data.workflow = workflow;
|
||||
let out;
|
||||
try { out = { ok: true, result: extractGenerationParams(data) }; } catch { out = { ok: false }; }
|
||||
process.stdout.write(JSON.stringify(out) + '\n');
|
||||
}
|
||||
@@ -225,48 +225,6 @@ test('record accepts closed reason codes and rejects contradictory ones', () =>
|
||||
}
|
||||
});
|
||||
|
||||
test('resolution validator accepts closed reason codes and a bounded node record, rejects others', () => {
|
||||
const toRecord = contract('toInspectionRecord');
|
||||
for (const reason_code of ['unsupported-node', 'runtime-value', 'composite']) {
|
||||
for (const node of [{ id: '7', class_type: 'N'.repeat(200) }, { id: '7'.repeat(200), class_type: null }, null]) {
|
||||
const data = envelope(); Object.assign(data.resolution, { state: 'unresolved', reason_code, node });
|
||||
assert.doesNotThrow(() => toRecord(data, image(), extractGenerationFacts), JSON.stringify({ reason_code, node }));
|
||||
}
|
||||
}
|
||||
for (const change of [
|
||||
{ reason_code: 'unsupported-node', node: { id: '7', class_type: 'N'.repeat(201) } },
|
||||
{ reason_code: 'unsupported-node', node: { id: '7'.repeat(201), class_type: 'X' } },
|
||||
{ reason_code: 'unsupported-node', node: { id: '', class_type: 'X' } },
|
||||
{ reason_code: 'unsupported-node', node: { id: '7', class_type: 'X', extra: true } },
|
||||
{ reason_code: 'unsupported-node', node: [] },
|
||||
{ reason_code: 'malformed', node: { id: '7', class_type: 'X' } },
|
||||
{ reason_code: 'work-limit', node: { id: '7', class_type: 'X' } },
|
||||
{ reason_code: 'no-sampler', node: { id: '7', class_type: 'X' } },
|
||||
{ reason_code: 'Unsupported-Node', node: null },
|
||||
{ reason_code: '', node: null },
|
||||
]) {
|
||||
const data = envelope(); Object.assign(data.resolution, { state: 'unresolved', ...change });
|
||||
assert.throws(() => toRecord(data, image(), extractGenerationFacts), TypeError, JSON.stringify(change));
|
||||
}
|
||||
});
|
||||
|
||||
test('role outcome lines are bounded text on non-resolved roles only', () => {
|
||||
const toRecord = contract('toInspectionRecord');
|
||||
const line = 'Prompt uses a node the inspector does not support (<img src=x onerror=alert(1)>).';
|
||||
const data = envelope(); data.prompts.negative = { status: 'unresolved', text: null, source: null, message: line };
|
||||
assert.equal(toRecord(data, image(), extractGenerationFacts).prompts.negative.message, line);
|
||||
for (const [role, record] of [
|
||||
['positive', { status: 'resolved', text: 'exact', source: 'execution', message: 'Hidden reason.' }],
|
||||
['negative', { status: 'unresolved', text: null, source: null, message: '' }],
|
||||
['negative', { status: 'unresolved', text: null, source: null, message: 7 }],
|
||||
['negative', { status: 'unresolved', text: null, source: null, message: null }],
|
||||
['negative', { status: 'unresolved', text: null, source: null, message: 'x'.repeat(513) }],
|
||||
]) {
|
||||
const bad = envelope(); bad.prompts[role] = record;
|
||||
assert.throws(() => toRecord(bad, image(), extractGenerationFacts), TypeError, JSON.stringify(record));
|
||||
}
|
||||
});
|
||||
|
||||
test('record rejects root path media and invalid versioned fields with retryable TypeError', () => {
|
||||
const toRecord = contract('toInspectionRecord');
|
||||
for (const mutate of [
|
||||
@@ -464,94 +422,3 @@ test('rooted controller keeps legacy descriptors with ignored noncloneable field
|
||||
let pending;assert.doesNotThrow(()=>{pending=controller.select(selected);});
|
||||
requests[0].resolve(envelope());await pending;assert.equal(controller.state.record.identity.key,'0:folder/a #%.png');controller.dispose();
|
||||
});
|
||||
|
||||
// The maintainer's standard pipeline: one PromptManager pair feeds a base pass, an
|
||||
// upscale pass and a FaceDetailer, saved by a custom saver. Text and seeds are invented.
|
||||
function detailerPipelineGraph() {
|
||||
return {
|
||||
1: { class_type: 'CheckpointLoaderSimple', inputs: { ckpt_name: 'Base/example_model.safetensors' } },
|
||||
5: { class_type: 'KSampler', inputs: { seed: 111, steps: 4, cfg: 1.0, sampler_name: 'lcm', scheduler: 'normal', denoise: 1.0, model: ['47', 0], positive: ['25', 0], negative: ['26', 0], latent_image: ['20', 0] } },
|
||||
6: { class_type: 'VAEDecode', inputs: { samples: ['5', 0], vae: ['1', 2] } },
|
||||
7: { class_type: 'PreviewImage', inputs: { images: ['6', 0] } },
|
||||
8: { class_type: 'UpscaleModelLoader', inputs: { model_name: 'upscaler.pth' } },
|
||||
9: { class_type: 'ImageUpscaleWithModel', inputs: { upscale_model: ['8', 0], image: ['6', 0] } },
|
||||
10: { class_type: 'ImageScaleBy', inputs: { upscale_method: 'lanczos', scale_by: 0.75, image: ['9', 0] } },
|
||||
11: { class_type: 'VAEEncode', inputs: { pixels: ['10', 0], vae: ['1', 2] } },
|
||||
12: { class_type: 'KSampler', inputs: { seed: 222, steps: 6, cfg: 1.0, sampler_name: 'lcm', scheduler: 'normal', denoise: 0.45, model: ['47', 0], positive: ['25', 0], negative: ['26', 0], latent_image: ['11', 0] } },
|
||||
13: { class_type: 'VAEDecode', inputs: { samples: ['12', 0], vae: ['1', 2] } },
|
||||
17: { class_type: 'FaceDetailer', inputs: { seed: 333, steps: 4, cfg: 1.0, sampler_name: 'lcm', scheduler: 'normal', denoise: 0.4, image: ['13', 0], model: ['47', 0], clip: ['47', 1], vae: ['1', 2], positive: ['25', 0], negative: ['26', 0] } },
|
||||
20: { class_type: 'EmptyLatentBatch', inputs: { width: 640, height: 1536, batch_size: 1 } },
|
||||
25: { class_type: 'PromptManager', inputs: { text: 'a lighthouse at dusk, oil painting', category: '', tags: '', search_text: '', prepend_text: '', append_text: '', clip: ['47', 1] } },
|
||||
26: { class_type: 'PromptManager', inputs: { text: 'blurry, watermark', category: '', tags: '', search_text: '', prepend_text: '', append_text: '', clip: ['47', 1] } },
|
||||
47: { class_type: 'Lora Loader (LoraManager)', inputs: { text: '', model: ['1', 0], clip: ['1', 1] } },
|
||||
48: { class_type: 'KikoSaveImage', inputs: { filename_prefix: ['49', 0], format: 'PNG', images: ['17', 0] } },
|
||||
49: { class_type: 'KikoProjectFilePath', inputs: { project_name: 'example', filename: 'image' } },
|
||||
};
|
||||
}
|
||||
|
||||
test('workflow prompts and settings match the legacy parser when the server cannot decide', t => {
|
||||
const parser = require('../../web/js/comfy-metadata.js'), { extractGenerationParams } = parser;
|
||||
globalThis.ComfyMetadata = parser;
|
||||
t.after(() => { delete globalThis.ComfyMetadata; });
|
||||
const entry = image(), data = envelope(entry);
|
||||
data.prompts = { positive: { status: 'ambiguous', text: null, source: null }, negative: { status: 'ambiguous', text: null, source: null } };
|
||||
data.metadata.prompt_graph = { status: 'parsed', value: detailerPipelineGraph() };
|
||||
data.resolution = { state: 'ambiguous', sampler_id: null, reason: 'Multiple sampler branches' };
|
||||
const record = contract('toInspectionRecord')(data, entry, extractGenerationFacts);
|
||||
const legacy = extractGenerationParams({ prompt: detailerPipelineGraph() });
|
||||
assert.deepEqual(record.prompts.positive, { status: 'resolved', text: legacy.positivePrompt, source: 'graph-inferred' });
|
||||
assert.deepEqual(record.prompts.negative, { status: 'resolved', text: legacy.negativePrompt, source: 'graph-inferred' });
|
||||
assert.equal(record.prompts.positive.text, 'a lighthouse at dusk, oil painting');
|
||||
assert.equal(record.prompts.negative.text, 'blurry, watermark');
|
||||
for (const [field, key] of [['seed', 'seed'], ['steps', 'steps'], ['cfg', 'cfgScale'], ['sampler', 'sampler'], ['model', 'checkpoint']]) {
|
||||
assert.equal(record.generation[field].status, 'known', field);
|
||||
assert.equal(record.generation[field].value, String(legacy[key]), field);
|
||||
}
|
||||
});
|
||||
|
||||
test('recorded and saved prompts are never replaced by the workflow parse', t => {
|
||||
globalThis.ComfyMetadata = require('../../web/js/comfy-metadata.js');
|
||||
t.after(() => { delete globalThis.ComfyMetadata; });
|
||||
const entry = image(), data = envelope(entry);
|
||||
data.metadata.prompt_graph = { status: 'parsed', value: detailerPipelineGraph() };
|
||||
const record = contract('toInspectionRecord')(data, entry, extractGenerationFacts);
|
||||
assert.deepEqual(record.prompts.positive, { status: 'resolved', text: 'exact\npositive', source: 'execution' });
|
||||
assert.equal(record.prompts.negative.text, 'blurry, watermark');
|
||||
});
|
||||
|
||||
test('a definite server prompt is kept even when the legacy parser reads it differently', t => {
|
||||
globalThis.ComfyMetadata = require('../../web/js/comfy-metadata.js');
|
||||
t.after(() => { delete globalThis.ComfyMetadata; });
|
||||
const entry = image(), data = envelope(entry);
|
||||
// The legacy parser follows ConditioningZeroOut back to the positive text node.
|
||||
const graph = detailerPipelineGraph();
|
||||
graph[30] = { class_type: 'ConditioningZeroOut', inputs: { conditioning: ['25', 0] } };
|
||||
graph[5].inputs.negative = ['30', 0];
|
||||
data.metadata.prompt_graph = { status: 'parsed', value: graph };
|
||||
data.prompts = { positive: { status: 'ambiguous', text: null, source: null }, negative: { status: 'resolved', text: '', source: 'zeroed-conditioning' } };
|
||||
const record = contract('toInspectionRecord')(data, entry, extractGenerationFacts);
|
||||
assert.deepEqual(record.prompts.negative, { status: 'resolved', text: '', source: 'zeroed-conditioning' });
|
||||
assert.equal(record.prompts.positive.text, 'a lighthouse at dusk, oil painting');
|
||||
});
|
||||
|
||||
test('callers without the browser global pass the legacy parser explicitly', () => {
|
||||
const parser = require('../../web/js/comfy-metadata.js');
|
||||
const entry = image(), data = envelope(entry);
|
||||
data.prompts = { positive: { status: 'ambiguous', text: null, source: null }, negative: { status: 'ambiguous', text: null, source: null } };
|
||||
data.metadata.prompt_graph = { status: 'parsed', value: detailerPipelineGraph() };
|
||||
const identity = { kind: 'rooted-file', key: '0:x', root_index: 0, path: entry.path, root_scope: scopeA };
|
||||
const record = contract('projectInspectionPayload')(data, identity, null, extractGenerationFacts, false, parser);
|
||||
assert.equal(record.prompts.positive.text, 'a lighthouse at dusk, oil painting');
|
||||
assert.equal(record.prompts.negative.text, 'blurry, watermark');
|
||||
});
|
||||
|
||||
test('a setting the file does not record is never filled from the legacy parse', t => {
|
||||
globalThis.ComfyMetadata = require('../../web/js/comfy-metadata.js');
|
||||
t.after(() => { delete globalThis.ComfyMetadata; });
|
||||
const entry = image(), data = envelope(entry);
|
||||
data.metadata.prompt_graph = { status: 'parsed', value: detailerPipelineGraph() };
|
||||
const extract = input => ({ ...extractGenerationFacts(input), cfg: { status: 'not-recorded', value: null, source: null, evidence: null, reason: null, sampler_id: null } });
|
||||
const record = contract('toInspectionRecord')(data, entry, extract);
|
||||
assert.equal(record.generation.cfg.status, 'not-recorded');
|
||||
assert.equal(record.generation.cfg.value, null);
|
||||
});
|
||||
|
||||
@@ -158,34 +158,3 @@ test('real inspection navigation preserves slideshow across two advances and def
|
||||
assert.equal(f.container.children.length,0);assert.equal(controller.state.record.identity.occurrence.output_node_id,'3');
|
||||
} finally {view.dispose();controller.dispose();parentScope.dispose();globalThis.setTimeout=oldSetTimeout;globalThis.clearTimeout=oldClearTimeout;f.restore();}
|
||||
});
|
||||
|
||||
test('an inline Viewer still loading its image is destroyed only after it has viewed it', async () => {
|
||||
const {reportedLocationAdapter:adapter} = await import('../../web/js/workspace/history-media.mjs');
|
||||
const f = mountedDom(), parentScope = createScope(), controller = borrowedController(), items = [occurrenceMedia('1',1,'c')];
|
||||
const instances = [];
|
||||
// Viewer.js's inline destroy() leaves an in-flight view running; its callback then needs a live instance.
|
||||
f.win.Viewer = class { constructor(node,options) { this.node = node; this.options = options; this.destroyed = 0; this.viewed = false; instances.push(this); }
|
||||
destroy() { this.destroyed++; } resize() {} };
|
||||
const globals = {ResizeObserver:globalThis.ResizeObserver, requestAnimationFrame:globalThis.requestAnimationFrame, cancelAnimationFrame:globalThis.cancelAnimationFrame};
|
||||
Object.assign(globalThis, {ResizeObserver:class { observe() {} disconnect() {} }, requestAnimationFrame:() => 1, cancelAnimationFrame() {}});
|
||||
const view = module.createMediaViewer({container:f.container,parentScope,inspectionController:controller,adapter,sender:{open(){}}});
|
||||
try {
|
||||
view.open({items,selectedKey:'c'.repeat(64)});
|
||||
const [loading] = instances, image = loading.node, removed = [];
|
||||
image.removeAttribute = key => removed.push(key);
|
||||
f.walk(f.container,n => n.attributes['aria-label'] === 'Close preview')[0].fire('click');
|
||||
assert.equal(loading.destroyed, 0, 'not destroyed while its image view is pending');
|
||||
// Viewer.js lists only images that still have a src; the pending view needs it.
|
||||
assert.deepEqual(removed, [], 'the image keeps its src while the view is pending');
|
||||
loading.viewed = true; loading.options.viewed.call(loading);
|
||||
assert.equal(loading.destroyed, 1, 'destroyed once its pending view settles');
|
||||
assert.deepEqual(removed, ['src'], 'the src is released after the viewer');
|
||||
loading.options.viewed.call(loading);
|
||||
assert.equal(loading.destroyed, 1, 'never destroyed twice');
|
||||
|
||||
view.open({items,selectedKey:'c'.repeat(64)});
|
||||
const shown = instances[1]; shown.viewed = true; shown.options.viewed.call(shown);
|
||||
f.walk(f.container,n => n.attributes['aria-label'] === 'Close preview')[0].fire('click');
|
||||
assert.equal(shown.destroyed, 1, 'a viewed image is destroyed at once');
|
||||
} finally { view.dispose(); parentScope.dispose(); f.restore(); Object.assign(globalThis, globals); }
|
||||
});
|
||||
|
||||
@@ -12,21 +12,6 @@ test('workspace routes resolve only supported deep links', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('tags is a native route titled Tags; only unbuilt routes hand off to legacy tools', async () => {
|
||||
const { resolveRoute, routeTitle, isHandoff } = await source;
|
||||
const tags = resolveRoute('#/tags');
|
||||
assert.equal(tags.label, 'Tags & categories');
|
||||
assert.equal(routeTitle(tags), 'Tags');
|
||||
assert.equal(tags.href, '/prompt_manager/admin#/tags', 'Overview keeps the existing tag manager card');
|
||||
for (const id of ['images', 'library', 'inspect', 'overview', 'system']) {
|
||||
const route = resolveRoute(`#/${id}`);
|
||||
assert.equal(routeTitle(route), route.label);
|
||||
}
|
||||
assert.equal(routeTitle(resolveRoute('#/library/7')), 'Prompt library');
|
||||
for (const id of ['images', 'library', 'tags', 'overview']) assert.equal(isHandoff(resolveRoute(`#/${id}`)), false, id);
|
||||
for (const id of ['inspect', 'system']) assert.equal(isHandoff(resolveRoute(`#/${id}`)), true, id);
|
||||
});
|
||||
|
||||
test('statistics preserve zero and reject incomplete or invalid counts', async () => {
|
||||
const { summarizeStats } = await source;
|
||||
assert.deepEqual(summarizeStats({total_prompts:0, total_images:12, total_tags:3}), [
|
||||
|
||||
@@ -1,516 +0,0 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const {ApiError} = require('../../web/js/api-client.js');
|
||||
// Guarded import: a missing module fails the assertions, not the file load.
|
||||
const imported = import('../../web/js/workspace/vocabulary-state.mjs').catch(() => ({}));
|
||||
const source = imported.then(module => {
|
||||
for (const name of ['createVocabularyStream', 'createVocabularyCommand', 'browseFilters']) {
|
||||
assert.equal(typeof module[name], 'function', `vocabulary-state.mjs exports ${name}`);
|
||||
}
|
||||
return module;
|
||||
});
|
||||
|
||||
const EPOCH = '11111111-2222-4333-8444-555555555555';
|
||||
const OTHER_EPOCH = '99999999-2222-4333-8444-555555555555';
|
||||
const TOKEN = 'a'.repeat(64);
|
||||
const tag = (id, name = `tag-${id}`, prompt_count = id) => ({id, name, prompt_count});
|
||||
const category = (value, prompt_count = 1) => ({value, prompt_count});
|
||||
const tagPage = (tags, cursor = null, {epoch = EPOCH, total = 3} = {}) =>
|
||||
({success: true, library_epoch: epoch, total, has_more: cursor !== null, next_cursor: cursor, tags});
|
||||
const categoryPage = (categories, cursor = null, {epoch = EPOCH, total = 3} = {}) =>
|
||||
({success: true, library_epoch: epoch, total, has_more: cursor !== null, next_cursor: cursor, categories});
|
||||
const queued = pages => {
|
||||
const calls = [];
|
||||
return {calls, api: {get: async (url, options) => {calls.push({url, options}); return pages.shift();}}};
|
||||
};
|
||||
const deferred = () => {
|
||||
const requests = [];
|
||||
return {requests, api: {get: (url, {signal}) => new Promise((resolve, reject) => requests.push({url, signal, resolve, reject}))}};
|
||||
};
|
||||
const params = url => new URL(url, 'https://x').searchParams;
|
||||
const impact = {positive_prompts: 3, other_prompts: 1, negative_variants: 2, archived_negative_variants: 1};
|
||||
const preview = (overrides = {}) => ({success: true, library_epoch: EPOCH, source: 'Landscapes', target: 'Scenery',
|
||||
target_exists: false, impact, impact_token: TOKEN, ...overrides});
|
||||
const tagReceipt = (overrides = {}) => ({success: true, library_epoch: EPOCH, created: true, tag: tag(7, 'sunset', 0), ...overrides});
|
||||
const renameReceipt = (overrides = {}) => ({success: true, library_epoch: EPOCH, merged: false, tag: tag(1, 'dusk', 2),
|
||||
components_changed: 2, pairs_changed: 1, ...overrides});
|
||||
|
||||
test('tag stream reads bounded fresh pages, carries the first epoch and dedupes appended ids', async () => {
|
||||
const {createVocabularyStream} = await source;
|
||||
const {calls, api} = queued([tagPage([tag(1), tag(2)], 'next'), tagPage([tag(2), tag(3)])]);
|
||||
const states = [];
|
||||
const stream = createVocabularyStream({api, kind: 'tags', onChange: state => states.push(state)});
|
||||
assert.equal(Object.isFrozen(stream.state), true);
|
||||
assert.equal(await stream.loadMore(), 2);
|
||||
assert.equal(stream.state.epoch, EPOCH);
|
||||
assert.equal(await stream.loadMore(), 1);
|
||||
assert.deepEqual(stream.state.items.map(row => row.id), [1, 2, 3]);
|
||||
assert.equal(Object.isFrozen(stream.state.items[0]), true);
|
||||
assert.equal(stream.state.hasMore, false);
|
||||
assert.equal(stream.state.total, 3);
|
||||
assert.equal(await stream.loadMore(), 0);
|
||||
assert.equal(calls.length, 2);
|
||||
assert.match(calls[0].url, /^\/prompt_manager\/vocabulary\/tags\?/);
|
||||
assert.equal(params(calls[0].url).get('limit'), '100');
|
||||
assert.equal(params(calls[0].url).has('cursor'), false);
|
||||
assert.equal(params(calls[1].url).get('cursor'), 'next');
|
||||
assert.equal(calls[0].options.cache, 'no-store');
|
||||
assert.ok(states.some(state => state.loading));
|
||||
stream.dispose();
|
||||
});
|
||||
|
||||
test('category stream dedupes by value and keeps the first occurrence when keyset order shifts', async () => {
|
||||
const {createVocabularyStream} = await source;
|
||||
const {calls, api} = queued([categoryPage([category('Landscapes', 3), category('Portraits', 2)], 'c1'),
|
||||
categoryPage([category('Landscapes', 9), category('Studio', 1)])]);
|
||||
const stream = createVocabularyStream({api, kind: 'categories'});
|
||||
await stream.loadMore();
|
||||
assert.equal(await stream.loadMore(), 1);
|
||||
assert.deepEqual(stream.state.items.map(row => [row.value, row.prompt_count]),
|
||||
[['Landscapes', 3], ['Portraits', 2], ['Studio', 1]]);
|
||||
assert.match(calls[0].url, /^\/prompt_manager\/vocabulary\/categories\?/);
|
||||
stream.dispose();
|
||||
});
|
||||
|
||||
test('malformed and nonprogressing pages are rejected atomically', async () => {
|
||||
const {createVocabularyStream} = await source;
|
||||
const malformedTags = [
|
||||
null, {}, {...tagPage([]), success: undefined}, {...tagPage([]), library_epoch: 'nope'},
|
||||
tagPage([tag(1, 'a', 1.5)]), tagPage([tag(1, 'a', -1)]), tagPage([tag(1, 'a', '3')]),
|
||||
tagPage([tag(0)]), tagPage([{...tag(1), name: 2}]), tagPage([tag(1), tag(1, 'other')]),
|
||||
{...tagPage([]), has_more: true}, {...tagPage([], 'next'), has_more: false},
|
||||
{...tagPage([]), next_cursor: ''}, {...tagPage([]), total: 1.5}, {...tagPage([]), tags: 'x'},
|
||||
{...tagPage([]), tags: undefined, categories: []},
|
||||
];
|
||||
for (const malformed of malformedTags) {
|
||||
const stream = createVocabularyStream({api: {get: async () => malformed}, kind: 'tags'});
|
||||
await stream.loadMore();
|
||||
assert.ok(stream.state.error, JSON.stringify(malformed));
|
||||
assert.equal(stream.state.items.length, 0);
|
||||
assert.equal(stream.state.epoch, null);
|
||||
stream.dispose();
|
||||
}
|
||||
for (const malformed of [categoryPage([category('A'), category('A')]), categoryPage([category('A', 1.5)]),
|
||||
categoryPage([{value: 3, prompt_count: 1}]), categoryPage([category('')])]) {
|
||||
const stream = createVocabularyStream({api: {get: async () => malformed}, kind: 'categories'});
|
||||
await stream.loadMore();
|
||||
assert.ok(stream.state.error, JSON.stringify(malformed));
|
||||
stream.dispose();
|
||||
}
|
||||
const repeated = createVocabularyStream({api: {get: async () => tagPage([tag(1)], 'same')}, kind: 'tags'});
|
||||
await repeated.loadMore();
|
||||
await repeated.loadMore();
|
||||
assert.ok(repeated.state.error);
|
||||
assert.deepEqual(repeated.state.items.map(row => row.id), [1]);
|
||||
assert.equal(repeated.state.cursor, 'same');
|
||||
repeated.dispose();
|
||||
assert.throws(() => createVocabularyStream({api: {}, kind: 'images'}), TypeError);
|
||||
});
|
||||
|
||||
test('reset aborts the older request and its late response cannot replace the first page', async () => {
|
||||
const {createVocabularyStream} = await source;
|
||||
const {requests, api} = deferred();
|
||||
const stream = createVocabularyStream({api, kind: 'tags'});
|
||||
const first = stream.loadMore();
|
||||
const second = stream.reset();
|
||||
assert.equal(requests[0].signal.aborted, true);
|
||||
assert.equal(params(requests[1].url).has('cursor'), false);
|
||||
requests[1].resolve(tagPage([tag(2)]));
|
||||
assert.equal(await second, 1);
|
||||
requests[0].resolve(tagPage([tag(1)], 'old', {epoch: OTHER_EPOCH}));
|
||||
assert.equal(await first, 0);
|
||||
assert.deepEqual(stream.state.items.map(row => row.id), [2]);
|
||||
assert.equal(stream.state.epoch, EPOCH);
|
||||
const third = stream.reset();
|
||||
requests[2].reject(new Error('late failure'));
|
||||
const fourth = stream.reset();
|
||||
await third;
|
||||
assert.equal(stream.state.error, null);
|
||||
requests[3].resolve(tagPage([tag(5)]));
|
||||
await fourth;
|
||||
assert.deepEqual(stream.state.items.map(row => row.id), [5]);
|
||||
stream.dispose();
|
||||
});
|
||||
|
||||
test('dispose aborts the in-flight read and ignores its late result', async () => {
|
||||
const {createVocabularyStream} = await source;
|
||||
const {requests, api} = deferred();
|
||||
const states = [];
|
||||
const stream = createVocabularyStream({api, kind: 'categories', onChange: state => states.push(state)});
|
||||
const pending = stream.loadMore();
|
||||
stream.dispose();
|
||||
assert.equal(requests[0].signal.aborted, true);
|
||||
const count = states.length;
|
||||
requests[0].resolve(categoryPage([category('A')]));
|
||||
assert.equal(await pending, 0);
|
||||
assert.equal(states.length, count);
|
||||
assert.equal(await stream.loadMore(), 0);
|
||||
assert.equal(await stream.reset(), 0);
|
||||
assert.equal(requests.length, 1);
|
||||
});
|
||||
|
||||
test('a changed epoch on append is an error that requires a reset', async () => {
|
||||
const {createVocabularyStream} = await source;
|
||||
const {calls, api} = queued([tagPage([tag(1)], 'next'), tagPage([tag(2)], null, {epoch: OTHER_EPOCH}),
|
||||
tagPage([tag(4)], null, {epoch: OTHER_EPOCH})]);
|
||||
const stream = createVocabularyStream({api, kind: 'tags'});
|
||||
await stream.loadMore();
|
||||
assert.equal(await stream.loadMore(), 0);
|
||||
assert.equal(stream.state.error.code, 'storage-changed');
|
||||
assert.equal(stream.state.resetRequired, true);
|
||||
assert.deepEqual(stream.state.items.map(row => row.id), [1]);
|
||||
assert.equal(stream.state.epoch, EPOCH);
|
||||
assert.equal(await stream.loadMore(), 0);
|
||||
assert.equal(calls.length, 2);
|
||||
assert.equal(await stream.reset(), 1);
|
||||
assert.equal(stream.state.resetRequired, false);
|
||||
assert.equal(stream.state.epoch, OTHER_EPOCH);
|
||||
assert.deepEqual(stream.state.items.map(row => row.id), [4]);
|
||||
stream.dispose();
|
||||
});
|
||||
|
||||
test('failed reads keep loaded rows and cursor so Retry continues', async () => {
|
||||
const {createVocabularyStream} = await source;
|
||||
let calls = 0;
|
||||
const stream = createVocabularyStream({kind: 'tags', api: {get: async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) return tagPage([tag(1)], 'next');
|
||||
if (calls === 2) throw new Error('offline');
|
||||
return tagPage([tag(2)]);
|
||||
}}});
|
||||
await stream.loadMore();
|
||||
await stream.loadMore();
|
||||
assert.match(stream.state.error.message, /offline/);
|
||||
assert.equal(stream.state.resetRequired, false);
|
||||
assert.equal(stream.state.cursor, 'next');
|
||||
assert.equal(await stream.loadMore(), 1);
|
||||
assert.equal(stream.state.error, null);
|
||||
stream.dispose();
|
||||
});
|
||||
|
||||
test('create tag sends the epoch once, completes with the receipt and re-reads streams from the first page', async () => {
|
||||
const {createVocabularyCommand, createVocabularyStream} = await source;
|
||||
const reads = queued([tagPage([tag(1)], 'next'), tagPage([tag(1), tag(7, 'sunset', 0)])]);
|
||||
const stream = createVocabularyStream({api: reads.api, kind: 'tags'});
|
||||
await stream.loadMore();
|
||||
const posts = [];
|
||||
const invalidations = [];
|
||||
const command = createVocabularyCommand({
|
||||
api: {post: async (url, body) => {posts.push({url, body}); return tagReceipt();}},
|
||||
onInvalidate: event => {invalidations.push(event); stream.reset();},
|
||||
});
|
||||
assert.equal(command.state.phase, 'idle');
|
||||
await command.createTag('sunset', EPOCH);
|
||||
assert.deepEqual(posts, [{url: '/prompt_manager/vocabulary/tags', body: {expected_epoch: EPOCH, name: 'sunset'}}]);
|
||||
assert.equal(command.state.phase, 'done');
|
||||
assert.equal(command.state.kind, 'create-tag');
|
||||
assert.equal(command.state.receipt.tag.name, 'sunset');
|
||||
assert.equal(Object.isFrozen(command.state.receipt), true);
|
||||
assert.equal(invalidations.length, 1);
|
||||
assert.equal(invalidations[0].phase, 'done');
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.equal(params(reads.calls[1].url).has('cursor'), false);
|
||||
assert.deepEqual(stream.state.items.map(row => row.id), [1, 7]);
|
||||
await command.createTag('again', EPOCH);
|
||||
assert.equal(posts.length, 1, 'done is terminal until reset()');
|
||||
command.reset();
|
||||
assert.equal(command.state.phase, 'idle');
|
||||
stream.dispose();
|
||||
});
|
||||
|
||||
test('rename and delete carry the exact tag identity and invalidate after success', async () => {
|
||||
const {createVocabularyCommand} = await source;
|
||||
const calls = [];
|
||||
const invalidations = [];
|
||||
const command = createVocabularyCommand({
|
||||
api: {
|
||||
patch: async (url, body) => {calls.push({method: 'patch', url, body}); return renameReceipt({merged: true});},
|
||||
post: async (url, body) => {calls.push({method: 'post', url, body});
|
||||
return {success: true, library_epoch: EPOCH, deleted: true, components_changed: 1, pairs_changed: 0};},
|
||||
},
|
||||
onInvalidate: event => invalidations.push(event),
|
||||
});
|
||||
await command.renameTag({id: 1, name: 'dawn', prompt_count: 2}, 'dusk', EPOCH);
|
||||
assert.deepEqual(calls[0], {method: 'patch', url: '/prompt_manager/vocabulary/tags/1',
|
||||
body: {expected_epoch: EPOCH, name: 'dawn', new_name: 'dusk'}});
|
||||
assert.equal(command.state.receipt.merged, true);
|
||||
command.reset();
|
||||
await command.deleteTag({id: 1, name: 'dawn'}, EPOCH);
|
||||
assert.deepEqual(calls[1], {method: 'post', url: '/prompt_manager/vocabulary/tags/1/delete',
|
||||
body: {expected_epoch: EPOCH, name: 'dawn'}});
|
||||
assert.equal(command.state.phase, 'done');
|
||||
assert.equal(command.state.kind, 'delete-tag');
|
||||
assert.deepEqual(invalidations.map(event => event.phase), ['done', 'done']);
|
||||
command.reset();
|
||||
assert.throws(() => command.renameTag({id: 0, name: 'x'}, 'y', EPOCH), TypeError);
|
||||
assert.throws(() => command.createTag('x', 'not-an-epoch'), TypeError);
|
||||
assert.throws(() => command.deleteTag({id: 1}, EPOCH), TypeError);
|
||||
assert.equal(calls.length, 2);
|
||||
});
|
||||
|
||||
test('409 storage-changed and vocabulary-conflict map to recovery phases; other failures stay honest', async () => {
|
||||
const {createVocabularyCommand} = await source;
|
||||
const cases = [
|
||||
[new ApiError(409, 'restored', 'storage-changed'), 'storage-changed', true],
|
||||
[new ApiError(409, 'changed', 'vocabulary-conflict'), 'conflict', true],
|
||||
[new ApiError(409, 'other', null), 'write-error', false],
|
||||
[new ApiError(400, 'Tag names cannot contain commas', null), 'write-error', false],
|
||||
[new ApiError(502, 'Bad gateway'), 'unknown-outcome', true],
|
||||
[new ApiError(200, 'Invalid JSON response'), 'unknown-outcome', true],
|
||||
];
|
||||
for (const [error, phase, invalidates] of cases) {
|
||||
const invalidations = [];
|
||||
const command = createVocabularyCommand({api: {post: async () => {throw error;}}, onInvalidate: event => invalidations.push(event)});
|
||||
await command.createTag('sunset', EPOCH);
|
||||
assert.equal(command.state.phase, phase, `${error.status} ${error.code}`);
|
||||
assert.equal(command.state.error, error);
|
||||
assert.equal(invalidations.length, invalidates ? 1 : 0);
|
||||
command.dispose();
|
||||
}
|
||||
const invalid = createVocabularyCommand({api: {post: async () => tagReceipt({library_epoch: OTHER_EPOCH})}});
|
||||
await invalid.createTag('sunset', EPOCH);
|
||||
assert.equal(invalid.state.phase, 'unknown-outcome');
|
||||
const retryable = createVocabularyCommand({api: {post: async () => {throw new ApiError(400, 'bad');}}});
|
||||
await retryable.createTag('a,b', EPOCH);
|
||||
await retryable.createTag('ab', EPOCH);
|
||||
assert.equal(retryable.state.phase, 'write-error');
|
||||
});
|
||||
|
||||
test('a network TypeError is an unknown outcome and is never retried automatically', async () => {
|
||||
const {createVocabularyCommand} = await source;
|
||||
let calls = 0;
|
||||
const command = createVocabularyCommand({api: {patch: async () => {calls += 1; throw new TypeError('Failed to fetch');}}});
|
||||
await command.renameTag({id: 3, name: 'dawn'}, 'dusk', EPOCH);
|
||||
assert.equal(command.state.phase, 'unknown-outcome');
|
||||
assert.equal(calls, 1);
|
||||
await command.renameTag({id: 3, name: 'dawn'}, 'dusk', EPOCH);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.equal(calls, 1);
|
||||
command.reset();
|
||||
assert.equal(command.state.phase, 'idle');
|
||||
assert.equal(command.state.error, null);
|
||||
});
|
||||
|
||||
test('a second Apply while saving is ignored', async () => {
|
||||
const {createVocabularyCommand} = await source;
|
||||
const posts = [];
|
||||
let release;
|
||||
const command = createVocabularyCommand({api: {post: (url, body) => {
|
||||
posts.push({url, body});
|
||||
if (url.endsWith('/preview')) return Promise.resolve(preview());
|
||||
return new Promise(resolve => {release = resolve;});
|
||||
}}});
|
||||
await command.previewCategory('Landscapes', 'Scenery', EPOCH);
|
||||
const shown = command.state.preview;
|
||||
const first = command.applyCategory(shown);
|
||||
assert.equal(command.state.phase, 'saving');
|
||||
await command.applyCategory(shown);
|
||||
await command.createTag('x', EPOCH);
|
||||
await command.previewCategory('Landscapes', 'Other', EPOCH);
|
||||
command.edit();
|
||||
command.reset();
|
||||
assert.equal(command.state.phase, 'saving');
|
||||
assert.equal(posts.length, 2);
|
||||
release({success: true, library_epoch: EPOCH, source: 'Landscapes', target: 'Scenery', consolidated: false, impact});
|
||||
await first;
|
||||
assert.equal(command.state.phase, 'done');
|
||||
assert.deepEqual(posts[1], {url: '/prompt_manager/vocabulary/categories/apply',
|
||||
body: {expected_epoch: EPOCH, source: 'Landscapes', target: 'Scenery', impact_token: TOKEN}});
|
||||
});
|
||||
|
||||
test('category preview shows real impact; editing its inputs returns to idle and a stale preview cannot apply', async () => {
|
||||
const {createVocabularyCommand} = await source;
|
||||
const posts = [];
|
||||
const invalidations = [];
|
||||
const responses = [preview(), preview({target: 'Scenery', target_exists: true})];
|
||||
const command = createVocabularyCommand({
|
||||
api: {post: async (url, body) => {posts.push({url, body}); return responses.shift();}},
|
||||
onInvalidate: event => invalidations.push(event),
|
||||
});
|
||||
await command.previewCategory('Landscapes', ' Scenery ', EPOCH);
|
||||
assert.deepEqual(posts[0], {url: '/prompt_manager/vocabulary/categories/preview',
|
||||
body: {expected_epoch: EPOCH, source: 'Landscapes', target: ' Scenery '}});
|
||||
assert.equal(command.state.phase, 'previewed');
|
||||
assert.equal(command.state.kind, 'preview-category');
|
||||
const stale = command.state.preview;
|
||||
assert.deepEqual(stale.impact, impact);
|
||||
assert.equal(invalidations.length, 0, 'a preview writes nothing');
|
||||
command.edit();
|
||||
assert.equal(command.state.phase, 'idle');
|
||||
assert.equal(command.state.preview, null);
|
||||
await command.applyCategory(stale);
|
||||
assert.equal(posts.length, 1);
|
||||
await command.previewCategory('Landscapes', 'Scenery', EPOCH);
|
||||
assert.equal(command.state.preview.target_exists, true);
|
||||
await command.applyCategory(stale);
|
||||
assert.equal(posts.length, 2, 'only the preview on screen can be applied');
|
||||
command.edit();
|
||||
command.edit();
|
||||
assert.equal(command.state.phase, 'idle');
|
||||
for (const malformed of [preview({source: 'Portraits'}), preview({impact_token: 'short'}),
|
||||
preview({impact: {...impact, positive_prompts: 1.5}}), preview({library_epoch: OTHER_EPOCH})]) {
|
||||
const check = createVocabularyCommand({api: {post: async () => malformed}});
|
||||
await check.previewCategory('Landscapes', 'Scenery', EPOCH);
|
||||
assert.equal(check.state.phase, 'write-error', JSON.stringify(malformed));
|
||||
assert.equal(check.state.preview, null);
|
||||
}
|
||||
const lost = createVocabularyCommand({api: {post: async () => {throw new TypeError('Failed to fetch');}}});
|
||||
await lost.previewCategory('Landscapes', 'Scenery', EPOCH);
|
||||
assert.equal(lost.state.phase, 'write-error', 'a lost preview read wrote nothing');
|
||||
const conflicted = createVocabularyCommand({api: {post: async () => {throw new ApiError(409, 'gone', 'vocabulary-conflict');}}});
|
||||
await conflicted.previewCategory('Landscapes', 'Scenery', EPOCH);
|
||||
assert.equal(conflicted.state.phase, 'conflict');
|
||||
assert.throws(() => command.previewCategory(3, 'Scenery', EPOCH), TypeError);
|
||||
});
|
||||
|
||||
test('an edit while a preview is pending discards its late result so Apply cannot send the old values', async () => {
|
||||
const {createVocabularyCommand} = await source;
|
||||
const posts = [];
|
||||
const pending = [];
|
||||
const command = createVocabularyCommand({api: {post: (url, body) => {
|
||||
posts.push({url, body});
|
||||
return new Promise((resolve, reject) => pending.push({resolve, reject}));
|
||||
}}});
|
||||
const first = command.previewCategory('Landscapes', 'Scenery', EPOCH);
|
||||
assert.equal(command.state.phase, 'saving');
|
||||
command.edit();
|
||||
assert.equal(command.state.phase, 'saving', 'the request is still in flight');
|
||||
pending.shift().resolve(preview());
|
||||
await first;
|
||||
assert.equal(command.state.phase, 'idle', 'the late preview describes values no longer on screen');
|
||||
assert.equal(command.state.preview, null);
|
||||
await command.applyCategory(preview());
|
||||
assert.equal(posts.length, 1, 'nothing is applied from a discarded preview');
|
||||
// A fresh preview of the edited values is applicable as usual.
|
||||
const second = command.previewCategory('Landscapes', 'Vistas', EPOCH);
|
||||
pending.shift().resolve(preview({target: 'Vistas'}));
|
||||
await second;
|
||||
assert.equal(command.state.phase, 'previewed');
|
||||
assert.equal(command.state.preview.target, 'Vistas');
|
||||
// A discarded preview's plain failure is not shown either; a recovery outcome still is, since it describes storage.
|
||||
command.edit();
|
||||
const failed = command.previewCategory('Landscapes', 'Vistas', EPOCH);
|
||||
command.edit();
|
||||
pending.shift().reject(new TypeError('Failed to fetch'));
|
||||
await failed;
|
||||
assert.equal(command.state.phase, 'idle');
|
||||
assert.equal(command.state.error, null);
|
||||
const restored = command.previewCategory('Landscapes', 'Vistas', EPOCH);
|
||||
command.edit();
|
||||
pending.shift().reject(new ApiError(409, 'restored', 'storage-changed'));
|
||||
await restored;
|
||||
assert.equal(command.state.phase, 'storage-changed');
|
||||
});
|
||||
|
||||
test('a token conflict on apply requires a new preview', async () => {
|
||||
const {createVocabularyCommand} = await source;
|
||||
const responses = [() => preview(), () => {throw new ApiError(409, 'changed', 'vocabulary-conflict');}, () => preview()];
|
||||
const command = createVocabularyCommand({api: {post: async () => responses.shift()()}});
|
||||
await command.previewCategory('Landscapes', 'Scenery', EPOCH);
|
||||
await command.applyCategory(command.state.preview);
|
||||
assert.equal(command.state.phase, 'conflict');
|
||||
assert.equal(command.state.preview, null);
|
||||
await command.previewCategory('Landscapes', 'Scenery', EPOCH);
|
||||
assert.equal(command.state.phase, 'previewed');
|
||||
});
|
||||
|
||||
test('dispose ignores late command results but a landed write still invalidates once', async () => {
|
||||
const {createVocabularyCommand} = await source;
|
||||
let release;
|
||||
const states = [];
|
||||
const invalidations = [];
|
||||
const command = createVocabularyCommand({api: {post: () => new Promise(resolve => {release = resolve;})},
|
||||
onChange: state => states.push(state), onInvalidate: event => invalidations.push(event)});
|
||||
const pending = command.createTag('sunset', EPOCH);
|
||||
command.dispose();
|
||||
const count = states.length;
|
||||
const receipt = tagReceipt();
|
||||
release(receipt);
|
||||
await pending;
|
||||
assert.equal(states.length, count, 'a disposed dialog is never rendered again');
|
||||
// The write committed after its dialog went away: cached Library views must still be dropped.
|
||||
assert.equal(invalidations.length, 1);
|
||||
assert.equal(invalidations[0].phase, 'done');
|
||||
assert.equal(invalidations[0].kind, 'create-tag');
|
||||
assert.deepEqual(invalidations[0].receipt, receipt);
|
||||
assert.ok(Object.isFrozen(invalidations[0]));
|
||||
assert.equal(command.state.phase, 'disposed');
|
||||
await command.createTag('x', EPOCH);
|
||||
assert.equal(invalidations.length, 1);
|
||||
});
|
||||
|
||||
test('after dispose an unconfirmed write invalidates and a failed write does not', async () => {
|
||||
const {createVocabularyCommand} = await source;
|
||||
const cases = [
|
||||
[new TypeError('Failed to fetch'), 'unknown-outcome', 1],
|
||||
[new ApiError(409, 'changed', 'vocabulary-conflict'), 'conflict', 1],
|
||||
[new ApiError(400, 'Tag names cannot contain commas'), null, 0],
|
||||
];
|
||||
for (const [error, phase, expected] of cases) {
|
||||
let fail;
|
||||
const invalidations = [];
|
||||
const command = createVocabularyCommand({api: {patch: () => new Promise((resolve, reject) => {fail = reject;})},
|
||||
onInvalidate: event => invalidations.push(event)});
|
||||
const pending = command.renameTag({id: 3, name: 'dawn'}, 'dusk', EPOCH);
|
||||
command.dispose();
|
||||
fail(error);
|
||||
await pending;
|
||||
assert.equal(invalidations.length, expected, error.message);
|
||||
if (expected) assert.deepEqual({...invalidations[0]}, {phase, kind: 'rename-tag', receipt: null});
|
||||
assert.equal(command.state.phase, 'disposed');
|
||||
}
|
||||
});
|
||||
|
||||
test('browseFilters hands Library one exact constraint', async () => {
|
||||
const {browseFilters} = await source;
|
||||
assert.deepEqual(browseFilters('tags', 'a'), {text: '', category: '', tags: ['a'], sort: 'newest'});
|
||||
assert.deepEqual(browseFilters('categories', ' A '), {text: '', category: ' A ', tags: [], sort: 'newest'});
|
||||
assert.throws(() => browseFilters('images', 'a'), TypeError);
|
||||
assert.throws(() => browseFilters('tags', 2), TypeError);
|
||||
assert.throws(() => browseFilters('categories', ''), TypeError);
|
||||
});
|
||||
|
||||
const helpers = imported.then(module => {
|
||||
// Count wording comes from the shared plural.mjs helper (#243); this module no longer exports its own.
|
||||
assert.equal(module.noun, undefined);
|
||||
for (const name of ['readTagImpact', 'categoryPreviewText', 'deleteTagText']) {
|
||||
assert.equal(typeof module[name], 'function', `vocabulary-state.mjs exports ${name}`);
|
||||
}
|
||||
return module;
|
||||
});
|
||||
const zero = {positive_prompts: 0, other_prompts: 0, negative_variants: 0, archived_negative_variants: 0};
|
||||
|
||||
test('readTagImpact reads one fresh, validated impact for the exact tag id', async () => {
|
||||
const {readTagImpact} = await helpers;
|
||||
const calls = [];
|
||||
const signal = new AbortController().signal;
|
||||
const api = {get: async (url, options) => {calls.push({url, options}); return {success: true, library_epoch: EPOCH, impact};}};
|
||||
const value = await readTagImpact(api, {id: 4, name: 'mist'}, {signal});
|
||||
assert.deepEqual(calls, [{url: '/prompt_manager/vocabulary/tags/4/impact', options: {signal, cache: 'no-store'}}]);
|
||||
assert.deepEqual(value, {library_epoch: EPOCH, impact});
|
||||
assert.ok(Object.isFrozen(value) && Object.isFrozen(value.impact));
|
||||
for (const malformed of [null, {success: false}, {success: true, library_epoch: 'x', impact},
|
||||
{success: true, library_epoch: EPOCH, impact: {...impact, other_prompts: -1}}, {success: true, library_epoch: EPOCH}]) {
|
||||
await assert.rejects(readTagImpact({get: async () => malformed}, {id: 4, name: 'mist'}), TypeError, JSON.stringify(malformed));
|
||||
}
|
||||
assert.throws(() => readTagImpact(api, {id: 0, name: 'mist'}), TypeError);
|
||||
});
|
||||
|
||||
test('category preview text states the real impact and whether it renames or consolidates', async () => {
|
||||
const {categoryPreviewText} = await helpers;
|
||||
assert.equal(categoryPreviewText(preview()),
|
||||
'“Landscapes” is used by 3 positive prompts, 3 negative variants (1 archived) and 1 other saved prompt. Apply renames it to “Scenery”.');
|
||||
assert.equal(categoryPreviewText(preview({target: 'Architecture', target_exists: true,
|
||||
impact: {...zero, positive_prompts: 1, negative_variants: 1}})),
|
||||
'“Landscapes” is used by 1 positive prompt and 1 negative variant. Apply consolidates it into the existing “Architecture”.');
|
||||
assert.equal(categoryPreviewText(preview({impact: {...zero, other_prompts: 2}})),
|
||||
'“Landscapes” is used by 2 other saved prompts. Apply renames it to “Scenery”.');
|
||||
});
|
||||
|
||||
test('delete text names the tag, its real links and what stays unchanged', async () => {
|
||||
const {deleteTagText} = await helpers;
|
||||
assert.equal(deleteTagText('mist', impact),
|
||||
'Deleting “mist” removes the name and its links from 3 positive prompts, 3 negative variants (1 archived) and 1 other saved prompt. The prompts, negative variants, images and history themselves are kept.');
|
||||
assert.equal(deleteTagText('unused', zero),
|
||||
'“unused” is not linked to any saved prompt. Deleting it removes only the name.');
|
||||
});
|
||||
@@ -244,10 +244,10 @@ class TestPromptInspector(ImageAPITestCase):
|
||||
|
||||
async def test_corrupt_malformed_and_no_metadata_do_not_invent_text(self):
|
||||
path = self.output_dir / "image.png"
|
||||
for content, expected, message in (
|
||||
(b"broken", "unresolved", "Metadata in this file could not be read."),
|
||||
(None, "absent", None),
|
||||
("bad-json", "unresolved", "Prompt metadata in this file is malformed."),
|
||||
for content, expected in (
|
||||
(b"broken", "unresolved"),
|
||||
(None, "absent"),
|
||||
("bad-json", "unresolved"),
|
||||
):
|
||||
if isinstance(content, bytes):
|
||||
path.write_bytes(content)
|
||||
@@ -256,7 +256,6 @@ class TestPromptInspector(ImageAPITestCase):
|
||||
_, result = await self.inspect()
|
||||
self.assertEqual(result["prompts"]["positive"]["status"], expected)
|
||||
self.assertIsNone(result["prompts"]["positive"]["text"])
|
||||
self.assertEqual(result["prompts"]["negative"].get("message"), message)
|
||||
|
||||
async def test_compressed_international_and_post_pixel_metadata(self):
|
||||
import struct
|
||||
@@ -774,11 +773,8 @@ class TestPromptInspector(ImageAPITestCase):
|
||||
self.assertEqual(result["metadata"]["prompt_graph"]["status"], "parsed")
|
||||
|
||||
async def test_inspect_route_reports_no_sampler_instead_of_absent(self):
|
||||
# f17 with a sampler class the inspector does not know: a prompt graph
|
||||
# is a record, never "not recorded".
|
||||
built = parity.build("f17")
|
||||
built["prompt"]["13"]["class_type"] = "FutureSampler"
|
||||
parity.write_png(self.output_dir / "f17.png", built)
|
||||
# f17: a BasicGuider-only prompt graph is a record, never "not recorded".
|
||||
self.parity_fixture("f17")
|
||||
response, result = await self.inspect("f17.png")
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertEqual(
|
||||
@@ -789,32 +785,17 @@ class TestPromptInspector(ImageAPITestCase):
|
||||
self.assertEqual(result["resolution"]["reason_code"], "no-sampler")
|
||||
self.assertEqual(
|
||||
result["resolution"]["reason"],
|
||||
"No supported sampler pair found (saw FutureSampler)",
|
||||
)
|
||||
self.assertEqual(
|
||||
result["prompts"]["positive"]["message"], "No supported sampler was found."
|
||||
"No supported sampler pair found (saw SamplerCustomAdvanced)",
|
||||
)
|
||||
|
||||
async def test_inspect_route_exposes_reason_code_and_blocking_node(self):
|
||||
# f06a's encoder is supported since Task 2.4; an unknown encoder is not.
|
||||
built = parity.build("f06a")
|
||||
built["prompt"]["6"]["class_type"] = "FutureTextEncode"
|
||||
parity.write_png(self.output_dir / "f06a.png", built)
|
||||
self.parity_fixture("f06a")
|
||||
_, result = await self.inspect("f06a.png")
|
||||
resolution = result["resolution"]
|
||||
self.assertEqual(resolution["state"], "unresolved")
|
||||
self.assertEqual(resolution["reason_code"], "unsupported-node")
|
||||
self.assertEqual(
|
||||
resolution["node"], {"id": "6", "class_type": "FutureTextEncode"}
|
||||
)
|
||||
self.assertEqual(
|
||||
{role: side.get("message") for role, side in result["prompts"].items()},
|
||||
dict.fromkeys(
|
||||
("positive", "negative"),
|
||||
"Prompt uses a node the inspector does not support "
|
||||
"(FutureTextEncode).",
|
||||
),
|
||||
)
|
||||
self.assertEqual(set(resolution["node"]), {"id", "class_type"})
|
||||
self.assertIsInstance(resolution["node"]["class_type"], str)
|
||||
png(self.output_dir / "image.png", prompt="{broken")
|
||||
_, result = await self.inspect()
|
||||
self.assertEqual(result["resolution"]["reason_code"], "malformed")
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
"""Tests for utils.generation_params, the Python port of the browser parser.
|
||||
|
||||
Expected values are what web/js/comfy-metadata.js extractGenerationParams
|
||||
returns for the same input (tests/test_generation_params_js_parity.py checks
|
||||
that directly when Node is available).
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from utils.generation_params import ( # noqa: E402
|
||||
PLACEHOLDERS,
|
||||
GenerationParamsError,
|
||||
extract_generation_params,
|
||||
extract_from_records,
|
||||
parse_js_json,
|
||||
)
|
||||
|
||||
try:
|
||||
from tests.generation_params_cases import CASES # noqa: E402
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from generation_params_cases import CASES # noqa: E402
|
||||
|
||||
|
||||
def run(name):
|
||||
return extract_from_records(CASES[name]["prompt"], CASES[name]["workflow"])
|
||||
|
||||
|
||||
class TracesPrompts(unittest.TestCase):
|
||||
def test_sampler_prompts_and_settings(self):
|
||||
self.assertEqual(
|
||||
run("basic"),
|
||||
{
|
||||
"positivePrompt": "a cat on a windowsill",
|
||||
"negativePrompt": "blurry, watermark",
|
||||
"checkpoint": "example.safetensors",
|
||||
"steps": 20,
|
||||
"cfgScale": 7,
|
||||
"sampler": "euler",
|
||||
"seed": 5,
|
||||
},
|
||||
)
|
||||
|
||||
def test_prompt_manager_joins_prepend_and_append_once(self):
|
||||
result = run("prompt-manager-prepend-append")
|
||||
self.assertEqual(result["positivePrompt"], "masterpiece a cat 4k")
|
||||
# The text already starts with the prepend text, so it is not repeated.
|
||||
self.assertEqual(result["negativePrompt"], "masterpiece, a dog 4k")
|
||||
|
||||
def test_linked_text_joins_text_inputs_with_the_delimiter(self):
|
||||
result = run("linked-text-concat")
|
||||
self.assertEqual(result["positivePrompt"], "first part, second part")
|
||||
self.assertEqual(result["negativePrompt"], PLACEHOLDERS["negativePrompt"])
|
||||
|
||||
def test_prompt_manager_pair_outputs(self):
|
||||
result = run("prompt-manager-pair")
|
||||
self.assertEqual(result["positivePrompt"], "pair positive")
|
||||
self.assertEqual(result["negativePrompt"], "pair negative")
|
||||
|
||||
def test_conditioning_passthrough_follows_the_role(self):
|
||||
result = run("conditioning-passthrough")
|
||||
self.assertEqual(result["positivePrompt"], "through a controlnet")
|
||||
self.assertEqual(result["negativePrompt"], "negative through it")
|
||||
|
||||
def test_several_samplers_sharing_one_prompt_pair(self):
|
||||
result = run("shared-prompt-pipeline")
|
||||
self.assertEqual(result["positivePrompt"], "a lighthouse at dusk, oil painting")
|
||||
self.assertEqual(result["negativePrompt"], "blurry, watermark")
|
||||
self.assertEqual(
|
||||
(result["seed"], result["steps"], result["sampler"]), (111, 4, "lcm")
|
||||
)
|
||||
|
||||
def test_overwritten_prompt_manager_text_is_recovered_from_widgets(self):
|
||||
result = run("overwritten-prompt-manager-text")
|
||||
self.assertEqual(result["positivePrompt"], "the real positive")
|
||||
self.assertEqual(result["negativePrompt"], "the real negative")
|
||||
|
||||
|
||||
class ChoosesTheSampler(unittest.TestCase):
|
||||
def test_first_sampler_follows_javascript_key_order(self):
|
||||
# Integer-like IDs come first in ascending order, then the others.
|
||||
result = run("first-sampler-in-js-key-order")
|
||||
self.assertEqual(result["positivePrompt"], "from sampler five")
|
||||
self.assertEqual(result["seed"], 55)
|
||||
|
||||
def test_custom_sampler_reads_its_guider_and_split_settings(self):
|
||||
result = run("custom-sampler-cfg-guider")
|
||||
self.assertEqual(result["positivePrompt"], "a red fox in snow")
|
||||
self.assertEqual(result["negativePrompt"], "lowres")
|
||||
self.assertEqual(
|
||||
(result["seed"], result["steps"], result["cfgScale"], result["sampler"]),
|
||||
(42, 8, 3.5, "dpmpp_2m"),
|
||||
)
|
||||
|
||||
def test_a_guider_without_prompt_inputs_leaves_prompts_unknown(self):
|
||||
result = run("custom-sampler-basic-guider")
|
||||
self.assertEqual(result["positivePrompt"], PLACEHOLDERS["positivePrompt"])
|
||||
# KSamplerSelect still matches the loose /ksampler/ settings lookup.
|
||||
self.assertEqual(result["sampler"], "dpmpp_2m")
|
||||
self.assertEqual(result["seed"], PLACEHOLDERS["seed"])
|
||||
|
||||
def test_no_sampler_reads_loose_settings(self):
|
||||
result = run("no-sampler-loose-settings")
|
||||
self.assertEqual(result["positivePrompt"], PLACEHOLDERS["positivePrompt"])
|
||||
self.assertEqual(
|
||||
(result["seed"], result["steps"], result["cfgScale"], result["sampler"]),
|
||||
(9, 12, 2, "ddim"),
|
||||
)
|
||||
self.assertEqual(result["checkpoint"], "example.safetensors")
|
||||
|
||||
def test_linked_seed_prefers_the_input_of_the_same_name(self):
|
||||
self.assertEqual(run("primitive-seed")["seed"], 77)
|
||||
|
||||
|
||||
class ReadsTheWorkflow(unittest.TestCase):
|
||||
def test_workflow_only_file(self):
|
||||
self.assertEqual(
|
||||
run("workflow-only"),
|
||||
{
|
||||
"positivePrompt": "a tidepool at noon",
|
||||
"negativePrompt": "noisy",
|
||||
"checkpoint": "example.safetensors",
|
||||
"steps": 25,
|
||||
"cfgScale": 6.5,
|
||||
"sampler": "dpmpp_2m",
|
||||
"seed": 314,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class StaysBounded(unittest.TestCase):
|
||||
def test_cycles_terminate(self):
|
||||
result = run("conditioning-cycle")
|
||||
self.assertEqual(result["positivePrompt"], PLACEHOLDERS["positivePrompt"])
|
||||
|
||||
def test_long_chains_resolve_within_the_visit_limit(self):
|
||||
self.assertEqual(
|
||||
run("chain-within-visit-limit")["positivePrompt"], "deep chain text"
|
||||
)
|
||||
|
||||
def test_chains_beyond_the_visit_limit_stop_without_recursion_errors(self):
|
||||
result = run("chain-beyond-visit-limit")
|
||||
self.assertEqual(result["positivePrompt"], PLACEHOLDERS["positivePrompt"])
|
||||
self.assertEqual(result["negativePrompt"], "deep chain text")
|
||||
|
||||
def test_the_visit_limit_boundary_matches_the_browser_parser(self):
|
||||
# 499 pass-through nodes and the encoder are exactly 500 visits.
|
||||
self.assertEqual(
|
||||
run("chain-at-last-visit")["positivePrompt"], "deep chain text"
|
||||
)
|
||||
self.assertEqual(
|
||||
run("chain-one-past-last-visit")["positivePrompt"],
|
||||
PLACEHOLDERS["positivePrompt"],
|
||||
)
|
||||
|
||||
def test_very_long_chains_do_not_exhaust_the_python_stack(self):
|
||||
graph = {"1": {"class_type": "CLIPTextEncode", "inputs": {"text": "t"}}}
|
||||
for index in range(5000):
|
||||
graph[str(10 + index)] = {
|
||||
"class_type": "P",
|
||||
"inputs": {"conditioning": [str(9 + index if index else 1), 0]},
|
||||
}
|
||||
graph["2"] = {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"positive": ["5009", 0],
|
||||
"negative": ["1", 0],
|
||||
"model": ["1", 0],
|
||||
},
|
||||
}
|
||||
result = extract_generation_params({"prompt": graph})
|
||||
self.assertEqual(result["positivePrompt"], PLACEHOLDERS["positivePrompt"])
|
||||
self.assertEqual(result["negativePrompt"], "t")
|
||||
|
||||
|
||||
class Placeholders(unittest.TestCase):
|
||||
def test_blank_text_is_a_placeholder_and_zero_seed_is_kept(self):
|
||||
result = run("blank-text-and-zero-seed")
|
||||
self.assertEqual(result["positivePrompt"], PLACEHOLDERS["positivePrompt"])
|
||||
self.assertEqual(result["negativePrompt"], "kept negative")
|
||||
self.assertEqual(result["seed"], 0)
|
||||
|
||||
def test_nothing_recorded(self):
|
||||
self.assertEqual(run("nothing"), dict(PLACEHOLDERS))
|
||||
self.assertEqual(run("unparsable-records"), dict(PLACEHOLDERS))
|
||||
|
||||
|
||||
class MalformedInput(unittest.TestCase):
|
||||
def test_a_null_workflow_node_fails_like_the_browser_parser(self):
|
||||
with self.assertRaises(GenerationParamsError):
|
||||
run("null-workflow-node")
|
||||
|
||||
|
||||
class ParsesLikeJavaScript(unittest.TestCase):
|
||||
def test_rejects_non_standard_numbers(self):
|
||||
for text in ('{"a": NaN}', '{"a": Infinity}', "[-Infinity]", "not json", ""):
|
||||
with self.subTest(text=text):
|
||||
self.assertIsNone(parse_js_json(text))
|
||||
|
||||
def test_large_integers_round_like_doubles(self):
|
||||
self.assertEqual(
|
||||
parse_js_json('{"seed": 9007199254740993}'), {"seed": 9007199254740992.0}
|
||||
)
|
||||
self.assertEqual(
|
||||
parse_js_json('{"seed": 9007199254740991}'), {"seed": 9007199254740991}
|
||||
)
|
||||
|
||||
def test_seeds_beyond_double_precision_round_like_the_browser(self):
|
||||
self.assertEqual(
|
||||
run("seed-beyond-double-precision")["seed"], 4974052037430000001.0
|
||||
)
|
||||
|
||||
def test_duplicate_keys_keep_the_last_value(self):
|
||||
self.assertEqual(parse_js_json('{"a": 1, "a": 2}'), {"a": 2})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,637 +0,0 @@
|
||||
"""Randomized differential test: the Python parser equals the browser parser.
|
||||
|
||||
Generates seeded random API graphs and UI workflows, from realistic to
|
||||
malformed, and requires utils.generation_params to return exactly what
|
||||
web/js/comfy-metadata.js extractGenerationParams returns, or to fail where it
|
||||
throws. Runs only where Node is available. Set PM_PARAMS_FUZZ_CASES to run more.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
try:
|
||||
from tests.test_generation_params_js_parity import (
|
||||
NODE,
|
||||
js_equal,
|
||||
run_oracle,
|
||||
run_python,
|
||||
) # noqa: E402
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from test_generation_params_js_parity import (
|
||||
NODE,
|
||||
js_equal,
|
||||
run_oracle,
|
||||
run_python,
|
||||
) # noqa: E402
|
||||
|
||||
CASE_COUNT = int(os.environ.get("PM_PARAMS_FUZZ_CASES", "3000"))
|
||||
SEEDS = (20261020, 7, 424242)
|
||||
|
||||
NODE_IDS = [
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5",
|
||||
"6",
|
||||
"7",
|
||||
"9",
|
||||
"10",
|
||||
"12",
|
||||
"25",
|
||||
"26",
|
||||
"100",
|
||||
"1300:5",
|
||||
"1300:8",
|
||||
"x",
|
||||
"05",
|
||||
"4294967295",
|
||||
"-1",
|
||||
"constructor",
|
||||
]
|
||||
CLASS_TYPES = [
|
||||
"KSampler",
|
||||
"KSamplerAdvanced",
|
||||
"SamplerCustomAdvanced",
|
||||
"CFGGuider",
|
||||
"BasicGuider",
|
||||
"CLIPTextEncode",
|
||||
"PromptManager",
|
||||
"PromptManagerPair",
|
||||
"PromptManagerText",
|
||||
"StringConcatenate",
|
||||
"PrimitiveString",
|
||||
"ControlNetApplyAdvanced",
|
||||
"ConditioningCombine",
|
||||
"CheckpointLoaderSimple",
|
||||
"UNETLoader",
|
||||
"LoraLoader",
|
||||
"RandomNoise",
|
||||
"KSamplerSelect",
|
||||
"BasicScheduler",
|
||||
"Reroute",
|
||||
"MyKSamplerThing",
|
||||
"SaveImage",
|
||||
"FaceDetailer",
|
||||
"toString",
|
||||
"",
|
||||
]
|
||||
INPUT_KEYS = [
|
||||
"text",
|
||||
"positive",
|
||||
"negative",
|
||||
"model",
|
||||
"conditioning",
|
||||
"conditioning_1",
|
||||
"conditioning_to",
|
||||
"guider",
|
||||
"sigmas",
|
||||
"noise",
|
||||
"sampler",
|
||||
"seed",
|
||||
"noise_seed",
|
||||
"steps",
|
||||
"cfg",
|
||||
"sampler_name",
|
||||
"ckpt_name",
|
||||
"unet_name",
|
||||
"model_name",
|
||||
"prepend_text",
|
||||
"append_text",
|
||||
"negative_text",
|
||||
"delimiter",
|
||||
"string_a",
|
||||
"string_b",
|
||||
"value",
|
||||
"text_g",
|
||||
"prompt",
|
||||
"0",
|
||||
"10",
|
||||
"clip",
|
||||
]
|
||||
TEXTS = [
|
||||
"a cat",
|
||||
" padded text ",
|
||||
"",
|
||||
" ",
|
||||
"bom text ",
|
||||
"line one\nline two",
|
||||
"masterpiece",
|
||||
"masterpiece, a dog",
|
||||
"4k",
|
||||
"a dog 4k",
|
||||
"ünïcödé ✓",
|
||||
", ",
|
||||
]
|
||||
WORKFLOW_TYPES = [
|
||||
"KSampler",
|
||||
"KSamplerAdvanced",
|
||||
"CLIPTextEncode",
|
||||
"PromptManager",
|
||||
"PromptManagerPair",
|
||||
"CheckpointLoaderSimple",
|
||||
"CheckpointLoader",
|
||||
"UNETLoader",
|
||||
"MyUnetLoader",
|
||||
"Reroute",
|
||||
"GetNode",
|
||||
"SetNode",
|
||||
"Note",
|
||||
"toString",
|
||||
"valueOf",
|
||||
]
|
||||
|
||||
|
||||
class Generator:
|
||||
def __init__(self, seed):
|
||||
self.random = random.Random(seed)
|
||||
|
||||
def pick(self, items):
|
||||
return self.random.choice(items)
|
||||
|
||||
def chance(self, probability):
|
||||
return self.random.random() < probability
|
||||
|
||||
def number(self):
|
||||
return self.pick(
|
||||
[
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
5,
|
||||
20,
|
||||
7,
|
||||
1.0,
|
||||
1.5,
|
||||
6.5,
|
||||
0.45,
|
||||
1.5e-7,
|
||||
2.5e21,
|
||||
-3,
|
||||
9007199254740991,
|
||||
]
|
||||
)
|
||||
|
||||
def link(self):
|
||||
return [
|
||||
self.pick(NODE_IDS + [3, 12, 5.0]),
|
||||
self.pick([0, 1, 2, 3, 1.0, 0, 0, 0, -1]),
|
||||
]
|
||||
|
||||
def value(self, key):
|
||||
roll = self.random.random()
|
||||
if (
|
||||
key
|
||||
in (
|
||||
"positive",
|
||||
"negative",
|
||||
"model",
|
||||
"conditioning",
|
||||
"conditioning_1",
|
||||
"conditioning_to",
|
||||
"guider",
|
||||
"sigmas",
|
||||
"noise",
|
||||
"sampler",
|
||||
"clip",
|
||||
)
|
||||
and roll < 0.8
|
||||
):
|
||||
return self.link()
|
||||
if roll < 0.3:
|
||||
return self.link()
|
||||
if roll < 0.6:
|
||||
return self.pick(TEXTS)
|
||||
if roll < 0.85:
|
||||
return self.number()
|
||||
return self.pick(
|
||||
[True, False, None, [1, 2, 3], {"nested": "x"}, [], "randomize"]
|
||||
)
|
||||
|
||||
def inputs(self):
|
||||
if self.chance(0.04):
|
||||
return self.pick([[], ["text"], "text", 3, None, True])
|
||||
keys = self.random.sample(INPUT_KEYS, self.random.randint(0, 9))
|
||||
return {key: self.value(key) for key in keys}
|
||||
|
||||
def node(self):
|
||||
if self.chance(0.03):
|
||||
return self.pick([None, "node", 5, [1, 2]])
|
||||
node = {"class_type": self.pick(CLASS_TYPES), "inputs": self.inputs()}
|
||||
if self.chance(0.05):
|
||||
del node["class_type"]
|
||||
return node
|
||||
|
||||
def graph(self):
|
||||
ids = self.random.sample(NODE_IDS, self.random.randint(1, 12))
|
||||
if self.chance(0.03):
|
||||
return [self.node() for _ in ids]
|
||||
return {node_id: self.node() for node_id in ids}
|
||||
|
||||
def workflow_node(self, node_id):
|
||||
if self.chance(0.02):
|
||||
return self.pick([None, 3, "node"])
|
||||
node = {
|
||||
"id": node_id,
|
||||
"type": self.pick(WORKFLOW_TYPES),
|
||||
"mode": self.pick([0, 0, 0, 2, 4]),
|
||||
}
|
||||
inputs = []
|
||||
for _ in range(self.random.randint(0, 4)):
|
||||
item = {
|
||||
"name": self.pick(INPUT_KEYS),
|
||||
"link": self.pick([None, 1, 2, 3, 4, 5, 6, 7, 8, "2"]),
|
||||
"type": self.pick(["CONDITIONING", "MODEL", "CLIP", "*", "STRING"]),
|
||||
}
|
||||
if self.chance(0.2):
|
||||
item["widget"] = {"name": self.pick(INPUT_KEYS)}
|
||||
inputs.append(item)
|
||||
node["inputs"] = (
|
||||
inputs if not self.chance(0.03) else self.pick([{"a": 1}, "abc", None])
|
||||
)
|
||||
node["widgets_values"] = [
|
||||
self.value(self.pick(INPUT_KEYS)) for _ in range(self.random.randint(0, 6))
|
||||
]
|
||||
if self.chance(0.1):
|
||||
node["widgets_values"].insert(1, "randomize")
|
||||
if self.chance(0.05):
|
||||
node["widgets_values_named"] = {
|
||||
"text": self.pick(TEXTS),
|
||||
"seed": self.number(),
|
||||
}
|
||||
return node
|
||||
|
||||
def workflow(self):
|
||||
if self.chance(0.03):
|
||||
return self.pick([[], "workflow", {"nodes": "x"}, {"links": []}])
|
||||
ids = self.random.sample(
|
||||
[1, 2, 3, 4, 5, 6, 7, 8, "9", 10.0], self.random.randint(1, 8)
|
||||
)
|
||||
links = []
|
||||
for link_id in range(1, self.random.randint(1, 9)):
|
||||
origin, slot = self.pick(ids), self.pick([0, 1, 2])
|
||||
if self.chance(0.5):
|
||||
links.append(
|
||||
[
|
||||
link_id,
|
||||
origin,
|
||||
slot,
|
||||
self.pick(ids),
|
||||
0,
|
||||
self.pick(["CONDITIONING", "MODEL", "*", "STRING"]),
|
||||
]
|
||||
)
|
||||
else:
|
||||
links.append(
|
||||
{
|
||||
"id": link_id,
|
||||
"origin_id": origin,
|
||||
"origin_slot": slot,
|
||||
"type": self.pick(["CONDITIONING", "MODEL", "*"]),
|
||||
}
|
||||
)
|
||||
return {
|
||||
"nodes": [self.workflow_node(node_id) for node_id in ids],
|
||||
"links": links,
|
||||
}
|
||||
|
||||
# --- coherent pipelines, then corrupted ------------------------------------
|
||||
|
||||
def text_source(self, graph, ids, depth=0):
|
||||
"""A node id producing text: an encoder, PromptManager, pair or a text chain."""
|
||||
node_id = ids.pop()
|
||||
kind = self.pick(
|
||||
["encoder", "manager", "pair", "concat", "primitive"]
|
||||
if depth < 2
|
||||
else ["encoder", "manager"]
|
||||
)
|
||||
if kind == "encoder":
|
||||
text = (
|
||||
self.pick(TEXTS)
|
||||
if self.chance(0.7)
|
||||
else [self.text_source(graph, ids, depth + 1), 0]
|
||||
)
|
||||
graph[node_id] = {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": text, "clip": ["4", 1]},
|
||||
}
|
||||
elif kind == "manager":
|
||||
graph[node_id] = {
|
||||
"class_type": self.pick(["PromptManager", "PromptManagerText"]),
|
||||
"inputs": {
|
||||
"text": self.pick(TEXTS),
|
||||
"prepend_text": self.pick(TEXTS + ["masterpiece"]),
|
||||
"append_text": self.pick(TEXTS + ["4k"]),
|
||||
"clip": ["4", 1],
|
||||
},
|
||||
}
|
||||
elif kind == "pair":
|
||||
graph[node_id] = {
|
||||
"class_type": "PromptManagerPair",
|
||||
"inputs": {
|
||||
"text": self.pick(TEXTS),
|
||||
"negative_text": self.pick(TEXTS),
|
||||
"prepend_text": "",
|
||||
"append_text": "",
|
||||
},
|
||||
}
|
||||
elif kind == "concat":
|
||||
graph[node_id] = {
|
||||
"class_type": "StringConcatenate",
|
||||
"inputs": {
|
||||
"string_a": (
|
||||
[self.text_source(graph, ids, depth + 1), 0] if ids else "x"
|
||||
),
|
||||
"string_b": self.pick(TEXTS),
|
||||
"delimiter": self.pick([", ", " ", 3, "|"]),
|
||||
},
|
||||
}
|
||||
else:
|
||||
graph[node_id] = {
|
||||
"class_type": "PrimitiveString",
|
||||
"inputs": {"value": self.pick(TEXTS)},
|
||||
}
|
||||
return node_id
|
||||
|
||||
def conditioning(self, graph, ids, role):
|
||||
source = self.text_source(graph, ids)
|
||||
slot = (
|
||||
self.pick([0, 1, 2, 3])
|
||||
if graph[source]["class_type"] == "PromptManagerPair"
|
||||
else 0
|
||||
)
|
||||
link = [source, slot]
|
||||
for _ in range(self.pick([0, 0, 1, 2, 3])):
|
||||
if not ids:
|
||||
break
|
||||
node_id = ids.pop()
|
||||
key = self.pick([role, "conditioning", "conditioning_to", "conditioning_1"])
|
||||
graph[node_id] = {
|
||||
"class_type": self.pick(
|
||||
[
|
||||
"ControlNetApplyAdvanced",
|
||||
"ConditioningCombine",
|
||||
"ConditioningSetArea",
|
||||
]
|
||||
),
|
||||
"inputs": {key: link, "strength": 1.0},
|
||||
}
|
||||
link = [node_id, 0]
|
||||
return link
|
||||
|
||||
def pipeline(self):
|
||||
ids = [str(i) for i in self.random.sample(range(1, 400), 120)] + [
|
||||
"1300:7",
|
||||
"x",
|
||||
"constructor",
|
||||
]
|
||||
self.random.shuffle(ids)
|
||||
graph = {
|
||||
"4": {
|
||||
"class_type": self.pick(["CheckpointLoaderSimple", "UNETLoader"]),
|
||||
"inputs": {
|
||||
self.pick(
|
||||
["ckpt_name", "unet_name", "gguf_name"]
|
||||
): "model.safetensors"
|
||||
},
|
||||
}
|
||||
}
|
||||
for _ in range(self.pick([1, 1, 2, 3])):
|
||||
positive, negative = self.conditioning(
|
||||
graph, ids, "positive"
|
||||
), self.conditioning(graph, ids, "negative")
|
||||
settings = {
|
||||
"seed": self.number() if self.chance(0.8) else [ids.pop(), 0],
|
||||
"steps": self.number(),
|
||||
"cfg": self.number(),
|
||||
"sampler_name": self.pick(["euler", "lcm", "dpmpp_2m"]),
|
||||
}
|
||||
if settings["seed"] and isinstance(settings["seed"], list):
|
||||
graph[settings["seed"][0]] = {
|
||||
"class_type": "Seed",
|
||||
"inputs": {"seed": self.number(), "other": "x"},
|
||||
}
|
||||
if self.chance(0.25):
|
||||
guider = ids.pop()
|
||||
graph[guider] = {
|
||||
"class_type": "CFGGuider",
|
||||
"inputs": {
|
||||
"model": ["4", 0],
|
||||
"positive": positive,
|
||||
"negative": negative,
|
||||
"cfg": self.number(),
|
||||
},
|
||||
}
|
||||
noise, chooser, sigmas = ids.pop(), ids.pop(), ids.pop()
|
||||
graph[noise] = {
|
||||
"class_type": "RandomNoise",
|
||||
"inputs": {"noise_seed": self.number()},
|
||||
}
|
||||
graph[chooser] = {
|
||||
"class_type": "KSamplerSelect",
|
||||
"inputs": {"sampler_name": "euler"},
|
||||
}
|
||||
graph[sigmas] = {
|
||||
"class_type": "BasicScheduler",
|
||||
"inputs": {"steps": self.number()},
|
||||
}
|
||||
graph[ids.pop()] = {
|
||||
"class_type": "SamplerCustomAdvanced",
|
||||
"inputs": {
|
||||
"noise": [noise, 0],
|
||||
"guider": [guider, 0],
|
||||
"sampler": [chooser, 0],
|
||||
"sigmas": [sigmas, 0],
|
||||
},
|
||||
}
|
||||
else:
|
||||
graph[ids.pop()] = {
|
||||
"class_type": self.pick(
|
||||
["KSampler", "KSamplerAdvanced", "FaceDetailer"]
|
||||
),
|
||||
"inputs": {
|
||||
"model": ["4", 0],
|
||||
"positive": positive,
|
||||
"negative": negative,
|
||||
**settings,
|
||||
},
|
||||
}
|
||||
for _ in range(self.pick([0, 0, 1, 3])): # corruption
|
||||
node_id = self.pick(list(graph))
|
||||
node = graph[node_id]
|
||||
if (
|
||||
self.chance(0.5)
|
||||
and isinstance(node, dict)
|
||||
and isinstance(node.get("inputs"), dict)
|
||||
and node["inputs"]
|
||||
):
|
||||
key = self.pick(list(graph[node_id]["inputs"]))
|
||||
graph[node_id]["inputs"][key] = self.value(key)
|
||||
else:
|
||||
graph[node_id] = self.node()
|
||||
return graph
|
||||
|
||||
def pipeline_workflow(self):
|
||||
"""A connected workflow: loader, encoders, Reroute or Set/Get hops, sampler."""
|
||||
nodes, links = [], []
|
||||
|
||||
def link(origin, slot, kind):
|
||||
links.append(
|
||||
[len(links) + 1, origin, slot, 0, 0, kind]
|
||||
if self.chance(0.6)
|
||||
else {
|
||||
"id": len(links) + 1,
|
||||
"origin_id": origin,
|
||||
"origin_slot": slot,
|
||||
"type": kind,
|
||||
}
|
||||
)
|
||||
return len(links)
|
||||
|
||||
nodes.append(
|
||||
{
|
||||
"id": 4,
|
||||
"type": self.pick(
|
||||
["CheckpointLoaderSimple", "UNETLoader", "MyUnetLoader"]
|
||||
),
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"widgets_values": ["model.safetensors"],
|
||||
}
|
||||
)
|
||||
refs = []
|
||||
for index, role in enumerate(("positive", "negative")):
|
||||
encoder = 10 + index
|
||||
nodes.append(
|
||||
{
|
||||
"id": encoder,
|
||||
"type": self.pick(
|
||||
["CLIPTextEncode", "PromptManager", "PromptManagerText"]
|
||||
),
|
||||
"mode": self.pick([0, 0, 0, 4]),
|
||||
"inputs": [
|
||||
{"name": "clip", "link": link(4, 1, "CLIP"), "type": "CLIP"}
|
||||
],
|
||||
"widgets_values": [
|
||||
self.pick(TEXTS),
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
self.pick(TEXTS),
|
||||
self.pick(TEXTS),
|
||||
],
|
||||
}
|
||||
)
|
||||
ref = link(encoder, 0, "CONDITIONING")
|
||||
hop = self.pick(["none", "none", "reroute", "setget"])
|
||||
if hop == "reroute":
|
||||
nodes.append(
|
||||
{
|
||||
"id": 20 + index,
|
||||
"type": "Reroute",
|
||||
"mode": 0,
|
||||
"inputs": [{"name": "", "link": ref}],
|
||||
}
|
||||
)
|
||||
ref = link(20 + index, 0, "*")
|
||||
elif hop == "setget":
|
||||
name = self.pick(["cond_" + role, "shared"])
|
||||
nodes.append(
|
||||
{
|
||||
"id": 30 + index,
|
||||
"type": "SetNode",
|
||||
"mode": 0,
|
||||
"inputs": [{"name": "c", "link": ref}],
|
||||
"widgets_values": [name],
|
||||
}
|
||||
)
|
||||
nodes.append(
|
||||
{
|
||||
"id": 40 + index,
|
||||
"type": "GetNode",
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"widgets_values": [name],
|
||||
}
|
||||
)
|
||||
ref = link(40 + index, 0, "CONDITIONING")
|
||||
refs.append(ref)
|
||||
widgets = [
|
||||
self.number(),
|
||||
self.pick(["randomize", "fixed", "x"]),
|
||||
self.number(),
|
||||
self.number(),
|
||||
"euler",
|
||||
"normal",
|
||||
1,
|
||||
]
|
||||
nodes.append(
|
||||
{
|
||||
"id": 3,
|
||||
"type": self.pick(["KSampler", "KSamplerAdvanced"]),
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "model", "link": link(4, 0, "MODEL"), "type": "MODEL"},
|
||||
{"name": "positive", "link": refs[0], "type": "CONDITIONING"},
|
||||
{"name": "negative", "link": refs[1], "type": "CONDITIONING"},
|
||||
],
|
||||
"widgets_values": widgets,
|
||||
}
|
||||
)
|
||||
if self.chance(0.3):
|
||||
nodes[self.random.randrange(len(nodes))] = self.workflow_node(
|
||||
self.pick([1, 2, 99])
|
||||
)
|
||||
return {"nodes": nodes, "links": links}
|
||||
|
||||
def case(self):
|
||||
roll = self.random.random()
|
||||
structured = self.chance(0.7)
|
||||
prompt = (
|
||||
json.dumps(self.pipeline() if structured else self.graph())
|
||||
if roll < 0.75
|
||||
else None
|
||||
)
|
||||
workflow = (
|
||||
json.dumps(self.pipeline_workflow() if structured else self.workflow())
|
||||
if roll > 0.45
|
||||
else None
|
||||
)
|
||||
if self.chance(0.02):
|
||||
prompt = self.pick(['{"1": NaN}', "{", "null", "[]", '"text"', "12"])
|
||||
return {"prompt": prompt, "workflow": workflow}
|
||||
|
||||
|
||||
@unittest.skipUnless(NODE, "Node is required to run the browser parser")
|
||||
class RandomGraphs(unittest.TestCase):
|
||||
def test_random_graphs_match_the_browser_parser(self):
|
||||
for seed in SEEDS:
|
||||
generator = Generator(seed)
|
||||
cases = [generator.case() for _ in range(CASE_COUNT)]
|
||||
expected = run_oracle(cases)
|
||||
self.assertEqual(len(expected), len(cases))
|
||||
mismatches = []
|
||||
for index, (case, result) in enumerate(zip(cases, expected)):
|
||||
actual = run_python(case)
|
||||
if not js_equal(actual, result):
|
||||
mismatches.append((index, case, actual, result))
|
||||
with self.subTest(seed=seed):
|
||||
outcomes = {
|
||||
"ok": sum(r["ok"] for r in expected),
|
||||
"throws": sum(not r["ok"] for r in expected),
|
||||
}
|
||||
self.assertEqual(
|
||||
mismatches[:3],
|
||||
[],
|
||||
f"{len(mismatches)} of {len(cases)} differ; outcomes {outcomes}",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,155 +0,0 @@
|
||||
"""The Python generation-parameter parser equals the browser parser.
|
||||
|
||||
Golden vectors (tests/fixtures/generation_params/vectors.json) hold the browser
|
||||
parser's results for every named case and every PNG fixture, so this runs on
|
||||
machines without Node. With Node, the live browser parser is compared too and
|
||||
the vectors are checked for staleness. Regenerate them with:
|
||||
|
||||
python tests/test_generation_params_js_parity.py --write-vectors
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
import shutil
|
||||
import subprocess # nosec B404 - runs the repository's own Node oracle
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from utils.generation_params import ( # noqa: E402
|
||||
GenerationParamsError,
|
||||
extract_from_records,
|
||||
)
|
||||
|
||||
try:
|
||||
from tests.generation_params_cases import CASES # noqa: E402
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from generation_params_cases import CASES # noqa: E402
|
||||
|
||||
VECTORS = ROOT / "tests" / "fixtures" / "generation_params" / "vectors.json"
|
||||
ORACLE = ROOT / "tests" / "js" / "support" / "generation-params-oracle.mjs"
|
||||
FIXTURE_DIRS = [ROOT / "tests" / "fixtures"]
|
||||
NODE = shutil.which("node")
|
||||
|
||||
|
||||
def fixture_cases():
|
||||
"""Text records of every PNG fixture, keyed by its repository path."""
|
||||
from PIL import Image
|
||||
|
||||
cases = {}
|
||||
for directory in FIXTURE_DIRS:
|
||||
for path in sorted(directory.rglob("*.png")):
|
||||
with Image.open(path) as image:
|
||||
info = {k: v for k, v in image.info.items() if isinstance(v, str)}
|
||||
if "prompt" in info or "workflow" in info:
|
||||
name = path.relative_to(ROOT).as_posix()
|
||||
cases[name] = {
|
||||
"prompt": info.get("prompt"),
|
||||
"workflow": info.get("workflow"),
|
||||
}
|
||||
return cases
|
||||
|
||||
|
||||
def all_cases():
|
||||
return {**{f"case:{k}": v for k, v in CASES.items()}, **fixture_cases()}
|
||||
|
||||
|
||||
def run_oracle(cases):
|
||||
"""Browser parser results for case records, in order."""
|
||||
lines = "".join(json.dumps(case) + "\n" for case in cases)
|
||||
completed = subprocess.run( # nosec B603 - fixed argv, no shell
|
||||
[NODE, str(ORACLE)],
|
||||
input=lines,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
check=True,
|
||||
timeout=600,
|
||||
)
|
||||
return [json.loads(line) for line in completed.stdout.splitlines()]
|
||||
|
||||
|
||||
def run_python(case):
|
||||
try:
|
||||
return {
|
||||
"ok": True,
|
||||
"result": extract_from_records(case["prompt"], case["workflow"]),
|
||||
}
|
||||
except GenerationParamsError:
|
||||
return {"ok": False}
|
||||
|
||||
|
||||
def js_equal(left, right):
|
||||
"""Equality as JavaScript values: 1 == 1.0, but true is not 1."""
|
||||
if isinstance(left, bool) or isinstance(right, bool):
|
||||
return isinstance(left, bool) and isinstance(right, bool) and left == right
|
||||
if isinstance(left, (int, float)) and isinstance(right, (int, float)):
|
||||
# Every JavaScript number is a double; compare as doubles.
|
||||
left, right = float(left), float(right)
|
||||
return left == right or (math.isnan(left) and math.isnan(right))
|
||||
if isinstance(left, list) and isinstance(right, list):
|
||||
return len(left) == len(right) and all(
|
||||
js_equal(a, b) for a, b in zip(left, right)
|
||||
)
|
||||
if isinstance(left, dict) and isinstance(right, dict):
|
||||
return left.keys() == right.keys() and all(
|
||||
js_equal(left[k], right[k]) for k in left
|
||||
)
|
||||
return type(left) is type(right) and left == right
|
||||
|
||||
|
||||
def write_vectors():
|
||||
cases = all_cases()
|
||||
results = run_oracle(cases.values())
|
||||
vectors = [
|
||||
{"name": name, **case, "expected": result}
|
||||
for (name, case), result in zip(cases.items(), results)
|
||||
]
|
||||
VECTORS.parent.mkdir(parents=True, exist_ok=True)
|
||||
VECTORS.write_text(json.dumps(vectors, indent=1, sort_keys=True) + "\n", "utf-8")
|
||||
print(f"wrote {len(vectors)} vectors to {VECTORS.relative_to(ROOT)}")
|
||||
|
||||
|
||||
class GoldenVectors(unittest.TestCase):
|
||||
def test_every_vector_matches(self):
|
||||
vectors = json.loads(VECTORS.read_text("utf-8"))
|
||||
self.assertGreater(len(vectors), len(CASES))
|
||||
for vector in vectors:
|
||||
with self.subTest(vector=vector["name"]):
|
||||
actual = run_python(vector)
|
||||
self.assertTrue(
|
||||
js_equal(actual, vector["expected"]), (actual, vector["expected"])
|
||||
)
|
||||
|
||||
def test_vectors_cover_every_case_and_fixture(self):
|
||||
names = {vector["name"] for vector in json.loads(VECTORS.read_text("utf-8"))}
|
||||
self.assertEqual(names, set(all_cases()))
|
||||
|
||||
|
||||
@unittest.skipUnless(NODE, "Node is required to run the browser parser")
|
||||
class LiveBrowserParser(unittest.TestCase):
|
||||
def test_live_results_match_python_and_the_vectors(self):
|
||||
cases = all_cases()
|
||||
stored = {
|
||||
vector["name"]: vector["expected"]
|
||||
for vector in json.loads(VECTORS.read_text("utf-8"))
|
||||
}
|
||||
for (name, case), expected in zip(cases.items(), run_oracle(cases.values())):
|
||||
with self.subTest(case=name):
|
||||
self.assertTrue(
|
||||
js_equal(run_python(case), expected), (run_python(case), expected)
|
||||
)
|
||||
self.assertTrue(
|
||||
js_equal(stored[name], expected),
|
||||
"vectors are stale: regenerate them",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if "--write-vectors" in sys.argv:
|
||||
write_vectors()
|
||||
else:
|
||||
unittest.main()
|
||||
@@ -449,46 +449,6 @@ class CurrentLocationTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(reads, [], "Unsupported media must not reach the parser")
|
||||
|
||||
def test_preview_files_in_the_host_temp_folder_keep_preview_only_stages(self):
|
||||
# Saved raw image plus a FaceDetailer that only previews (Codex P2).
|
||||
import json
|
||||
|
||||
from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
from tests import inspection_parity as parity
|
||||
|
||||
service = self.module("py.api.inspection_service")
|
||||
graph = parity.build("f19")["prompt"]
|
||||
graph["9"]["class_type"] = "PreviewImage"
|
||||
graph["14"] = {"class_type": "SaveImage", "inputs": {"images": ["8", 0]}}
|
||||
info = PngInfo()
|
||||
info.add_text("prompt", json.dumps(graph))
|
||||
observed = {}
|
||||
for name, folder in (("output", self.output), ("temp", self.temp)):
|
||||
path = folder / "nested" / "same.png"
|
||||
path.parent.mkdir()
|
||||
with Image.new("RGB", (4, 4)) as image:
|
||||
image.save(path, pnginfo=info)
|
||||
observed[name] = service.inspect_current_file(
|
||||
path, source_label=name, saved=None, library={"status": "none"}
|
||||
)
|
||||
saved = observed["output"]["prompts"]
|
||||
self.assertEqual(saved["positive"]["status"], "resolved")
|
||||
self.assertEqual(saved["positive"]["text"], parity.POS)
|
||||
self.assertEqual(observed["output"]["resolution"]["sampler_id"], "3")
|
||||
preview = observed["temp"]["prompts"]
|
||||
self.assertEqual(preview["positive"]["status"], "ambiguous")
|
||||
self.assertIsNone(preview["positive"]["text"])
|
||||
# An unknown host temp folder treats the file as saved output.
|
||||
self.host_temp = None
|
||||
again = service.inspect_current_file(
|
||||
self.output / "nested" / "same.png",
|
||||
source_label="output",
|
||||
saved=None,
|
||||
library={"status": "none"},
|
||||
)
|
||||
self.assertEqual(again["prompts"]["positive"]["status"], "resolved")
|
||||
|
||||
|
||||
class HistoryImportTests(unittest.TestCase):
|
||||
def test_api_import_does_not_require_a_running_host_server(self):
|
||||
|
||||
@@ -8,7 +8,6 @@ leaves it unresolved, while the inspection profile resolves it.
|
||||
|
||||
import logging
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from utils import prompt_pair_resolver as resolver
|
||||
@@ -73,19 +72,12 @@ class TestIngestionKeepsDefaultProfile(unittest.TestCase):
|
||||
|
||||
mixin = admin.AdminRoutesMixin.__new__(admin.AdminRoutesMixin)
|
||||
mixin.db = Mock()
|
||||
mixin.db.get_prompt_by_hash.return_value = None
|
||||
mixin.db.save_prompt.return_value = 7
|
||||
spy = _Spy()
|
||||
with patch.object(admin, "resolve_prompt_pair", spy):
|
||||
status = mixin._ingest_scanned_file(Path("/out/f03.png"), _f03_metadata())
|
||||
status = mixin._ingest_scanned_file("/out/f03.png", _f03_metadata())
|
||||
self.assertEqual(status, "unresolved")
|
||||
spy.assert_default_and_gated(self)
|
||||
# The resolver leaves f03 unresolved; the scan links the prompt the
|
||||
# browser parser traces, as the inspector shows it.
|
||||
self.assertEqual(status, "added")
|
||||
self.assertEqual(
|
||||
mixin.db.save_prompt.call_args.args[0], "a red fox in snow, detailed fur"
|
||||
)
|
||||
mixin.db.link_image_to_prompt.assert_called_once()
|
||||
mixin.db.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,943 +0,0 @@
|
||||
"""Legacy parser rules ported into the inspection resolver (#240, Task 2.7b).
|
||||
|
||||
Every graph here is synthetic (neutral texts, generic names). Each class names
|
||||
the rule from the parser reconciliation (R1, R3, ...) it ports. The default
|
||||
(ingestion) profile must stay exactly as it was, so each rule also checks that
|
||||
``DEFAULT_PROFILE`` ignores it.
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
import unittest.mock
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from utils import prompt_pair_resolver as resolver
|
||||
from utils.prompt_inspector import (
|
||||
DUPLICATE_ROLE_BYTES,
|
||||
MetadataReadError,
|
||||
inspect_prompt_evidence,
|
||||
read_inspection_file,
|
||||
)
|
||||
from utils.prompt_pair_resolver import resolve_prompt_pair
|
||||
|
||||
try:
|
||||
from tests import inspection_parity as parity
|
||||
except ImportError: # discovered with ``-s tests``
|
||||
import inspection_parity as parity
|
||||
|
||||
|
||||
def _inspect(metadata: dict, **options) -> dict:
|
||||
return resolve_prompt_pair(metadata, profile=resolver.INSPECTION_PROFILE, **options)
|
||||
|
||||
|
||||
def _texts(result: dict) -> tuple:
|
||||
return tuple(
|
||||
result[role]["text"] if isinstance(result[role], dict) else None
|
||||
for role in ("positive", "negative")
|
||||
)
|
||||
|
||||
|
||||
def _pm_encoder(text: str) -> dict:
|
||||
"""A PromptManager node whose API class was rewritten to CLIPTextEncode."""
|
||||
return {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {
|
||||
"text": text,
|
||||
"category": "",
|
||||
"tags": "",
|
||||
"search_text": "",
|
||||
"clip": ["4", 1],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _overwritten(widgets=("a cat", "a dog", "a bird"), api_text="a bird") -> dict:
|
||||
"""Pre-3.2.3 shape: three PromptManager nodes, every API ``text`` overwritten.
|
||||
|
||||
KSampler 3 reads node 17 (widget A) for its positive; the saved API text of
|
||||
17, 113 and 119 is the last-run node's prompt (C) for all three.
|
||||
"""
|
||||
prompt = parity.build("f02")["prompt"]
|
||||
prompt["3"]["inputs"]["positive"] = ["17", 0]
|
||||
del prompt["6"]
|
||||
for node_id in ("17", "113", "119"):
|
||||
prompt[node_id] = _pm_encoder(api_text)
|
||||
workflow = {
|
||||
"nodes": [
|
||||
{"id": int(node_id), "type": "PromptManager", "widgets_values": [widget]}
|
||||
for node_id, widget in zip(("17", "113", "119"), widgets)
|
||||
]
|
||||
+ [{"id": 7, "type": "CLIPTextEncode", "widgets_values": [parity.NEG]}],
|
||||
"links": [],
|
||||
}
|
||||
return {"prompt": prompt, "workflow": workflow}
|
||||
|
||||
|
||||
class TestOverwrittenPromptManagerText(unittest.TestCase):
|
||||
"""R1 (comfy-metadata.js:52-86): never resolve to the overwritten text."""
|
||||
|
||||
def test_overwritten_text_is_refused_not_resolved(self):
|
||||
result = _inspect(_overwritten())
|
||||
self.assertEqual(result["state"], "unresolved")
|
||||
self.assertEqual(result["reason_code"], "malformed")
|
||||
self.assertEqual(result["reason"], resolver.OVERWRITTEN_PROMPT_REASON)
|
||||
self.assertIsNone(result["positive"])
|
||||
|
||||
def test_the_inspector_passes_the_workflow_to_the_resolver(self):
|
||||
metadata = _overwritten()
|
||||
evidence = inspect_prompt_evidence(
|
||||
{
|
||||
"prompt": json.dumps(metadata["prompt"]),
|
||||
"workflow": json.dumps(metadata["workflow"]),
|
||||
}
|
||||
)
|
||||
self.assertEqual(evidence["resolution"]["reason_code"], "malformed")
|
||||
self.assertEqual(evidence["prompts"]["positive"]["status"], "unresolved")
|
||||
self.assertNotEqual(evidence["prompts"]["positive"]["text"], "a bird")
|
||||
self.assertEqual(
|
||||
evidence["prompts"]["positive"]["message"],
|
||||
"Prompt metadata in this file is malformed.",
|
||||
)
|
||||
|
||||
def test_untouched_shapes_still_resolve(self):
|
||||
cases = {
|
||||
# One PromptManager node, or several whose widgets agree.
|
||||
"single": ("a bird",),
|
||||
"agreeing widgets": ("a bird", "a bird", "a bird"),
|
||||
}
|
||||
for label, widgets in cases.items():
|
||||
with self.subTest(label):
|
||||
metadata = _overwritten()
|
||||
metadata["workflow"]["nodes"] = [
|
||||
node
|
||||
for node in metadata["workflow"]["nodes"]
|
||||
if node["type"] != "PromptManager"
|
||||
] + [
|
||||
{"id": node_id, "type": "PromptManager", "widgets_values": [text]}
|
||||
for node_id, text in zip((17, 113, 119), widgets)
|
||||
]
|
||||
self.assertEqual(_inspect(metadata)["state"], "resolved")
|
||||
# Distinct API texts are what ran, whatever the widgets say.
|
||||
metadata = _overwritten()
|
||||
metadata["prompt"]["113"]["inputs"]["text"] = "a dog"
|
||||
metadata["prompt"]["119"]["inputs"]["text"] = "a cat"
|
||||
metadata["prompt"]["17"]["inputs"]["text"] = "a fish"
|
||||
self.assertEqual(_texts(_inspect(metadata)), ("a fish", parity.NEG))
|
||||
# A PromptManagerPair widget is its positive and negative: not the signature.
|
||||
metadata = _overwritten()
|
||||
for node in metadata["workflow"]["nodes"]:
|
||||
node["type"] = node["type"].replace("PromptManager", "PromptManagerPair")
|
||||
self.assertEqual(_inspect(metadata)["state"], "resolved")
|
||||
# No workflow, or a malformed one, cannot show the signature.
|
||||
orphans = {
|
||||
"nodes": [{"id": 5, "type": "PromptManager", "widgets_values": ["a"]}]
|
||||
}
|
||||
for workflow in (None, "nodes", {"nodes": "x"}, {"nodes": [1, None]}, orphans):
|
||||
with self.subTest(workflow=workflow):
|
||||
metadata = _overwritten()
|
||||
metadata["workflow"] = workflow
|
||||
self.assertEqual(_inspect(metadata)["state"], "resolved")
|
||||
|
||||
def test_an_untouched_branch_beside_overwritten_nodes_resolves(self):
|
||||
# Only the overwritten nodes are refused: the negative is not one.
|
||||
metadata = _overwritten()
|
||||
metadata["prompt"]["3"]["inputs"]["positive"] = ["7", 0]
|
||||
self.assertEqual(_texts(_inspect(metadata)), (parity.NEG, parity.NEG))
|
||||
|
||||
def test_overwritten_nodes_inside_subgraphs_are_refused(self):
|
||||
# Review M-1: subgraph nodes run as "<instance>:<inner>" API IDs.
|
||||
def nested(depth: int = 1) -> dict:
|
||||
metadata = _overwritten()
|
||||
prompt, workflow = metadata["prompt"], metadata["workflow"]
|
||||
prefix = ":".join(["50", "60", "70"][:depth]) + ":"
|
||||
for node_id in ("17", "113", "119"):
|
||||
prompt[prefix + node_id] = prompt.pop(node_id)
|
||||
prompt["3"]["inputs"]["positive"] = [prefix + "17", 0]
|
||||
inner = [n for n in workflow["nodes"] if n["type"] == "PromptManager"]
|
||||
outer = [n for n in workflow["nodes"] if n["type"] != "PromptManager"]
|
||||
subgraphs = []
|
||||
for level in range(depth, 0, -1):
|
||||
nodes = (
|
||||
inner
|
||||
if level == depth
|
||||
else [{"id": [50, 60, 70][level], "type": f"sub-{level + 1}"}]
|
||||
)
|
||||
subgraphs.append({"id": f"sub-{level}", "nodes": nodes})
|
||||
metadata["workflow"] = {
|
||||
"nodes": outer + [{"id": 50, "type": "sub-1", "widgets_values": []}],
|
||||
"links": [],
|
||||
"definitions": {"subgraphs": subgraphs},
|
||||
}
|
||||
return metadata
|
||||
|
||||
for depth in (1, 2, 3):
|
||||
with self.subTest(depth=depth):
|
||||
for anchor in (None, "9"):
|
||||
result = _inspect(nested(depth), output_node_id=anchor)
|
||||
self.assertEqual(
|
||||
result["reason"], resolver.OVERWRITTEN_PROMPT_REASON, anchor
|
||||
)
|
||||
# A subgraph definition with no instance runs nothing.
|
||||
metadata = nested()
|
||||
metadata["workflow"]["nodes"] = metadata["workflow"]["nodes"][:-1]
|
||||
self.assertEqual(_inspect(metadata)["state"], "resolved")
|
||||
# Malformed definitions never raise.
|
||||
for definitions in ("x", {"subgraphs": "x"}, {"subgraphs": [1, {"id": 3}]}):
|
||||
metadata = nested()
|
||||
metadata["workflow"]["definitions"] = definitions
|
||||
self.assertEqual(_inspect(metadata)["state"], "resolved", definitions)
|
||||
# A self-referencing subgraph is bounded.
|
||||
metadata = nested()
|
||||
metadata["workflow"]["definitions"]["subgraphs"].append(
|
||||
{"id": "loop", "nodes": [{"id": 1, "type": "loop"}]}
|
||||
)
|
||||
metadata["workflow"]["nodes"].append({"id": 99, "type": "loop"})
|
||||
self.assertEqual(
|
||||
_inspect(metadata)["reason"], resolver.OVERWRITTEN_PROMPT_REASON
|
||||
)
|
||||
|
||||
def test_object_widget_values_name_the_text(self):
|
||||
metadata = _overwritten()
|
||||
for node in metadata["workflow"]["nodes"]:
|
||||
node["widgets_values"] = {"text": node["widgets_values"][0]}
|
||||
self.assertEqual(
|
||||
_inspect(metadata)["reason"], resolver.OVERWRITTEN_PROMPT_REASON
|
||||
)
|
||||
|
||||
def test_the_workflow_is_parsed_only_for_prompt_manager_graphs(self):
|
||||
# Review M-4: no PromptManager-like node, no workflow parse.
|
||||
|
||||
from utils import prompt_inspector
|
||||
|
||||
prompt = parity.build("f02")["prompt"]
|
||||
with unittest.mock.patch.object(
|
||||
prompt_inspector,
|
||||
"_parsed_workflow",
|
||||
wraps=prompt_inspector._parsed_workflow,
|
||||
) as parsed:
|
||||
inspect_prompt_evidence(
|
||||
{"prompt": json.dumps(prompt), "workflow": json.dumps({"nodes": []})}
|
||||
)
|
||||
self.assertEqual(parsed.call_count, 0)
|
||||
metadata = _overwritten()
|
||||
inspect_prompt_evidence(
|
||||
{
|
||||
"prompt": json.dumps(metadata["prompt"]),
|
||||
"workflow": json.dumps(metadata["workflow"]),
|
||||
}
|
||||
)
|
||||
self.assertEqual(parsed.call_count, 1)
|
||||
|
||||
def test_default_profile_ignores_the_workflow(self):
|
||||
result = resolve_prompt_pair(_overwritten())
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertEqual(_texts(result), ("a bird", parity.NEG))
|
||||
|
||||
|
||||
def _historical(class_type: str, slot: int, inputs: dict, kind: str) -> dict:
|
||||
"""``f02`` whose positive reads ``class_type`` output ``slot`` (node 10).
|
||||
|
||||
A conditioning output feeds the sampler directly; a string output feeds a
|
||||
CLIPTextEncode (node 6).
|
||||
"""
|
||||
prompt = parity.build("f02")["prompt"]
|
||||
prompt["10"] = {"class_type": class_type, "inputs": {"clip": ["4", 1], **inputs}}
|
||||
if kind == "conditioning":
|
||||
prompt["3"]["inputs"]["positive"] = ["10", slot]
|
||||
else:
|
||||
prompt["6"]["inputs"]["text"] = ["10", slot]
|
||||
return {"prompt": prompt}
|
||||
|
||||
|
||||
class TestHistoricalPromptManagerNodes(unittest.TestCase):
|
||||
"""R7: this repository's node classes up to 07a5af3, with their own join."""
|
||||
|
||||
COMPOSED = {"text": " a cat ", "prepend_text": " trig ", "append_text": "sky "}
|
||||
|
||||
def test_v2_outputs_and_comma_join(self):
|
||||
inputs = {**self.COMPOSED, "negative_text": " bad hands "}
|
||||
for slot, kind, expected in (
|
||||
(0, "conditioning", "trig, a cat, sky"),
|
||||
(1, "conditioning", "bad hands"),
|
||||
(2, "string", "trig, a cat, sky"),
|
||||
(3, "string", "bad hands"),
|
||||
):
|
||||
with self.subTest(slot=slot):
|
||||
result = _inspect(_historical("PromptManagerV2", slot, inputs, kind))
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertEqual(result["positive"]["text"], expected)
|
||||
|
||||
def test_comma_rule_matches_the_historical_code(self):
|
||||
cases = (
|
||||
({"text": "a cat", "prepend_text": "trig,"}, "trig,a cat"),
|
||||
({"text": "a cat,", "append_text": "sky"}, "a cat,sky"),
|
||||
({"text": "", "prepend_text": "trig", "append_text": "sky"}, "trig, sky"),
|
||||
({"text": " ", "prepend_text": " ", "append_text": "sky"}, "sky"),
|
||||
({"text": "a cat"}, "a cat"),
|
||||
# Review M-5: an explicit null part is skipped, as ``if prepend_text:``.
|
||||
({"text": "a cat", "prepend_text": None, "append_text": None}, "a cat"),
|
||||
)
|
||||
for inputs, expected in cases:
|
||||
with self.subTest(inputs=inputs):
|
||||
result = _inspect(_historical("PromptManagerV2", 2, inputs, "string"))
|
||||
self.assertEqual(result["positive"]["text"], expected)
|
||||
|
||||
def test_single_role_classes(self):
|
||||
cases = (
|
||||
("PromptManagerPositive", 0, "conditioning", "trig, a cat, sky"),
|
||||
("PromptManagerPositive", 1, "string", "trig, a cat, sky"),
|
||||
("PromptManagerDetailer", 0, "conditioning", "trig, a cat, sky"),
|
||||
("PromptManagerDetailer", 1, "string", "trig, a cat, sky"),
|
||||
("PromptManagerV2Text", 0, "string", "trig, a cat, sky"),
|
||||
("PromptManagerPositiveText", 0, "string", "trig, a cat, sky"),
|
||||
("PromptManagerNegativeText", 0, "string", "trig, a cat, sky"),
|
||||
# The negative-only encoder never composes.
|
||||
("PromptManagerNegative", 0, "conditioning", "a cat"),
|
||||
("PromptManagerNegative", 1, "string", "a cat"),
|
||||
)
|
||||
for class_type, slot, kind, expected in cases:
|
||||
with self.subTest(node=class_type, slot=slot):
|
||||
result = _inspect(_historical(class_type, slot, self.COMPOSED, kind))
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertEqual(result["positive"]["text"], expected)
|
||||
result = _inspect(
|
||||
_historical(
|
||||
"PromptManagerV2Text",
|
||||
1,
|
||||
{"text": "a cat", "negative_text": " bad "},
|
||||
"string",
|
||||
)
|
||||
)
|
||||
self.assertEqual(result["positive"]["text"], "bad")
|
||||
|
||||
def test_optional_negative_defaults_to_empty(self):
|
||||
result = _inspect(
|
||||
_historical("PromptManagerV2", 0, {"text": "a cat"}, "conditioning")
|
||||
)
|
||||
self.assertEqual(result["positive"]["text"], "a cat")
|
||||
prompt = _historical("PromptManagerV2", 0, {"text": "a cat"}, "conditioning")
|
||||
prompt["prompt"]["3"]["inputs"]["negative"] = ["10", 1]
|
||||
self.assertEqual(_texts(_inspect(prompt)), ("a cat", ""))
|
||||
|
||||
def test_unlisted_outputs_and_kinds_are_not_guessed(self):
|
||||
# PromptManagerDetailer output 2 is an INT prompt id.
|
||||
result = _inspect(
|
||||
_historical("PromptManagerDetailer", 2, self.COMPOSED, "string")
|
||||
)
|
||||
self.assertEqual(result["reason_code"], "unsupported-node")
|
||||
# A STRING output read as conditioning is a malformed link.
|
||||
result = _inspect(
|
||||
_historical("PromptManagerV2", 2, self.COMPOSED, "conditioning")
|
||||
)
|
||||
self.assertEqual(result["reason_code"], "malformed")
|
||||
|
||||
def test_linked_parts_are_traced(self):
|
||||
source = _historical(
|
||||
"PromptManagerV2",
|
||||
0,
|
||||
{"text": ["11", 0], "prepend_text": ["12", 0]},
|
||||
"conditioning",
|
||||
)
|
||||
source["prompt"]["11"] = {
|
||||
"class_type": "PrimitiveString",
|
||||
"inputs": {"value": "a cat"},
|
||||
}
|
||||
source["prompt"]["12"] = {
|
||||
"class_type": "PrimitiveString",
|
||||
"inputs": {"value": "trig"},
|
||||
}
|
||||
result = _inspect(source)
|
||||
self.assertEqual(result["positive"]["text"], "trig, a cat")
|
||||
self.assertEqual(
|
||||
[step["node_id"] for step in result["positive"]["provenance"]["path"]],
|
||||
["10", "12", "11"],
|
||||
)
|
||||
|
||||
def test_overwritten_historical_nodes_are_still_refused(self):
|
||||
metadata = _overwritten()
|
||||
for node_id in ("17", "113", "119"):
|
||||
metadata["prompt"][node_id]["class_type"] = "PromptManagerV2"
|
||||
for node in metadata["workflow"]["nodes"]:
|
||||
if node["type"] == "PromptManager":
|
||||
node["type"] = "PromptManagerV2"
|
||||
self.assertEqual(_inspect(metadata)["reason_code"], "malformed")
|
||||
|
||||
def test_default_profile_keeps_them_unsupported(self):
|
||||
result = resolve_prompt_pair(
|
||||
_historical("PromptManagerV2", 0, self.COMPOSED, "conditioning")
|
||||
)
|
||||
self.assertEqual(result["state"], "unresolved")
|
||||
self.assertEqual(result["reason"], "Unsupported node semantics or output slot")
|
||||
|
||||
|
||||
FOOTER = "Steps: 20, Sampler: Euler, CFG scale: 7, Seed: 5"
|
||||
|
||||
|
||||
def _parameters(text: str, *, legacy: bool = False) -> dict:
|
||||
profile = resolver.DEFAULT_PROFILE if legacy else resolver.INSPECTION_PROFILE
|
||||
return resolve_prompt_pair({"parameters": text}, profile=profile)
|
||||
|
||||
|
||||
class TestA1111Text(unittest.TestCase):
|
||||
"""R4 and R5 (#165, py/api/__init__.py:738-750) for inspection."""
|
||||
|
||||
def test_text_starting_with_the_negative_delimiter_has_no_positive(self):
|
||||
for text in (
|
||||
f"Negative prompt: bad\n{FOOTER}",
|
||||
f"\nNegative prompt: bad\n{FOOTER}",
|
||||
):
|
||||
with self.subTest(text=text):
|
||||
result = _parameters(text)
|
||||
self.assertEqual(result["state"], "unresolved")
|
||||
self.assertEqual(result["reason"], resolver.EMPTY_POSITIVE_REASON)
|
||||
self.assertIsNone(result["positive"])
|
||||
# Two delimiters, one at the start, are still two.
|
||||
result = _parameters(f"Negative prompt: a\nNegative prompt: b\n{FOOTER}")
|
||||
self.assertEqual(result["state"], "ambiguous")
|
||||
|
||||
def test_dynamic_prompts_template_lines_follow_the_footer(self):
|
||||
base = f"a cat\nNegative prompt: bad\n{FOOTER}"
|
||||
for trailer in (
|
||||
"\nTemplate: a {cat|dog}",
|
||||
"\nTemplate: a {cat|dog}\nNegative Template: bad",
|
||||
"\nNegative Template: bad",
|
||||
"\nTemplate: a {cat|dog}\nNegative Template: bad\n",
|
||||
):
|
||||
with self.subTest(trailer=trailer):
|
||||
result = _parameters(base + trailer)
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertEqual(_texts(result), ("a cat", "bad"))
|
||||
for trailer in (
|
||||
"\nTemplate: a\nTemplate: b",
|
||||
"\nNegative Template: bad\nTemplate: a",
|
||||
"\nTemplate: a\nmore template text",
|
||||
"\ntrailing",
|
||||
):
|
||||
with self.subTest(trailer=trailer):
|
||||
result = _parameters(base + trailer)
|
||||
self.assertEqual(result["reason_code"], "malformed")
|
||||
|
||||
def test_footer_with_only_steps(self):
|
||||
result = _parameters("a cat\nNegative prompt: bad\nSteps: 20")
|
||||
self.assertEqual(_texts(result), ("a cat", "bad"))
|
||||
result = _parameters("a cat\nSteps: 20\nTemplate: a {cat|dog}")
|
||||
self.assertEqual((result["state"], _texts(result)[0]), ("absent", "a cat"))
|
||||
# A full footer wins over a bare ``Steps: N`` line in the prompt text.
|
||||
result = _parameters(f"a cat\nSteps: 3\nNegative prompt: bad\n{FOOTER}")
|
||||
self.assertEqual(_texts(result), ("a cat\nSteps: 3", "bad"))
|
||||
result = _parameters("a cat\nSteps: 3\nNegative prompt: bad\nSteps: 20")
|
||||
self.assertEqual(result["reason_code"], "malformed")
|
||||
|
||||
def test_default_profile_keeps_the_historical_split(self):
|
||||
result = _parameters(f"Negative prompt: bad\n{FOOTER}", legacy=True)
|
||||
self.assertEqual(_texts(result), ("Negative prompt: bad", None))
|
||||
for text in (
|
||||
f"a cat\nNegative prompt: bad\n{FOOTER}\nTemplate: a {{cat|dog}}",
|
||||
"a cat\nNegative prompt: bad\nSteps: 20",
|
||||
):
|
||||
with self.subTest(text=text):
|
||||
result = _parameters(text, legacy=True)
|
||||
self.assertEqual(
|
||||
result["reason"], "A1111 settings footer is not unique"
|
||||
)
|
||||
|
||||
|
||||
def _chunk(keyword: str, text: str, kind: bytes = b"tEXt") -> bytes:
|
||||
body = text.encode("latin-1")
|
||||
if kind == b"zTXt":
|
||||
body = b"\0" + zlib.compress(body)
|
||||
elif kind == b"iTXt":
|
||||
body = b"\0\0\0\0" + text.encode("utf-8")
|
||||
return parity._raw_chunk(kind, keyword.encode("latin-1") + b"\0" + body)
|
||||
|
||||
|
||||
def _batch(index: int, positive: str = "a cat", seed: int = 5) -> str:
|
||||
return (
|
||||
f"{positive}\nNegative prompt: bad\nSteps: 20, Sampler: Euler, "
|
||||
f"CFG scale: 7, Seed: {seed}, Batch index: {index}, Batch size: 12"
|
||||
)
|
||||
|
||||
|
||||
class TestBatchIndexDuplicates(unittest.TestCase):
|
||||
"""R3: an accumulating batch saver repeats ``parameters`` per batch item."""
|
||||
|
||||
def setUp(self):
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
self.directory = Path(temporary.name)
|
||||
|
||||
def png(self, chunks, name="batch.png"):
|
||||
buffer = io.BytesIO()
|
||||
with Image.new("RGB", (4, 4), (40, 80, 120)) as image:
|
||||
image.save(buffer, format="PNG")
|
||||
data = buffer.getvalue()
|
||||
iend = data.rindex(b"IEND") - 4
|
||||
path = self.directory / name
|
||||
path.write_bytes(data[:iend] + b"".join(chunks) + data[iend:])
|
||||
return path
|
||||
|
||||
def test_copies_differing_only_in_batch_index_read_as_the_last(self):
|
||||
cases = {
|
||||
"same length": [0, 1, 2],
|
||||
"growing length": [8, 9, 10, 11],
|
||||
"any order": [3, 0],
|
||||
"repeat of an earlier copy": [0, 1, 0],
|
||||
}
|
||||
for label, indexes in cases.items():
|
||||
with self.subTest(label):
|
||||
path = self.png([_chunk("parameters", _batch(i)) for i in indexes])
|
||||
read = read_inspection_file(path)
|
||||
self.assertEqual(read.metadata["parameters"], _batch(indexes[-1]))
|
||||
self.assertEqual(read.record_formats["parameters"], "original-text")
|
||||
evidence = inspect_prompt_evidence(read.metadata)
|
||||
self.assertEqual(evidence["resolution"]["state"], "resolved")
|
||||
self.assertEqual(evidence["prompts"]["positive"]["text"], "a cat")
|
||||
# Encodings may differ; the decoded copies are compared.
|
||||
path = self.png(
|
||||
[
|
||||
_chunk("parameters", _batch(0)),
|
||||
_chunk("parameters", _batch(1), b"zTXt"),
|
||||
_chunk("parameters", _batch(2), b"iTXt"),
|
||||
]
|
||||
)
|
||||
self.assertEqual(read_inspection_file(path).metadata["parameters"], _batch(2))
|
||||
|
||||
def test_any_other_difference_is_still_a_conflict(self):
|
||||
cases = {
|
||||
"positive": [_batch(0), _batch(1, positive="a dog")],
|
||||
"seed": [_batch(0), _batch(1, seed=6)],
|
||||
"no batch index": [_batch(0), _batch(0).replace(", Batch index: 0", "")],
|
||||
"two batch indexes": [_batch(0), _batch(1) + ", Batch index: 2"],
|
||||
"prose": [_batch(0), _batch(1).replace("a cat", "a cat Batch index: 1")],
|
||||
# Review I-1: the field is matched only in the settings footer.
|
||||
"prompt field": [
|
||||
_batch(0).replace("a cat", "a cat, Batch index: 0"),
|
||||
_batch(0).replace("a cat", "a cat, Batch index: 1"),
|
||||
],
|
||||
"prompt only": [
|
||||
"a cat, Batch index: 0\nNegative prompt: bad\nSteps: 20, Seed: 5",
|
||||
"a cat, Batch index: 1\nNegative prompt: bad\nSteps: 20, Seed: 5",
|
||||
],
|
||||
"negative field": [
|
||||
_batch(0).replace("bad", "bad, Batch index: 0"),
|
||||
_batch(1).replace("bad", "bad, Batch index: 1"),
|
||||
],
|
||||
"no footer": [
|
||||
"a cat\nBatch index: 0, Batch size: 2",
|
||||
"a cat\nBatch index: 1, Batch size: 2",
|
||||
],
|
||||
}
|
||||
for label, texts in cases.items():
|
||||
with self.subTest(label):
|
||||
path = self.png([_chunk("parameters", text) for text in texts])
|
||||
with self.assertRaisesRegex(
|
||||
MetadataReadError, "Duplicate prompt metadata"
|
||||
):
|
||||
read_inspection_file(path)
|
||||
# The rule is only for ``parameters``: prompt graphs stay byte-identical.
|
||||
prompt = json.dumps(parity.build("f02")["prompt"])
|
||||
path = self.png([_chunk("prompt", prompt), _chunk("prompt", prompt + " ")])
|
||||
with self.assertRaisesRegex(MetadataReadError, "Duplicate prompt metadata"):
|
||||
read_inspection_file(path)
|
||||
|
||||
def test_decoded_copies_are_bounded(self):
|
||||
# Small compressed copies must not decode unbounded text.
|
||||
text = _batch(0) + "," + " " * (DUPLICATE_ROLE_BYTES // 8)
|
||||
chunks = [
|
||||
_chunk("parameters", text.replace("index: 0", f"index: {i}"), b"zTXt")
|
||||
for i in range(10)
|
||||
]
|
||||
path = self.png(chunks)
|
||||
with self.assertRaisesRegex(MetadataReadError, "exceeds inspection limits"):
|
||||
read_inspection_file(path)
|
||||
# The kept copy still fits the role budget when a later one is longer.
|
||||
from utils.prompt_inspector import ROLE_METADATA_BYTES
|
||||
|
||||
first = _batch(9)
|
||||
first += "," + " " * (ROLE_METADATA_BYTES - len(first) - 1)
|
||||
later = first.replace("index: 9", "index: 10")
|
||||
for copy in (later, later + " " * ROLE_METADATA_BYTES):
|
||||
with self.subTest(size=len(copy)):
|
||||
path = self.png(
|
||||
[
|
||||
_chunk("parameters", first, b"zTXt"),
|
||||
_chunk("parameters", copy, b"zTXt"),
|
||||
]
|
||||
)
|
||||
with self.assertRaisesRegex(
|
||||
MetadataReadError, "exceeds inspection limits"
|
||||
):
|
||||
read_inspection_file(path)
|
||||
|
||||
|
||||
def _f02(**changes) -> dict:
|
||||
"""``f02`` (KSampler 3, encoders 6 and 7) with nodes added or replaced."""
|
||||
prompt = parity.build("f02")["prompt"]
|
||||
prompt.update(changes)
|
||||
return prompt
|
||||
|
||||
|
||||
def _stage(class_type: str, image_input: str, positive=("6", 0), **inputs) -> dict:
|
||||
"""``f19``'s shape with ``class_type`` as the detailer stage (node 50)."""
|
||||
prompt = parity.build("f19")["prompt"]
|
||||
if "upscale_model" in inputs:
|
||||
prompt["12"] = {"class_type": "UpscaleModelLoader", "inputs": {}}
|
||||
if "segs" in inputs:
|
||||
prompt["13"] = {"class_type": "SegsDetector", "inputs": {"image": ["8", 0]}}
|
||||
prompt["50"] = {
|
||||
"class_type": class_type,
|
||||
"inputs": {
|
||||
image_input: ["8", 0],
|
||||
"model": ["4", 0],
|
||||
"vae": ["4", 2],
|
||||
"positive": list(positive),
|
||||
"negative": ["7", 0],
|
||||
"seed": 7,
|
||||
"steps": 12,
|
||||
"cfg": 4.5,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "normal",
|
||||
"denoise": 0.4,
|
||||
**inputs,
|
||||
},
|
||||
}
|
||||
return prompt
|
||||
|
||||
|
||||
DETAILERS = (
|
||||
("UltimateSDUpscale", "image", {"upscale_model": ["12", 0], "upscale_by": 2}),
|
||||
("UltimateSDUpscaleNoUpscale", "upscaled_image", {}),
|
||||
("DetailerForEach", "image", {"segs": ["13", 0], "wildcard": ""}),
|
||||
)
|
||||
|
||||
|
||||
class TestInstalledNodeSemantics(unittest.TestCase):
|
||||
"""R6 entries verified against the installed node sources (see the report)."""
|
||||
|
||||
def test_upscale_and_segs_detailers_are_stages(self):
|
||||
for class_type, image, extra in DETAILERS:
|
||||
with self.subTest(node=class_type):
|
||||
same = _inspect({"prompt": _stage(class_type, image, **extra)})
|
||||
self.assertEqual(same["state"], "resolved")
|
||||
self.assertEqual(same["sampler_id"], "50")
|
||||
self.assertEqual(_texts(same), (parity.POS, parity.NEG))
|
||||
other = _inspect(
|
||||
{"prompt": _stage(class_type, image, ("10", 0), **extra)}
|
||||
)
|
||||
self.assertEqual(other["state"], "ambiguous")
|
||||
self.assertEqual(other["reason_code"], "multistage-differs")
|
||||
self.assertEqual(
|
||||
sorted(item["sampler_id"] for item in other["candidates"]),
|
||||
["3", "50"],
|
||||
)
|
||||
|
||||
def test_a_detailer_wildcard_replaces_its_prompt(self):
|
||||
# Impact Pack core.py: a non-empty wildcard is encoded instead of positive.
|
||||
for class_type in ("DetailerForEach", "FaceDetailer"):
|
||||
with self.subTest(node=class_type):
|
||||
result = _inspect(
|
||||
{"prompt": _stage(class_type, "image", wildcard="a smiling face")}
|
||||
)
|
||||
self.assertEqual(result["state"], "unresolved")
|
||||
self.assertEqual(result["reason_code"], "runtime-value")
|
||||
self.assertEqual(result["node"], {"id": "50", "class_type": class_type})
|
||||
linked = _stage(class_type, "image", wildcard=["14", 0])
|
||||
linked["14"] = {"class_type": "WildcardSource", "inputs": {}}
|
||||
result = _inspect({"prompt": linked})
|
||||
self.assertEqual(result["reason_code"], "unsupported-node")
|
||||
self.assertEqual(
|
||||
result["node"], {"id": "14", "class_type": "WildcardSource"}
|
||||
)
|
||||
for empty in ("", None):
|
||||
result = _inspect(
|
||||
{"prompt": _stage(class_type, "image", wildcard=empty)}
|
||||
)
|
||||
self.assertEqual(result["state"], "resolved", empty)
|
||||
|
||||
def test_conditioning_passthroughs_keep_each_role(self):
|
||||
pair = {"positive": ["6", 0], "negative": ["7", 0]}
|
||||
cases = {
|
||||
"ControlNetInpaintingAliMamaApply": (
|
||||
{
|
||||
**pair,
|
||||
"control_net": ["11", 0],
|
||||
"vae": ["4", 2],
|
||||
"image": ["12", 0],
|
||||
"mask": ["12", 1],
|
||||
"strength": 1.0,
|
||||
},
|
||||
0,
|
||||
1,
|
||||
),
|
||||
"LTXVConditioning": ({**pair, "frame_rate": 25.0}, 0, 1),
|
||||
"FluxKontextMultiReferenceLatentMethod": (
|
||||
{"conditioning": ["6", 0], "reference_latents_method": "offset"},
|
||||
0,
|
||||
None,
|
||||
),
|
||||
}
|
||||
for class_type, (inputs, positive, negative) in cases.items():
|
||||
with self.subTest(node=class_type):
|
||||
prompt = _f02(**{"20": {"class_type": class_type, "inputs": inputs}})
|
||||
prompt["3"]["inputs"]["positive"] = ["20", positive]
|
||||
if negative is not None:
|
||||
prompt["3"]["inputs"]["negative"] = ["20", negative]
|
||||
result = _inspect({"prompt": prompt})
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertEqual(_texts(result), (parity.POS, parity.NEG))
|
||||
self.assertEqual(
|
||||
result["positive"]["provenance"]["path"][0]["node_id"], "20"
|
||||
)
|
||||
if negative is None:
|
||||
prompt["3"]["inputs"]["negative"] = ["20", 1]
|
||||
self.assertEqual(
|
||||
_inspect({"prompt": prompt})["reason_code"], "unsupported-node"
|
||||
)
|
||||
|
||||
def test_text_nodes(self):
|
||||
prompt = _f02(
|
||||
**{
|
||||
"6": {
|
||||
"class_type": "Text to Conditioning",
|
||||
"inputs": {"clip": ["4", 1], "text": ["21", 0]},
|
||||
},
|
||||
"21": {"class_type": "DisplayText", "inputs": {"text": ["22", 0]}},
|
||||
"22": {"class_type": "TextInput", "inputs": {"text": parity.POS}},
|
||||
}
|
||||
)
|
||||
result = _inspect({"prompt": prompt})
|
||||
self.assertEqual(_texts(result), (parity.POS, parity.NEG))
|
||||
self.assertEqual(
|
||||
[step["node_id"] for step in result["positive"]["provenance"]["path"]],
|
||||
["6", "21", "22"],
|
||||
)
|
||||
# Text to Conditioning has one output; TextInput reads only ``text``.
|
||||
prompt["3"]["inputs"]["positive"] = ["6", 1]
|
||||
self.assertEqual(
|
||||
_inspect({"prompt": prompt})["reason_code"], "unsupported-node"
|
||||
)
|
||||
|
||||
def test_overwritten_text_behind_a_new_passthrough_is_still_refused(self):
|
||||
# R1 before R6: the pass-through must not let overwritten text resolve.
|
||||
metadata = _overwritten()
|
||||
metadata["prompt"]["20"] = {
|
||||
"class_type": "FluxKontextMultiReferenceLatentMethod",
|
||||
"inputs": {"conditioning": ["17", 0], "reference_latents_method": "offset"},
|
||||
}
|
||||
metadata["prompt"]["3"]["inputs"]["positive"] = ["20", 0]
|
||||
result = _inspect(metadata)
|
||||
self.assertEqual(result["reason"], resolver.OVERWRITTEN_PROMPT_REASON)
|
||||
|
||||
def test_default_profile_keeps_them_unsupported(self):
|
||||
for class_type, image, extra in DETAILERS:
|
||||
with self.subTest(node=class_type):
|
||||
result = resolve_prompt_pair(
|
||||
{"prompt": _stage(class_type, image, **extra)}
|
||||
)
|
||||
# The historical rule: the one KSampler, whatever reads its image.
|
||||
self.assertEqual(result["sampler_id"], "3")
|
||||
prompt = _f02(
|
||||
**{"6": {"class_type": "Text to Conditioning", "inputs": {"text": "a"}}}
|
||||
)
|
||||
self.assertEqual(
|
||||
resolve_prompt_pair({"prompt": prompt})["reason"],
|
||||
"Unsupported node semantics or output slot",
|
||||
)
|
||||
|
||||
|
||||
def _sampled(class_type: str, latent: str = "latent_image", **inputs) -> dict:
|
||||
"""``f02`` with its KSampler (node 3) replaced by ``class_type``."""
|
||||
prompt = _f02()
|
||||
sampler = dict(prompt["3"]["inputs"], **inputs)
|
||||
sampler[latent] = sampler.pop("latent_image")
|
||||
prompt["3"] = {"class_type": class_type, "inputs": sampler}
|
||||
return prompt
|
||||
|
||||
|
||||
SAMPLERS = (
|
||||
("ClownsharKSampler_Beta", "latent_image", "options"),
|
||||
("KSampler (Efficient)", "latent_image", "script"),
|
||||
("ttN KSampler_v2", "latent", "adv_xyPlot"),
|
||||
)
|
||||
|
||||
|
||||
class TestUpstreamVerifiedNodeSemantics(unittest.TestCase):
|
||||
"""R6 entries for nodes not installed here, read from their pinned sources.
|
||||
|
||||
Each source is the commit the maintainer's saved workflows name in the
|
||||
node's ``cnr_id``/``ver`` properties (the report lists repository, commit,
|
||||
file and line).
|
||||
"""
|
||||
|
||||
def test_ksampler_shaped_samplers_resolve(self):
|
||||
for class_type, latent, _ in SAMPLERS:
|
||||
with self.subTest(node=class_type):
|
||||
result = _inspect({"prompt": _sampled(class_type, latent)})
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertEqual(result["sampler_id"], "3")
|
||||
self.assertEqual(_texts(result), (parity.POS, parity.NEG))
|
||||
|
||||
def test_inputs_that_replace_the_prompt_are_not_followed(self):
|
||||
# RES4LYF options may carry a guider; Efficiency scripts and the ttN
|
||||
# XY plot re-run the sampler with other settings.
|
||||
for class_type, latent, override in SAMPLERS:
|
||||
with self.subTest(node=class_type):
|
||||
prompt = _sampled(class_type, latent, **{override: ["30", 0]})
|
||||
prompt["30"] = {"class_type": "SomeScript", "inputs": {}}
|
||||
result = _inspect({"prompt": prompt})
|
||||
self.assertEqual(result["reason_code"], "unsupported-node")
|
||||
self.assertEqual(
|
||||
result["node"], {"id": "30", "class_type": "SomeScript"}
|
||||
)
|
||||
|
||||
def test_chained_samplers_are_stages(self):
|
||||
prompt = _sampled("ClownsharKSampler_Beta")
|
||||
prompt["31"] = dict(prompt["3"], inputs=dict(prompt["3"]["inputs"]))
|
||||
prompt["31"]["inputs"]["latent_image"] = ["3", 0]
|
||||
prompt["8"]["inputs"]["samples"] = ["31", 0]
|
||||
# Unanchored, two listed samplers are two branches, as for KSampler.
|
||||
self.assertEqual(_inspect({"prompt": prompt})["state"], "ambiguous")
|
||||
result = _inspect({"prompt": prompt}, output_node_id="9")
|
||||
self.assertEqual(result["reason"], resolver.MULTISTAGE_UNATTRIBUTED_REASON)
|
||||
|
||||
def test_seed_variance_enhancer_passes_its_conditioning(self):
|
||||
prompt = _f02(
|
||||
**{
|
||||
"20": {
|
||||
"class_type": "SeedVarianceEnhancer",
|
||||
"inputs": {"conditioning": ["6", 0], "seed": 1, "strength": 20.0},
|
||||
}
|
||||
}
|
||||
)
|
||||
prompt["3"]["inputs"]["positive"] = ["20", 0]
|
||||
self.assertEqual(_texts(_inspect({"prompt": prompt})), (parity.POS, parity.NEG))
|
||||
|
||||
def test_overwritten_text_behind_seed_variance_is_still_refused(self):
|
||||
# R1 before R6: the recon's 24 files differ from this only in names.
|
||||
metadata = _overwritten()
|
||||
metadata["prompt"]["20"] = {
|
||||
"class_type": "SeedVarianceEnhancer",
|
||||
"inputs": {"conditioning": ["17", 0], "seed": 1},
|
||||
}
|
||||
metadata["prompt"]["3"]["inputs"]["positive"] = ["20", 0]
|
||||
result = _inspect(metadata)
|
||||
self.assertEqual(result["state"], "unresolved")
|
||||
self.assertEqual(result["reason"], resolver.OVERWRITTEN_PROMPT_REASON)
|
||||
|
||||
def test_instantid_advanced_outputs(self):
|
||||
prompt = _f02(
|
||||
**{
|
||||
"20": {
|
||||
"class_type": "ApplyInstantIDAdvanced",
|
||||
"inputs": {
|
||||
"model": ["4", 0],
|
||||
"positive": ["6", 0],
|
||||
"negative": ["7", 0],
|
||||
"ip_weight": 0.8,
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
prompt["3"]["inputs"].update(positive=["20", 1], negative=["20", 2])
|
||||
self.assertEqual(_texts(_inspect({"prompt": prompt})), (parity.POS, parity.NEG))
|
||||
prompt["3"]["inputs"]["positive"] = ["20", 0] # output 0 is the MODEL
|
||||
self.assertEqual(
|
||||
_inspect({"prompt": prompt})["reason_code"], "unsupported-node"
|
||||
)
|
||||
|
||||
def test_crystools_conditioning_switch(self):
|
||||
def switch(condition) -> dict:
|
||||
prompt = _f02(
|
||||
**{
|
||||
"11": _pm_encoder("a dog"),
|
||||
"20": {
|
||||
"class_type": "Switch conditioning [Crystools]",
|
||||
"inputs": {
|
||||
"on_true": ["6", 0],
|
||||
"on_false": ["11", 0],
|
||||
"boolean": condition,
|
||||
},
|
||||
},
|
||||
"21": {"class_type": "PrimitiveBoolean", "inputs": {"value": True}},
|
||||
}
|
||||
)
|
||||
prompt["3"]["inputs"]["positive"] = ["20", 0]
|
||||
return _inspect({"prompt": prompt})
|
||||
|
||||
self.assertEqual(_texts(switch(True))[0], parity.POS)
|
||||
self.assertEqual(_texts(switch(False))[0], "a dog")
|
||||
result = switch(["21", 0])
|
||||
self.assertEqual(result["state"], "ambiguous")
|
||||
self.assertEqual(
|
||||
sorted(_texts(item)[0] for item in result["candidates"]),
|
||||
sorted([parity.POS, "a dog"]),
|
||||
)
|
||||
|
||||
def test_smz_encoder_reads_text_unless_sdxl_may_apply(self):
|
||||
def smz(**inputs) -> dict:
|
||||
node = {
|
||||
"class_type": "smZ CLIPTextEncode",
|
||||
"inputs": {
|
||||
"clip": ["4", 1],
|
||||
"text": "a cat",
|
||||
"text_g": "a cat",
|
||||
"text_l": "a cat",
|
||||
"with_SDXL": False,
|
||||
"parser": "comfy",
|
||||
**inputs,
|
||||
},
|
||||
}
|
||||
return _inspect({"prompt": _f02(**{"6": node})})
|
||||
|
||||
self.assertEqual(_texts(smz(text_g="", text_l="x")), ("a cat", parity.NEG))
|
||||
# with_SDXL: the connected CLIP decides at run time which inputs are read.
|
||||
self.assertEqual(_texts(smz(with_SDXL=True)), ("a cat", parity.NEG))
|
||||
for parts in ({"text_g": "x"}, {"text_l": ""}, {"text": "x"}):
|
||||
with self.subTest(parts=parts):
|
||||
result = smz(with_SDXL=True, **parts)
|
||||
self.assertEqual(result["reason_code"], "runtime-value")
|
||||
self.assertEqual(
|
||||
result["node"], {"id": "6", "class_type": "smZ CLIPTextEncode"}
|
||||
)
|
||||
linked = smz(with_SDXL=["21", 0])
|
||||
self.assertEqual(linked["reason_code"], "unsupported-node")
|
||||
for broken in ({"with_SDXL": "yes"}, {"with_SDXL": None}):
|
||||
with self.subTest(flag=broken):
|
||||
self.assertEqual(smz(**broken)["reason_code"], "malformed")
|
||||
|
||||
def test_string_sources(self):
|
||||
cases = (
|
||||
("ttN text", "text"),
|
||||
("CR Text", "text"),
|
||||
("CR Prompt Text", "prompt"),
|
||||
("TextBoxMira", "text"),
|
||||
)
|
||||
for class_type, name in cases:
|
||||
with self.subTest(node=class_type):
|
||||
prompt = _f02(
|
||||
**{"10": {"class_type": class_type, "inputs": {name: parity.POS}}}
|
||||
)
|
||||
prompt["6"]["inputs"]["text"] = ["10", 0]
|
||||
self.assertEqual(
|
||||
_texts(_inspect({"prompt": prompt})), (parity.POS, parity.NEG)
|
||||
)
|
||||
# Output 1 of the Comfyroll nodes is their help URL.
|
||||
prompt["6"]["inputs"]["text"] = ["10", 1]
|
||||
self.assertEqual(
|
||||
_inspect({"prompt": prompt})["reason_code"], "unsupported-node"
|
||||
)
|
||||
|
||||
def test_default_profile_keeps_them_unsupported(self):
|
||||
for class_type, latent, _ in SAMPLERS:
|
||||
with self.subTest(node=class_type):
|
||||
result = resolve_prompt_pair({"prompt": _sampled(class_type, latent)})
|
||||
self.assertEqual(result["reason"], "No supported sampler pair found")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,486 +0,0 @@
|
||||
"""Non-resolved prompt outcomes carry one short, truthful line per role."""
|
||||
|
||||
import ast
|
||||
import json
|
||||
import unittest
|
||||
import unittest.mock
|
||||
from pathlib import Path
|
||||
from dataclasses import replace
|
||||
|
||||
from utils import prompt_pair_resolver
|
||||
from utils.inspection_outcomes import (
|
||||
_UNCODED_LINES,
|
||||
CLASS_NAME_LENGTH,
|
||||
REASON_CODES,
|
||||
outcome_message,
|
||||
project_resolution,
|
||||
)
|
||||
from utils.inspection_response import encode_inspection_response
|
||||
from utils.prompt_inspector import inspect_prompt_evidence
|
||||
from utils.prompt_pair_resolver import (
|
||||
EMPTY_POSITIVE_REASON,
|
||||
INSPECTION_PROFILE,
|
||||
MULTISTAGE_UNATTRIBUTED_REASON,
|
||||
SEMANTICS_UNAVAILABLE_REASON,
|
||||
)
|
||||
|
||||
LIMITS = "Prompt exceeds inspection limits."
|
||||
|
||||
|
||||
def _encode(text: str) -> dict:
|
||||
return {"class_type": "CLIPTextEncode", "inputs": {"text": text, "clip": ["9", 0]}}
|
||||
|
||||
|
||||
def _latent() -> dict:
|
||||
return {
|
||||
"class_type": "EmptyLatentImage",
|
||||
"inputs": {"width": 8, "height": 8, "batch_size": 1},
|
||||
}
|
||||
|
||||
|
||||
def _ksampler(positive: str = "7") -> dict:
|
||||
return {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"positive": [positive, 0],
|
||||
"negative": ["6", 0],
|
||||
"latent_image": ["4", 0],
|
||||
"model": ["9", 0],
|
||||
"seed": 1,
|
||||
"steps": 2,
|
||||
"cfg": 1.0,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "normal",
|
||||
"denoise": 1.0,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _dual_cfg() -> dict:
|
||||
return {
|
||||
"1": _encode("positive"),
|
||||
"6": _encode("negative"),
|
||||
"2": {
|
||||
"class_type": "DualCFGGuider",
|
||||
"inputs": {
|
||||
"cond1": ["1", 0],
|
||||
"cond2": ["1", 0],
|
||||
"negative": ["6", 0],
|
||||
"model": ["9", 0],
|
||||
},
|
||||
},
|
||||
"3": {
|
||||
"class_type": "SamplerCustomAdvanced",
|
||||
"inputs": {
|
||||
"guider": ["2", 0],
|
||||
"noise": ["9", 0],
|
||||
"sampler": ["9", 0],
|
||||
"sigmas": ["9", 0],
|
||||
"latent_image": ["4", 0],
|
||||
},
|
||||
},
|
||||
"4": _latent(),
|
||||
}
|
||||
|
||||
|
||||
def _runtime() -> dict:
|
||||
return {
|
||||
"1": {"class_type": "PromptSearchList", "inputs": {"search": "cat"}},
|
||||
"7": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": ["1", 0], "clip": ["9", 0]},
|
||||
},
|
||||
"6": _encode("negative"),
|
||||
"3": _ksampler(),
|
||||
"4": _latent(),
|
||||
}
|
||||
|
||||
|
||||
def _composite() -> dict:
|
||||
graph = _runtime()
|
||||
graph["7"] = {
|
||||
"class_type": "ConditioningCombine",
|
||||
"inputs": {"conditioning_1": ["6", 0], "conditioning_2": ["8", 0]},
|
||||
}
|
||||
graph["8"] = _encode("other")
|
||||
return graph
|
||||
|
||||
|
||||
def _messages(evidence: dict) -> dict:
|
||||
return {role: side.get("message") for role, side in evidence["prompts"].items()}
|
||||
|
||||
|
||||
def _statuses(evidence: dict) -> dict:
|
||||
return {role: side["status"] for role, side in evidence["prompts"].items()}
|
||||
|
||||
|
||||
class TestOutcomeMessages(unittest.TestCase):
|
||||
def assert_unresolved_with(self, evidence: dict, message: str) -> None:
|
||||
self.assertEqual(
|
||||
_statuses(evidence), {"positive": "unresolved", "negative": "unresolved"}
|
||||
)
|
||||
self.assertEqual(
|
||||
_messages(evidence), {"positive": message, "negative": message}
|
||||
)
|
||||
|
||||
def test_unsupported_node_status_names_the_class(self):
|
||||
evidence = inspect_prompt_evidence({"prompt": _dual_cfg()})
|
||||
self.assertEqual(evidence["resolution"]["reason_code"], "unsupported-node")
|
||||
self.assert_unresolved_with(
|
||||
evidence,
|
||||
"Prompt uses a node the inspector does not support (DualCFGGuider).",
|
||||
)
|
||||
|
||||
def test_runtime_and_composite_statuses(self):
|
||||
self.assert_unresolved_with(
|
||||
inspect_prompt_evidence({"prompt": _runtime()}),
|
||||
"Prompt is chosen at run time (PromptSearchList).",
|
||||
)
|
||||
self.assert_unresolved_with(
|
||||
inspect_prompt_evidence({"prompt": _composite()}),
|
||||
"Prompt combines several texts (ConditioningCombine).",
|
||||
)
|
||||
|
||||
def test_limit_status(self):
|
||||
tight = replace(INSPECTION_PROFILE, per_candidate_visits=1, total_visits=1)
|
||||
evidence = inspect_prompt_evidence({"prompt": _runtime()}, profile=tight)
|
||||
self.assertEqual(evidence["resolution"]["reason_code"], "work-limit")
|
||||
self.assert_unresolved_with(evidence, LIMITS)
|
||||
for code in ("work-limit", "depth-limit", "text-limit", "graph-ceiling"):
|
||||
self.assertEqual(
|
||||
outcome_message({"state": "unresolved", "reason_code": code}), LIMITS
|
||||
)
|
||||
|
||||
def test_every_closed_code_and_known_uncoded_reason_has_a_line(self):
|
||||
for code in REASON_CODES:
|
||||
message = outcome_message({"state": "unresolved", "reason_code": code})
|
||||
self.assertIsInstance(message, str, code)
|
||||
self.assertTrue(message.endswith("."), code)
|
||||
self.assertNotIn("could not be resolved", message)
|
||||
for reason, expected in (
|
||||
(EMPTY_POSITIVE_REASON, "Positive prompt is empty."),
|
||||
(
|
||||
SEMANTICS_UNAVAILABLE_REASON,
|
||||
"The inspector's node table is unavailable.",
|
||||
),
|
||||
(
|
||||
MULTISTAGE_UNATTRIBUTED_REASON,
|
||||
"Several sampler stages were found; their prompts were not compared.",
|
||||
),
|
||||
(
|
||||
"Media metadata could not be read",
|
||||
"Metadata in this file could not be read.",
|
||||
),
|
||||
):
|
||||
resolution = {"state": "unresolved", "reason": reason, "reason_code": None}
|
||||
self.assertEqual(outcome_message(resolution), expected)
|
||||
|
||||
def test_named_codes_without_a_class_omit_the_name(self):
|
||||
for code, line in (
|
||||
("unsupported-node", "Prompt uses a node the inspector does not support."),
|
||||
("runtime-value", "Prompt is chosen at run time."),
|
||||
("composite", "Prompt combines several texts."),
|
||||
):
|
||||
for node in (None, {"id": "7", "class_type": None}):
|
||||
resolution = {"state": "unresolved", "reason_code": code, "node": node}
|
||||
self.assertEqual(outcome_message(resolution), line)
|
||||
|
||||
def test_class_name_is_bounded(self):
|
||||
resolution = {
|
||||
"state": "unresolved",
|
||||
"reason_code": "unsupported-node",
|
||||
"node": {"id": "7", "class_type": "N" * 500},
|
||||
}
|
||||
self.assertEqual(
|
||||
outcome_message(resolution),
|
||||
"Prompt uses a node the inspector does not support "
|
||||
f"({'N' * CLASS_NAME_LENGTH}).",
|
||||
)
|
||||
self.assertEqual(CLASS_NAME_LENGTH, 200)
|
||||
|
||||
def test_resolved_ambiguous_absent_and_unknown_outcomes_carry_no_line(self):
|
||||
for resolution in (
|
||||
{"state": "resolved", "reason_code": None},
|
||||
{"state": "ambiguous", "reason_code": "multistage-differs"},
|
||||
{"state": "ambiguous", "reason_code": None, "reason": "Multiple"},
|
||||
{"state": "absent", "reason_code": None, "reason": "No parsed"},
|
||||
{"state": "unresolved", "reason_code": "guess"},
|
||||
{"state": "unresolved", "reason_code": None, "reason": "Anything else"},
|
||||
None,
|
||||
):
|
||||
self.assertIsNone(outcome_message(resolution), resolution)
|
||||
|
||||
def test_inspector_lines_for_no_sampler_malformed_and_empty_positive(self):
|
||||
evidence = inspect_prompt_evidence({"prompt": {"1": _encode("x")}})
|
||||
self.assert_unresolved_with(evidence, "No supported sampler was found.")
|
||||
evidence = inspect_prompt_evidence({"prompt": "not-json"})
|
||||
self.assert_unresolved_with(
|
||||
evidence, "Prompt metadata in this file is malformed."
|
||||
)
|
||||
graph = _runtime()
|
||||
graph["7"] = _encode(" ")
|
||||
evidence = inspect_prompt_evidence({"prompt": graph})
|
||||
self.assertEqual(evidence["resolution"]["reason"], EMPTY_POSITIVE_REASON)
|
||||
self.assert_unresolved_with(evidence, "Positive prompt is empty.")
|
||||
|
||||
def test_a_resolved_pair_with_an_empty_positive_explains_only_that_role(self):
|
||||
side = {"text": " ", "provenance": {"text_source": "graph-inferred"}}
|
||||
negative = {"text": "kept", "provenance": {"text_source": "graph-inferred"}}
|
||||
resolved = {
|
||||
"state": "resolved",
|
||||
"positive": side,
|
||||
"negative": negative,
|
||||
"sampler_id": "3",
|
||||
"reason": "",
|
||||
"reason_code": None,
|
||||
"node": None,
|
||||
}
|
||||
with unittest.mock.patch(
|
||||
"utils.prompt_inspector.resolve_prompt_pair", return_value=resolved
|
||||
):
|
||||
evidence = inspect_prompt_evidence({"prompt": {}})
|
||||
self.assertEqual(
|
||||
evidence["prompts"]["positive"],
|
||||
{
|
||||
"status": "unresolved",
|
||||
"text": None,
|
||||
"source": None,
|
||||
"message": "Positive prompt is empty.",
|
||||
},
|
||||
)
|
||||
self.assertEqual(evidence["prompts"]["negative"]["text"], "kept")
|
||||
self.assertNotIn("message", evidence["prompts"]["negative"])
|
||||
|
||||
def test_candidates_and_absence_keep_their_status_lines(self):
|
||||
graph = _runtime()
|
||||
graph["7"] = _encode("positive")
|
||||
graph["5"] = _ksampler()
|
||||
evidence = inspect_prompt_evidence({"prompt": graph})
|
||||
self.assertEqual(evidence["resolution"]["state"], "ambiguous")
|
||||
self.assertEqual(_messages(evidence), {"positive": None, "negative": None})
|
||||
evidence = inspect_prompt_evidence({})
|
||||
self.assertEqual(_messages(evidence), {"positive": None, "negative": None})
|
||||
graph = _runtime()
|
||||
graph["7"] = _encode("positive")
|
||||
evidence = inspect_prompt_evidence({"prompt": graph})
|
||||
self.assertEqual(evidence["resolution"]["state"], "resolved")
|
||||
self.assertEqual(_messages(evidence), {"positive": None, "negative": None})
|
||||
|
||||
|
||||
class TestResolutionProjection(unittest.TestCase):
|
||||
def test_closed_codes_and_named_nodes_pass_unchanged(self):
|
||||
for code in REASON_CODES:
|
||||
resolution = {
|
||||
"state": "unresolved",
|
||||
"sampler_id": "3",
|
||||
"reason": "r",
|
||||
"reason_code": code,
|
||||
"node": None,
|
||||
}
|
||||
self.assertEqual(project_resolution(resolution), resolution)
|
||||
named = {
|
||||
"state": "unresolved",
|
||||
"sampler_id": None,
|
||||
"reason": "r",
|
||||
"reason_code": "composite",
|
||||
"node": {"id": "7", "class_type": None},
|
||||
}
|
||||
self.assertEqual(project_resolution(named), named)
|
||||
|
||||
def test_unknown_codes_and_unbounded_nodes_fail_closed(self):
|
||||
base = {"state": "unresolved", "sampler_id": None, "reason": "r"}
|
||||
for code, node in (
|
||||
("guess", None),
|
||||
(1, None),
|
||||
("malformed", {"id": "7", "class_type": "X"}),
|
||||
(None, {"id": "7", "class_type": "X"}),
|
||||
("unsupported-node", {"id": 7, "class_type": "X"}),
|
||||
("unsupported-node", {"id": "", "class_type": "X"}),
|
||||
("unsupported-node", {"id": "7"}),
|
||||
("unsupported-node", {"id": "7", "class_type": 7}),
|
||||
("unsupported-node", {"id": "7", "class_type": "X", "extra": 1}),
|
||||
("unsupported-node", "X"),
|
||||
):
|
||||
projected = project_resolution({**base, "reason_code": code, "node": node})
|
||||
self.assertIsNone(projected["node"], (code, node))
|
||||
if code not in REASON_CODES:
|
||||
self.assertIsNone(projected["reason_code"], code)
|
||||
for state in ("resolved", "absent"):
|
||||
projected = project_resolution(
|
||||
{**base, "state": state, "reason_code": "work-limit", "node": None}
|
||||
)
|
||||
self.assertIsNone(projected["reason_code"])
|
||||
long_name = project_resolution(
|
||||
{
|
||||
**base,
|
||||
"reason_code": "runtime-value",
|
||||
"node": {"id": "7" * 300, "class_type": "N" * 300},
|
||||
}
|
||||
)["node"]
|
||||
self.assertEqual(
|
||||
long_name, {"id": "7" * CLASS_NAME_LENGTH, "class_type": "N" * 200}
|
||||
)
|
||||
unnamed = project_resolution(
|
||||
{
|
||||
**base,
|
||||
"reason_code": "composite",
|
||||
"node": {"id": "7", "class_type": ""},
|
||||
}
|
||||
)["node"]
|
||||
self.assertEqual(unnamed, {"id": "7", "class_type": None})
|
||||
|
||||
def test_wire_carries_role_lines_and_projected_resolution(self):
|
||||
evidence = inspect_prompt_evidence({"prompt": _dual_cfg()})
|
||||
resolution = dict(evidence["resolution"], reason_code="guess")
|
||||
body = json.loads(
|
||||
encode_inspection_response(
|
||||
image={"root_index": 0, "path": "a.png", "filename": "a.png"},
|
||||
root_scope="a" * 64,
|
||||
file={
|
||||
"observation": "current-file",
|
||||
"source_label": "output",
|
||||
"media_type": "image",
|
||||
"extension": ".png",
|
||||
"size": 1,
|
||||
"modified_time": 1,
|
||||
},
|
||||
prompts=evidence["prompts"],
|
||||
resolution=resolution,
|
||||
inspection=None,
|
||||
library={"status": "none", "tags": []},
|
||||
)
|
||||
)
|
||||
self.assertEqual(body["prompts"], evidence["prompts"])
|
||||
self.assertIsNone(body["resolution"]["reason_code"])
|
||||
self.assertIsNone(body["resolution"]["node"])
|
||||
|
||||
|
||||
# Uncoded unresolved reasons only the default (ingestion) profile emits; the
|
||||
# inspector never shows them, so they need no line.
|
||||
_DEFAULT_PROFILE_ONLY = frozenset(
|
||||
{"Cyclic or excessive latent ancestry", "Resolution limit reached"}
|
||||
)
|
||||
|
||||
|
||||
def _uncoded_unresolved_reasons() -> set:
|
||||
"""Every literal reason the resolver can return as unresolved without a code.
|
||||
|
||||
Reads the source: ``_Unresolved(reason)`` without a code, and
|
||||
``_result("unresolved", ..., reason=...)`` without ``reason_code``. A reason
|
||||
is a string literal or a module constant naming one.
|
||||
"""
|
||||
source = Path(prompt_pair_resolver.__file__).read_text(encoding="utf-8")
|
||||
tree = ast.parse(source)
|
||||
|
||||
def literal(node):
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
||||
return node.value
|
||||
if isinstance(node, ast.Name):
|
||||
value = getattr(prompt_pair_resolver, node.id, None)
|
||||
return value if isinstance(value, str) else None
|
||||
return None
|
||||
|
||||
found = set()
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Name):
|
||||
continue
|
||||
keywords = {item.arg: item.value for item in node.keywords}
|
||||
if node.func.id == "_Unresolved":
|
||||
if len(node.args) < 2 and "code" not in keywords and node.args:
|
||||
reason = literal(node.args[0])
|
||||
if reason is None:
|
||||
raise AssertionError(f"line {node.lineno}: unreadable reason")
|
||||
found.add(reason)
|
||||
elif node.func.id == "_result" and node.args:
|
||||
state = literal(node.args[0])
|
||||
coded = "reason_code" in keywords or len(node.args) > 6
|
||||
reason = keywords.get(
|
||||
"reason", node.args[4] if len(node.args) > 4 else None
|
||||
)
|
||||
if state == "unresolved" and not coded and reason is not None:
|
||||
text = literal(reason)
|
||||
if text is None:
|
||||
raise AssertionError(f"line {node.lineno}: unreadable reason")
|
||||
found.add(text)
|
||||
return found
|
||||
|
||||
|
||||
class TestEveryUncodedOutcomeHasALine(unittest.TestCase):
|
||||
"""Task 2.7 review M1: a new uncoded unresolved reason fails here."""
|
||||
|
||||
def test_resolver_reasons_without_a_code_have_a_line(self):
|
||||
reasons = _uncoded_unresolved_reasons()
|
||||
self.assertIn(EMPTY_POSITIVE_REASON, reasons, "the scan sees the source")
|
||||
missing = reasons - set(_UNCODED_LINES) - _DEFAULT_PROFILE_ONLY
|
||||
self.assertEqual(missing, set(), "add a code or a line in inspection_outcomes")
|
||||
# The allowance names only reasons the source still emits.
|
||||
self.assertLessEqual(_DEFAULT_PROFILE_ONLY, reasons)
|
||||
|
||||
def test_default_profile_only_reasons_are_coded_in_inspection(self):
|
||||
# The limit outcome is uncoded only on the default profile.
|
||||
source = {"prompt": {"1": {"class_type": "KSampler", "inputs": {}}}}
|
||||
for limited in ({"max_depth": 0}, {"max_text_length": 0}):
|
||||
with self.subTest(**limited):
|
||||
default = prompt_pair_resolver.resolve_prompt_pair(source, **limited)
|
||||
inspected = prompt_pair_resolver.resolve_prompt_pair(
|
||||
source, profile=INSPECTION_PROFILE, **limited
|
||||
)
|
||||
self.assertEqual(default["reason"], "Resolution limit reached")
|
||||
self.assertIsNone(default["reason_code"])
|
||||
self.assertEqual(inspected["reason"], "Resolution limit reached")
|
||||
self.assertIsNotNone(inspected["reason_code"])
|
||||
|
||||
|
||||
class TestRoleMessageBound(unittest.TestCase):
|
||||
"""Task 2.7 review M4: the encoder bounds role lines as the client does."""
|
||||
|
||||
def encode(self, prompts: dict) -> dict:
|
||||
return json.loads(
|
||||
encode_inspection_response(
|
||||
image={"root_index": 0, "path": "a.png", "filename": "a.png"},
|
||||
root_scope="a" * 64,
|
||||
file={
|
||||
"observation": "current-file",
|
||||
"source_label": "output",
|
||||
"media_type": "image",
|
||||
"extension": ".png",
|
||||
"size": 1,
|
||||
"modified_time": 1,
|
||||
},
|
||||
prompts=prompts,
|
||||
resolution={"state": "unresolved", "reason": "x"},
|
||||
inspection=None,
|
||||
library={"status": "none", "tags": []},
|
||||
)
|
||||
)["prompts"]
|
||||
|
||||
def test_role_messages_are_bounded_text_on_unresolved_roles(self):
|
||||
side = {"status": "unresolved", "text": None, "source": None}
|
||||
prompts = self.encode(
|
||||
{
|
||||
"positive": {**side, "message": "x" * 600},
|
||||
"negative": {**side, "message": "\U0001f600" * 300},
|
||||
}
|
||||
)
|
||||
self.assertEqual(prompts["positive"]["message"], "x" * 512)
|
||||
# The client counts UTF-16 code units.
|
||||
self.assertLessEqual(
|
||||
len(prompts["negative"]["message"].encode("utf-16-le")), 1024
|
||||
)
|
||||
self.assertEqual(prompts["negative"]["message"], "\U0001f600" * 256)
|
||||
for message in ("", None, 7):
|
||||
with self.subTest(message=message):
|
||||
prompts = self.encode(
|
||||
{"positive": {**side, "message": message}, "negative": side}
|
||||
)
|
||||
self.assertNotIn("message", prompts["positive"])
|
||||
resolved = {"status": "resolved", "text": "a", "source": "graph-inferred"}
|
||||
prompts = self.encode(
|
||||
{"positive": {**resolved, "message": "line"}, "negative": side}
|
||||
)
|
||||
self.assertNotIn("message", prompts["positive"])
|
||||
self.assertEqual(prompts["negative"], side)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,838 +0,0 @@
|
||||
"""The declarative inspection node semantics table and its loader (#240, spec 7).
|
||||
|
||||
``web/js/inspection-semantics.json`` is the single table. Python validates it at
|
||||
import and fails loudly; the browser facts tables are checked against its
|
||||
``facts`` section by ``tests/js/inspection-semantics-sync.test.js``.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
import unittest.mock
|
||||
from pathlib import Path
|
||||
from types import MappingProxyType
|
||||
|
||||
from utils import inspection_semantics as semantics
|
||||
from utils.inspection_semantics import SemanticsError, load_semantics
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
TABLE = REPO / "web" / "js" / "inspection-semantics.json"
|
||||
|
||||
|
||||
def _minimal():
|
||||
"""A small valid table; mutation tests break exactly one thing in a copy."""
|
||||
return {
|
||||
"schema": 1,
|
||||
"samplers": {
|
||||
"KSampler": {
|
||||
"strategy": "ksampler",
|
||||
"conditioning": {"positive": "positive", "negative": "negative"},
|
||||
"latent": "latent_image",
|
||||
"facts": "ksampler",
|
||||
}
|
||||
},
|
||||
"guiders": {"BasicGuider": {"strategy": "basic", "positive": "conditioning"}},
|
||||
"conditioning": [
|
||||
{
|
||||
"class": "FluxGuidance",
|
||||
"slot": 0,
|
||||
"strategy": "passthrough",
|
||||
"input": "conditioning",
|
||||
}
|
||||
],
|
||||
"selectors": {},
|
||||
"composites": {},
|
||||
"encoders": [
|
||||
{
|
||||
"class": "CLIPTextEncode",
|
||||
"slot": 0,
|
||||
"strategy": "single",
|
||||
"inputs": ["text"],
|
||||
}
|
||||
],
|
||||
"strings": [],
|
||||
"runtime": [],
|
||||
"savers": {},
|
||||
"non_file_outputs": [],
|
||||
"stage_inputs": {"shaped": ["positive"], "with_model": ["samples"]},
|
||||
"facts": {
|
||||
"configuration_outputs": {},
|
||||
"model_outputs": {},
|
||||
"model_passthroughs": [],
|
||||
"scalar_outputs": {},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class _TableCase(unittest.TestCase):
|
||||
def load_text(self, text):
|
||||
# Windows: the file handle is closed before the directory is removed.
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
path = Path(root) / "semantics.json"
|
||||
path.write_text(text, encoding="utf-8")
|
||||
return load_semantics(path)
|
||||
|
||||
def load_doc(self, doc):
|
||||
return self.load_text(json.dumps(doc))
|
||||
|
||||
def assertRejected(self, doc, fragment):
|
||||
with self.assertRaises(SemanticsError) as caught:
|
||||
self.load_doc(doc)
|
||||
self.assertIn(fragment, str(caught.exception))
|
||||
|
||||
|
||||
class SpecEntriesTest(unittest.TestCase):
|
||||
def test_semantics_file_validates_and_lists_spec_entries(self):
|
||||
table = load_semantics()
|
||||
|
||||
self.assertEqual(semantics.SEMANTICS_PATH, TABLE)
|
||||
self.assertEqual(
|
||||
semantics.SEMANTICS_PATH,
|
||||
Path(semantics.__file__).resolve().parents[1]
|
||||
/ "web"
|
||||
/ "js"
|
||||
/ "inspection-semantics.json",
|
||||
)
|
||||
|
||||
pair = {"positive": "positive", "negative": "negative"}
|
||||
for name in ("KSampler", "KSamplerAdvanced"):
|
||||
self.assertEqual(
|
||||
table.samplers[name],
|
||||
{
|
||||
"strategy": "ksampler",
|
||||
"conditioning": pair,
|
||||
"latent": "latent_image",
|
||||
"facts": "ksampler",
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
table.samplers["SamplerCustom"],
|
||||
{
|
||||
"strategy": "ksampler",
|
||||
"conditioning": pair,
|
||||
"latent": "latent_image",
|
||||
"facts": "custom",
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
table.samplers["SamplerCustomAdvanced"],
|
||||
{
|
||||
"strategy": "custom-advanced",
|
||||
"conditioning": {"guider": "guider"},
|
||||
"latent": "latent_image",
|
||||
"facts": "custom-advanced",
|
||||
},
|
||||
)
|
||||
# D19: FaceDetailer is a stage over its upstream image, ksampler-shaped facts.
|
||||
# A non-empty wildcard replaces the detail prompt (Impact core.py:267).
|
||||
self.assertEqual(
|
||||
table.samplers["FaceDetailer"],
|
||||
{
|
||||
"strategy": "detailer-stage",
|
||||
"conditioning": pair,
|
||||
"image_stage": "image",
|
||||
"facts": "ksampler",
|
||||
"prompt_overrides": ("wildcard",),
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
table.guiders["CFGGuider"],
|
||||
{"strategy": "cfg", "positive": "positive", "negative": "negative"},
|
||||
)
|
||||
self.assertEqual(
|
||||
table.guiders["BasicGuider"],
|
||||
{
|
||||
"strategy": "basic",
|
||||
"positive": "conditioning",
|
||||
"absent_reason": "Guider has no negative input",
|
||||
},
|
||||
)
|
||||
# D17: DualCFGGuider has two positives and stays an unsupported node.
|
||||
self.assertNotIn("DualCFGGuider", table.guiders)
|
||||
self.assertNotIn("DualCFGGuider", table.all_classes)
|
||||
|
||||
single_input = (
|
||||
"FluxGuidance",
|
||||
"ReferenceLatent",
|
||||
"ConditioningSetArea",
|
||||
"ConditioningSetAreaPercentage",
|
||||
"ConditioningSetAreaStrength",
|
||||
"ConditioningSetMask",
|
||||
"ConditioningSetTimestepRange",
|
||||
"ControlNetApply",
|
||||
)
|
||||
for name in single_input:
|
||||
self.assertEqual(
|
||||
table.conditioning[(name, 0)],
|
||||
{"strategy": "passthrough", "input": "conditioning"},
|
||||
)
|
||||
self.assertNotIn((name, 1), table.conditioning)
|
||||
for name in (
|
||||
"ControlNetApplyAdvanced",
|
||||
"ControlNetApplySD3",
|
||||
"InpaintModelConditioning",
|
||||
"WanImageToVideo",
|
||||
):
|
||||
self.assertEqual(
|
||||
table.conditioning[(name, 0)],
|
||||
{"strategy": "passthrough", "input": "positive"},
|
||||
)
|
||||
self.assertEqual(
|
||||
table.conditioning[(name, 1)],
|
||||
{"strategy": "passthrough", "input": "negative"},
|
||||
)
|
||||
# Slot 2 (a latent) is not conditioning; spec 7.4 handles origins.
|
||||
self.assertNotIn((name, 2), table.conditioning)
|
||||
self.assertEqual(
|
||||
table.conditioning[("ConditioningZeroOut", 0)],
|
||||
{"strategy": "zeroed", "input": "conditioning"},
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
table.selectors["easy ifElse"],
|
||||
{
|
||||
"strategy": "boolean-select",
|
||||
"condition": "boolean",
|
||||
"branches": {"true": "on_true", "false": "on_false"},
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
table.composites["ConditioningCombine"],
|
||||
{"strategy": "combine", "inputs": ("conditioning_1", "conditioning_2")},
|
||||
)
|
||||
for name in ("ConditioningConcat", "ConditioningAverage"):
|
||||
self.assertEqual(
|
||||
table.composites[name],
|
||||
{
|
||||
"strategy": "combine",
|
||||
"inputs": ("conditioning_to", "conditioning_from"),
|
||||
},
|
||||
)
|
||||
|
||||
encoders = {
|
||||
("CLIPTextEncode", 0): ("single", ("text",)),
|
||||
("CLIPTextEncodeSDXL", 0): ("parts-equal", ("text_g", "text_l")),
|
||||
("CLIPTextEncodeFlux", 0): ("parts-equal", ("clip_l", "t5xxl")),
|
||||
("CLIPTextEncodeSD3", 0): ("parts-equal", ("clip_l", "clip_g", "t5xxl")),
|
||||
("TextEncodeQwenImageEdit", 0): ("single", ("prompt",)),
|
||||
("TextEncodeQwenImageEditPlus", 0): ("single", ("prompt",)),
|
||||
("TextEncodeQwenImage21", 0): ("single", ("prompt",)),
|
||||
("TextEncodeQwenImage21", 1): ("single", ("negative_prompt",)),
|
||||
# D11: a plain text encoder, no composition.
|
||||
("KikoTextEncode", 0): ("single", ("text",)),
|
||||
}
|
||||
for key, (strategy, inputs) in encoders.items():
|
||||
self.assertEqual(
|
||||
table.encoders[key], {"strategy": strategy, "inputs": inputs}, key
|
||||
)
|
||||
self.assertNotIn(("TextEncodeQwenImage21", 2), table.encoders)
|
||||
|
||||
self.assertEqual(
|
||||
table.strings[("StringConstant", 0)],
|
||||
{"strategy": "literal", "inputs": ("string",)},
|
||||
)
|
||||
self.assertEqual(
|
||||
table.strings[("StringConstantMultiline", 0)],
|
||||
{
|
||||
"strategy": "strip-newlines",
|
||||
"inputs": ("string",),
|
||||
"params": {"flag": "strip_newlines"},
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
table.strings[("JoinStringMulti", 0)],
|
||||
{
|
||||
"strategy": "join-multi",
|
||||
"inputs": ("string_1",),
|
||||
"params": {
|
||||
"count": "inputcount",
|
||||
"delimiter": "delimiter",
|
||||
"list_flag": "return_list",
|
||||
"input_prefix": "string_",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
# D18: decided at run time.
|
||||
self.assertEqual(table.runtime, frozenset({"PromptSearchList"}))
|
||||
|
||||
comfy = "{name}_{counter:05}_.{ext}"
|
||||
for name, image in (
|
||||
("SaveImage", "images"),
|
||||
("SaveAnimatedWEBP", "images"),
|
||||
("SaveAnimatedPNG", "images"),
|
||||
("SaveVideo", "video"),
|
||||
("SaveWEBM", "images"),
|
||||
):
|
||||
self.assertEqual(
|
||||
table.savers[name],
|
||||
{
|
||||
"strategy": "comfy-counter",
|
||||
"prefix_input": "filename_prefix",
|
||||
"image_input": image,
|
||||
"pattern": comfy,
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
table.savers["VHS_VideoCombine"],
|
||||
{
|
||||
"strategy": "vhs-counter",
|
||||
"prefix_input": "filename_prefix",
|
||||
"image_input": "images",
|
||||
"pattern": "{name}_{counter:05}.{ext}",
|
||||
},
|
||||
)
|
||||
# Until its naming is verified in pinned source it stays terminal-unknown.
|
||||
self.assertNotIn("KikoSaveImage", table.all_classes)
|
||||
self.assertEqual(
|
||||
table.non_file_outputs,
|
||||
frozenset(
|
||||
{
|
||||
"PreviewImage",
|
||||
"PreviewAny",
|
||||
"SaveImageWebsocket",
|
||||
"Image Comparer (rgthree)",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
# Not listed this slice: they stay unsupported-node with the class named.
|
||||
for name in (
|
||||
"ImpactWildcardProcessor",
|
||||
"RegexReplace",
|
||||
"LoRAfromText",
|
||||
"XYZPrompt",
|
||||
):
|
||||
self.assertNotIn(name, table.all_classes)
|
||||
# Existing code adapters stay in code, unchanged.
|
||||
for name in ("PromptManager", "PromptManagerPair", "PromptManagerText"):
|
||||
self.assertNotIn(name, table.all_classes)
|
||||
|
||||
def test_legacy_parser_ports_are_listed_with_their_source_inputs(self):
|
||||
# Task 2.7b (R6): input names and slots read from each node's source;
|
||||
# the report lists the file and line for every one.
|
||||
table = load_semantics()
|
||||
pair = {"positive": "positive", "negative": "negative"}
|
||||
for name, image in (
|
||||
("DetailerForEach", "image"),
|
||||
("UltimateSDUpscale", "image"),
|
||||
("UltimateSDUpscaleNoUpscale", "upscaled_image"),
|
||||
):
|
||||
entry = dict(table.samplers[name])
|
||||
self.assertEqual(
|
||||
entry.pop("prompt_overrides", None) is not None,
|
||||
name == "DetailerForEach",
|
||||
name,
|
||||
)
|
||||
self.assertEqual(
|
||||
entry,
|
||||
{
|
||||
"strategy": "detailer-stage",
|
||||
"conditioning": pair,
|
||||
"image_stage": image,
|
||||
"facts": "ksampler",
|
||||
},
|
||||
name,
|
||||
)
|
||||
for (name, slot), source in {
|
||||
("ControlNetInpaintingAliMamaApply", 0): "positive",
|
||||
("ControlNetInpaintingAliMamaApply", 1): "negative",
|
||||
("LTXVConditioning", 0): "positive",
|
||||
("LTXVConditioning", 1): "negative",
|
||||
("FluxKontextMultiReferenceLatentMethod", 0): "conditioning",
|
||||
}.items():
|
||||
self.assertEqual(
|
||||
table.conditioning[(name, slot)],
|
||||
{"strategy": "passthrough", "input": source},
|
||||
)
|
||||
self.assertNotIn(
|
||||
("FluxKontextMultiReferenceLatentMethod", 1), table.conditioning
|
||||
)
|
||||
self.assertEqual(
|
||||
table.encoders[("Text to Conditioning", 0)],
|
||||
{"strategy": "single", "inputs": ("text",)},
|
||||
)
|
||||
for name in ("TextInput", "DisplayText"):
|
||||
self.assertEqual(
|
||||
table.strings[(name, 0)], {"strategy": "literal", "inputs": ("text",)}
|
||||
)
|
||||
|
||||
def test_stage_inputs_are_the_one_source_for_both_stage_walks(self):
|
||||
# Review I-2: Python's resolver and comfy-metadata.js read these lists here.
|
||||
stage_inputs = load_semantics().stage_inputs
|
||||
self.assertEqual(
|
||||
stage_inputs["shaped"],
|
||||
(
|
||||
"latent_image",
|
||||
"guider",
|
||||
"positive",
|
||||
"negative",
|
||||
"pipe",
|
||||
"basic_pipe",
|
||||
"detailer_pipe",
|
||||
"sampler",
|
||||
"sigmas",
|
||||
"noise",
|
||||
"text_embeds",
|
||||
"hyvid_embeds",
|
||||
),
|
||||
)
|
||||
self.assertEqual(
|
||||
stage_inputs["with_model"],
|
||||
("conditioning", "neg_conditioning", "samples", "latent"),
|
||||
)
|
||||
# The default profile's own sampler-shape names stay a subset of the table's.
|
||||
from utils.prompt_pair_resolver import _SAMPLER_LIKE_INPUTS
|
||||
|
||||
self.assertLessEqual(set(_SAMPLER_LIKE_INPUTS), set(stage_inputs["shaped"]))
|
||||
|
||||
def test_facts_section_mirrors_the_browser_tables(self):
|
||||
facts = load_semantics().facts
|
||||
self.assertEqual(
|
||||
facts["configuration_outputs"],
|
||||
{
|
||||
"RandomNoise": {"kind": "noise", "fields": ("noise_seed",)},
|
||||
"KSamplerSelect": {"kind": "sampler", "fields": ("sampler_name",)},
|
||||
"BasicScheduler": {"kind": "sigmas", "fields": ("steps", "scheduler")},
|
||||
# D8: Flux2Scheduler has no scheduler input; its type names it.
|
||||
"Flux2Scheduler": {
|
||||
"kind": "sigmas",
|
||||
"fields": ("steps",),
|
||||
"type_fields": ("scheduler",),
|
||||
},
|
||||
"CFGGuider": {"kind": "guider", "fields": ("cfg", "model")},
|
||||
"BasicGuider": {"kind": "guider", "fields": ("model",)},
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
facts["model_outputs"],
|
||||
{
|
||||
"CheckpointLoader": "ckpt_name",
|
||||
"CheckpointLoaderSimple": "ckpt_name",
|
||||
"unCLIPCheckpointLoader": "ckpt_name",
|
||||
"UNETLoader": "unet_name",
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
facts["model_passthroughs"],
|
||||
(
|
||||
"LoraLoader",
|
||||
"LoraLoaderModelOnly",
|
||||
"ModelSamplingFlux",
|
||||
"ModelSamplingAuraFlow",
|
||||
"ModelSamplingSD3",
|
||||
# Task 2.7b (R9): model in, model out, verified in their sources.
|
||||
"Lora Loader (LoraManager)",
|
||||
"Power Lora Loader (rgthree)",
|
||||
"ModelPatchTorchSettings",
|
||||
"CFGNorm",
|
||||
"PerturbedAttentionGuidance",
|
||||
"CR Apply LoRA Stack",
|
||||
),
|
||||
)
|
||||
self.assertEqual(
|
||||
facts["scalar_outputs"],
|
||||
{
|
||||
"PrimitiveInt": "number",
|
||||
"PrimitiveFloat": "number",
|
||||
"PrimitiveString": "string",
|
||||
"PrimitiveStringMultiline": "string",
|
||||
},
|
||||
)
|
||||
|
||||
def test_table_is_loaded_lazily_and_once(self):
|
||||
# Importing the module reads nothing; the inspection path asks for it.
|
||||
self.assertFalse(hasattr(semantics, "SEMANTICS"))
|
||||
semantics.get_semantics.cache_clear()
|
||||
first = semantics.get_semantics()
|
||||
self.assertIs(first, semantics.get_semantics())
|
||||
self.assertEqual(first, load_semantics())
|
||||
|
||||
def test_loaded_table_is_frozen(self):
|
||||
table = load_semantics()
|
||||
self.assertIsInstance(table.samplers, MappingProxyType)
|
||||
self.assertIsInstance(table.samplers["KSampler"], MappingProxyType)
|
||||
with self.assertRaises(TypeError):
|
||||
table.samplers["Other"] = {}
|
||||
with self.assertRaises(TypeError):
|
||||
table.samplers["KSampler"]["latent"] = "x"
|
||||
with self.assertRaises(AttributeError):
|
||||
table.samplers = {}
|
||||
self.assertIsInstance(table.composites["ConditioningCombine"]["inputs"], tuple)
|
||||
|
||||
|
||||
class ValidationTest(_TableCase):
|
||||
def test_minimal_table_loads(self):
|
||||
table = self.load_doc(_minimal())
|
||||
self.assertEqual(table.encoders[("CLIPTextEncode", 0)]["inputs"], ("text",))
|
||||
self.assertEqual(table.runtime, frozenset())
|
||||
|
||||
def test_unknown_strategy_rejected(self):
|
||||
for category, mutate in (
|
||||
("samplers", lambda d: d["samplers"]["KSampler"].update(strategy="dual")),
|
||||
# A known strategy of another category is still unknown here.
|
||||
("conditioning", lambda d: d["conditioning"][0].update(strategy="single")),
|
||||
("encoders", lambda d: d["encoders"][0].update(strategy="guess")),
|
||||
("guiders", lambda d: d["guiders"]["BasicGuider"].update(strategy=None)),
|
||||
):
|
||||
with self.subTest(category=category):
|
||||
doc = _minimal()
|
||||
mutate(doc)
|
||||
self.assertRejected(doc, "unknown strategy")
|
||||
|
||||
def test_strategies_are_the_closed_spec_set(self):
|
||||
self.assertEqual(
|
||||
dict(semantics.STRATEGIES),
|
||||
{
|
||||
"samplers": frozenset(
|
||||
{"ksampler", "custom-advanced", "detailer-stage"}
|
||||
),
|
||||
"guiders": frozenset({"cfg", "basic"}),
|
||||
"conditioning": frozenset({"passthrough", "zeroed"}),
|
||||
"selectors": frozenset({"boolean-select"}),
|
||||
"composites": frozenset({"combine"}),
|
||||
"encoders": frozenset(
|
||||
{"single", "parts-equal", "pm-compose", "flag-parts"}
|
||||
),
|
||||
"strings": frozenset(
|
||||
{"literal", "strip-newlines", "join-multi", "concat"}
|
||||
),
|
||||
"savers": frozenset({"comfy-counter", "vhs-counter"}),
|
||||
},
|
||||
)
|
||||
with self.assertRaises(TypeError):
|
||||
semantics.STRATEGIES["samplers"] = frozenset()
|
||||
|
||||
def test_duplicate_output_key_rejected(self):
|
||||
doc = _minimal()
|
||||
doc["encoders"].append(copy.deepcopy(doc["encoders"][0]))
|
||||
self.assertRejected(doc, "duplicate")
|
||||
|
||||
doc = _minimal()
|
||||
doc["conditioning"].append(
|
||||
{
|
||||
"class": "CLIPTextEncode",
|
||||
"slot": 0,
|
||||
"strategy": "passthrough",
|
||||
"input": "conditioning",
|
||||
}
|
||||
)
|
||||
self.assertRejected(doc, "duplicate")
|
||||
|
||||
doc = _minimal()
|
||||
doc["runtime"] = ["KSampler"]
|
||||
self.assertRejected(doc, "duplicate")
|
||||
|
||||
doc = _minimal()
|
||||
doc["non_file_outputs"] = ["PreviewImage", "PreviewImage"]
|
||||
self.assertRejected(doc, "duplicate")
|
||||
|
||||
# JSON objects with a repeated key would silently keep the last one.
|
||||
text = json.dumps(_minimal())
|
||||
entry = json.dumps(_minimal()["samplers"]["KSampler"])
|
||||
text = text.replace(
|
||||
'"samplers": {', '"samplers": {"KSampler": ' + entry + ", ", 1
|
||||
)
|
||||
with self.assertRaises(SemanticsError) as caught:
|
||||
self.load_text(text)
|
||||
self.assertIn("duplicate", str(caught.exception))
|
||||
|
||||
def test_slot_must_be_nonnegative_int(self):
|
||||
for slot in (-1, 1.5, True, "0", None, 2**31):
|
||||
with self.subTest(slot=slot):
|
||||
doc = _minimal()
|
||||
doc["encoders"][0]["slot"] = slot
|
||||
self.assertRejected(doc, "slot")
|
||||
doc = _minimal()
|
||||
del doc["conditioning"][0]["slot"]
|
||||
self.assertRejected(doc, "slot")
|
||||
|
||||
def test_missing_input_name_rejected(self):
|
||||
cases = (
|
||||
lambda d: d["samplers"]["KSampler"]["conditioning"].pop("negative"),
|
||||
lambda d: d["samplers"]["KSampler"].pop("latent"),
|
||||
lambda d: d["guiders"]["BasicGuider"].update(positive=""),
|
||||
lambda d: d["conditioning"][0].pop("input"),
|
||||
lambda d: d["encoders"][0].update(inputs=[]),
|
||||
lambda d: d["encoders"][0].update(inputs=["text", "text"]),
|
||||
lambda d: d["encoders"][0].update(inputs=[3]),
|
||||
lambda d: d["encoders"][0].update(inputs="text"),
|
||||
)
|
||||
for index, mutate in enumerate(cases):
|
||||
with self.subTest(case=index):
|
||||
doc = _minimal()
|
||||
mutate(doc)
|
||||
self.assertRejected(doc, "input")
|
||||
|
||||
def test_entry_shape_is_strict(self):
|
||||
cases = (
|
||||
(lambda d: d.update(extra={}), "category"),
|
||||
(lambda d: d.pop("guiders"), "category"),
|
||||
(lambda d: d.update(schema=2), "schema"),
|
||||
(lambda d: d["samplers"]["KSampler"].update(note="x"), "field"),
|
||||
(lambda d: d["samplers"]["KSampler"].update(facts="flux"), "facts"),
|
||||
(lambda d: d["guiders"]["BasicGuider"].update(negative="n"), "field"),
|
||||
(lambda d: d["encoders"][0].update({"class": ""}), "class"),
|
||||
(lambda d: d["facts"].update(extra={}), "facts"),
|
||||
(
|
||||
lambda d: d["facts"]["scalar_outputs"].update(PrimitiveInt="int"),
|
||||
"facts",
|
||||
),
|
||||
(lambda d: d.pop("stage_inputs"), "category"),
|
||||
(lambda d: d["stage_inputs"].update(extra=["x"]), "stage_inputs"),
|
||||
(lambda d: d["stage_inputs"].pop("with_model"), "stage_inputs"),
|
||||
(lambda d: d["stage_inputs"].update(shaped=[]), "input name"),
|
||||
(lambda d: d["stage_inputs"].update(shaped="pipe"), "input name"),
|
||||
(lambda d: d["stage_inputs"].update(with_model=["positive"]), "both"),
|
||||
(lambda d: d["stage_inputs"].update(shaped=["model"]), "model"),
|
||||
(
|
||||
lambda d: d["facts"]["configuration_outputs"].update(
|
||||
X={"kind": "sigmas", "fields": ["steps"], "type_fields": []}
|
||||
),
|
||||
"input name",
|
||||
),
|
||||
(
|
||||
lambda d: d["facts"]["configuration_outputs"].update(
|
||||
X={"kind": "sigmas", "fields": ["steps"], "type_fields": ["steps"]}
|
||||
),
|
||||
"type_fields",
|
||||
),
|
||||
(
|
||||
lambda d: d["facts"]["configuration_outputs"].update(
|
||||
X={"kind": "sigmas", "fields": ["steps"], "names": ["x"]}
|
||||
),
|
||||
"field",
|
||||
),
|
||||
)
|
||||
for index, (mutate, fragment) in enumerate(cases):
|
||||
with self.subTest(case=index):
|
||||
doc = _minimal()
|
||||
mutate(doc)
|
||||
self.assertRejected(doc, fragment)
|
||||
|
||||
def test_strategy_specific_requirements(self):
|
||||
doc = _minimal()
|
||||
doc["samplers"]["X"] = {
|
||||
"strategy": "custom-advanced",
|
||||
"conditioning": {"positive": "positive", "negative": "negative"},
|
||||
"latent": "latent_image",
|
||||
"facts": "custom-advanced",
|
||||
}
|
||||
self.assertRejected(doc, "input")
|
||||
|
||||
doc = _minimal()
|
||||
doc["samplers"]["D"] = {
|
||||
"strategy": "detailer-stage",
|
||||
"conditioning": {"positive": "positive", "negative": "negative"},
|
||||
"facts": "ksampler",
|
||||
}
|
||||
self.assertRejected(doc, "input")
|
||||
|
||||
doc = _minimal()
|
||||
doc["guiders"]["G"] = {"strategy": "cfg", "positive": "positive"}
|
||||
self.assertRejected(doc, "input")
|
||||
|
||||
doc = _minimal()
|
||||
doc["encoders"][0].update(strategy="parts-equal", inputs=["text_g"])
|
||||
self.assertRejected(doc, "input")
|
||||
|
||||
doc = _minimal()
|
||||
doc["savers"]["S"] = {
|
||||
"strategy": "comfy-counter",
|
||||
"prefix_input": "filename_prefix",
|
||||
"image_input": "images",
|
||||
"pattern": "{name}_{counter:05}.{ext}",
|
||||
}
|
||||
self.assertRejected(doc, "pattern")
|
||||
|
||||
doc = _minimal()
|
||||
doc["selectors"]["easy ifElse"] = {
|
||||
"strategy": "boolean-select",
|
||||
"condition": "boolean",
|
||||
"branches": {"true": "on_true"},
|
||||
}
|
||||
self.assertRejected(doc, "input")
|
||||
|
||||
doc = _minimal()
|
||||
doc["strings"].append(
|
||||
{
|
||||
"class": "JoinStringMulti",
|
||||
"slot": 0,
|
||||
"strategy": "join-multi",
|
||||
"inputs": ["string_1"],
|
||||
"params": {"count": "inputcount"},
|
||||
}
|
||||
)
|
||||
self.assertRejected(doc, "params")
|
||||
|
||||
def test_unreadable_table_fails_loudly(self):
|
||||
with self.assertRaises(SemanticsError):
|
||||
self.load_text("{not json")
|
||||
with self.assertRaises(SemanticsError):
|
||||
self.load_text("[]")
|
||||
with self.assertRaises(SemanticsError):
|
||||
self.load_text(
|
||||
json.dumps(_minimal()).replace('"schema": 1', '"schema": NaN')
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
with self.assertRaises(SemanticsError):
|
||||
load_semantics(Path(root) / "missing.json")
|
||||
|
||||
def test_oversized_table_is_rejected_before_parsing(self):
|
||||
text = json.dumps(_minimal()) + " " * semantics.MAX_TABLE_BYTES
|
||||
with self.assertRaises(SemanticsError) as caught:
|
||||
self.load_text(text)
|
||||
self.assertIn("exceeds", str(caught.exception))
|
||||
|
||||
def test_deeply_nested_table_is_a_semantics_error(self):
|
||||
# Past the nesting bound, and far past the interpreter's recursion limit.
|
||||
for depth in (semantics.MAX_NESTING + 1, 100_000):
|
||||
with self.subTest(depth=depth):
|
||||
nested = "[" * depth + "]" * depth
|
||||
text = json.dumps(_minimal()).replace(
|
||||
'"selectors": {}', '"selectors": ' + nested
|
||||
)
|
||||
with self.assertRaises(SemanticsError) as caught:
|
||||
self.load_text(text)
|
||||
self.assertIn("nest", str(caught.exception))
|
||||
self.assertGreater(semantics.MAX_NESTING, _depth(json.loads(TABLE.read_text())))
|
||||
# Whichever interpreter limit the parser hits, it is a SemanticsError.
|
||||
with unittest.mock.patch.object(
|
||||
semantics.json, "loads", side_effect=RecursionError
|
||||
):
|
||||
with self.assertRaises(SemanticsError) as caught:
|
||||
self.load_doc(_minimal())
|
||||
self.assertIn("nests deeper", str(caught.exception))
|
||||
|
||||
|
||||
def _depth(value, level=1):
|
||||
children = value.values() if isinstance(value, dict) else value
|
||||
if not isinstance(value, (dict, list)):
|
||||
return level - 1
|
||||
return max([level] + [_depth(item, level + 1) for item in children])
|
||||
|
||||
|
||||
class SamplerOptionsTest(_TableCase):
|
||||
"""Task 2.7b: optional sampler lists are input names, or the table fails."""
|
||||
|
||||
def test_upstream_verified_ports_are_listed_with_their_source_inputs(self):
|
||||
# Not installed here: read from the pinned upstream commits the saved
|
||||
# workflows name (the report lists repository, commit, file and line).
|
||||
table = load_semantics()
|
||||
pair = {"positive": "positive", "negative": "negative"}
|
||||
for name, latent, prompt_overrides, fact_overrides in (
|
||||
(
|
||||
"ClownsharKSampler_Beta",
|
||||
"latent_image",
|
||||
("options",),
|
||||
("options", "sigmas"),
|
||||
),
|
||||
("KSampler (Efficient)", "latent_image", ("script",), ("script",)),
|
||||
("ttN KSampler_v2", "latent", ("adv_xyPlot",), ("adv_xyPlot",)),
|
||||
):
|
||||
self.assertEqual(
|
||||
table.samplers[name],
|
||||
{
|
||||
"strategy": "ksampler",
|
||||
"conditioning": pair,
|
||||
"latent": latent,
|
||||
"facts": "ksampler",
|
||||
"prompt_overrides": prompt_overrides,
|
||||
"fact_overrides": fact_overrides,
|
||||
},
|
||||
name,
|
||||
)
|
||||
for (name, slot), source in {
|
||||
("SeedVarianceEnhancer", 0): "conditioning",
|
||||
("ApplyInstantIDAdvanced", 1): "positive",
|
||||
("ApplyInstantIDAdvanced", 2): "negative",
|
||||
}.items():
|
||||
self.assertEqual(
|
||||
table.conditioning[(name, slot)],
|
||||
{"strategy": "passthrough", "input": source},
|
||||
)
|
||||
self.assertNotIn(("ApplyInstantIDAdvanced", 0), table.conditioning)
|
||||
self.assertEqual(
|
||||
table.selectors["Switch conditioning [Crystools]"],
|
||||
table.selectors["easy ifElse"],
|
||||
)
|
||||
self.assertEqual(
|
||||
table.encoders[("smZ CLIPTextEncode", 0)],
|
||||
{
|
||||
"strategy": "flag-parts",
|
||||
"inputs": ("text", "text_g", "text_l"),
|
||||
"params": {"flag": "with_SDXL"},
|
||||
},
|
||||
)
|
||||
for name, source in (
|
||||
("ttN text", "text"),
|
||||
("CR Text", "text"),
|
||||
("CR Prompt Text", "prompt"),
|
||||
("TextBoxMira", "text"),
|
||||
):
|
||||
self.assertEqual(
|
||||
table.strings[(name, 0)], {"strategy": "literal", "inputs": (source,)}
|
||||
)
|
||||
self.assertNotIn((name, 1), table.strings)
|
||||
|
||||
def test_flag_parts_needs_a_flag_and_two_inputs(self):
|
||||
doc = _minimal()
|
||||
entry = {
|
||||
"class": "smZ CLIPTextEncode",
|
||||
"slot": 0,
|
||||
"strategy": "flag-parts",
|
||||
"inputs": ["text", "text_g"],
|
||||
"params": {"flag": "with_SDXL"},
|
||||
}
|
||||
doc["encoders"].append(entry)
|
||||
self.assertEqual(
|
||||
self.load_doc(doc).encoders[("smZ CLIPTextEncode", 0)]["params"],
|
||||
{"flag": "with_SDXL"},
|
||||
)
|
||||
for change in (
|
||||
{"params": {}},
|
||||
{"params": {"flag": "x", "other": "y"}},
|
||||
{"params": {"flag": ""}},
|
||||
{"params": None},
|
||||
{"inputs": ["text"]},
|
||||
{"compose": True},
|
||||
):
|
||||
with self.subTest(change=change):
|
||||
broken = _minimal()
|
||||
broken["encoders"].append({**entry, **change})
|
||||
with self.assertRaises(SemanticsError):
|
||||
self.load_doc(broken)
|
||||
broken = _minimal()
|
||||
broken["encoders"].append(
|
||||
{key: item for key, item in entry.items() if key != "params"}
|
||||
)
|
||||
with self.assertRaises(SemanticsError):
|
||||
self.load_doc(broken)
|
||||
|
||||
def test_prompt_overrides_are_input_names(self):
|
||||
doc = _minimal()
|
||||
doc["samplers"]["KSampler"]["prompt_overrides"] = ["wildcard"]
|
||||
sampler = self.load_doc(doc).samplers["KSampler"]
|
||||
self.assertEqual(sampler["prompt_overrides"], ("wildcard",))
|
||||
for value in ([], "wildcard", [""], ["a", "a"], [1]):
|
||||
with self.subTest(value=value):
|
||||
doc["samplers"]["KSampler"]["prompt_overrides"] = value
|
||||
with self.assertRaises(SemanticsError):
|
||||
self.load_doc(doc)
|
||||
doc["samplers"]["KSampler"].pop("prompt_overrides")
|
||||
doc["samplers"]["KSampler"]["fact_overrides"] = ["script", "sigmas"]
|
||||
sampler = self.load_doc(doc).samplers["KSampler"]
|
||||
self.assertEqual(sampler["fact_overrides"], ("script", "sigmas"))
|
||||
doc["samplers"]["KSampler"]["fact_overrides"] = ["script", "script"]
|
||||
with self.assertRaises(SemanticsError):
|
||||
self.load_doc(doc)
|
||||
doc["samplers"]["KSampler"].pop("fact_overrides")
|
||||
doc["samplers"]["KSampler"]["overrides"] = ["wildcard"]
|
||||
self.assertRejected(doc, "unknown field")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+9
-67
@@ -17,14 +17,6 @@ def graph(positive="cat", negative="blur"):
|
||||
}
|
||||
|
||||
|
||||
def sampled_graph(positive="cat", negative="blur"):
|
||||
"""A KSampler with a model link, as real workflows have."""
|
||||
g = graph(positive, negative)
|
||||
g["4"] = {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "m"}}
|
||||
g["3"]["inputs"]["model"] = ["4", 0]
|
||||
return g
|
||||
|
||||
|
||||
class TestPairScan(AdminAPITestCase):
|
||||
def test_pair_scan_repeated_is_unchanged_and_empty_negative_is_supported(self):
|
||||
path = self.output_dir / "pair.png"
|
||||
@@ -36,66 +28,16 @@ class TestPairScan(AdminAPITestCase):
|
||||
self.assertEqual(pairs[0]["negative_text"], "")
|
||||
self.assertEqual(pairs[0]["run_count"], 0)
|
||||
|
||||
def _scanned(self, name, prompt_graph):
|
||||
meta = {"prompt": json.dumps(prompt_graph)}
|
||||
return self.api._ingest_scanned_file(self.output_dir / name, meta)
|
||||
|
||||
def _texts(self):
|
||||
return [p["text"] for p in self.api.db.get_recent_prompts()["prompts"]]
|
||||
|
||||
def test_samplers_sharing_one_prompt_link_that_prompt(self):
|
||||
g = sampled_graph()
|
||||
g["5"] = {**g["3"]}
|
||||
self.assertEqual(self._scanned("multi.png", g), "added")
|
||||
self.assertEqual(self._texts(), ["cat"])
|
||||
|
||||
def test_samplers_with_different_prompts_link_the_first_sampler_prompt(self):
|
||||
# The prompt the inspector and the legacy panels show for this file.
|
||||
g = sampled_graph()
|
||||
g["6"] = {"class_type": "CLIPTextEncode", "inputs": {"text": "dog"}}
|
||||
g["12"] = {**g["3"], "inputs": {**g["3"]["inputs"], "positive": ["6", 0]}}
|
||||
self.assertEqual(self._scanned("different.png", g), "added")
|
||||
self.assertEqual(self._texts(), ["cat"])
|
||||
|
||||
def test_an_unsupported_pass_through_still_links_the_traced_prompt(self):
|
||||
g = sampled_graph()
|
||||
g["7"] = {
|
||||
"class_type": "MyConditioningTweak",
|
||||
"inputs": {"conditioning": ["1", 0]},
|
||||
}
|
||||
g["3"]["inputs"]["positive"] = ["7", 0]
|
||||
g["5"] = {**g["3"]}
|
||||
self.assertEqual(self._scanned("tweak.png", g), "added")
|
||||
self.assertEqual(self._texts(), ["cat"])
|
||||
|
||||
def test_a_graph_without_a_traceable_prompt_saves_nothing(self):
|
||||
g = sampled_graph()
|
||||
g["5"] = {**g["3"]}
|
||||
for sampler in ("3", "5"):
|
||||
g[sampler]["inputs"]["positive"] = ["9", 0]
|
||||
g["9"] = {
|
||||
"class_type": "BasicGuider",
|
||||
"inputs": {"width": 2048, "height": 2048},
|
||||
}
|
||||
self.assertEqual(self._scanned("untraceable.png", g), "ambiguous")
|
||||
self.assertEqual(self.api.db.list_prompt_pairs()["total"], 0)
|
||||
self.assertEqual(self._texts(), [])
|
||||
|
||||
def test_numeric_widgets_are_never_stored_as_a_prompt(self):
|
||||
# The pre-redesign scan stored "2048" from a resolution widget here.
|
||||
g = {
|
||||
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": ["8", 0]}},
|
||||
"8": {"class_type": "DisplayText", "inputs": {"text_size": 2048}},
|
||||
"3": {
|
||||
"class_type": "SamplerCustomAdvanced",
|
||||
"inputs": {"guider": ["4", 0], "sigmas": ["5", 0]},
|
||||
},
|
||||
"4": {"class_type": "BasicGuider", "inputs": {"conditioning": ["1", 0]}},
|
||||
}
|
||||
self.assertIn(
|
||||
self._scanned("numbers.png", g), ("ambiguous", "unresolved", "found")
|
||||
def test_ambiguous_sampler_scan_does_not_save_a_guess(self):
|
||||
g = graph()
|
||||
g["4"] = {**g["3"]}
|
||||
meta = {"prompt": json.dumps(g)}
|
||||
self.assertEqual(
|
||||
self.api._ingest_scanned_file(self.output_dir / "multi.png", meta),
|
||||
"ambiguous",
|
||||
)
|
||||
self.assertEqual(self._texts(), [])
|
||||
self.assertEqual(self.api.db.list_prompt_pairs()["total"], 0)
|
||||
self.assertEqual(self.api.db.get_recent_prompts()["prompts"], [])
|
||||
|
||||
def test_batch_exposes_pair_counts_and_actual_unchanged_status(self):
|
||||
meta = {"parameters": "cat\nNegative prompt: blur\nSteps: 20, Sampler: Euler"}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Vendored
-30
@@ -1,30 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
// Concept comparisons measure layout, and layout depends on the fonts a browser falls back to (the concept names Inter,
|
||||
// which neither environment ships). Page-workflow jobs therefore run in the same pinned Playwright image as local runs,
|
||||
// so CI and a developer machine resolve identical fonts and engines.
|
||||
const workflow = readFileSync(new URL('../../.github/workflows/viewerjs-check.yml', import.meta.url), 'utf8');
|
||||
const lock = JSON.parse(readFileSync(new URL('../../tools/vendor/package-lock.json', import.meta.url), 'utf8'));
|
||||
const version = lock.packages['node_modules/@playwright/test'].version;
|
||||
|
||||
function job(name) {
|
||||
const match = workflow.match(new RegExp(`^ ${name}:\\n([\\s\\S]*?)(?=^ [a-z][a-z-]*:\\n|(?![\\s\\S]))`, 'm'));
|
||||
assert.ok(match, `${name} job exists`);
|
||||
return match[1];
|
||||
}
|
||||
|
||||
for (const name of ['frontend-coverage', 'browser-workflows']) {
|
||||
test(`${name} runs in the pinned Playwright image matching @playwright/test`, () => {
|
||||
const body = job(name);
|
||||
assert.match(body, new RegExp(`^ container:\\n image: mcr\\.microsoft\\.com/playwright:v${version.replaceAll('.', '\\.')}-noble$`, 'm'));
|
||||
assert.doesNotMatch(body, /playwright install/, 'browsers and their fonts come from the image, not a runner install');
|
||||
// Toolcache interpreters are the runner's own builds: their entry points name host paths that do not exist in
|
||||
// the container (setup-python failed with `spawn .../bin/pip ENOENT`). Use the image's Python and Node.
|
||||
assert.doesNotMatch(body, /actions\/setup-(python|node)@/);
|
||||
assert.match(body, /python3 -m venv --without-pip /, 'a user-space venv on the image Python (no ensurepip, no root)');
|
||||
assert.match(body, /sha256sum -c/, 'the pip wheel that bootstraps the venv is checksum-pinned');
|
||||
assert.match(body, /node --version \| grep -q '\^v24\\\.'/, 'the image Node is the Node 24 the project requires');
|
||||
});
|
||||
}
|
||||
+2
-20
@@ -19,7 +19,7 @@
|
||||
},
|
||||
"comfy-metadata.js": {
|
||||
"lines": 97,
|
||||
"branches": 92,
|
||||
"branches": 91,
|
||||
"functions": 98,
|
||||
"statements": 97
|
||||
},
|
||||
@@ -199,7 +199,7 @@
|
||||
},
|
||||
"workspace/images.mjs": {
|
||||
"lines": 97,
|
||||
"branches": 89,
|
||||
"branches": 88,
|
||||
"functions": 98,
|
||||
"statements": 97
|
||||
},
|
||||
@@ -298,23 +298,5 @@
|
||||
"branches": 95,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/vocabulary-dialog.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 88,
|
||||
"functions": 95,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/vocabulary-state.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 96,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/vocabulary.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 91,
|
||||
"functions": 93,
|
||||
"statements": 98
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,943 +0,0 @@
|
||||
"""Generation parameters traced from ComfyUI metadata: the browser parser in Python.
|
||||
|
||||
A port of ``extractGenerationParams`` in ``web/js/comfy-metadata.js``, the parser
|
||||
behind the gallery, admin and metadata pages. For the same records it returns
|
||||
what that function returns; ``tests/test_generation_params_js_parity.py``
|
||||
enforces it against the JavaScript itself.
|
||||
|
||||
It traces the API ``prompt`` graph from the sampler: the positive and negative
|
||||
inputs back to their text nodes, the model link back to a loader, and the
|
||||
sampler settings, following primitives and pass-through nodes. Fields the API
|
||||
graph cannot give are read from the UI ``workflow`` graph.
|
||||
|
||||
The result depends on JavaScript value semantics, so those are reproduced
|
||||
explicitly: property order, truthiness, ``String()``, ``trim()``, strict
|
||||
equality, ``Map``/``Set`` keys, and the inputs on which the JavaScript throws
|
||||
(raised here as ``GenerationParamsError``). The recursive tracing runs on an
|
||||
explicit stack, so a crafted graph cannot exhaust the Python stack.
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from types import MappingProxyType
|
||||
|
||||
__all__ = [
|
||||
"PLACEHOLDERS",
|
||||
"GenerationParamsError",
|
||||
"extract_generation_params",
|
||||
"extract_from_records",
|
||||
"parse_js_json",
|
||||
]
|
||||
|
||||
PLACEHOLDERS = MappingProxyType(
|
||||
{
|
||||
"positivePrompt": "No prompt found",
|
||||
"negativePrompt": "No negative prompt found",
|
||||
"checkpoint": "Unknown",
|
||||
"steps": "Unknown",
|
||||
"cfgScale": "Unknown",
|
||||
"sampler": "Unknown",
|
||||
"seed": "Unknown",
|
||||
}
|
||||
)
|
||||
|
||||
# Upper bound on nodes expanded per lookup; crafted files can contain any graph.
|
||||
MAX_VISITS = 500
|
||||
MODEL_NAME_KEYS = ("ckpt_name", "unet_name", "model_name", "gguf_name")
|
||||
SEED_CONTROL_VALUES = frozenset(("fixed", "increment", "decrement", "randomize"))
|
||||
PASS_THROUGH_TYPES = frozenset(("Reroute", "GetNode", "SetNode"))
|
||||
MODE_MUTED = 2
|
||||
MODE_BYPASSED = 4
|
||||
# Widget order for core nodes, used when a workflow carries no widget names.
|
||||
PROMPT_MANAGER_WIDGETS = (
|
||||
"text",
|
||||
"category",
|
||||
"tags",
|
||||
"search_text",
|
||||
"prepend_text",
|
||||
"append_text",
|
||||
)
|
||||
WIDGET_POSITIONS = {
|
||||
"KSampler": ("seed", "steps", "cfg", "sampler_name", "scheduler", "denoise"),
|
||||
"KSamplerAdvanced": (
|
||||
"add_noise",
|
||||
"noise_seed",
|
||||
"steps",
|
||||
"cfg",
|
||||
"sampler_name",
|
||||
"scheduler",
|
||||
"start_at_step",
|
||||
"end_at_step",
|
||||
"return_with_leftover_noise",
|
||||
),
|
||||
"CLIPTextEncode": ("text",),
|
||||
"PromptManager": PROMPT_MANAGER_WIDGETS,
|
||||
"PromptManagerText": PROMPT_MANAGER_WIDGETS,
|
||||
"PromptManagerFocused": PROMPT_MANAGER_WIDGETS,
|
||||
"PromptManagerSplit": PROMPT_MANAGER_WIDGETS,
|
||||
"PromptManagerPair": (
|
||||
"text",
|
||||
"negative_text",
|
||||
"category",
|
||||
"tags",
|
||||
"search_text",
|
||||
"prepend_text",
|
||||
"append_text",
|
||||
),
|
||||
"CheckpointLoader": ("config_name", "ckpt_name"),
|
||||
"UNETLoader": ("unet_name", "weight_dtype"),
|
||||
}
|
||||
|
||||
# JavaScript regular expressions without the ``u`` flag fold case in ASCII only.
|
||||
_FLAGS = re.IGNORECASE | re.ASCII
|
||||
TEXT_KEYS = re.compile(r"(text|string|prompt|value)(_?[a-z0-9]+)?", _FLAGS)
|
||||
_PROMPT_MANAGER = re.compile(r"promptmanager", _FLAGS)
|
||||
_KSAMPLER = re.compile(r"ksampler", _FLAGS)
|
||||
_CHECKPOINT_LOADER = re.compile(r"checkpointloader", _FLAGS)
|
||||
_UNET = re.compile(r"unet", _FLAGS)
|
||||
_SEED_NAME = re.compile(r"seed\Z")
|
||||
_ARRAY_INDEX = re.compile(r"(?:0|[1-9][0-9]*)\Z")
|
||||
_MAX_ARRAY_INDEX = 2**32 - 2
|
||||
_MAX_SAFE_INTEGER = 2**53
|
||||
# String.prototype.trim() removes WhiteSpace and LineTerminator code points.
|
||||
_JS_WHITESPACE = "\t\n\v\f\r " "
"
|
||||
# Properties every object literal inherits; reading one yields a function or object.
|
||||
_OBJECT_PROTOTYPE_KEYS = frozenset(
|
||||
(
|
||||
"constructor",
|
||||
"hasOwnProperty",
|
||||
"isPrototypeOf",
|
||||
"propertyIsEnumerable",
|
||||
"toLocaleString",
|
||||
"toString",
|
||||
"valueOf",
|
||||
"__proto__",
|
||||
"__defineGetter__",
|
||||
"__defineSetter__",
|
||||
"__lookupGetter__",
|
||||
"__lookupSetter__",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class GenerationParamsError(ValueError):
|
||||
"""The browser parser throws on this input, so there is no result."""
|
||||
|
||||
|
||||
class _Undefined:
|
||||
"""JavaScript ``undefined``: a missing property, distinct from JSON ``null``."""
|
||||
|
||||
__slots__ = ()
|
||||
|
||||
def __repr__(self):
|
||||
return "undefined"
|
||||
|
||||
|
||||
UNDEFINED = _Undefined()
|
||||
|
||||
|
||||
# --- JavaScript value semantics -------------------------------------------------
|
||||
|
||||
|
||||
def _is_number(value):
|
||||
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
||||
|
||||
|
||||
def _nullish(value):
|
||||
return value is None or value is UNDEFINED
|
||||
|
||||
|
||||
def _truthy(value):
|
||||
if value is None or value is UNDEFINED or value is False:
|
||||
return False
|
||||
if _is_number(value):
|
||||
return value != 0 and not (isinstance(value, float) and math.isnan(value))
|
||||
if isinstance(value, str):
|
||||
return value != ""
|
||||
return True
|
||||
|
||||
|
||||
def _typeof(value):
|
||||
if value is UNDEFINED:
|
||||
return "undefined"
|
||||
if isinstance(value, bool):
|
||||
return "boolean"
|
||||
if _is_number(value):
|
||||
return "number"
|
||||
if isinstance(value, str):
|
||||
return "string"
|
||||
return "object"
|
||||
|
||||
|
||||
def _number_string(value):
|
||||
"""``String(number)`` for a double, per the ECMAScript Number::toString rules."""
|
||||
if isinstance(value, int):
|
||||
return str(value)
|
||||
if math.isnan(value):
|
||||
return "NaN"
|
||||
if math.isinf(value):
|
||||
return "Infinity" if value > 0 else "-Infinity"
|
||||
if value == 0:
|
||||
return "0"
|
||||
sign = "-" if value < 0 else ""
|
||||
text = repr(abs(value))
|
||||
mantissa, _, exponent = text.partition("e")
|
||||
whole, _, fraction = mantissa.partition(".")
|
||||
raw = whole + fraction
|
||||
leading = len(raw) - len(raw.lstrip("0"))
|
||||
digits = raw.lstrip("0").rstrip("0")
|
||||
point = len(whole) - leading + int(exponent or 0)
|
||||
count = len(digits)
|
||||
if count <= point <= 21:
|
||||
body = digits + "0" * (point - count)
|
||||
elif 0 < point <= 21:
|
||||
body = digits[:point] + "." + digits[point:]
|
||||
elif -6 < point <= 0:
|
||||
body = "0." + "0" * -point + digits
|
||||
else:
|
||||
power = point - 1
|
||||
mark = ("+" if power >= 0 else "-") + str(abs(power))
|
||||
body = (digits if count == 1 else digits[0] + "." + digits[1:]) + "e" + mark
|
||||
return sign + body
|
||||
|
||||
|
||||
def _js_string(value):
|
||||
"""``String(value)`` for any JSON-derived value."""
|
||||
if value is UNDEFINED:
|
||||
return "undefined"
|
||||
if value is None:
|
||||
return "null"
|
||||
if value is True:
|
||||
return "true"
|
||||
if value is False:
|
||||
return "false"
|
||||
if _is_number(value):
|
||||
return _number_string(value)
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
if isinstance(value, list):
|
||||
return ",".join("" if _nullish(item) else _js_string(item) for item in value)
|
||||
return "[object Object]"
|
||||
|
||||
|
||||
def _key(value):
|
||||
return value if isinstance(value, str) else _js_string(value)
|
||||
|
||||
|
||||
def _index(key):
|
||||
"""The array index a property key names, or None."""
|
||||
if (
|
||||
_is_number(key)
|
||||
and not isinstance(key, bool)
|
||||
and float(key).is_integer()
|
||||
and key >= 0
|
||||
):
|
||||
number = int(key)
|
||||
return number if number <= _MAX_ARRAY_INDEX else None
|
||||
text = _key(key)
|
||||
if _ARRAY_INDEX.match(text) and int(text) <= _MAX_ARRAY_INDEX:
|
||||
return int(text)
|
||||
return None
|
||||
|
||||
|
||||
def _get(obj, key):
|
||||
"""Property read ``obj[key]``; reading from null or undefined throws."""
|
||||
if obj is None or obj is UNDEFINED:
|
||||
raise GenerationParamsError(f"cannot read {key!r} of {_js_string(obj)}")
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(_key(key), UNDEFINED)
|
||||
if isinstance(obj, (list, str)):
|
||||
if key == "length":
|
||||
return len(obj)
|
||||
index = _index(key)
|
||||
return obj[index] if index is not None and index < len(obj) else UNDEFINED
|
||||
return UNDEFINED
|
||||
|
||||
|
||||
def _has(obj, key):
|
||||
"""``key in obj``; throws on primitives."""
|
||||
if isinstance(obj, dict):
|
||||
return _key(key) in obj
|
||||
if isinstance(obj, list):
|
||||
index = _index(key)
|
||||
return key == "length" or (index is not None and index < len(obj))
|
||||
raise GenerationParamsError(f"cannot use 'in' on {_typeof(obj)}")
|
||||
|
||||
|
||||
def _own(obj, key):
|
||||
"""``Object.prototype.hasOwnProperty.call(obj, key)``."""
|
||||
if obj is None or obj is UNDEFINED:
|
||||
raise GenerationParamsError("hasOwnProperty on null or undefined")
|
||||
if isinstance(obj, dict):
|
||||
return _key(key) in obj
|
||||
if isinstance(obj, (list, str)):
|
||||
index = _index(key)
|
||||
return key == "length" or (index is not None and index < len(obj))
|
||||
return False
|
||||
|
||||
|
||||
def _ordered_keys(obj):
|
||||
"""Own property order: array indexes ascending, then other keys as inserted."""
|
||||
indexes = sorted(
|
||||
(k for k in obj if _ARRAY_INDEX.match(k) and int(k) <= _MAX_ARRAY_INDEX),
|
||||
key=int,
|
||||
)
|
||||
marked = set(indexes)
|
||||
return indexes + [k for k in obj if k not in marked]
|
||||
|
||||
|
||||
def _entries(obj):
|
||||
"""``Object.entries(obj)``."""
|
||||
if obj is None or obj is UNDEFINED:
|
||||
raise GenerationParamsError("Object.entries of null or undefined")
|
||||
if isinstance(obj, dict):
|
||||
return [(k, obj[k]) for k in _ordered_keys(obj)]
|
||||
if isinstance(obj, (list, str)):
|
||||
return [(str(i), item) for i, item in enumerate(obj)]
|
||||
return []
|
||||
|
||||
|
||||
def _values(obj):
|
||||
"""``Object.values(obj)``."""
|
||||
return [value for _, value in _entries(obj)]
|
||||
|
||||
|
||||
def _strict_equal(left, right):
|
||||
"""``left === right``."""
|
||||
if left is UNDEFINED or right is UNDEFINED or left is None or right is None:
|
||||
return left is right
|
||||
if isinstance(left, bool) or isinstance(right, bool):
|
||||
return isinstance(left, bool) and isinstance(right, bool) and left == right
|
||||
if _is_number(left) and _is_number(right):
|
||||
return left == right
|
||||
if isinstance(left, str) and isinstance(right, str):
|
||||
return left == right
|
||||
return left is right
|
||||
|
||||
|
||||
def _same_value_key(value):
|
||||
"""A hashable key with ``Map``/``Set`` (SameValueZero) equality."""
|
||||
if value is UNDEFINED:
|
||||
return ("undefined", None)
|
||||
if value is None:
|
||||
return ("null", None)
|
||||
if isinstance(value, bool):
|
||||
return ("boolean", value)
|
||||
if _is_number(value):
|
||||
return (
|
||||
("nan", None)
|
||||
if isinstance(value, float) and math.isnan(value)
|
||||
else ("number", value)
|
||||
)
|
||||
if isinstance(value, str):
|
||||
return ("string", value)
|
||||
return ("object", id(value))
|
||||
|
||||
|
||||
def _trim(text):
|
||||
return text.strip(_JS_WHITESPACE)
|
||||
|
||||
|
||||
def _first_truthy(*values):
|
||||
"""``a || b || ...``: the first truthy operand, else the last."""
|
||||
for value in values:
|
||||
if _truthy(value):
|
||||
return value
|
||||
return values[-1]
|
||||
|
||||
|
||||
def _and_chain(obj, *steps):
|
||||
"""``a && a[k1] && a[k1][k2] ...``: stops at the first falsy operand."""
|
||||
value = obj
|
||||
for step in steps:
|
||||
if not _truthy(value):
|
||||
return value
|
||||
value = _get(value, step)
|
||||
return value
|
||||
|
||||
|
||||
def _coalesce(value, fallback):
|
||||
"""``value ?? fallback()``; the fallback runs only when needed."""
|
||||
return fallback() if _nullish(value) else value
|
||||
|
||||
|
||||
def _array(value):
|
||||
"""A value used with an Array method; anything else throws."""
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
raise GenerationParamsError(f"{_typeof(value)} has no array methods")
|
||||
|
||||
|
||||
def _iterate(value):
|
||||
"""``for (const x of value)``: arrays and strings iterate, other values throw."""
|
||||
if isinstance(value, (list, str)):
|
||||
return value
|
||||
raise GenerationParamsError(f"{_typeof(value)} is not iterable")
|
||||
|
||||
|
||||
def _set(obj, key, value):
|
||||
"""``obj[key] = value`` on an object literal (``__proto__`` is not an own key)."""
|
||||
name = _key(key)
|
||||
if name == "__proto__" and name not in obj:
|
||||
return
|
||||
obj[name] = value
|
||||
|
||||
|
||||
def _matches(pattern, value):
|
||||
"""``pattern.test(value || "")``."""
|
||||
return pattern.search(_js_string(value if _truthy(value) else "")) is not None
|
||||
|
||||
|
||||
def _run(generator):
|
||||
"""Drive generator-based recursion on an explicit stack, not the call stack."""
|
||||
stack = [generator]
|
||||
value = None
|
||||
while stack:
|
||||
try:
|
||||
request = stack[-1].send(value)
|
||||
except StopIteration as done:
|
||||
stack.pop()
|
||||
value = done.value
|
||||
continue
|
||||
stack.append(request)
|
||||
value = None
|
||||
return value
|
||||
|
||||
|
||||
# --- Tracing ---------------------------------------------------------------------
|
||||
|
||||
|
||||
def _is_link(value):
|
||||
return (
|
||||
isinstance(value, list)
|
||||
and len(value) == 2
|
||||
and (isinstance(value[0], str) or _is_number(value[0]))
|
||||
and _is_number(value[1])
|
||||
)
|
||||
|
||||
|
||||
def _is_sampler(node):
|
||||
"""A KSampler or a guider (CFGGuider); the model link excludes pass-throughs."""
|
||||
if not _truthy(node):
|
||||
return False
|
||||
inputs = _get(node, "inputs")
|
||||
return (
|
||||
_truthy(inputs)
|
||||
and _is_link(_get(inputs, "positive"))
|
||||
and _is_link(_get(inputs, "negative"))
|
||||
and _is_link(_get(inputs, "model"))
|
||||
)
|
||||
|
||||
|
||||
def _is_custom_sampler(node):
|
||||
"""SamplerCustomAdvanced-style nodes split settings across noise/sampler/sigmas."""
|
||||
if not _truthy(node):
|
||||
return False
|
||||
inputs = _get(node, "inputs")
|
||||
return (
|
||||
_truthy(inputs)
|
||||
and _is_link(_get(inputs, "guider"))
|
||||
and _is_link(_get(inputs, "sigmas"))
|
||||
)
|
||||
|
||||
|
||||
def _workflow_nodes(workflow):
|
||||
if not _truthy(workflow):
|
||||
return []
|
||||
nodes = _get(workflow, "nodes")
|
||||
return nodes if isinstance(nodes, list) else []
|
||||
|
||||
|
||||
def _widgets_of(node):
|
||||
values = _get(node, "widgets_values")
|
||||
return values if isinstance(values, list) else []
|
||||
|
||||
|
||||
class _Context:
|
||||
"""The graph being traced and recovered PromptManager widget text."""
|
||||
|
||||
__slots__ = ("graph", "widget_text")
|
||||
|
||||
def __init__(self, graph, widget_text):
|
||||
self.graph = graph
|
||||
self.widget_text = widget_text
|
||||
|
||||
|
||||
def _create_context(graph, workflow):
|
||||
"""Recover PromptManager text overwritten by the pre-3.2.3 SaveImage patch.
|
||||
|
||||
The signature is two or more PromptManager nodes with identical API text
|
||||
whose workflow widgets differ; only those get their widget text back.
|
||||
Otherwise the API graph is what actually ran and always wins.
|
||||
"""
|
||||
widgets = {}
|
||||
for node in _workflow_nodes(workflow):
|
||||
text = _get(_widgets_of(node), 0)
|
||||
node_type = _get(node, "type")
|
||||
if (
|
||||
not _strict_equal(node_type, "PromptManagerPair")
|
||||
and _matches(_PROMPT_MANAGER, node_type)
|
||||
and isinstance(text, str)
|
||||
):
|
||||
widgets[_js_string(_get(node, "id"))] = text
|
||||
by_api_text = {}
|
||||
for node_id in widgets:
|
||||
api_text = _and_chain(graph, node_id, "inputs", "text")
|
||||
if isinstance(api_text, str):
|
||||
by_api_text.setdefault(api_text, []).append(node_id)
|
||||
widget_text = {}
|
||||
for ids in by_api_text.values():
|
||||
if len(ids) > 1 and len({widgets[node_id] for node_id in ids}) > 1:
|
||||
for node_id in ids:
|
||||
widget_text[node_id] = widgets[node_id]
|
||||
return _Context(graph, widget_text)
|
||||
|
||||
|
||||
def _enter(context, link, seen):
|
||||
"""``(node_id, node)`` for a link; None when unresolved or already expanded."""
|
||||
if not _is_link(link) or len(seen) >= MAX_VISITS:
|
||||
return None
|
||||
node_id = _js_string(link[0])
|
||||
node = _get(context.graph, node_id)
|
||||
key = f"{node_id}:{_js_string(link[1])}"
|
||||
if not _truthy(node) or not _truthy(_get(node, "inputs")) or key in seen:
|
||||
return None
|
||||
seen.add(key)
|
||||
return node_id, node
|
||||
|
||||
|
||||
def _resolve_scalar(context, value, name, seen):
|
||||
"""A scalar input, following a link; an input named like the field wins."""
|
||||
if not _is_link(value):
|
||||
return value
|
||||
entered = _enter(context, value, seen)
|
||||
if entered is None:
|
||||
return UNDEFINED
|
||||
inputs = _get(entered[1], "inputs")
|
||||
if _truthy(name) and _own(inputs, name):
|
||||
return (yield _resolve_scalar(context, _get(inputs, name), name, seen))
|
||||
for item in _values(inputs):
|
||||
resolved = yield _resolve_scalar(context, item, name, seen)
|
||||
if resolved is not UNDEFINED and _typeof(resolved) != "object":
|
||||
return resolved
|
||||
return UNDEFINED
|
||||
|
||||
|
||||
def _resolve_linked_input(context, link, keys):
|
||||
"""The first of ``keys`` on the node a link points to, resolving further links."""
|
||||
seen = set()
|
||||
entered = _enter(context, link, seen)
|
||||
if entered is None:
|
||||
return UNDEFINED
|
||||
inputs = _get(entered[1], "inputs")
|
||||
key = next((k for k in keys if _has(inputs, k)), None)
|
||||
if key is None:
|
||||
return UNDEFINED
|
||||
return (yield _resolve_scalar(context, _get(inputs, key), key, seen))
|
||||
|
||||
|
||||
def _resolve_string(context, value, seen):
|
||||
"""A string input; text-producing nodes may be chained (concat, primitives)."""
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
entered = _enter(context, value, seen)
|
||||
if entered is None:
|
||||
return ""
|
||||
node_id, node = entered
|
||||
inputs = _get(node, "inputs")
|
||||
if _strict_equal(_get(node, "class_type"), "PromptManagerPair"):
|
||||
if _strict_equal(value[1], 3):
|
||||
return (yield _resolve_string(context, _get(inputs, "negative_text"), seen))
|
||||
if _strict_equal(value[1], 2):
|
||||
return (yield _compose_node_text(context, node_id, node, seen))
|
||||
return ""
|
||||
if _has(inputs, "text"):
|
||||
return (yield _compose_node_text(context, node_id, node, seen))
|
||||
delimiter = _get(inputs, "delimiter")
|
||||
if not isinstance(delimiter, str):
|
||||
delimiter = " "
|
||||
parts = []
|
||||
for key, item in _entries(inputs):
|
||||
if TEXT_KEYS.fullmatch(key):
|
||||
parts.append((yield _resolve_string(context, item, seen)))
|
||||
return delimiter.join(part for part in parts if _trim(part))
|
||||
|
||||
|
||||
def _compose_node_text(context, node_id, node, seen):
|
||||
"""An encoder's text, joined like PromptManager: prepend + text + append."""
|
||||
inputs = _get(node, "inputs")
|
||||
if node_id in context.widget_text:
|
||||
raw = context.widget_text[node_id]
|
||||
else:
|
||||
raw = yield _resolve_string(context, _get(inputs, "text"), seen)
|
||||
text = _trim(raw)
|
||||
if not text:
|
||||
return ""
|
||||
# One shared set even across siblings keeps crafted chains linear.
|
||||
prepend = _trim(
|
||||
(yield _resolve_string(context, _get(inputs, "prepend_text"), seen))
|
||||
)
|
||||
append = _trim((yield _resolve_string(context, _get(inputs, "append_text"), seen)))
|
||||
# Older saves stored the already-combined prompt in ``text``.
|
||||
parts = (
|
||||
prepend if prepend and not text.startswith(prepend) else "",
|
||||
text,
|
||||
append if append and not text.endswith(append) else "",
|
||||
)
|
||||
return " ".join(part for part in parts if part)
|
||||
|
||||
|
||||
def _trace_conditioning(context, link, role, seen):
|
||||
"""Follow a conditioning link upstream to the encoder that produced it."""
|
||||
entered = _enter(context, link, seen)
|
||||
if entered is None:
|
||||
return ""
|
||||
node_id, node = entered
|
||||
inputs = _get(node, "inputs")
|
||||
if _strict_equal(_get(node, "class_type"), "PromptManagerPair"):
|
||||
if _strict_equal(link[1], 1):
|
||||
return (yield _resolve_string(context, _get(inputs, "negative_text"), seen))
|
||||
if _strict_equal(link[1], 0):
|
||||
return (yield _compose_node_text(context, node_id, node, seen))
|
||||
return ""
|
||||
if _has(inputs, "text"):
|
||||
return (yield _compose_node_text(context, node_id, node, seen))
|
||||
# Pass-through nodes (ControlNet apply, conditioning combine/set area, ...).
|
||||
following = _first_truthy(
|
||||
_get(inputs, role),
|
||||
_get(inputs, "conditioning"),
|
||||
_get(inputs, "conditioning_to"),
|
||||
_get(inputs, "conditioning_1"),
|
||||
)
|
||||
return (yield _trace_conditioning(context, following, role, seen))
|
||||
|
||||
|
||||
def _trace_model_name(context, link, seen):
|
||||
"""Follow the model link upstream (through LoRA loaders etc.) to a loader."""
|
||||
entered = _enter(context, link, seen)
|
||||
if entered is None:
|
||||
return UNDEFINED
|
||||
inputs = _get(entered[1], "inputs")
|
||||
key = next((k for k in MODEL_NAME_KEYS if isinstance(_get(inputs, k), str)), None)
|
||||
if key is not None:
|
||||
return _get(inputs, key)
|
||||
return (yield _trace_model_name(context, _get(inputs, "model"), seen))
|
||||
|
||||
|
||||
def _find_any_model_name(nodes):
|
||||
for node in nodes:
|
||||
inputs = _and_chain(node, "inputs")
|
||||
if not _truthy(inputs):
|
||||
inputs = {}
|
||||
key = next(
|
||||
(k for k in MODEL_NAME_KEYS if isinstance(_get(inputs, k), str)), None
|
||||
)
|
||||
if key is not None:
|
||||
return _get(inputs, key)
|
||||
return UNDEFINED
|
||||
|
||||
|
||||
def _from_prompt_graph(graph, workflow):
|
||||
context = _create_context(graph, workflow)
|
||||
nodes = _values(graph)
|
||||
custom = next((node for node in nodes if _is_custom_sampler(node)), UNDEFINED)
|
||||
guider = UNDEFINED
|
||||
if custom is not UNDEFINED:
|
||||
entered = _enter(context, _get(_get(custom, "inputs"), "guider"), set())
|
||||
guider = entered[1] if entered is not None else UNDEFINED
|
||||
sampler = (
|
||||
guider
|
||||
if _is_sampler(guider)
|
||||
else next((n for n in nodes if _is_sampler(n)), UNDEFINED)
|
||||
)
|
||||
|
||||
if sampler is UNDEFINED:
|
||||
loose = next(
|
||||
(
|
||||
n
|
||||
for n in nodes
|
||||
if _truthy(n)
|
||||
and _truthy(_get(n, "inputs"))
|
||||
and _matches(_KSAMPLER, _get(n, "class_type"))
|
||||
),
|
||||
UNDEFINED,
|
||||
)
|
||||
inputs = _and_chain(loose, "inputs")
|
||||
if not _truthy(inputs):
|
||||
inputs = {}
|
||||
seed = _coalesce(_get(inputs, "seed"), lambda: _get(inputs, "noise_seed"))
|
||||
return {
|
||||
"checkpoint": _find_any_model_name(nodes),
|
||||
"seed": (yield _resolve_scalar(context, seed, "seed", set())),
|
||||
"steps": (
|
||||
yield _resolve_scalar(context, _get(inputs, "steps"), "steps", set())
|
||||
),
|
||||
"cfgScale": (
|
||||
yield _resolve_scalar(context, _get(inputs, "cfg"), "cfg", set())
|
||||
),
|
||||
"sampler": (
|
||||
yield _resolve_scalar(
|
||||
context, _get(inputs, "sampler_name"), "sampler_name", set()
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
inputs = _get(sampler, "inputs")
|
||||
|
||||
def from_custom(field, keys):
|
||||
if custom is UNDEFINED:
|
||||
return UNDEFINED
|
||||
return _run(
|
||||
_resolve_linked_input(context, _get(_get(custom, "inputs"), field), keys)
|
||||
)
|
||||
|
||||
result = {
|
||||
"positivePrompt": (
|
||||
yield _trace_conditioning(
|
||||
context, _get(inputs, "positive"), "positive", set()
|
||||
)
|
||||
),
|
||||
"negativePrompt": (
|
||||
yield _trace_conditioning(
|
||||
context, _get(inputs, "negative"), "negative", set()
|
||||
)
|
||||
),
|
||||
}
|
||||
model = yield _trace_model_name(context, _get(inputs, "model"), set())
|
||||
result["checkpoint"] = model if _truthy(model) else _find_any_model_name(nodes)
|
||||
seed_value = _coalesce(_get(inputs, "seed"), lambda: _get(inputs, "noise_seed"))
|
||||
seed_name = "seed" if _has(inputs, "seed") else "noise_seed"
|
||||
seed = yield _resolve_scalar(context, seed_value, seed_name, set())
|
||||
result["seed"] = _coalesce(
|
||||
seed, lambda: from_custom("noise", ("noise_seed", "seed"))
|
||||
)
|
||||
steps = yield _resolve_scalar(context, _get(inputs, "steps"), "steps", set())
|
||||
result["steps"] = _coalesce(steps, lambda: from_custom("sigmas", ("steps",)))
|
||||
result["cfgScale"] = yield _resolve_scalar(
|
||||
context, _get(inputs, "cfg"), "cfg", set()
|
||||
)
|
||||
sampler_name = yield _resolve_scalar(
|
||||
context, _get(inputs, "sampler_name"), "sampler_name", set()
|
||||
)
|
||||
result["sampler"] = _coalesce(
|
||||
sampler_name, lambda: from_custom("sampler", ("sampler_name",))
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
# --- UI workflow graph -------------------------------------------------------------
|
||||
|
||||
|
||||
def _widget_names(node):
|
||||
inputs = _first_truthy(_get(node, "inputs"), [])
|
||||
from_inputs = [
|
||||
_get(_get(item, "widget"), "name")
|
||||
for item in _array(inputs)
|
||||
if _truthy(item)
|
||||
and _truthy(_get(item, "widget"))
|
||||
and _truthy(_get(_get(item, "widget"), "name"))
|
||||
]
|
||||
if from_inputs:
|
||||
return from_inputs
|
||||
node_type = _get(node, "type")
|
||||
key = _js_string(node_type)
|
||||
if key in _OBJECT_PROTOTYPE_KEYS:
|
||||
raise GenerationParamsError(f"widget positions of {key!r} are not iterable")
|
||||
if key in WIDGET_POSITIONS:
|
||||
return list(WIDGET_POSITIONS[key])
|
||||
if _matches(_CHECKPOINT_LOADER, node_type):
|
||||
return ["ckpt_name"]
|
||||
if _matches(_UNET, node_type):
|
||||
return ["unet_name"]
|
||||
return []
|
||||
|
||||
|
||||
def _named_widgets(node):
|
||||
"""Map widget values to input names, skipping control_after_generate."""
|
||||
named = _get(node, "widgets_values_named")
|
||||
if _truthy(named) and isinstance(named, dict):
|
||||
return dict(named)
|
||||
values = _widgets_of(node)
|
||||
out = {}
|
||||
position = 0
|
||||
for name in _iterate(_widget_names(node)):
|
||||
if position >= len(values):
|
||||
break
|
||||
_set(out, name, values[position])
|
||||
position += 1
|
||||
following = values[position] if position < len(values) else UNDEFINED
|
||||
if (
|
||||
_SEED_NAME.search(_js_string(name))
|
||||
and isinstance(following, str)
|
||||
and following in SEED_CONTROL_VALUES
|
||||
):
|
||||
position += 1
|
||||
return out
|
||||
|
||||
|
||||
def _workflow_to_graph(workflow):
|
||||
"""Convert a UI workflow ({nodes, links}) into the API graph shape.
|
||||
|
||||
Set/Get pairs, Reroutes and bypassed nodes resolve to their real source;
|
||||
muted nodes are dropped.
|
||||
"""
|
||||
nodes = [
|
||||
n
|
||||
for n in _workflow_nodes(workflow)
|
||||
if _truthy(n) and not _nullish(_get(n, "id"))
|
||||
]
|
||||
by_id = {}
|
||||
for node in nodes:
|
||||
by_id[_js_string(_get(node, "id"))] = node
|
||||
links = {}
|
||||
raw_links = _get(workflow, "links")
|
||||
for link in raw_links if isinstance(raw_links, list) else []:
|
||||
if isinstance(link, list):
|
||||
links[_same_value_key(_get(link, 0))] = (
|
||||
_get(link, 1),
|
||||
_get(link, 2),
|
||||
_get(link, 5),
|
||||
)
|
||||
elif _truthy(link) and _typeof(link) == "object":
|
||||
links[_same_value_key(_get(link, "id"))] = (
|
||||
_get(link, "origin_id"),
|
||||
_get(link, "origin_slot"),
|
||||
_get(link, "type"),
|
||||
)
|
||||
setters = {}
|
||||
for node in nodes:
|
||||
if _strict_equal(_get(node, "type"), "SetNode"):
|
||||
setters[_same_value_key(_get(_widgets_of(node), 0))] = _and_chain(
|
||||
node, "inputs", 0, "link"
|
||||
)
|
||||
|
||||
def source(link_id):
|
||||
hops = 0
|
||||
while True:
|
||||
link = links.get(_same_value_key(link_id))
|
||||
node = by_id.get(_js_string(link[0])) if link is not None else None
|
||||
if (
|
||||
node is None
|
||||
or _strict_equal(_get(node, "mode"), MODE_MUTED)
|
||||
or hops > MAX_VISITS
|
||||
):
|
||||
return UNDEFINED
|
||||
hops += 1
|
||||
node_type = _get(node, "type")
|
||||
if _strict_equal(node_type, "Reroute"):
|
||||
link_id = _and_chain(node, "inputs", 0, "link")
|
||||
continue
|
||||
if _strict_equal(node_type, "GetNode"):
|
||||
link_id = setters.get(
|
||||
_same_value_key(_get(_widgets_of(node), 0)), UNDEFINED
|
||||
)
|
||||
continue
|
||||
if _strict_equal(_get(node, "mode"), MODE_BYPASSED):
|
||||
link_type = link[2]
|
||||
same = next(
|
||||
(
|
||||
item
|
||||
for item in _array(_first_truthy(_get(node, "inputs"), []))
|
||||
if _truthy(item)
|
||||
and not _nullish(_get(item, "link"))
|
||||
and (
|
||||
not _truthy(link_type)
|
||||
or _strict_equal(link_type, "*")
|
||||
or _strict_equal(_get(item, "type"), link_type)
|
||||
)
|
||||
),
|
||||
UNDEFINED,
|
||||
)
|
||||
if same is UNDEFINED:
|
||||
return UNDEFINED
|
||||
link_id = _get(same, "link")
|
||||
continue
|
||||
return [_js_string(_get(node, "id")), link[1]]
|
||||
|
||||
graph = {}
|
||||
for node in nodes:
|
||||
mode = _get(node, "mode")
|
||||
node_type = _get(node, "type")
|
||||
if (
|
||||
_strict_equal(mode, MODE_MUTED)
|
||||
or _strict_equal(mode, MODE_BYPASSED)
|
||||
or (isinstance(node_type, str) and node_type in PASS_THROUGH_TYPES)
|
||||
):
|
||||
continue
|
||||
inputs = _named_widgets(node)
|
||||
for item in _iterate(_first_truthy(_get(node, "inputs"), [])):
|
||||
if not _truthy(item) or _nullish(_get(item, "link")):
|
||||
continue
|
||||
found = source(_get(item, "link"))
|
||||
if _truthy(found):
|
||||
_set(inputs, _get(item, "name"), found)
|
||||
_set(graph, _get(node, "id"), {"class_type": node_type, "inputs": inputs})
|
||||
return graph
|
||||
|
||||
|
||||
# --- Public API ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def _has_value(value):
|
||||
return not _nullish(value) and not (isinstance(value, str) and value == "")
|
||||
|
||||
|
||||
def extract_generation_params(data):
|
||||
"""Prompts, model and sampler settings from parsed ``prompt``/``workflow`` records.
|
||||
|
||||
``data`` holds the parsed records (``{"prompt": ..., "workflow": ...}``). Each
|
||||
field falls back to its placeholder when neither graph provides it.
|
||||
Raises ``GenerationParamsError`` where the browser parser throws.
|
||||
"""
|
||||
data = data if _truthy(data) else {}
|
||||
prompt = _get(data, "prompt")
|
||||
workflow = _get(data, "workflow")
|
||||
primary = {}
|
||||
if _truthy(prompt) and _typeof(prompt) == "object":
|
||||
primary = _run(_from_prompt_graph(prompt, workflow))
|
||||
fallback = {}
|
||||
if _truthy(workflow):
|
||||
fallback = _run(_from_prompt_graph(_workflow_to_graph(workflow), None))
|
||||
merged = {}
|
||||
for key, placeholder in PLACEHOLDERS.items():
|
||||
value = primary.get(key, UNDEFINED)
|
||||
if not _has_value(value):
|
||||
value = fallback.get(key, UNDEFINED)
|
||||
merged[key] = value if _has_value(value) else placeholder
|
||||
return merged
|
||||
|
||||
|
||||
def _reject_constant(name):
|
||||
raise ValueError(f"{name} is not JSON")
|
||||
|
||||
|
||||
def _double_integer(text):
|
||||
"""Integers parse as doubles in JavaScript; beyond 2**53 they round."""
|
||||
value = int(text)
|
||||
return value if -_MAX_SAFE_INTEGER <= value <= _MAX_SAFE_INTEGER else float(text)
|
||||
|
||||
|
||||
def parse_js_json(text):
|
||||
"""``JSON.parse(text)``, or None where it throws (or the text is ``null``)."""
|
||||
if not isinstance(text, str):
|
||||
return None
|
||||
try:
|
||||
return json.loads(
|
||||
text, parse_constant=_reject_constant, parse_int=_double_integer
|
||||
)
|
||||
except (ValueError, RecursionError):
|
||||
return None
|
||||
|
||||
|
||||
def extract_from_records(prompt_text, workflow_text):
|
||||
"""``extract_generation_params`` for raw ``prompt``/``workflow`` record text.
|
||||
|
||||
A record that is absent or fails to parse is treated as absent, as the
|
||||
browser pages do.
|
||||
"""
|
||||
data = {}
|
||||
for key, text in (("prompt", prompt_text), ("workflow", workflow_text)):
|
||||
if isinstance(text, str):
|
||||
data[key] = parse_js_json(text)
|
||||
return extract_generation_params(data)
|
||||
@@ -1,111 +0,0 @@
|
||||
"""User-facing lines for non-resolved prompt outcomes, from the closed vocabulary.
|
||||
|
||||
The inspector shows one line per unresolved role in place of the generic
|
||||
"Prompt could not be resolved." Ambiguous and absent roles keep their status
|
||||
copy, so a candidate is never presented as a confirmed prompt. The wire
|
||||
projection accepts exactly the closed ``reason_code`` vocabulary and names a
|
||||
blocking node only beside a code that names one.
|
||||
"""
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from .prompt_pair_resolver import (
|
||||
EMPTY_POSITIVE_REASON,
|
||||
MULTISTAGE_UNATTRIBUTED_REASON,
|
||||
SEMANTICS_UNAVAILABLE_REASON,
|
||||
)
|
||||
|
||||
REASON_CODES = frozenset(
|
||||
{
|
||||
"work-limit",
|
||||
"depth-limit",
|
||||
"text-limit",
|
||||
"graph-ceiling",
|
||||
"unsupported-node",
|
||||
"runtime-value",
|
||||
"composite",
|
||||
"no-sampler",
|
||||
"malformed",
|
||||
"multistage-differs",
|
||||
}
|
||||
)
|
||||
LIMIT_CODES = frozenset({"work-limit", "depth-limit", "text-limit", "graph-ceiling"})
|
||||
# Codes whose outcome names the blocking node: ``node: {id, class_type}``.
|
||||
NAMED_CODES = frozenset({"unsupported-node", "runtime-value", "composite"})
|
||||
# Bound for a node ID or class name on the wire and in a line.
|
||||
CLASS_NAME_LENGTH = 200
|
||||
# Bound for a role's line on the wire, in UTF-16 code units (the client's count).
|
||||
MESSAGE_LENGTH = 512
|
||||
UNREADABLE_METADATA_REASON = "Media metadata could not be read"
|
||||
_NODE_KEYS = frozenset({"id", "class_type"})
|
||||
|
||||
_NAMED_LINES = {
|
||||
"unsupported-node": "Prompt uses a node the inspector does not support",
|
||||
"runtime-value": "Prompt is chosen at run time",
|
||||
"composite": "Prompt combines several texts",
|
||||
}
|
||||
_CODED_LINES = {
|
||||
**dict.fromkeys(LIMIT_CODES, "Prompt exceeds inspection limits."),
|
||||
"no-sampler": "No supported sampler was found.",
|
||||
"malformed": "Prompt metadata in this file is malformed.",
|
||||
"multistage-differs": "Sampler stages have different prompts.",
|
||||
}
|
||||
# Uncoded outcomes, keyed by the resolver's exact reason.
|
||||
_UNCODED_LINES = {
|
||||
EMPTY_POSITIVE_REASON: "Positive prompt is empty.",
|
||||
MULTISTAGE_UNATTRIBUTED_REASON: (
|
||||
"Several sampler stages were found; their prompts were not compared."
|
||||
),
|
||||
SEMANTICS_UNAVAILABLE_REASON: "The inspector's node table is unavailable.",
|
||||
UNREADABLE_METADATA_REASON: "Metadata in this file could not be read.",
|
||||
}
|
||||
|
||||
|
||||
def _text(value: Any) -> Optional[str]:
|
||||
return value if isinstance(value, str) else None
|
||||
|
||||
|
||||
def _class_name(node: Any) -> Optional[str]:
|
||||
if not isinstance(node, dict):
|
||||
return None
|
||||
name = node.get("class_type")
|
||||
return name[:CLASS_NAME_LENGTH] if isinstance(name, str) and name else None
|
||||
|
||||
|
||||
def outcome_message(resolution: Any) -> Optional[str]:
|
||||
"""One line for an unresolved outcome, or ``None`` to keep the status copy."""
|
||||
if not isinstance(resolution, dict) or resolution.get("state") != "unresolved":
|
||||
return None
|
||||
code = _text(resolution.get("reason_code"))
|
||||
if code in _NAMED_LINES:
|
||||
name = _class_name(resolution.get("node"))
|
||||
return f"{_NAMED_LINES[code]} ({name})." if name else f"{_NAMED_LINES[code]}."
|
||||
if code is None:
|
||||
return _UNCODED_LINES.get(_text(resolution.get("reason")))
|
||||
return _CODED_LINES.get(code)
|
||||
|
||||
|
||||
def _node(code: Optional[str], node: Any) -> Optional[dict]:
|
||||
if code not in NAMED_CODES or not isinstance(node, dict) or set(node) != _NODE_KEYS:
|
||||
return None
|
||||
node_id, name = node["id"], node["class_type"]
|
||||
if not isinstance(node_id, str) or not node_id:
|
||||
return None
|
||||
if name is not None and not isinstance(name, str):
|
||||
return None
|
||||
return {
|
||||
"id": node_id[:CLASS_NAME_LENGTH],
|
||||
"class_type": name[:CLASS_NAME_LENGTH] if name else None,
|
||||
}
|
||||
|
||||
|
||||
def project_resolution(resolution: dict) -> dict:
|
||||
"""Fail closed: an unknown or contradictory code and its node are dropped."""
|
||||
code = _text(resolution.get("reason_code"))
|
||||
if code not in REASON_CODES or resolution.get("state") in ("resolved", "absent"):
|
||||
code = None
|
||||
return {
|
||||
**resolution,
|
||||
"reason_code": code,
|
||||
"node": _node(code, resolution.get("node")),
|
||||
}
|
||||
@@ -3,9 +3,7 @@
|
||||
import json
|
||||
import math
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import TYPE_CHECKING, Any, Literal, Optional
|
||||
|
||||
from .inspection_outcomes import MESSAGE_LENGTH, project_resolution
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .prompt_inspector import InspectionRead
|
||||
@@ -314,29 +312,6 @@ def _library_projection(library: dict) -> dict:
|
||||
return result
|
||||
|
||||
|
||||
def _bounded_message(value: Any) -> Optional[str]:
|
||||
"""A role line as the client accepts it: 1 to 512 UTF-16 code units."""
|
||||
if not isinstance(value, str) or not value:
|
||||
return None
|
||||
value = value[:MESSAGE_LENGTH]
|
||||
while len(value.encode("utf-16-le", "surrogatepass")) > 2 * MESSAGE_LENGTH:
|
||||
value = value[:-1]
|
||||
return value
|
||||
|
||||
|
||||
def _bounded_messages(prompts: dict) -> dict:
|
||||
"""Keep a role ``message`` only as bounded text on a non-resolved role."""
|
||||
bounded = {}
|
||||
for role, side in prompts.items():
|
||||
if isinstance(side, dict) and "message" in side:
|
||||
side = {key: item for key, item in side.items() if key != "message"}
|
||||
message = _bounded_message(prompts[role]["message"])
|
||||
if message is not None and side.get("status") != "resolved":
|
||||
side["message"] = message
|
||||
bounded[role] = side
|
||||
return bounded
|
||||
|
||||
|
||||
def _encode_inspection(
|
||||
*,
|
||||
identity: dict,
|
||||
@@ -353,8 +328,8 @@ def _encode_inspection(
|
||||
"inspection_version": 1,
|
||||
**identity,
|
||||
"file": _file_projection(file, inspection),
|
||||
"prompts": _bounded_messages(prompts),
|
||||
"resolution": project_resolution(resolution),
|
||||
"prompts": prompts,
|
||||
"resolution": resolution,
|
||||
"metadata": metadata,
|
||||
"library": _library_projection(library),
|
||||
}
|
||||
|
||||
@@ -1,492 +0,0 @@
|
||||
"""Load and validate the declarative inspection node semantics table (#240).
|
||||
|
||||
The table lives once in ``web/js/inspection-semantics.json`` so a browser-local
|
||||
resolver can load the same data. It maps node classes (or ``(class, output
|
||||
slot)`` pairs) to a named strategy from the closed ``STRATEGIES`` set plus the
|
||||
exact input names that strategy reads. Code implements each strategy; adding a
|
||||
node that reuses one is a data-only change.
|
||||
|
||||
Only the inspection resolver profile consumes this table, through
|
||||
``get_semantics()``, which reads and validates it on first use and caches only a
|
||||
valid result. Importing this module reads nothing, and the default (ingestion)
|
||||
profile never imports it, so a missing or bad table can never break ingestion.
|
||||
An invalid table raises ``SemanticsError`` so a bad edit fails loudly instead of
|
||||
silently changing what the inspector resolves. The file is bounded in size and
|
||||
nesting before it is validated. Every class appears in exactly one category;
|
||||
classes that are not listed stay ``unsupported-node``.
|
||||
"""
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from types import MappingProxyType
|
||||
from typing import Any, Callable, Dict, FrozenSet, Mapping, Optional, Tuple, Union
|
||||
|
||||
SEMANTICS_PATH = (
|
||||
Path(__file__).resolve().parents[1] / "web" / "js" / "inspection-semantics.json"
|
||||
)
|
||||
SCHEMA_VERSION = 1
|
||||
# The real table is a few KiB and four levels deep; these only reject nonsense.
|
||||
MAX_TABLE_BYTES = 1024 * 1024
|
||||
MAX_NESTING = 8
|
||||
_NAME_LENGTH = 128
|
||||
# Comfy output slots are small; this only rejects nonsense, never a real node.
|
||||
_MAX_SLOT = 255
|
||||
|
||||
STRATEGIES: Mapping[str, FrozenSet[str]] = MappingProxyType(
|
||||
{
|
||||
"samplers": frozenset({"ksampler", "custom-advanced", "detailer-stage"}),
|
||||
"guiders": frozenset({"cfg", "basic"}),
|
||||
"conditioning": frozenset({"passthrough", "zeroed"}),
|
||||
"selectors": frozenset({"boolean-select"}),
|
||||
"composites": frozenset({"combine"}),
|
||||
"encoders": frozenset({"single", "parts-equal", "pm-compose", "flag-parts"}),
|
||||
"strings": frozenset({"literal", "strip-newlines", "join-multi", "concat"}),
|
||||
"savers": frozenset({"comfy-counter", "vhs-counter"}),
|
||||
}
|
||||
)
|
||||
# Shapes of the facts each sampler class provides (consumed by comfy-metadata.js).
|
||||
FACT_SHAPES = frozenset({"ksampler", "custom", "custom-advanced"})
|
||||
SAVER_PATTERNS = MappingProxyType(
|
||||
{
|
||||
"comfy-counter": "{name}_{counter:05}_.{ext}",
|
||||
"vhs-counter": "{name}_{counter:05}.{ext}",
|
||||
}
|
||||
)
|
||||
_SAMPLER_OPTIONS = frozenset({"prompt_overrides", "fact_overrides"})
|
||||
_CONFIGURATION_KINDS = frozenset({"noise", "sampler", "sigmas", "guider"})
|
||||
_SCALAR_KINDS = frozenset({"number", "string"})
|
||||
_TYPE_FIELDS = frozenset({"type_fields"})
|
||||
_CLASS_CATEGORIES = ("samplers", "guiders", "selectors", "composites", "savers")
|
||||
_SLOT_CATEGORIES = ("conditioning", "encoders", "strings")
|
||||
_SET_CATEGORIES = ("runtime", "non_file_outputs")
|
||||
_FACTS_TABLES = (
|
||||
"configuration_outputs",
|
||||
"model_outputs",
|
||||
"model_passthroughs",
|
||||
"scalar_outputs",
|
||||
)
|
||||
_CATEGORIES = frozenset(
|
||||
("schema",)
|
||||
+ _CLASS_CATEGORIES
|
||||
+ _SLOT_CATEGORIES
|
||||
+ _SET_CATEGORIES
|
||||
+ ("stage_inputs", "facts")
|
||||
)
|
||||
# Input names that make an unlisted node a possible sampler stage in an ancestry
|
||||
# walk (``shaped``: pipes, video embeds and custom-sampler parts carry a prompt
|
||||
# without a ``positive`` link), and those that do so beside a linked ``model``
|
||||
# (``with_model``: XlabsSampler's ``conditioning``, video samplers' ``samples``).
|
||||
# ``model`` alone (ModelSampling*) or with ``clip`` (LoraLoader) is a model patch.
|
||||
# The resolver and comfy-metadata.js both read these lists.
|
||||
_STAGE_INPUT_LISTS = ("shaped", "with_model")
|
||||
_STRING_PARAMS = MappingProxyType(
|
||||
{
|
||||
"literal": frozenset(),
|
||||
"concat": frozenset(),
|
||||
"strip-newlines": frozenset({"flag"}),
|
||||
"join-multi": frozenset({"count", "delimiter", "list_flag", "input_prefix"}),
|
||||
}
|
||||
)
|
||||
|
||||
Frozen = Union[Mapping[str, Any], Tuple[Any, ...], str, int, None]
|
||||
|
||||
|
||||
class SemanticsError(ValueError):
|
||||
"""The semantics table is unreadable or violates its schema."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Semantics:
|
||||
"""Validated, deeply frozen semantics. Slot categories key by ``(class, slot)``."""
|
||||
|
||||
samplers: Mapping[str, Mapping[str, Any]]
|
||||
guiders: Mapping[str, Mapping[str, Any]]
|
||||
conditioning: Mapping[Tuple[str, int], Mapping[str, Any]]
|
||||
selectors: Mapping[str, Mapping[str, Any]]
|
||||
composites: Mapping[str, Mapping[str, Any]]
|
||||
encoders: Mapping[Tuple[str, int], Mapping[str, Any]]
|
||||
strings: Mapping[Tuple[str, int], Mapping[str, Any]]
|
||||
runtime: FrozenSet[str]
|
||||
savers: Mapping[str, Mapping[str, Any]]
|
||||
non_file_outputs: FrozenSet[str]
|
||||
stage_inputs: Mapping[str, Tuple[str, ...]]
|
||||
facts: Mapping[str, Any]
|
||||
all_classes: FrozenSet[str]
|
||||
|
||||
|
||||
def _freeze(value: Any) -> Frozen:
|
||||
if isinstance(value, dict):
|
||||
return MappingProxyType({key: _freeze(item) for key, item in value.items()})
|
||||
if isinstance(value, list):
|
||||
return tuple(_freeze(item) for item in value)
|
||||
return value
|
||||
|
||||
|
||||
def _reject_duplicate_keys(pairs: list) -> dict:
|
||||
result: Dict[str, Any] = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
raise SemanticsError(f"duplicate key {key!r}")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def _reject_constant(name: str) -> None:
|
||||
raise SemanticsError(f"non-finite number {name} is not allowed")
|
||||
|
||||
|
||||
def _name(value: Any, where: str, what: str = "input") -> str:
|
||||
if not isinstance(value, str) or not value or len(value) > _NAME_LENGTH:
|
||||
raise SemanticsError(f"{where}: missing or invalid {what} name {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
def _names(value: Any, where: str, minimum: int = 1) -> None:
|
||||
if not isinstance(value, list) or len(value) < minimum:
|
||||
raise SemanticsError(f"{where}: needs at least {minimum} input name(s)")
|
||||
for item in value:
|
||||
_name(item, where)
|
||||
if len(set(value)) != len(value):
|
||||
raise SemanticsError(f"{where}: repeated input name")
|
||||
|
||||
|
||||
def _fields(
|
||||
entry: Any, where: str, required: FrozenSet[str], optional: FrozenSet[str]
|
||||
) -> None:
|
||||
if not isinstance(entry, dict):
|
||||
raise SemanticsError(f"{where}: entry must be an object")
|
||||
missing = sorted(required - entry.keys())
|
||||
if missing:
|
||||
raise SemanticsError(f"{where}: missing input field(s) {missing}")
|
||||
unknown = sorted(entry.keys() - required - optional)
|
||||
if unknown:
|
||||
raise SemanticsError(f"{where}: unknown field(s) {unknown}")
|
||||
|
||||
|
||||
def _strategy(entry: Any, category: str, where: str) -> str:
|
||||
strategy = entry.get("strategy") if isinstance(entry, dict) else None
|
||||
if not isinstance(strategy, str) or strategy not in STRATEGIES[category]:
|
||||
raise SemanticsError(f"{where}: unknown strategy {strategy!r}")
|
||||
return strategy
|
||||
|
||||
|
||||
def _check_sampler(entry: dict, strategy: str, where: str) -> None:
|
||||
stage = "image_stage" if strategy == "detailer-stage" else "latent"
|
||||
_fields(
|
||||
entry,
|
||||
where,
|
||||
frozenset({"strategy", "conditioning", stage, "facts"}),
|
||||
_SAMPLER_OPTIONS,
|
||||
)
|
||||
_name(entry[stage], where)
|
||||
# Inputs that, when set, replace the prompt the sampler's conditioning
|
||||
# inputs carry (a detailer's wildcard); the prompt is then not read.
|
||||
# ``fact_overrides`` (read by comfy-metadata.js): inputs that, when set,
|
||||
# replace the settings the facts would read (a script or an XY plot).
|
||||
for option in sorted(_SAMPLER_OPTIONS & entry.keys()):
|
||||
_names(entry[option], f"{where}.{option}")
|
||||
keys = {"guider"} if strategy == "custom-advanced" else {"positive", "negative"}
|
||||
conditioning = entry["conditioning"]
|
||||
_fields(conditioning, where + ".conditioning", frozenset(keys), frozenset())
|
||||
for key in keys:
|
||||
_name(conditioning[key], where + ".conditioning")
|
||||
if entry["facts"] not in FACT_SHAPES:
|
||||
raise SemanticsError(f"{where}: unknown facts shape {entry['facts']!r}")
|
||||
|
||||
|
||||
def _check_guider(entry: dict, strategy: str, where: str) -> None:
|
||||
if strategy == "cfg":
|
||||
_fields(
|
||||
entry, where, frozenset({"strategy", "positive", "negative"}), frozenset()
|
||||
)
|
||||
_name(entry["negative"], where)
|
||||
else:
|
||||
# Negative absent by design; its reason is fixed text, not an input.
|
||||
_fields(
|
||||
entry,
|
||||
where,
|
||||
frozenset({"strategy", "positive"}),
|
||||
frozenset({"absent_reason"}),
|
||||
)
|
||||
if "absent_reason" in entry:
|
||||
_name(entry["absent_reason"], where, "absent reason")
|
||||
_name(entry["positive"], where)
|
||||
|
||||
|
||||
def _check_selector(entry: dict, strategy: str, where: str) -> None:
|
||||
_fields(entry, where, frozenset({"strategy", "condition", "branches"}), frozenset())
|
||||
_name(entry["condition"], where)
|
||||
branches = entry["branches"]
|
||||
_fields(branches, where + ".branches", frozenset({"true", "false"}), frozenset())
|
||||
_name(branches["true"], where)
|
||||
_name(branches["false"], where)
|
||||
|
||||
|
||||
def _check_composite(entry: dict, strategy: str, where: str) -> None:
|
||||
_fields(entry, where, frozenset({"strategy", "inputs"}), frozenset())
|
||||
_names(entry["inputs"], where, minimum=2)
|
||||
|
||||
|
||||
def _check_saver(entry: dict, strategy: str, where: str) -> None:
|
||||
_fields(
|
||||
entry,
|
||||
where,
|
||||
frozenset({"strategy", "prefix_input", "image_input", "pattern"}),
|
||||
frozenset(),
|
||||
)
|
||||
_name(entry["prefix_input"], where)
|
||||
_name(entry["image_input"], where)
|
||||
if entry["pattern"] != SAVER_PATTERNS[strategy]:
|
||||
raise SemanticsError(f"{where}: pattern does not match {strategy}")
|
||||
|
||||
|
||||
def _check_conditioning(entry: dict, strategy: str, where: str) -> None:
|
||||
_fields(entry, where, frozenset({"strategy", "input"}), frozenset())
|
||||
_name(entry["input"], where)
|
||||
|
||||
|
||||
def _check_encoder(entry: dict, strategy: str, where: str) -> None:
|
||||
if strategy == "flag-parts":
|
||||
# A literal boolean ``flag`` false reads the first input; true needs
|
||||
# every input equal (smZ CLIPTextEncode's with_SDXL).
|
||||
_fields(entry, where, frozenset({"strategy", "inputs", "params"}), frozenset())
|
||||
params = entry["params"]
|
||||
if not isinstance(params, dict) or set(params) != {"flag"}:
|
||||
raise SemanticsError(f"{where}: params must be exactly ['flag']")
|
||||
_name(params["flag"], where + ".params")
|
||||
_names(entry["inputs"], where, minimum=2)
|
||||
return
|
||||
_fields(entry, where, frozenset({"strategy", "inputs"}), frozenset({"compose"}))
|
||||
_names(entry["inputs"], where, minimum=2 if strategy == "parts-equal" else 1)
|
||||
if "compose" in entry and not isinstance(entry["compose"], bool):
|
||||
raise SemanticsError(f"{where}: compose must be a boolean")
|
||||
|
||||
|
||||
def _check_string(entry: dict, strategy: str, where: str) -> None:
|
||||
required = _STRING_PARAMS[strategy]
|
||||
if required:
|
||||
_fields(entry, where, frozenset({"strategy", "inputs", "params"}), frozenset())
|
||||
params = entry["params"]
|
||||
if not isinstance(params, dict) or set(params) != required:
|
||||
raise SemanticsError(f"{where}: params must be exactly {sorted(required)}")
|
||||
for value in params.values():
|
||||
_name(value, where + ".params")
|
||||
else:
|
||||
_fields(entry, where, frozenset({"strategy", "inputs"}), frozenset())
|
||||
_names(entry["inputs"], where)
|
||||
|
||||
|
||||
_CHECKS: Mapping[str, Callable[[dict, str, str], None]] = MappingProxyType(
|
||||
{
|
||||
"samplers": _check_sampler,
|
||||
"guiders": _check_guider,
|
||||
"selectors": _check_selector,
|
||||
"composites": _check_composite,
|
||||
"savers": _check_saver,
|
||||
"conditioning": _check_conditioning,
|
||||
"encoders": _check_encoder,
|
||||
"strings": _check_string,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class _Owners:
|
||||
"""Each class belongs to one category; each (class, slot) is listed once."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._category: Dict[str, str] = {}
|
||||
self._outputs: set = set()
|
||||
|
||||
def claim(self, name: str, category: str, slot: Optional[int] = None) -> None:
|
||||
owner = self._category.setdefault(name, category)
|
||||
if owner != category:
|
||||
raise SemanticsError(f"duplicate class {name!r} in {owner} and {category}")
|
||||
key = (name, slot)
|
||||
if slot is not None and key in self._outputs:
|
||||
raise SemanticsError(f"{category}: duplicate output key {key!r}")
|
||||
self._outputs.add(key)
|
||||
|
||||
def classes(self) -> FrozenSet[str]:
|
||||
return frozenset(self._category)
|
||||
|
||||
|
||||
def _class_category(doc: dict, category: str, owners: _Owners) -> Mapping:
|
||||
table = doc[category]
|
||||
if not isinstance(table, dict):
|
||||
raise SemanticsError(f"category {category} must be an object")
|
||||
result = {}
|
||||
for name, entry in table.items():
|
||||
where = f"{category}[{name!r}]"
|
||||
_name(name, where, "class")
|
||||
strategy = _strategy(entry, category, where)
|
||||
_CHECKS[category](entry, strategy, where)
|
||||
owners.claim(name, category)
|
||||
result[name] = _freeze(entry)
|
||||
return MappingProxyType(result)
|
||||
|
||||
|
||||
def _slot(entry: dict, where: str) -> int:
|
||||
slot = entry.get("slot")
|
||||
# bool is an int subclass; a JSON true is not an output slot.
|
||||
if type(slot) is not int or not 0 <= slot <= _MAX_SLOT:
|
||||
raise SemanticsError(f"{where}: slot must be a non-negative int, not {slot!r}")
|
||||
return slot
|
||||
|
||||
|
||||
def _slot_category(doc: dict, category: str, owners: _Owners) -> Mapping:
|
||||
rows = doc[category]
|
||||
if not isinstance(rows, list):
|
||||
raise SemanticsError(f"category {category} must be a list")
|
||||
result = {}
|
||||
for index, row in enumerate(rows):
|
||||
where = f"{category}[{index}]"
|
||||
if not isinstance(row, dict):
|
||||
raise SemanticsError(f"{where}: entry must be an object")
|
||||
name = _name(row.get("class"), where, "class")
|
||||
slot = _slot(row, where)
|
||||
entry = {
|
||||
key: value for key, value in row.items() if key not in ("class", "slot")
|
||||
}
|
||||
strategy = _strategy(entry, category, where)
|
||||
_CHECKS[category](entry, strategy, where)
|
||||
owners.claim(name, category, slot)
|
||||
result[(name, slot)] = _freeze(entry)
|
||||
return MappingProxyType(result)
|
||||
|
||||
|
||||
def _set_category(doc: dict, category: str, owners: _Owners) -> FrozenSet[str]:
|
||||
rows = doc[category]
|
||||
if not isinstance(rows, list):
|
||||
raise SemanticsError(f"category {category} must be a list")
|
||||
seen: set = set()
|
||||
for name in rows:
|
||||
_name(name, category, "class")
|
||||
if name in seen:
|
||||
raise SemanticsError(f"{category}: duplicate class {name!r}")
|
||||
seen.add(name)
|
||||
owners.claim(name, category)
|
||||
return frozenset(seen)
|
||||
|
||||
|
||||
def _string_map(table: Any, where: str, allowed: Optional[FrozenSet[str]]) -> None:
|
||||
if not isinstance(table, dict):
|
||||
raise SemanticsError(f"{where} must be an object")
|
||||
for name, value in table.items():
|
||||
_name(name, where, "class")
|
||||
_name(value, where)
|
||||
if allowed is not None and value not in allowed:
|
||||
raise SemanticsError(f"{where}[{name!r}]: unknown kind {value!r}")
|
||||
|
||||
|
||||
def _stage_inputs(doc: dict) -> Mapping[str, Tuple[str, ...]]:
|
||||
table = doc["stage_inputs"]
|
||||
if not isinstance(table, dict) or set(table) != set(_STAGE_INPUT_LISTS):
|
||||
raise SemanticsError(
|
||||
f"stage_inputs must have exactly {list(_STAGE_INPUT_LISTS)}"
|
||||
)
|
||||
for key in _STAGE_INPUT_LISTS:
|
||||
_names(table[key], f"stage_inputs.{key}")
|
||||
if "model" in table[key]:
|
||||
raise SemanticsError(f"stage_inputs.{key}: model is a patch input")
|
||||
if set(table["shaped"]) & set(table["with_model"]):
|
||||
raise SemanticsError("stage_inputs: an input is listed in both lists")
|
||||
return _freeze(table)
|
||||
|
||||
|
||||
def _facts(doc: dict) -> Mapping[str, Any]:
|
||||
facts = doc["facts"]
|
||||
if not isinstance(facts, dict) or set(facts) != set(_FACTS_TABLES):
|
||||
raise SemanticsError(f"facts must have exactly {list(_FACTS_TABLES)}")
|
||||
configuration = facts["configuration_outputs"]
|
||||
if not isinstance(configuration, dict):
|
||||
raise SemanticsError("facts.configuration_outputs must be an object")
|
||||
for name, adapter in configuration.items():
|
||||
where = f"facts.configuration_outputs[{name!r}]"
|
||||
_name(name, where, "class")
|
||||
_fields(adapter, where, frozenset({"kind", "fields"}), _TYPE_FIELDS)
|
||||
if adapter["kind"] not in _CONFIGURATION_KINDS:
|
||||
raise SemanticsError(f"{where}: unknown kind {adapter['kind']!r}")
|
||||
_names(adapter["fields"], where)
|
||||
if "type_fields" in adapter:
|
||||
# A fact whose value is the provider's node type (D8: Flux2Scheduler).
|
||||
_names(adapter["type_fields"], where)
|
||||
if set(adapter["type_fields"]) & set(adapter["fields"]):
|
||||
raise SemanticsError(f"{where}: type_fields repeat an input field")
|
||||
_string_map(facts["model_outputs"], "facts.model_outputs", None)
|
||||
_string_map(facts["scalar_outputs"], "facts.scalar_outputs", _SCALAR_KINDS)
|
||||
passthroughs = facts["model_passthroughs"]
|
||||
_names(passthroughs, "facts.model_passthroughs", minimum=0)
|
||||
return _freeze(facts)
|
||||
|
||||
|
||||
def _check_nesting(doc: Any) -> None:
|
||||
"""Reject containers nested deeper than ``MAX_NESTING`` (iterative walk)."""
|
||||
stack = [(doc, 1)]
|
||||
while stack:
|
||||
value, level = stack.pop()
|
||||
if not isinstance(value, (dict, list)):
|
||||
continue
|
||||
if level > MAX_NESTING:
|
||||
raise SemanticsError(f"semantics table nests deeper than {MAX_NESTING}")
|
||||
items = value.values() if isinstance(value, dict) else value
|
||||
stack.extend((item, level + 1) for item in items)
|
||||
|
||||
|
||||
def _validate(doc: Any) -> Semantics:
|
||||
if not isinstance(doc, dict):
|
||||
raise SemanticsError("semantics table must be an object")
|
||||
if set(doc) != _CATEGORIES:
|
||||
missing = sorted(_CATEGORIES - doc.keys())
|
||||
unknown = sorted(doc.keys() - _CATEGORIES)
|
||||
raise SemanticsError(f"category mismatch: missing {missing}, unknown {unknown}")
|
||||
if type(doc["schema"]) is not int or doc["schema"] != SCHEMA_VERSION:
|
||||
raise SemanticsError(f"unsupported schema {doc['schema']!r}")
|
||||
owners = _Owners()
|
||||
tables: Dict[str, Any] = {}
|
||||
for category in _CLASS_CATEGORIES:
|
||||
tables[category] = _class_category(doc, category, owners)
|
||||
for category in _SLOT_CATEGORIES:
|
||||
tables[category] = _slot_category(doc, category, owners)
|
||||
for category in _SET_CATEGORIES:
|
||||
tables[category] = _set_category(doc, category, owners)
|
||||
return Semantics(
|
||||
stage_inputs=_stage_inputs(doc),
|
||||
facts=_facts(doc),
|
||||
all_classes=owners.classes(),
|
||||
**tables,
|
||||
)
|
||||
|
||||
|
||||
def load_semantics(path: Optional[Union[str, Path]] = None) -> Semantics:
|
||||
"""Read and validate a semantics table; ``SemanticsError`` on any problem."""
|
||||
source = Path(path) if path is not None else SEMANTICS_PATH
|
||||
try:
|
||||
with source.open("rb") as stream:
|
||||
data = stream.read(MAX_TABLE_BYTES + 1)
|
||||
if len(data) > MAX_TABLE_BYTES:
|
||||
raise SemanticsError(
|
||||
f"semantics table {source.name} exceeds {MAX_TABLE_BYTES} bytes"
|
||||
)
|
||||
doc = json.loads(
|
||||
data.decode("utf-8"),
|
||||
object_pairs_hook=_reject_duplicate_keys,
|
||||
parse_constant=_reject_constant,
|
||||
)
|
||||
except SemanticsError:
|
||||
raise
|
||||
except RecursionError:
|
||||
raise SemanticsError(
|
||||
f"semantics table {source.name} nests deeper than {MAX_NESTING}"
|
||||
) from None
|
||||
except (OSError, UnicodeDecodeError, ValueError) as error:
|
||||
raise SemanticsError(f"cannot read semantics table {source.name}: {error}")
|
||||
_check_nesting(doc)
|
||||
return _validate(doc)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_semantics() -> Semantics:
|
||||
"""The repository table, loaded on first use; failures are not cached."""
|
||||
return load_semantics()
|
||||
+21
-139
@@ -3,7 +3,6 @@
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import struct
|
||||
import subprocess # nosec B404
|
||||
import threading
|
||||
@@ -14,11 +13,8 @@ from typing import Any
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from .inspection_outcomes import UNREADABLE_METADATA_REASON, outcome_message
|
||||
from .pair_execution import EXECUTION_KEY
|
||||
from .prompt_pair_resolver import (
|
||||
_FOOTER,
|
||||
EMPTY_POSITIVE_REASON,
|
||||
INSPECTION_PROFILE,
|
||||
ResolverProfile,
|
||||
resolve_prompt_pair,
|
||||
@@ -43,23 +39,6 @@ MAX_PNG_CHUNKS = 10_000
|
||||
_ROLE_METADATA_KEYS = frozenset({"prompt", "parameters", EXECUTION_KEY})
|
||||
_METADATA_KEYS = _ROLE_METADATA_KEYS | {"workflow"}
|
||||
_TEXT_CHUNKS = (b"tEXt", b"zTXt", b"iTXt")
|
||||
# An accumulating batch saver writes one A1111 ``parameters`` record per batch
|
||||
# item, identical except this field (R3); Pillow keeps the last one.
|
||||
# Matched only inside the record's one ``Steps: N, ...`` settings footer line.
|
||||
_BATCH_INDEX = re.compile(r"(?:^|(?<=, ))Batch index: \d+(?=, |$)")
|
||||
|
||||
|
||||
def _batch_normalized(text: str) -> str | None:
|
||||
"""``text`` with its footer's single batch index blanked, else ``None``."""
|
||||
footers = list(_FOOTER.finditer(text))
|
||||
if len(footers) != 1:
|
||||
return None
|
||||
line, count = _BATCH_INDEX.subn("Batch index: #", footers[0].group())
|
||||
if count != 1:
|
||||
return None
|
||||
return text[: footers[0].start()] + line + text[footers[0].end() :]
|
||||
|
||||
|
||||
_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
||||
|
||||
|
||||
@@ -142,14 +121,13 @@ class _PngRecords:
|
||||
self.digests: dict[str, tuple[int, bytes]] = {}
|
||||
self.role_encoded = self.role_decoded = 0
|
||||
self.duplicate_workflow = self.duplicate_role = 0
|
||||
self.duplicate_decoded = 0
|
||||
|
||||
def skip(self, key: str, length: int) -> bool:
|
||||
"""Decide from the keyword and length alone whether to seek past a chunk."""
|
||||
if key not in _METADATA_KEYS or key in self.limited:
|
||||
return True # never retained, or a duplicate of a limited workflow
|
||||
if key in self.digests:
|
||||
if self.digests[key][0] != length and key != "parameters":
|
||||
if self.digests[key][0] != length:
|
||||
raise MetadataReadError("Duplicate prompt metadata")
|
||||
if key == "workflow":
|
||||
if self.duplicate_workflow + length > DUPLICATE_WORKFLOW_BYTES:
|
||||
@@ -177,11 +155,7 @@ class _PngRecords:
|
||||
digest = hashlib.sha256(kind + data).digest()
|
||||
if key in self.digests:
|
||||
if self.digests[key][1] != digest:
|
||||
if key != "parameters":
|
||||
raise MetadataReadError("Duplicate prompt metadata")
|
||||
self.batch_copy(kind, data)
|
||||
# The kept copy is the last one; later repeats compare with it.
|
||||
self.digests[key] = (len(data), digest)
|
||||
raise MetadataReadError("Duplicate prompt metadata")
|
||||
return
|
||||
self.digests[key] = (len(data), digest)
|
||||
workflow = key == "workflow"
|
||||
@@ -202,27 +176,6 @@ class _PngRecords:
|
||||
raise MetadataReadError("Metadata exceeds inspection limits")
|
||||
self.found[key] = value
|
||||
|
||||
def batch_copy(self, kind: bytes, data: bytes) -> None:
|
||||
"""Keep a later ``parameters`` copy that differs only in its batch index.
|
||||
|
||||
Each copy must carry exactly one ``Batch index: N`` settings field; any
|
||||
other difference is still a conflict. Decoding every copy draws from the
|
||||
duplicate role budget, so small compressed copies stay bounded.
|
||||
"""
|
||||
_, value = _png_text(kind, data, ROLE_METADATA_BYTES)
|
||||
size = ROLE_METADATA_BYTES + 1 if value is None else len(value.encode())
|
||||
self.duplicate_decoded += size
|
||||
if value is None or self.duplicate_decoded > DUPLICATE_ROLE_BYTES:
|
||||
raise MetadataReadError("Metadata exceeds inspection limits")
|
||||
kept = self.found["parameters"]
|
||||
normalized = _batch_normalized(kept)
|
||||
if normalized is None or normalized != _batch_normalized(value):
|
||||
raise MetadataReadError("Duplicate prompt metadata")
|
||||
self.role_decoded += max(0, size - len(kept.encode()))
|
||||
if self.role_decoded > ROLE_METADATA_BYTES:
|
||||
raise MetadataReadError("Metadata exceeds inspection limits")
|
||||
self.found["parameters"] = value
|
||||
|
||||
|
||||
def _png_metadata(stream: Any, size: int) -> tuple[dict, frozenset, int, int]:
|
||||
"""CRC-check retained text and structural chunks, seeking past everything else.
|
||||
@@ -447,70 +400,6 @@ def _side(status: str, text: str | None = None, source: str | None = None) -> di
|
||||
return {"status": status, "text": text, "source": source}
|
||||
|
||||
|
||||
_EMPTY_POSITIVE = outcome_message(
|
||||
{"state": "unresolved", "reason": EMPTY_POSITIVE_REASON}
|
||||
)
|
||||
|
||||
|
||||
def _explained(prompts: dict, resolution: dict) -> dict:
|
||||
"""Each unresolved role carries the outcome's line; other roles are kept."""
|
||||
message = outcome_message(resolution)
|
||||
if message is None:
|
||||
return prompts
|
||||
return {
|
||||
role: (
|
||||
{**side, "message": message}
|
||||
if side["status"] == "unresolved" and "message" not in side
|
||||
else side
|
||||
)
|
||||
for role, side in prompts.items()
|
||||
}
|
||||
|
||||
|
||||
def unreadable_evidence() -> dict:
|
||||
"""Both roles when the file's metadata could not be read at all."""
|
||||
resolution = {
|
||||
"state": "unresolved",
|
||||
"sampler_id": None,
|
||||
"reason": UNREADABLE_METADATA_REASON,
|
||||
"reason_code": None,
|
||||
"node": None,
|
||||
}
|
||||
prompts = {role: _side("unresolved") for role in ("positive", "negative")}
|
||||
return {"prompts": _explained(prompts, resolution), "resolution": resolution}
|
||||
|
||||
|
||||
def _may_hold_prompt_manager(metadata: dict) -> bool:
|
||||
"""Whether the API graph can show the R1 signature, so the workflow is needed.
|
||||
|
||||
PromptManager classes, or a ``CLIPTextEncode`` carrying PromptManager's own
|
||||
``search_text`` input (an older save rewrote the class).
|
||||
"""
|
||||
graph = metadata.get("prompt")
|
||||
if not isinstance(graph, dict):
|
||||
return False
|
||||
for node in graph.values():
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
name = node.get("class_type")
|
||||
inputs = node.get("inputs")
|
||||
if isinstance(name, str) and "promptmanager" in name.lower():
|
||||
return True
|
||||
if isinstance(inputs, dict) and "search_text" in inputs:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _parsed_workflow(value: Any) -> dict | None:
|
||||
"""The UI workflow as an object, or ``None`` (absent, invalid or not JSON)."""
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
value = json.loads(value)
|
||||
except (ValueError, RecursionError):
|
||||
return None
|
||||
return value if isinstance(value, dict) else None
|
||||
|
||||
|
||||
def inspect_prompt_evidence(
|
||||
metadata: dict,
|
||||
saved: dict | None = None,
|
||||
@@ -524,7 +413,6 @@ def inspect_prompt_evidence(
|
||||
directly with its default profile. ``resolution`` carries the closed
|
||||
``reason_code`` and, for a blocking node, ``node: {id, class_type}``.
|
||||
"""
|
||||
workflow = metadata.get("workflow")
|
||||
metadata = {
|
||||
key: value for key, value in metadata.items() if key in _ROLE_METADATA_KEYS
|
||||
}
|
||||
@@ -559,26 +447,21 @@ def inspect_prompt_evidence(
|
||||
},
|
||||
}
|
||||
canonical = dict(metadata)
|
||||
malformed = {
|
||||
"prompts": _explained(
|
||||
{role: _side("unresolved") for role in ("positive", "negative")},
|
||||
resolution,
|
||||
),
|
||||
"resolution": resolution,
|
||||
}
|
||||
if "prompt" not in canonical and "parameters" not in canonical:
|
||||
return malformed
|
||||
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 malformed
|
||||
workflow = (
|
||||
_parsed_workflow(workflow) if _may_hold_prompt_manager(canonical) else None
|
||||
)
|
||||
if workflow is not None and "prompt" in canonical:
|
||||
# Only to recognize overwritten PromptManager text (R1); never a role.
|
||||
canonical["workflow"] = workflow
|
||||
return {
|
||||
"prompts": {
|
||||
role: _side("unresolved") for role in ("positive", "negative")
|
||||
},
|
||||
"resolution": resolution,
|
||||
}
|
||||
resolved = resolve_prompt_pair(canonical, profile=profile)
|
||||
state = resolved["state"]
|
||||
result = {}
|
||||
@@ -592,19 +475,18 @@ def inspect_prompt_evidence(
|
||||
result[role] = _side(
|
||||
"resolved", side["text"], side["provenance"]["text_source"]
|
||||
)
|
||||
elif state == "resolved" and isinstance(side, dict):
|
||||
# A resolved pair withholds only an empty positive.
|
||||
result[role] = {**_side("unresolved"), "message": _EMPTY_POSITIVE}
|
||||
else:
|
||||
result[role] = _side(state if state != "resolved" else "unresolved")
|
||||
resolution = {
|
||||
"state": state,
|
||||
"sampler_id": resolved.get("sampler_id"),
|
||||
"reason": resolved.get("reason", ""),
|
||||
"reason_code": resolved.get("reason_code"),
|
||||
"node": resolved.get("node"),
|
||||
return {
|
||||
"prompts": result,
|
||||
"resolution": {
|
||||
"state": state,
|
||||
"sampler_id": resolved.get("sampler_id"),
|
||||
"reason": resolved.get("reason", ""),
|
||||
"reason_code": resolved.get("reason_code"),
|
||||
"node": resolved.get("node"),
|
||||
},
|
||||
}
|
||||
return {"prompts": _explained(result, resolution), "resolution": resolution}
|
||||
|
||||
|
||||
def inspect_prompt_roles(metadata: dict, saved: dict | None = None) -> dict:
|
||||
|
||||
+45
-1121
File diff suppressed because it is too large
Load Diff
+20
-216
@@ -403,53 +403,8 @@
|
||||
}
|
||||
|
||||
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");
|
||||
// These tables must equal inspection-semantics.json: SAMPLERS its `samplers` section
|
||||
// (facts shape, and a detailer stage's image input), the other four its `facts`
|
||||
// section (tests/js/inspection-semantics-sync.test.js). They stay inline literals
|
||||
// because the legacy pages load this file as a classic script.
|
||||
const SAMPLERS = Object.freeze({
|
||||
KSampler: { facts: "ksampler" },
|
||||
KSamplerAdvanced: { facts: "ksampler" },
|
||||
SamplerCustom: { facts: "custom" },
|
||||
SamplerCustomAdvanced: { facts: "custom-advanced" },
|
||||
FaceDetailer: { facts: "ksampler", image_stage: "image" },
|
||||
DetailerForEach: { facts: "ksampler", image_stage: "image" },
|
||||
UltimateSDUpscale: { facts: "ksampler", image_stage: "image" },
|
||||
UltimateSDUpscaleNoUpscale: { facts: "ksampler", image_stage: "upscaled_image" },
|
||||
// `fact_overrides`: inputs that, when set, replace the settings read here.
|
||||
ClownsharKSampler_Beta: { facts: "ksampler", fact_overrides: ["options", "sigmas"] },
|
||||
"KSampler (Efficient)": { facts: "ksampler", fact_overrides: ["script"] },
|
||||
"ttN KSampler_v2": { facts: "ksampler", fact_overrides: ["adv_xyPlot"] },
|
||||
});
|
||||
// Stages merged for one result: the resolver's candidate cap. Together with
|
||||
// MAX_VISITS per candidate walk these are deliberately tighter than the server's
|
||||
// budgets (2,000 visits, depth 64), so the JS walk always fails closed first.
|
||||
const MAX_STAGES = 32;
|
||||
// Every class the semantics table lists (the resolver's `all_classes`): a stage walk
|
||||
// passes these, and treats any other node shaped like a sampler as an unknown stage.
|
||||
const TABLE_CLASSES = new Set([
|
||||
"KSampler", "KSamplerAdvanced", "SamplerCustom", "SamplerCustomAdvanced", "FaceDetailer",
|
||||
"CFGGuider", "BasicGuider", "easy ifElse", "ConditioningCombine", "ConditioningConcat",
|
||||
"ConditioningAverage", "SaveImage", "SaveAnimatedWEBP", "SaveAnimatedPNG", "SaveVideo",
|
||||
"SaveWEBM", "VHS_VideoCombine", "FluxGuidance", "ReferenceLatent", "ConditioningSetArea",
|
||||
"ConditioningSetAreaPercentage", "ConditioningSetAreaStrength", "ConditioningSetMask", "ConditioningSetTimestepRange", "ControlNetApply",
|
||||
"ControlNetApplyAdvanced", "ControlNetApplySD3", "InpaintModelConditioning", "WanImageToVideo", "ConditioningZeroOut",
|
||||
"CLIPTextEncode", "CLIPTextEncodeSDXL", "CLIPTextEncodeFlux", "CLIPTextEncodeSD3", "TextEncodeQwenImageEdit",
|
||||
"TextEncodeQwenImageEditPlus", "TextEncodeQwenImage21", "KikoTextEncode", "StringConstant", "StringConstantMultiline",
|
||||
"JoinStringMulti", "PromptSearchList", "PreviewImage", "PreviewAny", "SaveImageWebsocket",
|
||||
"Image Comparer (rgthree)", "DetailerForEach", "UltimateSDUpscale", "UltimateSDUpscaleNoUpscale",
|
||||
"ControlNetInpaintingAliMamaApply", "LTXVConditioning", "FluxKontextMultiReferenceLatentMethod", "Text to Conditioning",
|
||||
"TextInput", "DisplayText", "ClownsharKSampler_Beta", "KSampler (Efficient)", "ttN KSampler_v2",
|
||||
"SeedVarianceEnhancer", "ApplyInstantIDAdvanced", "Switch conditioning [Crystools]", "smZ CLIPTextEncode", "ttN text",
|
||||
"CR Text", "CR Prompt Text", "TextBoxMira",
|
||||
]);
|
||||
// The table's `stage_inputs` (the resolver reads the same lists): inputs that make an
|
||||
// unlisted node a possible stage, and those that do so beside a linked `model`.
|
||||
const STAGE_INPUTS = Object.freeze({
|
||||
shaped: ["latent_image", "guider", "positive", "negative", "pipe", "basic_pipe", "detailer_pipe", "sampler", "sigmas", "noise", "text_embeds", "hyvid_embeds"],
|
||||
with_model: ["conditioning", "neg_conditioning", "samples", "latent"],
|
||||
});
|
||||
// Fixed output0 contracts from the pinned core node schemas, not input-name inference.
|
||||
const SCALAR_OUTPUTS = Object.freeze({
|
||||
PrimitiveInt: "number", PrimitiveFloat: "number",
|
||||
@@ -459,17 +414,11 @@
|
||||
CheckpointLoader: "ckpt_name", CheckpointLoaderSimple: "ckpt_name",
|
||||
unCLIPCheckpointLoader: "ckpt_name", UNETLoader: "unet_name",
|
||||
});
|
||||
const MODEL_PASSTHROUGHS = new Set([
|
||||
"LoraLoader", "LoraLoaderModelOnly", "ModelSamplingFlux", "ModelSamplingAuraFlow", "ModelSamplingSD3",
|
||||
"Lora Loader (LoraManager)", "Power Lora Loader (rgthree)", "ModelPatchTorchSettings", "CFGNorm", "PerturbedAttentionGuidance",
|
||||
"CR Apply LoRA Stack",
|
||||
]);
|
||||
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"] },
|
||||
// D8: no scheduler input; the node type is the scheduler configuration.
|
||||
Flux2Scheduler: { kind: "sigmas", fields: ["steps"], type_fields: ["scheduler"] },
|
||||
CFGGuider: { kind: "guider", fields: ["cfg", "model"] },
|
||||
BasicGuider: { kind: "guider", fields: ["model"] },
|
||||
});
|
||||
@@ -514,85 +463,24 @@
|
||||
const seen = new Set();
|
||||
const [, node, adapter] = configurationOutput(ctx, link, kind, seen);
|
||||
if (!node) return fact("unresolved", source, samplerId);
|
||||
if (keys.some(name => adapter.type_fields?.includes(name))) {
|
||||
const value = exactFactValue(node.class_type, field);
|
||||
return fact(value === null ? "unresolved" : "known", source, samplerId, value);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
const samplerEntry = (graph, id) => (Object.hasOwn(graph, id) && Object.hasOwn(SAMPLERS, graph[id].class_type) ? SAMPLERS[graph[id].class_type] : undefined);
|
||||
|
||||
// The resolver's `_link`: an integer or string id and a non-negative integer slot.
|
||||
// Stage walks use it so they cross exactly the server's edges; the legacy `isLink` stays.
|
||||
const stageLink = value => Array.isArray(value) && value.length === 2 &&
|
||||
(typeof value[0] === "string" || Number.isInteger(value[0])) && Number.isInteger(value[1]) && value[1] >= 0;
|
||||
const linked = (inputs, key) => Object.hasOwn(inputs, key) && stageLink(inputs[key]);
|
||||
/** An unlisted node that may itself be a sampler stage: the walk cannot judge it. */
|
||||
const stageShaped = ({ class_type, inputs }) => !TABLE_CLASSES.has(class_type) &&
|
||||
(STAGE_INPUTS.shaped.some(key => linked(inputs, key)) || (linked(inputs, "model") && STAGE_INPUTS.with_model.some(key => linked(inputs, key))));
|
||||
|
||||
/** Samplers reached from a detailer's image input, breadth-first, stopping at listed samplers. */
|
||||
function upstreamStages(graph, origin, value, budget) {
|
||||
if (!stageLink(value)) return null;
|
||||
const found = [], queue = [String(value[0])], queued = new Set(queue);
|
||||
for (let head = 0; head < queue.length; head++) {
|
||||
const id = queue[head];
|
||||
if (id === origin || ++budget.visits > MAX_VISITS || !Object.hasOwn(graph, id)) return null;
|
||||
if (samplerEntry(graph, id)) { found.push(id); continue; }
|
||||
if (stageShaped(graph[id])) return null;
|
||||
for (const input of Object.values(graph[id].inputs)) {
|
||||
const next = stageLink(input) ? String(input[0]) : null;
|
||||
if (next !== null && !queued.has(next)) { queued.add(next); queue.push(next); }
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect a final stage and every stage in its image ancestry into `stages`, as the
|
||||
* resolver judges a detailer chain. `later` holds the detailers already being judged
|
||||
* below this one, so a cyclic chain fails instead of recursing. False when the chain
|
||||
* cannot be walked within the bounds.
|
||||
*/
|
||||
// `stages` is shared on purpose across one result's candidates: re-adding a stage is
|
||||
// harmless, and MAX_STAGES caps the union, not each candidate.
|
||||
function collectStages(graph, id, later, stages, budget) {
|
||||
stages.add(id);
|
||||
if (stages.size > MAX_STAGES || later.size >= MAX_STAGES) return false;
|
||||
const entry = samplerEntry(graph, id);
|
||||
if (entry.image_stage === undefined) return true;
|
||||
const inputs = graph[id].inputs;
|
||||
const upstream = upstreamStages(graph, id, Object.hasOwn(inputs, entry.image_stage) ? inputs[entry.image_stage] : undefined, budget);
|
||||
if (upstream === null || upstream.some(stage => later.has(stage))) return false;
|
||||
const below = new Set(later).add(id);
|
||||
return upstream.every(stage => collectStages(graph, stage, below, stages, budget));
|
||||
}
|
||||
|
||||
/** Spec section 10 merge: equal known values stay known, differing ones are ambiguous. */
|
||||
function mergeFacts(sets, source) {
|
||||
if (sets.length === 1) return sets[0];
|
||||
return Object.fromEntries(FACT_FIELDS.map(field => {
|
||||
const facts = sets.map(set => set[field]);
|
||||
if (new Set(facts.filter(item => item.status === "known").map(item => item.value)).size > 1) return [field, fact("ambiguous", source)];
|
||||
const [first] = facts;
|
||||
const agreed = facts.every(item => item.status === first.status && item.value === first.value);
|
||||
// A merged fact names no single sampler: it is every stage's value.
|
||||
return [field, agreed ? fact(first.status, source, null, first.value, first.reason) : fact("unresolved", source)];
|
||||
}));
|
||||
}
|
||||
|
||||
function samplerFacts(ctx, samplerId, source) {
|
||||
const sampler = ctx.graph[samplerId];
|
||||
function graphFacts(graph, source, resolution) {
|
||||
if (!object(graph)) return factSet("unresolved", source);
|
||||
// Graph size is not a bound: each lookup is bounded by its MAX_VISITS seen-set,
|
||||
// and the server's node ceiling arrives as a `limited` projection.
|
||||
const entries = Object.entries(graph);
|
||||
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 { facts: shape, fact_overrides: overrides = [] } = SAMPLERS[sampler.class_type];
|
||||
// A set override (a script, an XY plot, custom sigmas) replaces the settings below.
|
||||
if (overrides.some(name => Object.hasOwn(inputs, name) && inputs[name] !== null && inputs[name] !== "")) {
|
||||
return Object.fromEntries(FACT_FIELDS.map(name => [name, fact("unresolved", source, samplerId)]));
|
||||
}
|
||||
const advanced = shape === "custom-advanced";
|
||||
const custom = advanced || shape === "custom";
|
||||
const advanced = sampler.class_type === "SamplerCustomAdvanced";
|
||||
const custom = advanced || sampler.class_type === "SamplerCustom";
|
||||
let settings = inputs;
|
||||
let settingsAdapter;
|
||||
const settingsPath = new Set();
|
||||
@@ -622,45 +510,6 @@
|
||||
return facts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Facts follow the server's resolution. Its `sampler_id` drives them alone when
|
||||
* resolved. Any other result naming a sampler (an ambiguous multistage chain, or an
|
||||
* absent or unresolved prompt of the branch the server chose, e.g. after dropping a
|
||||
* preview-only stage) merges per field across that sampler's stages, independent of
|
||||
* any selection (D5). Ambiguous without a named sampler (no candidates on the wire
|
||||
* until PR3), and unresolved or absent over several listed samplers, merge the same
|
||||
* way across every listed sampler and its stages. Otherwise (no resolution, or one
|
||||
* listed sampler) exactly one listed sampler supplies them; several leave every field ambiguous.
|
||||
*/
|
||||
function graphFacts(graph, source, resolution) {
|
||||
if (!object(graph)) return factSet("unresolved", source);
|
||||
// Graph size is not a bound: each lookup is bounded by its MAX_VISITS seen-set,
|
||||
// and the server's node ceiling arrives as a `limited` projection.
|
||||
const entries = Object.entries(graph);
|
||||
if (entries.some(([, node]) => !object(node) || !object(node.inputs) || typeof node.class_type !== "string")) return factSet("unresolved", source);
|
||||
const ctx = createContext(graph, null, true);
|
||||
const state = object(resolution) ? resolution.state : undefined;
|
||||
const named = typeof resolution?.sampler_id === "string" ? resolution.sampler_id : null;
|
||||
if (["resolved", "ambiguous", "absent", "unresolved"].includes(state) && named !== null) {
|
||||
if (!samplerEntry(graph, named)) return factSet("unresolved", source);
|
||||
if (state === "resolved") return samplerFacts(ctx, named, source);
|
||||
const stages = new Set();
|
||||
if (!collectStages(graph, named, new Set(), stages, { visits: 0 })) return factSet("unresolved", source);
|
||||
return mergeFacts([...stages].map(id => samplerFacts(ctx, id, source)), source);
|
||||
}
|
||||
const candidates = entries.filter(([id]) => samplerEntry(graph, id));
|
||||
// Review I-1: an unresolved or absent prompt does not make the run's settings
|
||||
// unknown, so several listed samplers merge the same way.
|
||||
if (state === "ambiguous" || ((state === "unresolved" || state === "absent") && candidates.length > 1)) {
|
||||
// The server's branches: every listed sampler, a detailer judged with its stages.
|
||||
const stages = new Set();
|
||||
if (!candidates.length || !candidates.every(([id]) => collectStages(graph, id, new Set(), stages, { visits: 0 }))) return factSet("unresolved", source);
|
||||
return mergeFacts([...stages].map(id => samplerFacts(ctx, id, source)), source);
|
||||
}
|
||||
if (candidates.length !== 1) return factSet(candidates.length > 1 ? "ambiguous" : "unresolved", source);
|
||||
return samplerFacts(ctx, candidates[0][0], source);
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -682,51 +531,18 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
// The inspection resolver's `_footer` (Python): R5 also reads a bare `Steps: N`
|
||||
// footer, and sd-dynamic-prompts' one-line template records after it.
|
||||
const FOOTER_TRAILERS = ["Template:", "Negative Template:"];
|
||||
function trailerLines(text) {
|
||||
const lines = text.trim().split(/\r?\n|\r/).filter((line, index, all) => all.length > 1 || line);
|
||||
const order = lines.map(line => FOOTER_TRAILERS.findIndex(name => line.startsWith(name)));
|
||||
return !order.includes(-1) && order.every((value, index) => index === 0 || value > order[index - 1]);
|
||||
}
|
||||
function settingsFooter(parameters) {
|
||||
let matches = [...parameters.matchAll(/^Steps:\s*\d+\s*,[^\n]*$/gm)];
|
||||
if (!matches.length) matches = [...parameters.matchAll(/^Steps:\s*\d+\s*$/gm)];
|
||||
if (matches.length !== 1) return null;
|
||||
return trailerLines(parameters.slice(matches[0].index + matches[0][0].length)) ? matches[0] : null;
|
||||
}
|
||||
/** R10: split settings on commas outside A1111's quoted values; unbalanced quotes split as before. */
|
||||
function settingsParts(line) {
|
||||
const parts = [];
|
||||
let start = 0, quoted = false;
|
||||
for (let index = 0; index < line.length; index++) {
|
||||
if (quoted && line[index] === "\\") index++;
|
||||
else if (line[index] === '"') quoted = !quoted;
|
||||
else if (line[index] === "," && !quoted) { parts.push(line.slice(start, index)); start = index + 1; }
|
||||
}
|
||||
if (quoted) return line.split(",");
|
||||
parts.push(line.slice(start));
|
||||
return parts;
|
||||
}
|
||||
function settingValue(value) {
|
||||
if (!/^"(?:\\.|[^\\"])*"$/.test(value)) return value;
|
||||
try { return JSON.parse(value); } catch { return value; }
|
||||
}
|
||||
|
||||
function parameterFacts(parameters) {
|
||||
const source = "embedded-parameters";
|
||||
const footer = settingsFooter(parameters);
|
||||
if (!footer) return factSet("unresolved", source);
|
||||
// R4: like the resolver, text starting with the delimiter has an empty positive.
|
||||
const body = "\n" + parameters.slice(0, footer.index).replace(/[\r\n]+$/, "");
|
||||
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 settingsParts(footer[0])) {
|
||||
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 : settingValue(value));
|
||||
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 => {
|
||||
@@ -759,18 +575,6 @@
|
||||
}
|
||||
|
||||
const api = { extractGenerationParams, extractGenerationFacts, PLACEHOLDERS };
|
||||
// For the semantics sync test only; not part of the legacy page contract.
|
||||
Object.defineProperty(api, "_factsTables", {
|
||||
value: Object.freeze({
|
||||
configuration_outputs: CONFIGURATION_OUTPUTS,
|
||||
model_outputs: MODEL_OUTPUTS,
|
||||
model_passthroughs: MODEL_PASSTHROUGHS,
|
||||
scalar_outputs: SCALAR_OUTPUTS,
|
||||
}),
|
||||
});
|
||||
Object.defineProperty(api, "_samplerTable", { value: SAMPLERS });
|
||||
Object.defineProperty(api, "_tableClasses", { value: TABLE_CLASSES });
|
||||
Object.defineProperty(api, "_stageInputs", { value: STAGE_INPUTS });
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
||||
else root.ComfyMetadata = api;
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
||||
@@ -1,270 +0,0 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"samplers": {
|
||||
"KSampler": {
|
||||
"strategy": "ksampler",
|
||||
"conditioning": { "positive": "positive", "negative": "negative" },
|
||||
"latent": "latent_image",
|
||||
"facts": "ksampler"
|
||||
},
|
||||
"KSamplerAdvanced": {
|
||||
"strategy": "ksampler",
|
||||
"conditioning": { "positive": "positive", "negative": "negative" },
|
||||
"latent": "latent_image",
|
||||
"facts": "ksampler"
|
||||
},
|
||||
"SamplerCustom": {
|
||||
"strategy": "ksampler",
|
||||
"conditioning": { "positive": "positive", "negative": "negative" },
|
||||
"latent": "latent_image",
|
||||
"facts": "custom"
|
||||
},
|
||||
"SamplerCustomAdvanced": {
|
||||
"strategy": "custom-advanced",
|
||||
"conditioning": { "guider": "guider" },
|
||||
"latent": "latent_image",
|
||||
"facts": "custom-advanced"
|
||||
},
|
||||
"FaceDetailer": {
|
||||
"strategy": "detailer-stage",
|
||||
"conditioning": { "positive": "positive", "negative": "negative" },
|
||||
"image_stage": "image",
|
||||
"facts": "ksampler",
|
||||
"prompt_overrides": ["wildcard"]
|
||||
},
|
||||
"DetailerForEach": {
|
||||
"strategy": "detailer-stage",
|
||||
"conditioning": { "positive": "positive", "negative": "negative" },
|
||||
"image_stage": "image",
|
||||
"facts": "ksampler",
|
||||
"prompt_overrides": ["wildcard"]
|
||||
},
|
||||
"UltimateSDUpscale": {
|
||||
"strategy": "detailer-stage",
|
||||
"conditioning": { "positive": "positive", "negative": "negative" },
|
||||
"image_stage": "image",
|
||||
"facts": "ksampler"
|
||||
},
|
||||
"UltimateSDUpscaleNoUpscale": {
|
||||
"strategy": "detailer-stage",
|
||||
"conditioning": { "positive": "positive", "negative": "negative" },
|
||||
"image_stage": "upscaled_image",
|
||||
"facts": "ksampler"
|
||||
},
|
||||
"ClownsharKSampler_Beta": {
|
||||
"strategy": "ksampler",
|
||||
"conditioning": { "positive": "positive", "negative": "negative" },
|
||||
"latent": "latent_image",
|
||||
"facts": "ksampler",
|
||||
"prompt_overrides": ["options"],
|
||||
"fact_overrides": ["options", "sigmas"]
|
||||
},
|
||||
"KSampler (Efficient)": {
|
||||
"strategy": "ksampler",
|
||||
"conditioning": { "positive": "positive", "negative": "negative" },
|
||||
"latent": "latent_image",
|
||||
"facts": "ksampler",
|
||||
"prompt_overrides": ["script"],
|
||||
"fact_overrides": ["script"]
|
||||
},
|
||||
"ttN KSampler_v2": {
|
||||
"strategy": "ksampler",
|
||||
"conditioning": { "positive": "positive", "negative": "negative" },
|
||||
"latent": "latent",
|
||||
"facts": "ksampler",
|
||||
"prompt_overrides": ["adv_xyPlot"],
|
||||
"fact_overrides": ["adv_xyPlot"]
|
||||
}
|
||||
},
|
||||
"guiders": {
|
||||
"CFGGuider": { "strategy": "cfg", "positive": "positive", "negative": "negative" },
|
||||
"BasicGuider": {
|
||||
"strategy": "basic",
|
||||
"positive": "conditioning",
|
||||
"absent_reason": "Guider has no negative input"
|
||||
}
|
||||
},
|
||||
"conditioning": [
|
||||
{ "class": "FluxGuidance", "slot": 0, "strategy": "passthrough", "input": "conditioning" },
|
||||
{ "class": "ReferenceLatent", "slot": 0, "strategy": "passthrough", "input": "conditioning" },
|
||||
{ "class": "ConditioningSetArea", "slot": 0, "strategy": "passthrough", "input": "conditioning" },
|
||||
{ "class": "ConditioningSetAreaPercentage", "slot": 0, "strategy": "passthrough", "input": "conditioning" },
|
||||
{ "class": "ConditioningSetAreaStrength", "slot": 0, "strategy": "passthrough", "input": "conditioning" },
|
||||
{ "class": "ConditioningSetMask", "slot": 0, "strategy": "passthrough", "input": "conditioning" },
|
||||
{ "class": "ConditioningSetTimestepRange", "slot": 0, "strategy": "passthrough", "input": "conditioning" },
|
||||
{ "class": "ControlNetApply", "slot": 0, "strategy": "passthrough", "input": "conditioning" },
|
||||
{ "class": "ControlNetApplyAdvanced", "slot": 0, "strategy": "passthrough", "input": "positive" },
|
||||
{ "class": "ControlNetApplyAdvanced", "slot": 1, "strategy": "passthrough", "input": "negative" },
|
||||
{ "class": "ControlNetApplySD3", "slot": 0, "strategy": "passthrough", "input": "positive" },
|
||||
{ "class": "ControlNetApplySD3", "slot": 1, "strategy": "passthrough", "input": "negative" },
|
||||
{ "class": "InpaintModelConditioning", "slot": 0, "strategy": "passthrough", "input": "positive" },
|
||||
{ "class": "InpaintModelConditioning", "slot": 1, "strategy": "passthrough", "input": "negative" },
|
||||
{ "class": "WanImageToVideo", "slot": 0, "strategy": "passthrough", "input": "positive" },
|
||||
{ "class": "WanImageToVideo", "slot": 1, "strategy": "passthrough", "input": "negative" },
|
||||
{ "class": "ControlNetInpaintingAliMamaApply", "slot": 0, "strategy": "passthrough", "input": "positive" },
|
||||
{ "class": "ControlNetInpaintingAliMamaApply", "slot": 1, "strategy": "passthrough", "input": "negative" },
|
||||
{ "class": "LTXVConditioning", "slot": 0, "strategy": "passthrough", "input": "positive" },
|
||||
{ "class": "LTXVConditioning", "slot": 1, "strategy": "passthrough", "input": "negative" },
|
||||
{ "class": "FluxKontextMultiReferenceLatentMethod", "slot": 0, "strategy": "passthrough", "input": "conditioning" },
|
||||
{ "class": "SeedVarianceEnhancer", "slot": 0, "strategy": "passthrough", "input": "conditioning" },
|
||||
{ "class": "ApplyInstantIDAdvanced", "slot": 1, "strategy": "passthrough", "input": "positive" },
|
||||
{ "class": "ApplyInstantIDAdvanced", "slot": 2, "strategy": "passthrough", "input": "negative" },
|
||||
{ "class": "ConditioningZeroOut", "slot": 0, "strategy": "zeroed", "input": "conditioning" }
|
||||
],
|
||||
"selectors": {
|
||||
"easy ifElse": {
|
||||
"strategy": "boolean-select",
|
||||
"condition": "boolean",
|
||||
"branches": { "true": "on_true", "false": "on_false" }
|
||||
},
|
||||
"Switch conditioning [Crystools]": {
|
||||
"strategy": "boolean-select",
|
||||
"condition": "boolean",
|
||||
"branches": { "true": "on_true", "false": "on_false" }
|
||||
}
|
||||
},
|
||||
"composites": {
|
||||
"ConditioningCombine": { "strategy": "combine", "inputs": ["conditioning_1", "conditioning_2"] },
|
||||
"ConditioningConcat": { "strategy": "combine", "inputs": ["conditioning_to", "conditioning_from"] },
|
||||
"ConditioningAverage": { "strategy": "combine", "inputs": ["conditioning_to", "conditioning_from"] }
|
||||
},
|
||||
"encoders": [
|
||||
{ "class": "CLIPTextEncode", "slot": 0, "strategy": "single", "inputs": ["text"] },
|
||||
{ "class": "CLIPTextEncodeSDXL", "slot": 0, "strategy": "parts-equal", "inputs": ["text_g", "text_l"] },
|
||||
{ "class": "CLIPTextEncodeFlux", "slot": 0, "strategy": "parts-equal", "inputs": ["clip_l", "t5xxl"] },
|
||||
{ "class": "CLIPTextEncodeSD3", "slot": 0, "strategy": "parts-equal", "inputs": ["clip_l", "clip_g", "t5xxl"] },
|
||||
{ "class": "TextEncodeQwenImageEdit", "slot": 0, "strategy": "single", "inputs": ["prompt"] },
|
||||
{ "class": "TextEncodeQwenImageEditPlus", "slot": 0, "strategy": "single", "inputs": ["prompt"] },
|
||||
{ "class": "TextEncodeQwenImage21", "slot": 0, "strategy": "single", "inputs": ["prompt"] },
|
||||
{ "class": "TextEncodeQwenImage21", "slot": 1, "strategy": "single", "inputs": ["negative_prompt"] },
|
||||
{ "class": "KikoTextEncode", "slot": 0, "strategy": "single", "inputs": ["text"] },
|
||||
{ "class": "Text to Conditioning", "slot": 0, "strategy": "single", "inputs": ["text"] },
|
||||
{
|
||||
"class": "smZ CLIPTextEncode",
|
||||
"slot": 0,
|
||||
"strategy": "flag-parts",
|
||||
"inputs": ["text", "text_g", "text_l"],
|
||||
"params": { "flag": "with_SDXL" }
|
||||
}
|
||||
],
|
||||
"strings": [
|
||||
{ "class": "StringConstant", "slot": 0, "strategy": "literal", "inputs": ["string"] },
|
||||
{ "class": "TextInput", "slot": 0, "strategy": "literal", "inputs": ["text"] },
|
||||
{ "class": "DisplayText", "slot": 0, "strategy": "literal", "inputs": ["text"] },
|
||||
{ "class": "ttN text", "slot": 0, "strategy": "literal", "inputs": ["text"] },
|
||||
{ "class": "CR Text", "slot": 0, "strategy": "literal", "inputs": ["text"] },
|
||||
{ "class": "CR Prompt Text", "slot": 0, "strategy": "literal", "inputs": ["prompt"] },
|
||||
{ "class": "TextBoxMira", "slot": 0, "strategy": "literal", "inputs": ["text"] },
|
||||
{
|
||||
"class": "StringConstantMultiline",
|
||||
"slot": 0,
|
||||
"strategy": "strip-newlines",
|
||||
"inputs": ["string"],
|
||||
"params": { "flag": "strip_newlines" }
|
||||
},
|
||||
{
|
||||
"class": "JoinStringMulti",
|
||||
"slot": 0,
|
||||
"strategy": "join-multi",
|
||||
"inputs": ["string_1"],
|
||||
"params": {
|
||||
"count": "inputcount",
|
||||
"delimiter": "delimiter",
|
||||
"list_flag": "return_list",
|
||||
"input_prefix": "string_"
|
||||
}
|
||||
}
|
||||
],
|
||||
"runtime": ["PromptSearchList"],
|
||||
"savers": {
|
||||
"SaveImage": {
|
||||
"strategy": "comfy-counter",
|
||||
"prefix_input": "filename_prefix",
|
||||
"image_input": "images",
|
||||
"pattern": "{name}_{counter:05}_.{ext}"
|
||||
},
|
||||
"SaveAnimatedWEBP": {
|
||||
"strategy": "comfy-counter",
|
||||
"prefix_input": "filename_prefix",
|
||||
"image_input": "images",
|
||||
"pattern": "{name}_{counter:05}_.{ext}"
|
||||
},
|
||||
"SaveAnimatedPNG": {
|
||||
"strategy": "comfy-counter",
|
||||
"prefix_input": "filename_prefix",
|
||||
"image_input": "images",
|
||||
"pattern": "{name}_{counter:05}_.{ext}"
|
||||
},
|
||||
"SaveVideo": {
|
||||
"strategy": "comfy-counter",
|
||||
"prefix_input": "filename_prefix",
|
||||
"image_input": "video",
|
||||
"pattern": "{name}_{counter:05}_.{ext}"
|
||||
},
|
||||
"SaveWEBM": {
|
||||
"strategy": "comfy-counter",
|
||||
"prefix_input": "filename_prefix",
|
||||
"image_input": "images",
|
||||
"pattern": "{name}_{counter:05}_.{ext}"
|
||||
},
|
||||
"VHS_VideoCombine": {
|
||||
"strategy": "vhs-counter",
|
||||
"prefix_input": "filename_prefix",
|
||||
"image_input": "images",
|
||||
"pattern": "{name}_{counter:05}.{ext}"
|
||||
}
|
||||
},
|
||||
"non_file_outputs": ["PreviewImage", "PreviewAny", "SaveImageWebsocket", "Image Comparer (rgthree)"],
|
||||
"stage_inputs": {
|
||||
"shaped": [
|
||||
"latent_image",
|
||||
"guider",
|
||||
"positive",
|
||||
"negative",
|
||||
"pipe",
|
||||
"basic_pipe",
|
||||
"detailer_pipe",
|
||||
"sampler",
|
||||
"sigmas",
|
||||
"noise",
|
||||
"text_embeds",
|
||||
"hyvid_embeds"
|
||||
],
|
||||
"with_model": ["conditioning", "neg_conditioning", "samples", "latent"]
|
||||
},
|
||||
"facts": {
|
||||
"configuration_outputs": {
|
||||
"RandomNoise": { "kind": "noise", "fields": ["noise_seed"] },
|
||||
"KSamplerSelect": { "kind": "sampler", "fields": ["sampler_name"] },
|
||||
"BasicScheduler": { "kind": "sigmas", "fields": ["steps", "scheduler"] },
|
||||
"Flux2Scheduler": { "kind": "sigmas", "fields": ["steps"], "type_fields": ["scheduler"] },
|
||||
"CFGGuider": { "kind": "guider", "fields": ["cfg", "model"] },
|
||||
"BasicGuider": { "kind": "guider", "fields": ["model"] }
|
||||
},
|
||||
"model_outputs": {
|
||||
"CheckpointLoader": "ckpt_name",
|
||||
"CheckpointLoaderSimple": "ckpt_name",
|
||||
"unCLIPCheckpointLoader": "ckpt_name",
|
||||
"UNETLoader": "unet_name"
|
||||
},
|
||||
"model_passthroughs": [
|
||||
"LoraLoader",
|
||||
"LoraLoaderModelOnly",
|
||||
"ModelSamplingFlux",
|
||||
"ModelSamplingAuraFlow",
|
||||
"ModelSamplingSD3",
|
||||
"Lora Loader (LoraManager)",
|
||||
"Power Lora Loader (rgthree)",
|
||||
"ModelPatchTorchSettings",
|
||||
"CFGNorm",
|
||||
"PerturbedAttentionGuidance",
|
||||
"CR Apply LoRA Stack"
|
||||
],
|
||||
"scalar_outputs": {
|
||||
"PrimitiveInt": "number",
|
||||
"PrimitiveFloat": "number",
|
||||
"PrimitiveString": "string",
|
||||
"PrimitiveStringMultiline": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,7 +85,7 @@ 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, promptContext = '', onPreview, onRetry, onRefresh, onInteraction = () => {}}) {
|
||||
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;
|
||||
@@ -145,7 +145,7 @@ export function mountFileInspector(container, {sender, parentScope, inline = fal
|
||||
}
|
||||
if (chips.childNodes.length) inner.append(chips);
|
||||
const prompts = el('div'); inner.append(prompts);
|
||||
const blocks = mountPromptBlocks(prompts, {sender, parentScope:contentScope, includePairAction:false, contextLabel:promptContext});
|
||||
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');
|
||||
@@ -226,7 +226,7 @@ export function mountFileInspector(container, {sender, parentScope, inline = fal
|
||||
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(promptContext ? `Send ${promptContext.toLowerCase()} prompt pair` : 'Send prompt pair'); pair.dataset.sendRole = 'pair';
|
||||
const pair = createSendButton('Send prompt pair'); pair.dataset.sendRole = 'pair';
|
||||
contentScope.listen(pair, 'click', () => sender.open(roles, pair)); footer.append(pair);
|
||||
}
|
||||
if (record.historyTarget) {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import '../api-client.js';
|
||||
import { createButton, createField, createScope, createSelect } from '../shared/toolkit/core.js';
|
||||
import { routes, mountRouter, summarizeStats, routeTitle, isHandoff } from './state.mjs';
|
||||
import { routes, mountRouter, summarizeStats } from './state.mjs';
|
||||
import { mountWorkspaceTheme } from './theme.mjs';
|
||||
import { mountImages } from './images.mjs';
|
||||
import { mountLibrary, mountNewPromptAction } from './library.mjs';
|
||||
import { mountVocabulary } from './vocabulary.mjs';
|
||||
import { createWorkspaceIcon } from './icons.mjs';
|
||||
|
||||
const { createApiClient, resolveUrl } = window.ApiClient;
|
||||
@@ -66,7 +65,7 @@ function mountWorkspace() {
|
||||
if (link.dataset.route === route.id) link.setAttribute('aria-current', 'page');
|
||||
else link.removeAttribute('aria-current');
|
||||
}
|
||||
document.title = `${routeTitle(route)} · Prompt Manager`;
|
||||
document.title = `${route.label} · Prompt Manager`;
|
||||
const heading = element('h1', route.label);
|
||||
// Focus target after a save, as the concept's page title.
|
||||
heading.tabIndex = -1;
|
||||
@@ -77,7 +76,7 @@ function mountWorkspace() {
|
||||
main.replaceChildren(headingRegion);
|
||||
if (!firstRender) main.focus();
|
||||
firstRender = false;
|
||||
document.getElementById('workspace-breadcrumb').textContent = routeTitle(route);
|
||||
document.getElementById('workspace-breadcrumb').textContent = route.label;
|
||||
if (route.id === 'images') {
|
||||
mountNewPromptAction(main, api, view);
|
||||
mountImages(main, api, view);
|
||||
@@ -87,11 +86,7 @@ function mountWorkspace() {
|
||||
mountLibrary(main, api, view, {positiveId:route.positiveId});
|
||||
return;
|
||||
}
|
||||
if (route.id === 'tags') {
|
||||
mountVocabulary(main, api, view);
|
||||
return;
|
||||
}
|
||||
if (isHandoff(route)) {
|
||||
if (route.id !== 'overview') {
|
||||
headingCopy.append(element('p', route.description, 'intro'));
|
||||
const panel = element('section', undefined, 'panel handoff');
|
||||
panel.append(element('h2', 'Your current tools are ready'),
|
||||
|
||||
@@ -9,10 +9,6 @@ const FACT_FIELDS = ['seed', 'steps', 'cfg', 'sampler', 'scheduler', 'model'];
|
||||
const RECORD_FORMATS = ['original-text', 'reserialized-json', 'invalid', 'limited'];
|
||||
const TEXTLESS_FORMATS = ['invalid', 'limited'];
|
||||
const REASON_CODES = ['work-limit', 'depth-limit', 'text-limit', 'graph-ceiling', 'unsupported-node', 'runtime-value', 'composite', 'no-sampler', 'malformed', 'multistage-differs'];
|
||||
// Only these codes name the blocking node; node IDs and class names are bounded.
|
||||
const NAMED_CODES = ['unsupported-node', 'runtime-value', 'composite'];
|
||||
const NAME_LENGTH = 200, MESSAGE_LENGTH = 512;
|
||||
const boundedText = (value, limit) => typeof value === 'string' && value.length > 0 && value.length <= limit;
|
||||
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;
|
||||
|
||||
@@ -81,8 +77,7 @@ function promptsFor(value) {
|
||||
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)) ||
|
||||
(record.message !== undefined && (record.status === 'resolved' || !boundedText(record.message, MESSAGE_LENGTH)))) throw new TypeError('Invalid inspection role');
|
||||
(record.status === 'resolved' && !Object.hasOwn(confirmed, role))) throw new TypeError('Invalid inspection role');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -146,8 +141,7 @@ function resolutionFor(value) {
|
||||
const code = value.reason_code ?? null, node = value.node ?? null;
|
||||
// Codes explain a non-resolved outcome; a blocking node is named only with its code.
|
||||
if ((code !== null && (!REASON_CODES.includes(code) || ['resolved', 'absent'].includes(value.state))) ||
|
||||
(node !== null && (!NAMED_CODES.includes(code) || !object(node) || Object.keys(node).sort().join() !== 'class_type,id' ||
|
||||
!boundedText(node.id, NAME_LENGTH) || (node.class_type !== null && !boundedText(node.class_type, NAME_LENGTH))))) throw new TypeError('Invalid inspection resolution reason');
|
||||
(node !== null && (code === null || !object(node) || typeof node.id !== 'string' || (node.class_type !== null && typeof node.class_type !== 'string')))) throw new TypeError('Invalid inspection resolution reason');
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -163,41 +157,11 @@ function libraryFor(value) {
|
||||
return library;
|
||||
}
|
||||
|
||||
// The legacy workflow parse (comfy-metadata.js, as of #170) also feeds the gallery and
|
||||
// admin pages; it fills any prompt or setting the server could not decide, so this page
|
||||
// never shows less than they do. A definite server answer is never replaced.
|
||||
const LEGACY_FACTS = [['seed', 'seed'], ['steps', 'steps'], ['cfg', 'cfgScale'], ['sampler', 'sampler'], ['model', 'checkpoint']];
|
||||
|
||||
function comfyMetadata() {
|
||||
return globalThis.ComfyMetadata || globalThis.window?.ComfyMetadata;
|
||||
}
|
||||
|
||||
function withWorkflowParams(record, parser = comfyMetadata()) {
|
||||
const graph = record.metadata.prompt_graph.status === 'parsed' ? record.metadata.prompt_graph.value : null;
|
||||
const workflow = record.metadata.workflow_graph.status === 'parsed' ? record.metadata.workflow_graph.value : null;
|
||||
if (typeof parser?.extractGenerationParams !== 'function' || (!graph && !workflow)) return;
|
||||
const params = parser.extractGenerationParams({ prompt: graph, workflow });
|
||||
const found = key => typeof params[key] === 'string' || typeof params[key] === 'number' ?
|
||||
String(params[key]) !== '' && params[key] !== parser.PLACEHOLDERS?.[key] : false;
|
||||
const prompts = { ...record.prompts };
|
||||
for (const [role, key] of [['positive', 'positivePrompt'], ['negative', 'negativePrompt']]) {
|
||||
const current = prompts[role];
|
||||
if (!found(key) || current?.status === 'resolved') continue;
|
||||
prompts[role] = { status: 'resolved', text: params[key], source: 'graph-inferred' };
|
||||
}
|
||||
record.prompts = prompts;
|
||||
for (const [field, key] of LEGACY_FACTS) {
|
||||
// Only open answers are filled: a known value or a definite "not recorded" stays.
|
||||
if (!found(key) || !['unresolved', 'ambiguous'].includes(record.generation[field]?.status)) continue;
|
||||
record.generation[field] = { status: 'known', value: String(params[key]), source: graph ? 'api-graph' : 'workflow', evidence: 'configuration', reason: null, sampler_id: null };
|
||||
}
|
||||
}
|
||||
|
||||
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, parser = comfyMetadata()) {
|
||||
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');
|
||||
@@ -207,11 +171,11 @@ export function toInspectionRecord(result, image, extractFacts = globalThis.Comf
|
||||
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');
|
||||
return projectInspectionPayload(result,
|
||||
{ kind: 'rooted-file', key: imageKey(image), root_index: image.root_index, path: image.path, root_scope: scope },
|
||||
downloadFor(image, !legacy || scope !== null), extractFacts, legacy, parser);
|
||||
downloadFor(image, !legacy || scope !== null), extractFacts, legacy);
|
||||
}
|
||||
|
||||
/** Shared bounded payload projection; adapters validate identity before entering here. */
|
||||
export function projectInspectionPayload(result, identity, download, extractFacts = globalThis.ComfyMetadata?.extractGenerationFacts || globalThis.window?.ComfyMetadata?.extractGenerationFacts, legacy = false, parser = comfyMetadata()) {
|
||||
export function projectInspectionPayload(result, identity, download, extractFacts = globalThis.ComfyMetadata?.extractGenerationFacts || globalThis.window?.ComfyMetadata?.extractGenerationFacts, legacy = false) {
|
||||
const record = {
|
||||
identity, file: null, prompts: promptsFor(result.prompts), library: null, metadata: null,
|
||||
generation: unavailableFacts(), historyTarget: null, download,
|
||||
@@ -224,7 +188,6 @@ export function projectInspectionPayload(result, identity, download, extractFact
|
||||
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 });
|
||||
withWorkflowParams(record, parser);
|
||||
if (record.library.status === 'linked' && record.library.positive_id !== null) record.historyTarget = `#/library/${record.library.positive_id}`;
|
||||
return record;
|
||||
}
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
/* Native recipe editor and vocabulary dialogs, and the shared toast: geometry and tokens follow the approved concept (style.css 859-1007, 1424-1531). */
|
||||
.library-editor, .vocabulary-dialog, .toast { --editor-red: #ff8c98; --editor-warning: #e6bd78; }
|
||||
[data-ca-theme="light"] :where(.library-editor, .vocabulary-dialog, .toast) { --editor-red: #b71931; --editor-warning: #815514; }
|
||||
.ca-tools :is(.library-editor, .vocabulary-dialog) { color: var(--ca-text); background: var(--ca-surface); border: 1px solid var(--ca-border); border-radius: 9px; padding: 24px; width: min(650px, calc(100vw - 24px)); max-height: 90dvh; overflow: auto; }
|
||||
.library-editor::backdrop, .vocabulary-dialog::backdrop { background: #080b12b8; }
|
||||
:is(.library-editor, .vocabulary-dialog) .dialog-header { display: flex; justify-content: space-between; align-items: center; gap: 18px; margin-bottom: 20px; }
|
||||
:is(.library-editor, .vocabulary-dialog) .dialog-header h2 { margin: 0; font-size: 18px; font-weight: 600; line-height: inherit; }
|
||||
:is(.library-editor, .vocabulary-dialog) :where(form, p) { margin: 0; line-height: inherit; }
|
||||
:is(.library-editor, .vocabulary-dialog) p + p { margin-top: 10px; }
|
||||
.ca-tools :is(.library-editor, .vocabulary-dialog) button { min-height: 32px; font-size: 12px; }
|
||||
.ca-tools :is(.library-editor, .vocabulary-dialog) .dialog-header > button { font-size: 20px; min-width: 32px; }
|
||||
:is(.library-editor, .vocabulary-dialog) .dialog-actions { display: flex; justify-content: flex-end; gap: 8px; flex-wrap: wrap; margin-top: 23px; }
|
||||
:is(.library-editor, .vocabulary-dialog) .field { display: grid; gap: 7px; font-size: 12px; margin: 15px 0; }
|
||||
:is(.library-editor, .vocabulary-dialog) .field :where(input, textarea, select) { width: 100%; font-size: 12px; }
|
||||
:is(.library-editor, .vocabulary-dialog) .field textarea { min-height: 100px; resize: vertical; }
|
||||
:is(.library-editor, .vocabulary-dialog) .two-col { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }
|
||||
:is(.library-editor, .vocabulary-dialog) .small { font-size: 12px; color: var(--ca-muted); }
|
||||
:is(.library-editor, .vocabulary-dialog) .warning-note { font-size: 11px; color: var(--editor-warning); margin: 13px 0; }
|
||||
.ca-tools :is(.library-editor, .vocabulary-dialog) .danger { color: var(--editor-red); border-color: var(--editor-red); }
|
||||
:is(.library-editor, .vocabulary-dialog) :where(button, input, select, textarea):focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 3px; }
|
||||
/* Native recipe editor and shared toast: geometry and tokens follow the approved concept (style.css 859-1007, 1424-1531). */
|
||||
.library-editor, .toast { --editor-red: #ff8c98; --editor-warning: #e6bd78; }
|
||||
[data-ca-theme="light"] :where(.library-editor, .toast) { --editor-red: #b71931; --editor-warning: #815514; }
|
||||
.ca-tools .library-editor { color: var(--ca-text); background: var(--ca-surface); border: 1px solid var(--ca-border); border-radius: 9px; padding: 24px; width: min(650px, calc(100vw - 24px)); max-height: 90dvh; overflow: auto; }
|
||||
.library-editor::backdrop { background: #080b12b8; }
|
||||
.library-editor .dialog-header { display: flex; justify-content: space-between; align-items: center; gap: 18px; margin-bottom: 20px; }
|
||||
.library-editor .dialog-header h2 { margin: 0; font-size: 18px; font-weight: 600; line-height: inherit; }
|
||||
.library-editor :where(form, p) { margin: 0; line-height: inherit; }
|
||||
.library-editor p + p { margin-top: 10px; }
|
||||
.ca-tools .library-editor button { min-height: 32px; font-size: 12px; }
|
||||
.ca-tools .library-editor .dialog-header > button { font-size: 20px; min-width: 32px; }
|
||||
.library-editor .dialog-actions { display: flex; justify-content: flex-end; gap: 8px; flex-wrap: wrap; margin-top: 23px; }
|
||||
.library-editor .field { display: grid; gap: 7px; font-size: 12px; margin: 15px 0; }
|
||||
.library-editor .field :where(input, textarea, select) { width: 100%; font-size: 12px; }
|
||||
.library-editor .field textarea { min-height: 100px; resize: vertical; }
|
||||
.library-editor .two-col { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }
|
||||
.library-editor .small { font-size: 12px; color: var(--ca-muted); }
|
||||
.library-editor .warning-note { font-size: 11px; color: var(--editor-warning); margin: 13px 0; }
|
||||
.ca-tools .library-editor .danger { color: var(--editor-red); border-color: var(--editor-red); }
|
||||
.library-editor :where(button, input, select, textarea):focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 3px; }
|
||||
.toast { position: fixed; bottom: 22px; left: 50%; transform: translateX(-50%); background: var(--ca-surface); border: 1px solid var(--ca-accent); padding: 12px 18px; border-radius: 6px; box-shadow: 0 8px 35px #0004; z-index: 10; max-width: min(650px, 90vw); font-size: 12px; }
|
||||
.toast:empty { display: none; }
|
||||
@media (max-width: 700px) {
|
||||
:is(.library-editor, .vocabulary-dialog) .two-col { grid-template-columns: 1fr; }
|
||||
.library-editor .two-col { grid-template-columns: 1fr; }
|
||||
.toast { bottom: 12px; }
|
||||
}
|
||||
@media (pointer: coarse) { .ca-tools :is(.library-editor, .vocabulary-dialog) button { min-height: 40px; } }
|
||||
@media (pointer: coarse) { .ca-tools .library-editor button { min-height: 40px; } }
|
||||
|
||||
@@ -41,17 +41,6 @@ export function invalidateLibraryAfterWrite(commit) {
|
||||
writeGeneration++;
|
||||
if(shelfVisit) shelfVisit={filters:shelfVisit.filters,anchor:shelfVisit.layout?.anchor ?? shelfVisit.anchor ?? null};
|
||||
}
|
||||
/**
|
||||
* Open the Library shelf with exactly these filters (Tags & categories Browse): the
|
||||
* visit is seeded like a returning shelf without a cached stream, so it reads page one
|
||||
* with the existing Library filters, and the heading takes focus as after a save.
|
||||
*/
|
||||
export function browseLibrary(filters) {
|
||||
const {text='',category='',tags=[],sort='newest'}=filters || {};
|
||||
if(typeof text!=='string' || typeof category!=='string' || !Array.isArray(tags) || tags.some(tag=>typeof tag!=='string') || typeof sort!=='string') throw new TypeError('Invalid Library filters');
|
||||
shelfVisit={filters:{text,category,tags:[...tags],sort},anchor:null};
|
||||
focusHeadingOnMount=true;window.location.hash='#/library';
|
||||
}
|
||||
/** Remember the selection a reconciled save names, so the destination history opens on it once. */
|
||||
export function rememberLibraryResult(result) {
|
||||
const positive=result?.detail?.positive, kind=result?.commit?.kind;
|
||||
|
||||
@@ -4,8 +4,6 @@ import {rootedFileAdapter} from './inspection-state.mjs';
|
||||
import {mountFileInspector} from './file-inspector.mjs';
|
||||
|
||||
let viewerId = 0;
|
||||
// Upper bound on waiting for a closed Viewer's in-flight image view before destroying it.
|
||||
const PENDING_VIEW_DESTROY_MS = 10000;
|
||||
export function readViewerPreferences(getStorage = () => window.localStorage, normalize = globalThis.GallerySettings.normalizeGallerySettings) {
|
||||
let stored;
|
||||
try { stored = JSON.parse(getStorage().getItem('gallerySettings')); } catch { stored = null; }
|
||||
@@ -129,19 +127,12 @@ export function createMediaViewer({container,parentScope,inspectionController,se
|
||||
} else {
|
||||
node.alt = selected.filename;node.decoding = 'async';
|
||||
node.hidden = !!window.Viewer;
|
||||
// With Viewer.js the src is released after the viewer (see release below).
|
||||
if (!window.Viewer) {
|
||||
mediaLocal.listen(node,'load',() => {if (current()) session.ready(key);});
|
||||
mediaLocal.cleanup(() => node.removeAttribute('src'));
|
||||
}
|
||||
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, viewSettled = false, released = false, fallback = 0;
|
||||
const release = () => {
|
||||
if (released) return;
|
||||
released = true;clearTimeout(fallback);instance.destroy();node.removeAttribute('src');
|
||||
};
|
||||
let fitFrame = 0, fitWidth, fitHeight;
|
||||
const scheduleFit = () => {
|
||||
if (!current() || fitFrame) return;
|
||||
fitFrame = requestAnimationFrame(() => {
|
||||
@@ -154,21 +145,11 @@ export function createMediaViewer({container,parentScope,inspectionController,se
|
||||
};
|
||||
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() {
|
||||
viewSettled = true;
|
||||
if (mediaLocal.signal.aborted) { release(); return; }
|
||||
if (!current()) return;
|
||||
session.ready(key);scheduleFit();
|
||||
}});
|
||||
const observer = new ResizeObserver(scheduleFit);observer.observe(stage);
|
||||
viewer = instance;mediaLocal.cleanup(() => {
|
||||
observer.disconnect();cancelAnimationFrame(fitFrame);
|
||||
// Inline Viewer.js keeps viewing a still-loading image after destroy(), and that view
|
||||
// rebuilds its list from images that still have a src; keep both until the view
|
||||
// settles, fails or times out.
|
||||
if (viewSettled) { release(); return; }
|
||||
node.addEventListener('error',release,{once:true});
|
||||
fallback = setTimeout(release,PENDING_VIEW_DESTROY_MS);
|
||||
});
|
||||
viewer = instance;mediaLocal.cleanup(() => {observer.disconnect();cancelAnimationFrame(fitFrame);instance.destroy();});
|
||||
}
|
||||
updateControls(session.state);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ const sources = {
|
||||
execution: 'Recorded at execution',
|
||||
'graph-inferred': 'Inferred from the saved workflow',
|
||||
'embedded-parameters': 'Embedded generation parameters',
|
||||
'zeroed-conditioning': 'Zeroed conditioning (no negative text)',
|
||||
'saved-association': 'Saved library association',
|
||||
};
|
||||
const statuses = {
|
||||
@@ -41,11 +40,7 @@ function childScope(parentScope) {
|
||||
return scope;
|
||||
}
|
||||
|
||||
export function mountPromptBlocks(container, {prompts, sender, parentScope, roles:visibleRoles = ['positive', 'negative'], onRetry, includePairAction = true, contextLabel = ''}) {
|
||||
// A nested set of blocks (the current file inside a recorded run) names its regions and actions with a
|
||||
// context so assistive technology never meets two identically named prompt regions in one dialog.
|
||||
const named = role => contextLabel ? `${contextLabel} ${role} prompt` : `${role} prompt`;
|
||||
const action = role => contextLabel ? `${contextLabel[0].toLowerCase()}${contextLabel.slice(1)} ${role} prompt` : `${role} prompt`;
|
||||
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');
|
||||
@@ -91,7 +86,7 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role
|
||||
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', contextLabel ? named(role) : title);
|
||||
block.setAttribute('aria-label', title);
|
||||
const heading = el('div', undefined, 'prompt-block-heading');
|
||||
heading.append(el('h3', title));
|
||||
block.append(heading);
|
||||
@@ -105,12 +100,12 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role
|
||||
block.append(text);
|
||||
}
|
||||
const actions = el('div', undefined, 'prompt-actions');
|
||||
const send = createSendButton(`Send ${action(role)}`);
|
||||
const send = createSendButton(`Send ${role} prompt`);
|
||||
send.dataset.sendRole = role;
|
||||
send.disabled = !actionsEnabled;
|
||||
const copy = createButton('', 'ca-icon-button');
|
||||
copy.setAttribute('aria-label', `Copy ${action(role)}`);
|
||||
copy.title = `Copy ${action(role)}`;
|
||||
copy.setAttribute('aria-label', `Copy ${role} prompt`);
|
||||
copy.title = `Copy ${role} prompt`;
|
||||
copy.append(createWorkspaceIcon('copy'));
|
||||
copy.dataset.copyRole = role;
|
||||
copy.disabled = !actionsEnabled;
|
||||
@@ -118,7 +113,7 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role
|
||||
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 ? record.message : statuses[record?.status] || statuses.unresolved, 'muted'));
|
||||
} 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')) {
|
||||
|
||||
@@ -68,7 +68,7 @@ export function mountRecordedRun(container, {api, controller, sender, parentScop
|
||||
const receiptNodes = new Map();
|
||||
const evidenceValues = new Map();
|
||||
const blocks = mountPromptBlocks(prompts, {sender, parentScope:local});
|
||||
const inspector = mountFileInspector(inspectorContainer, {sender, parentScope:local, inline:true, promptContext:'Current file', onRetry:() => inspection.retry(), onPreview:() => showViewer(), onRefresh:refreshCurrent});
|
||||
const inspector = mountFileInspector(inspectorContainer, {sender, parentScope:local, inline:true, onRetry:() => inspection.retry(), onPreview:() => showViewer(), onRefresh:refreshCurrent});
|
||||
function selectedReceipt() {
|
||||
const selected = latest.selection.receipt;
|
||||
return selected && latest.outputs.items.find(item => occurrenceKey(item.occurrence) === occurrenceKey(selected));
|
||||
|
||||
@@ -3,8 +3,7 @@ export const routes = [
|
||||
description: 'Browse your images, inspect their metadata and review generated tags.', action: 'Open existing image gallery' },
|
||||
{ id: 'library', label: 'Prompt library', href: '/prompt_manager/admin',
|
||||
description: 'Find, edit and organize the prompts already in your library.', action: 'Open existing prompt library' },
|
||||
// Native screen; `title` is the concept's breadcrumb/title, the href stays for Overview's card.
|
||||
{ id: 'tags', label: 'Tags & categories', title: 'Tags', href: '/prompt_manager/admin#/tags',
|
||||
{ id: 'tags', label: 'Tags & categories', href: '/prompt_manager/admin#/tags',
|
||||
description: 'Explore your vocabulary and find the prompts associated with each tag.', action: 'Open existing tag manager' },
|
||||
{ id: 'inspect', label: 'Inspect a file', href: '/prompt_manager/gallery.html',
|
||||
description: 'Inspect generation metadata from an image or video file.', action: 'Open existing metadata inspector' },
|
||||
@@ -13,18 +12,6 @@ export const routes = [
|
||||
description: 'Open the current dashboard, then choose Settings or Maintenance for configuration, scans, backups and diagnostics.', action: 'Open existing administration' },
|
||||
];
|
||||
|
||||
const NATIVE_ROUTES = new Set(['images', 'library', 'tags', 'overview']);
|
||||
|
||||
/** Breadcrumb and document title: the concept names a few pages differently from their nav label. */
|
||||
export function routeTitle(route) {
|
||||
return route.title || route.label;
|
||||
}
|
||||
|
||||
/** Routes the workspace does not render yet show the generic legacy handoff panel. */
|
||||
export function isHandoff(route) {
|
||||
return !NATIVE_ROUTES.has(route.id);
|
||||
}
|
||||
|
||||
export function resolveRoute(hash) {
|
||||
const match = /^#\/library\/([1-9]\d*)$/.exec(hash);
|
||||
if (match && Number.isSafeInteger(Number(match[1]))) {
|
||||
|
||||
@@ -9,8 +9,7 @@ body[data-ca-theme="light"] { --workspace-page: #f3f4f6; --workspace-sidebar: #f
|
||||
[hidden] { display: none !important; }
|
||||
a { color: var(--ca-focus); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
a:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 3px; }
|
||||
main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
|
||||
a:focus-visible, main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
|
||||
.workspace-shell { display: grid; grid-template-columns: 218px minmax(0, 1fr); min-block-size: 100dvh; }
|
||||
.sidebar { background: var(--workspace-sidebar); border-inline-end: 1px solid var(--ca-line); padding: 26px 14px 16px; display: flex; flex-direction: column; gap: 5px; position: sticky; inset-block-start: 0; block-size: 100dvh; align-self: start; overflow-y: auto; }
|
||||
.brand { display: flex; gap: 11px; align-items: center; color: var(--ca-text); font-weight: 650; font-size: 14px; margin: 0 4px 28px; line-height: 1.3; }
|
||||
|
||||
@@ -1,388 +0,0 @@
|
||||
/**
|
||||
* Tags & categories dialogs: Create tag, Rename or merge tag (with Delete tag…)
|
||||
* and Manage categories.
|
||||
*
|
||||
* One `dialog.vocabulary-dialog` in the recipe editor's modal shell (concept
|
||||
* simpleInput, app.js:1138-1143): header h2 + ×, `.field`, `.dialog-actions`
|
||||
* with Cancel then the primary action. Recorded deviations: "Apply in prototype"
|
||||
* is Apply, Manage categories adds a Category select and a Preview step (D9),
|
||||
* and Rename or merge carries Delete tag… (D14). Every write goes through
|
||||
* vocabulary-state.mjs; this module renders its phases as the editor does, with
|
||||
* inline `.warning-note` states, and never resends a write on its own.
|
||||
*/
|
||||
import { createButton } from '../shared/toolkit/core.js';
|
||||
import { showWorkspaceToast } from './library-editor.mjs';
|
||||
import { categoryPreviewText, deleteTagText, readTagImpact } from './vocabulary-state.mjs';
|
||||
|
||||
const RESTORED = 'Prompt storage was restored since this dialog opened. Nothing was changed.';
|
||||
const UNKNOWN = 'The result could not be confirmed. The lists were re-read; check them before applying again.';
|
||||
const TAG_CONFLICT = 'This tag changed since this dialog opened. Nothing was changed. The list was re-read; review it before trying again.';
|
||||
const CATEGORY_CONFLICT = 'This category changed since this dialog opened. Nothing was changed. Preview again before applying.';
|
||||
const NOT_LOADED = 'The vocabulary has not loaded yet. Nothing was changed.';
|
||||
const CHECKING = 'Checking linked prompts…';
|
||||
const IMPACT_FAILED = 'Linked prompts could not be read. Nothing was deleted.';
|
||||
const SAVING = 'Applying… The dialog stays open until the server answers.';
|
||||
const DELETING = 'Deleting… The dialog stays open until the server answers.';
|
||||
const PREVIEWING = 'Previewing… Nothing is changed by a preview.';
|
||||
const CATEGORIES_LOADING = 'Loading categories…';
|
||||
const CATEGORIES_FAILED = 'Categories could not be loaded. Nothing was changed.';
|
||||
// Manage categories reads at most this many categories (10 pages) into its select.
|
||||
const CATEGORY_CAP = 1000;
|
||||
const CATEGORIES_CAPPED = 'Only the first 1,000 categories are listed.';
|
||||
// Command phases after which the delete confirmation's impact no longer describes storage.
|
||||
const STALE_IMPACT = new Set(['conflict', 'storage-changed', 'unknown-outcome']);
|
||||
// Phases that send nothing until the user acts: the primary stays disabled.
|
||||
const BLOCKED = new Set(['saving', 'storage-changed']);
|
||||
|
||||
function el(tag, text, className) {
|
||||
const node = document.createElement(tag);
|
||||
if (text !== undefined) node.textContent = text;
|
||||
if (className) node.className = className;
|
||||
return node;
|
||||
}
|
||||
function field(label, control, id) {
|
||||
const wrapper = el('div', undefined, 'field');
|
||||
const caption = el('label', label);
|
||||
caption.htmlFor = id;
|
||||
control.id = id;
|
||||
wrapper.append(caption, control);
|
||||
return wrapper;
|
||||
}
|
||||
/** Re-parent only on a real change: moving a focused button would drop its focus. */
|
||||
function setChildren(parent, nodes) {
|
||||
const current = [...parent.children];
|
||||
if (current.length !== nodes.length || current.some((node, index) => node !== nodes[index])) parent.replaceChildren(...nodes);
|
||||
}
|
||||
|
||||
/** Toast copy for a confirmed receipt (decision D11). */
|
||||
function toastOf(kind, receipt) {
|
||||
switch (kind) {
|
||||
case 'create-tag': return receipt.created ? 'Tag created.' : 'Tag already exists.';
|
||||
case 'rename-tag': return receipt.merged ? 'Tag merged without duplicate links.' : 'Tag renamed.';
|
||||
case 'delete-tag': return 'Tag deleted.';
|
||||
default: return receipt.consolidated ? 'Categories consolidated.' : 'Category renamed.';
|
||||
}
|
||||
}
|
||||
|
||||
/** Inline state for a command phase; `conflict` is the kind-specific conflict copy, `failed` the refusal prefix. */
|
||||
function statusOf(state, {conflict, saving, failed, kept = ' Your entries are kept.'}) {
|
||||
switch (state.phase) {
|
||||
case 'saving': return {text: state.kind === 'preview-category' ? PREVIEWING : saving};
|
||||
case 'write-error': return {text: `${state.kind === 'preview-category' ? 'Not previewed' : failed}: ${state.error?.message}${kept}`};
|
||||
case 'conflict': return {text: conflict};
|
||||
case 'storage-changed': return {text: RESTORED, reload: true};
|
||||
case 'unknown-outcome': return {text: UNKNOWN};
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `epoch()` reads the loaded lists' epoch and `categories` is the page's category
|
||||
* list (`{stream, subscribe}`), which Manage categories reads to the end (capped);
|
||||
* `reload()` re-reads both lists and
|
||||
* `settled()` resolves once the latest re-read landed; `tagAction(id)` and
|
||||
* `categoryAction(value)` find a loaded row's action for focus; `heading()` is the
|
||||
* page h1, the focus fallback.
|
||||
*/
|
||||
export function createVocabularyDialog({api, scope, createCommand, epoch, categories, reload, settled, heading, tagAction, categoryAction}) {
|
||||
const dialog = el('dialog', undefined, 'vocabulary-dialog');
|
||||
dialog.setAttribute('aria-labelledby', 'vocabulary-dialog-title');
|
||||
const header = el('div', undefined, 'dialog-header');
|
||||
const title = el('h2');
|
||||
title.id = 'vocabulary-dialog-title';
|
||||
const close = createButton('×');
|
||||
close.setAttribute('aria-label', 'Close dialog');
|
||||
const body = el('div');
|
||||
header.append(title, close);
|
||||
dialog.append(header, body);
|
||||
let session = null, disposed = false;
|
||||
|
||||
const busy = () => session?.command.state.phase === 'saving';
|
||||
close.addEventListener('click', () => finish());
|
||||
dialog.addEventListener('cancel', event => {
|
||||
event.preventDefault();
|
||||
if (!busy()) finish();
|
||||
});
|
||||
// A browser may still force-close a modal; treat that as a dismissal.
|
||||
dialog.addEventListener('close', () => {if (!dialog.open) finish();});
|
||||
|
||||
/** Close without writing. Focus goes to `target`, else the trigger, else the page heading. */
|
||||
function finish(target = null) {
|
||||
if (!session) return;
|
||||
const {command, trigger, views} = session;
|
||||
session = null;
|
||||
views.abort();
|
||||
command.dispose();
|
||||
if (dialog.open) dialog.close();
|
||||
dialog.remove();
|
||||
(target ?? (trigger?.isConnected ? trigger : heading()))?.focus();
|
||||
}
|
||||
|
||||
/** A confirmed write: toast, close, then focus the resulting row once the re-read has landed. */
|
||||
async function complete(kind, receipt, focusTarget) {
|
||||
showWorkspaceToast(toastOf(kind, receipt));
|
||||
finish(heading());
|
||||
await settled();
|
||||
if (disposed || session) return;
|
||||
focusTarget()?.focus();
|
||||
}
|
||||
|
||||
function show(text, view) {
|
||||
title.textContent = text;
|
||||
body.replaceChildren(...view);
|
||||
if (!dialog.isConnected) document.body.append(dialog);
|
||||
if (!dialog.open) dialog.showModal();
|
||||
close.focus();
|
||||
}
|
||||
|
||||
/** Open a new view; an open dialog swaps its content, as the concept does. */
|
||||
function start(trigger, build) {
|
||||
if (disposed) return;
|
||||
const previous = session;
|
||||
if (previous) {
|
||||
previous.views.abort();
|
||||
previous.command.dispose();
|
||||
}
|
||||
const views = new AbortController();
|
||||
const next = {trigger: trigger ?? previous?.trigger ?? null, views, epoch: previous?.epoch ?? epoch(), render: () => {}};
|
||||
next.command = createCommand(state => {if (session === next) next.render(state);});
|
||||
session = next;
|
||||
build(next);
|
||||
}
|
||||
|
||||
/** One status line, an optional Reload, and focus kept inside the dialog when a disabled control drops it. */
|
||||
function chrome(views) {
|
||||
const note = el('p', '', 'warning-note');
|
||||
note.id = 'vocabulary-dialog-status';
|
||||
note.setAttribute('role', 'status');
|
||||
note.hidden = true;
|
||||
const actions = el('div', undefined, 'dialog-actions');
|
||||
const reloadButton = createButton('Reload');
|
||||
reloadButton.addEventListener('click', () => {finish(heading()); reload();}, {signal: views.signal});
|
||||
return {note, actions, reloadButton, set(text) {
|
||||
if (note.textContent !== (text || '')) note.textContent = text || '';
|
||||
note.hidden = !text;
|
||||
}, refocus(...candidates) {
|
||||
if (!dialog.open || (document.activeElement && document.activeElement !== document.body)) return;
|
||||
(candidates.find(node => node?.isConnected && !node.disabled && !node.hidden) ?? close).focus();
|
||||
}};
|
||||
}
|
||||
|
||||
/** The epoch this dialog opened with; null until the lists have loaded once. */
|
||||
function openedEpoch(current) {
|
||||
current.epoch ??= epoch();
|
||||
return current.epoch;
|
||||
}
|
||||
|
||||
/** Create tag and Rename or merge tag: one required name field. */
|
||||
function nameView(current, {row}) {
|
||||
const {command, views} = current;
|
||||
const form = el('form');
|
||||
const input = el('input');
|
||||
input.required = true;
|
||||
if (row) input.value = row.name;
|
||||
const ui = chrome(views);
|
||||
const remove = row ? createButton('Delete tag…', 'danger') : null;
|
||||
const cancel = createButton('Cancel'), apply = createButton('Apply', 'ca-primary');
|
||||
apply.type = 'submit';
|
||||
form.append(field(row ? 'New name; existing names merge' : 'Tag name', input, row ? 'vocabulary-new-name' : 'vocabulary-tag-name'),
|
||||
ui.note, ui.actions);
|
||||
cancel.addEventListener('click', () => finish(), {signal: views.signal});
|
||||
remove?.addEventListener('click', () => start(null, next => deleteView(next, row)), {signal: views.signal});
|
||||
let message = null;
|
||||
form.addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
const expected = openedEpoch(current);
|
||||
if (!expected) {message = NOT_LOADED; render(command.state); return;}
|
||||
message = null;
|
||||
// After an unconfirmed result the user checked the lists; applying again is their explicit choice.
|
||||
if (command.state.phase === 'unknown-outcome') command.reset();
|
||||
await (row ? command.renameTag(row, input.value, expected) : command.createTag(input.value, expected));
|
||||
const {phase, receipt, kind} = command.state;
|
||||
if (phase === 'done' && receipt) complete(kind, receipt, () => tagAction(receipt.tag.id));
|
||||
}, {signal: views.signal});
|
||||
|
||||
function render(state) {
|
||||
const status = statusOf(state, {conflict: TAG_CONFLICT, saving: SAVING, failed: 'Not changed'});
|
||||
ui.set(status?.text ?? message);
|
||||
const saving = state.phase === 'saving';
|
||||
apply.disabled = BLOCKED.has(state.phase) || state.phase === 'conflict';
|
||||
cancel.disabled = close.disabled = saving;
|
||||
if (remove) remove.disabled = saving;
|
||||
setChildren(ui.actions, [...(status?.reload ? [ui.reloadButton] : []), ...(remove ? [remove] : []), cancel, apply]);
|
||||
if (!saving && state.phase !== 'done') ui.refocus(status?.reload ? ui.reloadButton : null, apply, cancel);
|
||||
}
|
||||
current.render = render;
|
||||
show(row ? 'Rename or merge tag' : 'Create tag', [form]);
|
||||
render(command.state);
|
||||
}
|
||||
|
||||
/** The Delete tag confirmation: the exact name and its real links, then Cancel / Delete. */
|
||||
function deleteView(current, row) {
|
||||
const {command, views} = current;
|
||||
const impactText = el('p', '', 'small');
|
||||
impactText.hidden = true;
|
||||
const ui = chrome(views);
|
||||
const retry = createButton('Retry');
|
||||
const cancel = createButton('Cancel'), confirm = createButton('Delete', 'danger');
|
||||
let read = 'loading';
|
||||
cancel.addEventListener('click', () => finish(), {signal: views.signal});
|
||||
retry.addEventListener('click', () => {
|
||||
// After an unconfirmed delete, Retry only re-reads the impact; it never resends the delete.
|
||||
if (command.state.phase === 'unknown-outcome') command.reset();
|
||||
readImpact();
|
||||
}, {signal: views.signal});
|
||||
confirm.addEventListener('click', async () => {
|
||||
await command.deleteTag(row, current.epoch);
|
||||
const {phase, receipt, kind} = command.state;
|
||||
if (phase === 'done' && receipt) complete(kind, receipt, () => null);
|
||||
}, {signal: views.signal});
|
||||
|
||||
async function readImpact() {
|
||||
read = 'loading';
|
||||
render(command.state);
|
||||
try {
|
||||
const value = await readTagImpact(api, row, {signal: views.signal});
|
||||
if (views.signal.aborted) return;
|
||||
const expected = openedEpoch(current);
|
||||
if (expected && value.library_epoch !== expected) read = 'restored';
|
||||
else {
|
||||
read = 'ready';
|
||||
impactText.textContent = deleteTagText(row.name, value.impact);
|
||||
}
|
||||
} catch (error) {
|
||||
if (views.signal.aborted) return;
|
||||
// A missing tag is a changed tag: the lists are re-read for review.
|
||||
read = error?.status === 404 ? 'conflict' : 'failed';
|
||||
if (read === 'conflict') reload();
|
||||
}
|
||||
render(command.state);
|
||||
}
|
||||
|
||||
function render(state) {
|
||||
const status = statusOf(state, {conflict: TAG_CONFLICT, saving: DELETING, failed: 'Not deleted', kept: ''})
|
||||
?? {loading: {text: CHECKING}, failed: {text: IMPACT_FAILED, retry: true}, restored: {text: RESTORED, reload: true},
|
||||
conflict: {text: TAG_CONFLICT}}[read] ?? null;
|
||||
ui.set(status?.text);
|
||||
const stale = STALE_IMPACT.has(state.phase);
|
||||
impactText.hidden = read !== 'ready' || stale;
|
||||
const saving = state.phase === 'saving';
|
||||
confirm.disabled = read !== 'ready' || saving || stale;
|
||||
cancel.disabled = close.disabled = saving;
|
||||
const recovery = status?.reload ? ui.reloadButton : status?.retry || state.phase === 'unknown-outcome' ? retry : null;
|
||||
setChildren(ui.actions, [...(recovery ? [recovery] : []), cancel, confirm]);
|
||||
if (!saving && state.phase !== 'done') ui.refocus(recovery, confirm, cancel);
|
||||
}
|
||||
current.render = render;
|
||||
show('Delete tag', [impactText, ui.note, ui.actions]);
|
||||
readImpact();
|
||||
}
|
||||
|
||||
/** Manage categories: Category select, proposed name, Preview then Apply. */
|
||||
function manageView(current) {
|
||||
const {command, views} = current;
|
||||
const form = el('form');
|
||||
const {stream} = categories;
|
||||
// A category command carries the category list's own epoch.
|
||||
current.epoch = stream.state.epoch;
|
||||
const source = el('select');
|
||||
source.required = true;
|
||||
const target = el('input');
|
||||
target.required = true;
|
||||
// Its own id: the recipe editor already owns `datalist#categories`.
|
||||
target.setAttribute('list', 'vocabulary-category-options');
|
||||
const list = el('datalist');
|
||||
list.id = 'vocabulary-category-options';
|
||||
const retry = createButton('Retry');
|
||||
retry.addEventListener('click', () => stream.loadMore(), {signal: views.signal});
|
||||
let drain = 'loading', shown = null;
|
||||
function fill(values) {
|
||||
const key = values.join('\n');
|
||||
if (key === shown) return;
|
||||
shown = key;
|
||||
const selected = source.value;
|
||||
source.replaceChildren(...values.map(value => new Option(value, value)));
|
||||
if (values.includes(selected)) source.value = selected;
|
||||
list.replaceChildren(...values.map(value => Object.assign(document.createElement('option'), {value})));
|
||||
}
|
||||
/** Read the shared category list to its end (at most CATEGORY_CAP) before the select is offered. */
|
||||
function sync() {
|
||||
const state = stream.state;
|
||||
if (state.resetRequired) drain = 'restored';
|
||||
else if (state.error) drain = 'failed';
|
||||
// A reset emits its empty first state before it starts reading; the stream itself reads page one.
|
||||
else if (state.loading || state.epoch === null) drain = 'loading';
|
||||
else if (state.hasMore && state.items.length < CATEGORY_CAP) {
|
||||
drain = 'loading';
|
||||
stream.loadMore();
|
||||
return;
|
||||
} else {
|
||||
fill(state.items.slice(0, CATEGORY_CAP).map(item => item.value));
|
||||
drain = state.hasMore || state.items.length > CATEGORY_CAP ? 'capped' : 'ready';
|
||||
}
|
||||
render(command.state);
|
||||
}
|
||||
views.signal.addEventListener('abort', categories.subscribe(() => sync()), {once: true});
|
||||
const impactText = el('p', '', 'small');
|
||||
impactText.hidden = true;
|
||||
const ui = chrome(views);
|
||||
const cancel = createButton('Cancel'), primary = createButton('Preview', 'ca-primary');
|
||||
primary.type = 'submit';
|
||||
form.append(field('Category', source, 'vocabulary-category'), field('Proposed new category name', target, 'vocabulary-category-name'),
|
||||
list, impactText, ui.note, ui.actions);
|
||||
cancel.addEventListener('click', () => finish(), {signal: views.signal});
|
||||
for (const [control, type] of [[source, 'change'], [target, 'input']]) {
|
||||
control.addEventListener(type, () => command.edit(), {signal: views.signal});
|
||||
}
|
||||
let message = null;
|
||||
form.addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
const {phase, preview} = command.state;
|
||||
if (phase === 'previewed') {
|
||||
await command.applyCategory(preview);
|
||||
const {receipt, kind} = command.state;
|
||||
if (command.state.phase === 'done' && receipt) complete(kind, receipt, () => categoryAction(receipt.target));
|
||||
return;
|
||||
}
|
||||
const expected = current.epoch ??= stream.state.epoch;
|
||||
if (!expected) {message = NOT_LOADED; render(command.state); return;}
|
||||
message = null;
|
||||
if (phase === 'unknown-outcome') command.reset();
|
||||
await command.previewCategory(source.value, target.value, expected);
|
||||
}, {signal: views.signal});
|
||||
|
||||
function render(state) {
|
||||
const previewed = state.phase === 'previewed';
|
||||
const status = statusOf(state, {conflict: CATEGORY_CONFLICT, saving: SAVING, failed: 'Not changed'})
|
||||
?? (message ? {text: message} : null)
|
||||
?? {loading: {text: CATEGORIES_LOADING}, failed: {text: CATEGORIES_FAILED, retry: true},
|
||||
restored: {text: RESTORED, reload: true}, capped: {text: CATEGORIES_CAPPED}}[drain] ?? null;
|
||||
ui.set(status?.text);
|
||||
if (previewed) impactText.textContent = categoryPreviewText(state.preview);
|
||||
impactText.hidden = !previewed;
|
||||
primary.textContent = previewed ? 'Apply' : 'Preview';
|
||||
const saving = state.phase === 'saving';
|
||||
primary.disabled = BLOCKED.has(state.phase) || !['ready', 'capped'].includes(drain);
|
||||
cancel.disabled = close.disabled = saving;
|
||||
const recovery = status?.reload ? ui.reloadButton : status?.retry ? retry : null;
|
||||
setChildren(ui.actions, [...(recovery ? [recovery] : []), cancel, primary]);
|
||||
if (!saving && state.phase !== 'done') ui.refocus(recovery, primary, cancel);
|
||||
}
|
||||
current.render = render;
|
||||
show('Manage categories', [form]);
|
||||
sync();
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
if (disposed) return;
|
||||
finish();
|
||||
disposed = true;
|
||||
}
|
||||
scope.cleanup(dispose);
|
||||
return {
|
||||
createTag: trigger => start(trigger, current => nameView(current, {row: null})),
|
||||
renameTag: (row, trigger) => start(trigger, current => nameView(current, {row})),
|
||||
manageCategories: trigger => start(trigger, current => manageView(current)),
|
||||
dispose,
|
||||
};
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
/**
|
||||
* Native Tags & categories state: bounded vocabulary pages and guarded commands.
|
||||
*
|
||||
* UI-agnostic. The page renders `stream.state` / `command.state` and calls their
|
||||
* methods. Reads are owned by their stream: reset() or dispose() aborts the read
|
||||
* in flight and a superseded response never lands. Writes are sent once, without
|
||||
* an abort signal (aborting cannot undo a write), and are never replayed.
|
||||
*
|
||||
* Category pages are keyed by each value's lowest prompt id, which can move
|
||||
* between pages after an edit, so appended rows are deduplicated (first wins)
|
||||
* and every command that may have changed storage calls `onInvalidate` so the
|
||||
* page resets its streams to the first page.
|
||||
*/
|
||||
import { countOf } from './plural.mjs';
|
||||
|
||||
const LIMIT = 100;
|
||||
const BASE = '/prompt_manager/vocabulary';
|
||||
const KINDS = {
|
||||
tags: {key: row => row.id, valid: row => id(row.id) && typeof row.name === 'string' && row.name.length > 0},
|
||||
categories: {key: row => row.value, valid: row => typeof row.value === 'string' && row.value.length > 0},
|
||||
};
|
||||
const RECOVERY = {'storage-changed': 'storage-changed', 'vocabulary-conflict': 'conflict'};
|
||||
// A proxy timeout or bad gateway cannot tell whether the server committed the write.
|
||||
const UNCONFIRMED_STATUS = new Set([502, 503, 504]);
|
||||
// Phases that may send a command; done, storage-changed and unknown-outcome need reset().
|
||||
const READY = new Set(['idle', 'previewed', 'write-error', 'conflict']);
|
||||
const IMPACT_FIELDS = ['positive_prompts', 'other_prompts', 'negative_variants', 'archived_negative_variants'];
|
||||
|
||||
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
const id = value => Number.isSafeInteger(value) && value > 0;
|
||||
const count = value => Number.isSafeInteger(value) && value >= 0;
|
||||
const epochOf = value => typeof value === 'string' && /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(value);
|
||||
const hexToken = value => typeof value === 'string' && /^[0-9a-f]{64}$/.test(value);
|
||||
|
||||
function requireValue(valid, message) {if (!valid) throw new TypeError(message);}
|
||||
function freeze(value) {
|
||||
if (value && typeof value === 'object' && !Object.isFrozen(value)) {Object.values(value).forEach(freeze); Object.freeze(value);}
|
||||
return value;
|
||||
}
|
||||
function storageChanged() {
|
||||
return Object.assign(new Error('Prompt storage was restored. Reload the lists before continuing.'), {code: 'storage-changed'});
|
||||
}
|
||||
// Duck-typed by name: this ES module cannot import the UMD api-client.js.
|
||||
function isApiError(error) {return error instanceof Error && error.name === 'ApiError' && Number.isInteger(error.status);}
|
||||
|
||||
function validatePage(page, kind, cursor) {
|
||||
const rows = page?.[kind];
|
||||
const {key, valid} = KINDS[kind];
|
||||
requireValue(object(page) && page.success === true && epochOf(page.library_epoch) && Array.isArray(rows) &&
|
||||
rows.every(row => object(row) && valid(row) && count(row.prompt_count)) &&
|
||||
new Set(rows.map(key)).size === rows.length && count(page.total) && typeof page.has_more === 'boolean' &&
|
||||
(page.next_cursor === null || (typeof page.next_cursor === 'string' && page.next_cursor.length > 0)) &&
|
||||
page.has_more === (page.next_cursor !== null) && !(page.has_more && page.next_cursor === cursor),
|
||||
`Invalid ${kind} page`);
|
||||
}
|
||||
|
||||
/** One bounded, append-only list of tags or categories. */
|
||||
export function createVocabularyStream({api, kind, onChange = () => {}}) {
|
||||
requireValue(Object.hasOwn(KINDS, kind), 'Unknown vocabulary kind');
|
||||
const {key} = KINDS[kind];
|
||||
const initial = () => ({items: Object.freeze([]), total: 0, cursor: null, hasMore: true, loading: false, error: null,
|
||||
epoch: null, resetRequired: false});
|
||||
let state = Object.freeze(initial());
|
||||
let request = null, disposed = false, seen = new Set();
|
||||
const emit = patch => {state = Object.freeze({...state, ...patch}); onChange(state);};
|
||||
|
||||
async function loadMore() {
|
||||
if (disposed || state.loading || !state.hasMore || state.resetRequired) return 0;
|
||||
const current = request = new AbortController();
|
||||
const cursor = state.cursor;
|
||||
const query = new URLSearchParams({limit: String(LIMIT)});
|
||||
if (cursor !== null) query.set('cursor', cursor);
|
||||
emit({loading: true, error: null});
|
||||
try {
|
||||
const page = await api.get(`${BASE}/${kind}?${query}`, {signal: current.signal, cache: 'no-store'});
|
||||
if (disposed || current !== request) return 0;
|
||||
validatePage(page, kind, cursor);
|
||||
request = null;
|
||||
if (state.epoch !== null && page.library_epoch !== state.epoch) {
|
||||
emit({loading: false, error: storageChanged(), resetRequired: true});
|
||||
return 0;
|
||||
}
|
||||
const incoming = page[kind].filter(row => !seen.has(key(row))).map(row => Object.freeze({...row}));
|
||||
incoming.forEach(row => seen.add(key(row)));
|
||||
emit({items: incoming.length ? Object.freeze([...state.items, ...incoming]) : state.items, total: page.total,
|
||||
cursor: page.next_cursor, hasMore: page.has_more, loading: false, error: null, epoch: page.library_epoch});
|
||||
return incoming.length;
|
||||
} catch (error) {
|
||||
if (!disposed && current === request) {request = null; emit({loading: false, error});}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
get state() {return state;},
|
||||
loadMore,
|
||||
/** Drop every loaded row and read again from the first page. */
|
||||
reset() {
|
||||
if (disposed) return Promise.resolve(0);
|
||||
request?.abort();
|
||||
request = null;
|
||||
seen = new Set();
|
||||
emit(initial());
|
||||
return loadMore();
|
||||
},
|
||||
dispose() {disposed = true; request?.abort(); request = null;},
|
||||
};
|
||||
}
|
||||
|
||||
function validateTag(value) {return object(value) && KINDS.tags.valid(value) && count(value.prompt_count);}
|
||||
function validateImpact(value) {return object(value) && IMPACT_FIELDS.every(name => count(value[name]));}
|
||||
function requireRow(row) {requireValue(object(row) && id(row.id) && typeof row.name === 'string', 'Invalid tag row');}
|
||||
function requireEpoch(epoch) {requireValue(epochOf(epoch), 'Invalid library epoch');}
|
||||
function requireText(value, name) {requireValue(typeof value === 'string', `${name} must be a string`);}
|
||||
|
||||
const RECEIPTS = {
|
||||
'create-tag': value => typeof value.created === 'boolean' && validateTag(value.tag),
|
||||
'rename-tag': value => typeof value.merged === 'boolean' && validateTag(value.tag) &&
|
||||
count(value.components_changed) && count(value.pairs_changed),
|
||||
'delete-tag': value => value.deleted === true && count(value.components_changed) && count(value.pairs_changed),
|
||||
'apply-category': (value, body) => value.source === body.source && value.target === body.target &&
|
||||
typeof value.consolidated === 'boolean' && validateImpact(value.impact),
|
||||
};
|
||||
|
||||
function writePhase(error) {
|
||||
if (!isApiError(error)) return 'unknown-outcome';
|
||||
if (error.status === 409 && Object.hasOwn(RECOVERY, error.code)) return RECOVERY[error.code];
|
||||
if ((error.status >= 200 && error.status < 300) || UNCONFIRMED_STATUS.has(error.status)) return 'unknown-outcome';
|
||||
return 'write-error';
|
||||
}
|
||||
|
||||
/**
|
||||
* One dialog's command. Phases: idle, saving, previewed, write-error, conflict,
|
||||
* storage-changed, unknown-outcome, done (and disposed). `onInvalidate({phase,
|
||||
* kind, receipt})` fires whenever stored vocabulary may differ from the loaded
|
||||
* lists: after a receipt, a conflict, a restore or an unconfirmed write, including
|
||||
* one that lands after `dispose()`.
|
||||
*/
|
||||
export function createVocabularyCommand({api, onChange = () => {}, onInvalidate = () => {}}) {
|
||||
let state = freeze({phase: 'idle', kind: null, error: null, preview: null, receipt: null});
|
||||
let disposed = false;
|
||||
// Set when the dialog's inputs change while a category preview is in flight.
|
||||
let stalePreview = false;
|
||||
const emit = patch => {state = freeze({...state, ...patch}); onChange(state);};
|
||||
const invalidate = () => onInvalidate(freeze({phase: state.phase, kind: state.kind, receipt: state.receipt}));
|
||||
|
||||
async function write(kind, method, url, body) {
|
||||
emit({phase: 'saving', kind, error: null, preview: null, receipt: null});
|
||||
let phase = 'done', error = null, receipt = null;
|
||||
try {
|
||||
const value = await api[method](url, body);
|
||||
if (object(value) && value.success === true && value.library_epoch === body.expected_epoch && RECEIPTS[kind](value, body)) {
|
||||
receipt = freeze({...value});
|
||||
} else {
|
||||
// The request reached the server, so the write may have committed.
|
||||
phase = 'unknown-outcome';
|
||||
error = new TypeError('The server response could not be confirmed.');
|
||||
}
|
||||
} catch (failure) {
|
||||
phase = writePhase(failure);
|
||||
error = failure;
|
||||
}
|
||||
if (disposed) {
|
||||
// A write that may have committed still invalidates cached Library views after the dialog went away
|
||||
// (forced close, Back); nothing is rendered then.
|
||||
if (phase !== 'write-error') onInvalidate(freeze({phase, kind, receipt}));
|
||||
return;
|
||||
}
|
||||
emit({phase, error, receipt});
|
||||
if (phase !== 'write-error') invalidate();
|
||||
}
|
||||
|
||||
async function preview(body) {
|
||||
emit({phase: 'saving', kind: 'preview-category', error: null, preview: null, receipt: null});
|
||||
stalePreview = false;
|
||||
try {
|
||||
const value = await api.post(`${BASE}/categories/preview`, body);
|
||||
requireValue(object(value) && value.success === true && value.library_epoch === body.expected_epoch &&
|
||||
value.source === body.source && value.target === body.target.trim() && typeof value.target_exists === 'boolean' &&
|
||||
validateImpact(value.impact) && hexToken(value.impact_token), 'Invalid category preview');
|
||||
// Inputs edited while it was pending: it describes values no longer on screen, so Preview is needed again.
|
||||
if (!disposed) emit(stalePreview ? {phase: 'idle'} : {phase: 'previewed', preview: freeze({...value})});
|
||||
} catch (error) {
|
||||
if (disposed) return;
|
||||
// A preview writes nothing: only a restore or a changed category needs recovery.
|
||||
const phase = isApiError(error) && error.status === 409 && Object.hasOwn(RECOVERY, error.code) ? RECOVERY[error.code] : 'write-error';
|
||||
if (phase === 'write-error' && stalePreview) {emit({phase: 'idle'}); return;}
|
||||
emit({phase, error});
|
||||
if (phase !== 'write-error') invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
const ready = () => !disposed && READY.has(state.phase);
|
||||
return {
|
||||
get state() {return state;},
|
||||
createTag(name, epoch) {
|
||||
requireText(name, 'Tag name');
|
||||
requireEpoch(epoch);
|
||||
if (!ready()) return Promise.resolve();
|
||||
return write('create-tag', 'post', `${BASE}/tags`, {expected_epoch: epoch, name});
|
||||
},
|
||||
renameTag(row, newName, epoch) {
|
||||
requireRow(row);
|
||||
requireText(newName, 'New name');
|
||||
requireEpoch(epoch);
|
||||
if (!ready()) return Promise.resolve();
|
||||
return write('rename-tag', 'patch', `${BASE}/tags/${row.id}`, {expected_epoch: epoch, name: row.name, new_name: newName});
|
||||
},
|
||||
deleteTag(row, epoch) {
|
||||
requireRow(row);
|
||||
requireEpoch(epoch);
|
||||
if (!ready()) return Promise.resolve();
|
||||
return write('delete-tag', 'post', `${BASE}/tags/${row.id}/delete`, {expected_epoch: epoch, name: row.name});
|
||||
},
|
||||
previewCategory(source, target, epoch) {
|
||||
requireText(source, 'Category');
|
||||
requireText(target, 'New category name');
|
||||
requireEpoch(epoch);
|
||||
if (!ready()) return Promise.resolve();
|
||||
return preview({expected_epoch: epoch, source, target});
|
||||
},
|
||||
/** Apply only the preview currently shown; anything else is ignored. */
|
||||
applyCategory(shown) {
|
||||
if (!ready() || state.phase !== 'previewed' || shown !== state.preview) return Promise.resolve();
|
||||
return write('apply-category', 'post', `${BASE}/categories/apply`, {expected_epoch: shown.library_epoch,
|
||||
source: shown.source, target: shown.target, impact_token: shown.impact_token});
|
||||
},
|
||||
/** The user changed a dialog input: a shown or pending preview no longer describes it. */
|
||||
edit() {
|
||||
if (disposed) return;
|
||||
if (state.phase === 'previewed') emit({phase: 'idle', preview: null});
|
||||
else if (state.phase === 'saving' && state.kind === 'preview-category') stalePreview = true;
|
||||
},
|
||||
/** Explicit user recovery (after re-reading the lists); never resends anything. */
|
||||
reset() {
|
||||
if (!disposed && state.phase !== 'saving') emit({phase: 'idle', kind: null, error: null, preview: null, receipt: null});
|
||||
},
|
||||
dispose() {
|
||||
disposed = true;
|
||||
state = freeze({...state, phase: 'disposed'});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Library filters for a row's Browse: exactly one constraint, the stored value. */
|
||||
export function browseFilters(kind, value) {
|
||||
requireValue(Object.hasOwn(KINDS, kind), 'Unknown vocabulary kind');
|
||||
requireValue(typeof value === 'string' && value.length > 0, 'Invalid vocabulary value');
|
||||
return kind === 'tags'
|
||||
? {text: '', category: '', tags: [value], sort: 'newest'}
|
||||
: {text: '', category: value, tags: [], sort: 'newest'};
|
||||
}
|
||||
|
||||
/** The exact tag's current owner counts, for the delete confirmation. Throws on any unconfirmed shape. */
|
||||
export function readTagImpact(api, row, {signal} = {}) {
|
||||
requireRow(row);
|
||||
return api.get(`${BASE}/tags/${row.id}/impact`, {signal, cache: 'no-store'}).then(value => {
|
||||
requireValue(object(value) && value.success === true && epochOf(value.library_epoch) && validateImpact(value.impact), 'Invalid tag impact');
|
||||
return freeze({library_epoch: value.library_epoch, impact: {...value.impact}});
|
||||
});
|
||||
}
|
||||
|
||||
/** Owner kinds with a nonzero count, in sentence form ("a, b and c"); empty when nothing is linked. */
|
||||
function impactList(impact) {
|
||||
const negatives = impact.negative_variants + impact.archived_negative_variants;
|
||||
const parts = [];
|
||||
if (impact.positive_prompts) parts.push(countOf(impact.positive_prompts, 'positive prompt'));
|
||||
if (negatives) {
|
||||
const archived = impact.archived_negative_variants ? ` (${impact.archived_negative_variants} archived)` : '';
|
||||
parts.push(`${countOf(negatives, 'negative variant')}${archived}`);
|
||||
}
|
||||
if (impact.other_prompts) parts.push(countOf(impact.other_prompts, 'other saved prompt'));
|
||||
return parts.length > 1 ? `${parts.slice(0, -1).join(', ')} and ${parts.at(-1)}` : parts.join('');
|
||||
}
|
||||
|
||||
/** The Manage categories preview sentence (decision D9); zero counts are left out. */
|
||||
export function categoryPreviewText(shown) {
|
||||
const outcome = shown.target_exists ? `consolidates it into the existing “${shown.target}”` : `renames it to “${shown.target}”`;
|
||||
return `“${shown.source}” is used by ${impactList(shown.impact)}. Apply ${outcome}.`;
|
||||
}
|
||||
|
||||
/** The Delete tag confirmation sentence (decision D14). */
|
||||
export function deleteTagText(name, impact) {
|
||||
const list = impactList(impact);
|
||||
if (!list) return `“${name}” is not linked to any saved prompt. Deleting it removes only the name.`;
|
||||
return `Deleting “${name}” removes the name and its links from ${list}. The prompts, negative variants, images and history themselves are kept.`;
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
/* Native Tags & categories: geometry and tokens follow the approved concept (style.css 79-99, 283-304, 709-731, 840-895, 1195-1202, 1408-1526). */
|
||||
.vocabulary-main :where(.two-col, .surface, .table-wrap, .operation > div) { min-inline-size: 0; }
|
||||
.vocabulary-main .two-col { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }
|
||||
.vocabulary-main .surface { padding: 22px; background: var(--ca-surface); border: 1px solid var(--ca-line); border-radius: 7px; }
|
||||
.vocabulary-main .surface + .surface { margin-top: 18px; }
|
||||
.vocabulary-main .surface :where(h2, h3, p) { margin: 0; line-height: 1.5; }
|
||||
.vocabulary-main .surface h2 { font-size: 17px; font-weight: 600; }
|
||||
.vocabulary-main .surface > p { font-size: 12px; color: var(--ca-muted); margin-top: 7px; }
|
||||
.vocabulary-main .row-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.ca-tools .vocabulary-main .row-actions button, .ca-tools .vocabulary-main .vocabulary-continuation button { min-height: 32px; font-size: 12px; }
|
||||
.vocabulary-main .operation { display: flex; justify-content: space-between; gap: 18px; align-items: center; padding: 15px 0; border-top: 1px solid var(--ca-line); }
|
||||
.vocabulary-main .operation h3 { font-size: 12px; font-weight: 600; overflow-wrap: anywhere; }
|
||||
.vocabulary-main .operation p { font-size: 11px; color: var(--ca-muted); margin-top: 5px; }
|
||||
.vocabulary-main .operation .row-actions { flex-shrink: 0; }
|
||||
.vocabulary-main .table-wrap { overflow-x: auto; }
|
||||
.vocabulary-main table { border-collapse: collapse; width: 100%; font-size: 12px; }
|
||||
.vocabulary-main caption { text-align: start; font-size: 13px; font-weight: 600; margin: 0 0 15px; }
|
||||
.vocabulary-main :where(th, td) { text-align: start; border-bottom: 1px solid var(--ca-line); padding: 13px 10px; }
|
||||
.vocabulary-main th { color: var(--ca-muted); font-size: 10px; font-weight: 500; }
|
||||
/* Long tag names wrap inside the Tag cell so the Actions column keeps its intrinsic width and no row action is clipped;
|
||||
on two-column screens the minimum keeps short names (the concept's longest sample, "architecture", is about 5em) on
|
||||
one line, as the concept shows. */
|
||||
.vocabulary-main td:first-child { overflow-wrap: anywhere; min-inline-size: calc(6em + 20px); }
|
||||
.vocabulary-main td .row-actions { justify-content: flex-end; }
|
||||
.vocabulary-main .empty { padding: 50px; text-align: center; border: 1px dashed var(--ca-line); border-radius: 6px; color: var(--ca-muted); }
|
||||
/* Never a scroll anchor: appended rows must push the end of the list away, not chain further pages. */
|
||||
.vocabulary-main .vocabulary-continuation { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; padding-block: 15px 0; overflow-anchor: none; }
|
||||
.vocabulary-main .vocabulary-continuation p { margin: 0; flex: 1 1 200px; font-size: 12px; color: var(--ca-muted); }
|
||||
.vocabulary-main .vocabulary-continuation .vocabulary-error { color: var(--ca-error-text); }
|
||||
/* Focus rings come from the toolkit (2px --ca-focus, offset 2px), which is what the concept renders: its own 3px rule loses to controls.css. */
|
||||
@media (max-width: 700px) {
|
||||
/* Concept .heading stacks with align-items: flex-start (style.css 1408-1414); the shared shell computes `start`. */
|
||||
.vocabulary-main .workspace-heading { align-items: flex-start; }
|
||||
.vocabulary-main .two-col { grid-template-columns: 1fr; }
|
||||
.vocabulary-main .operation { align-items: flex-start; flex-direction: column; }
|
||||
.vocabulary-main .operation .row-actions { flex-wrap: wrap; }
|
||||
.vocabulary-main .table-wrap { margin-inline: -10px; }
|
||||
/* One column: the fit must not depend on font metrics. The Tag column has no fixed minimum, and the Actions column
|
||||
asks for no more than its stacked width (the concept's 390px rows stack Browse over Rename / merge), so all space
|
||||
left after Prompts, Images and the stacked actions goes to the tag name. Short names stay on one line wherever the
|
||||
concept's own table fits; with a wider fallback font the name wraps instead of pushing an action out of view. */
|
||||
.vocabulary-main td:first-child { min-inline-size: 0; }
|
||||
.vocabulary-main td .row-actions { inline-size: min-content; margin-inline-start: auto; }
|
||||
}
|
||||
@media (pointer: coarse) {
|
||||
.ca-tools .vocabulary-main .row-actions button, .ca-tools .vocabulary-main .vocabulary-continuation button { min-height: 40px; }
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
/**
|
||||
* Native Tags & categories screen (concept renderTags, app.js:719-729).
|
||||
*
|
||||
* Composition, labels and order follow the approved concept; the recorded
|
||||
* deviations are Auto-tag images omitted (no image-tag owner yet), Images cells
|
||||
* "—" / "Not available yet", and bounded lists with Load more. Rows are real
|
||||
* vocabulary pages from vocabulary-state.mjs; Browse hands the exact constraint
|
||||
* to the existing Library filters.
|
||||
*/
|
||||
import { createButton } from '../shared/toolkit/core.js';
|
||||
import { browseFilters, createVocabularyCommand, createVocabularyStream } from './vocabulary-state.mjs';
|
||||
import { countOf } from './plural.mjs';
|
||||
import { browseLibrary, invalidateLibraryAfterWrite } from './library.mjs';
|
||||
import { createVocabularyDialog } from './vocabulary-dialog.mjs';
|
||||
|
||||
const COPY = {
|
||||
tags: {empty: 'No tags yet.', error: 'Tags could not be loaded. Nothing was changed.', more: 'Load more tags'},
|
||||
categories: {empty: 'No categories yet.', error: 'Categories could not be loaded. Nothing was changed.', more: 'Load more categories'},
|
||||
};
|
||||
// Append the next page this far before the end of a list comes into view.
|
||||
const LOAD_MARGIN = 250;
|
||||
|
||||
function el(tag, text, className) {
|
||||
const node = document.createElement(tag);
|
||||
if (text !== undefined) node.textContent = text;
|
||||
if (className) node.className = className;
|
||||
return node;
|
||||
}
|
||||
|
||||
/** The concept's `.operation` row: h3 title, p description, one row action. */
|
||||
function operation(title, description, label) {
|
||||
const row = el('div', undefined, 'operation');
|
||||
const copy = el('div');
|
||||
copy.append(el('h3', title), el('p', description));
|
||||
const actions = el('div', undefined, 'row-actions');
|
||||
const button = createButton(label);
|
||||
actions.append(button);
|
||||
row.append(copy, actions);
|
||||
return {row, button};
|
||||
}
|
||||
|
||||
function tagRow(tag) {
|
||||
const row = el('tr');
|
||||
const images = el('td');
|
||||
const dash = el('span', '—');
|
||||
dash.setAttribute('aria-hidden', 'true');
|
||||
images.append(dash, el('span', 'Not available yet', 'ca-visually-hidden'));
|
||||
const cell = el('td');
|
||||
const actions = el('div', undefined, 'row-actions');
|
||||
const browse = createButton('Browse');
|
||||
browse.dataset.action = 'browse';
|
||||
const rename = createButton('Rename / merge');
|
||||
rename.dataset.action = 'rename';
|
||||
actions.append(browse, rename);
|
||||
cell.append(actions);
|
||||
row.append(el('td', tag.name), el('td', String(tag.prompt_count)), images, cell);
|
||||
return row;
|
||||
}
|
||||
|
||||
function categoryRow(category) {
|
||||
const {row, button} = operation(category.value, countOf(category.prompt_count, 'positive prompt'), 'Browse');
|
||||
button.dataset.action = 'browse';
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* One bounded list: rows append as pages arrive, the end of the list loads the
|
||||
* next page each time it scrolls into view, and `Load more` stays available while
|
||||
* more remain. Only a new intersection appends: when scroll anchoring keeps the end
|
||||
* in view (stacked layout below 700px), pages do not chain on their own. Before any row arrives (loading, failed) the list shows no rows, so a
|
||||
* failed read never looks like zero counts.
|
||||
*/
|
||||
function mountList(scope, api, kind, {content, rowsHost, renderRow, after}) {
|
||||
const copy = COPY[kind];
|
||||
const empty = el('p', copy.empty, 'empty');
|
||||
const continuation = el('div', undefined, 'vocabulary-continuation');
|
||||
const status = el('p', '', 'vocabulary-status');
|
||||
status.setAttribute('role', 'status');
|
||||
const alert = el('p', copy.error, 'vocabulary-error');
|
||||
alert.setAttribute('role', 'alert');
|
||||
const retry = createButton('Retry');
|
||||
const more = createButton(copy.more);
|
||||
continuation.append(status, alert, retry, more);
|
||||
after.after(empty, continuation);
|
||||
const items = new WeakMap();
|
||||
const listeners = new Set();
|
||||
let rendered = 0, first = null;
|
||||
|
||||
function maybeLoad() {
|
||||
const {state} = stream;
|
||||
if (scope.signal.aborted || !state.hasMore || state.loading || state.error || state.resetRequired) return;
|
||||
const bounds = continuation.getBoundingClientRect();
|
||||
if (bounds.top <= window.innerHeight + LOAD_MARGIN && bounds.bottom >= -LOAD_MARGIN) stream.loadMore();
|
||||
}
|
||||
function render(state) {
|
||||
// A reset starts again from page one; otherwise pages only append.
|
||||
if (state.items.length < rendered || (rendered && state.items[0] !== first)) {
|
||||
rowsHost.replaceChildren();
|
||||
rendered = 0;
|
||||
}
|
||||
const appended = [];
|
||||
for (const item of state.items.slice(rendered)) {
|
||||
const row = renderRow(item);
|
||||
items.set(row, item);
|
||||
appended.push(row);
|
||||
}
|
||||
rowsHost.append(...appended);
|
||||
rendered = state.items.length;
|
||||
first = state.items[0] ?? null;
|
||||
const failed = Boolean(state.error);
|
||||
content.hidden = !rendered;
|
||||
empty.hidden = Boolean(rendered || state.loading || failed || state.hasMore);
|
||||
status.textContent = state.loading ? 'Loading…' : '';
|
||||
status.hidden = !state.loading;
|
||||
alert.hidden = !failed;
|
||||
retry.hidden = !failed;
|
||||
const hideMore = !state.hasMore || failed || !rendered;
|
||||
// Keyboard users keep a focus target: Retry after a failed read, else the first new (or first) row.
|
||||
if (hideMore && document.activeElement === more) {
|
||||
(failed ? retry : appended[0]?.querySelector('button') ?? rowsHost.querySelector('button'))?.focus();
|
||||
}
|
||||
more.hidden = hideMore;
|
||||
more.setAttribute('aria-disabled', String(state.loading));
|
||||
continuation.hidden = !(state.loading || failed || state.hasMore);
|
||||
for (const listener of listeners) listener(state);
|
||||
}
|
||||
const stream = createVocabularyStream({api, kind, onChange: render});
|
||||
scope.listen(more, 'click', () => stream.loadMore());
|
||||
scope.listen(retry, 'click', () => (stream.state.resetRequired ? stream.reset() : stream.loadMore()));
|
||||
let observer = null;
|
||||
if (typeof IntersectionObserver === 'function') {
|
||||
observer = new IntersectionObserver(entries => {
|
||||
if (entries.some(entry => entry.isIntersecting)) maybeLoad();
|
||||
}, {rootMargin: `${LOAD_MARGIN}px`});
|
||||
observer.observe(continuation);
|
||||
}
|
||||
scope.cleanup(() => {
|
||||
stream.dispose();
|
||||
observer?.disconnect();
|
||||
});
|
||||
render(stream.state);
|
||||
/** The rendered row whose item matches, if it is loaded. */
|
||||
function rowFor(match) {
|
||||
return [...rowsHost.children].find(row => {const item = items.get(row); return item !== undefined && match(item);}) ?? null;
|
||||
}
|
||||
/** Follow this list's state (a dialog reading further pages); returns the unsubscribe. */
|
||||
function subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
return {stream, itemOf: row => items.get(row), rowFor, subscribe};
|
||||
}
|
||||
|
||||
/** Mount the screen into `main` below the shell's heading; `scope` owns every request, listener and observer. */
|
||||
export function mountVocabulary(main, api, scope) {
|
||||
main.classList.add('vocabulary-main');
|
||||
scope.cleanup(() => main.classList.remove('vocabulary-main'));
|
||||
const heading = main.querySelector('.workspace-heading');
|
||||
heading.querySelector('.workspace-heading-copy').append(el('p', 'Organize once. Reuse everywhere.', 'intro'));
|
||||
const actions = el('div', undefined, 'heading-actions');
|
||||
const create = createButton('+ Create tag');
|
||||
actions.append(create);
|
||||
heading.append(actions);
|
||||
|
||||
const grid = el('div', undefined, 'two-col');
|
||||
const tagSurface = el('section', undefined, 'surface');
|
||||
const wrap = el('div', undefined, 'table-wrap');
|
||||
const table = el('table');
|
||||
const head = el('thead');
|
||||
const headRow = el('tr');
|
||||
for (const label of ['Tag', 'Prompts', 'Images', 'Actions']) {
|
||||
const cell = el('th', label);
|
||||
cell.scope = 'col';
|
||||
headRow.append(cell);
|
||||
}
|
||||
head.append(headRow);
|
||||
const body = el('tbody');
|
||||
table.append(el('caption', 'Reusable tags'), head, body);
|
||||
wrap.append(table);
|
||||
tagSurface.append(el('h2', 'Tag vocabulary'),
|
||||
el('p', 'Category/tag autocomplete uses this same vocabulary across nodes and dialogs.'), wrap);
|
||||
|
||||
const categorySurface = el('section', undefined, 'surface');
|
||||
const categoryRows = el('div', undefined, 'vocabulary-categories');
|
||||
const manage = operation('Manage category', 'Rename or consolidate a category with a preview of affected recipes.', 'Review');
|
||||
categorySurface.append(el('h2', 'Categories'),
|
||||
el('p', 'Categories provide a broad home. Tags describe content; proposed collections group projects without changing either.'),
|
||||
categoryRows, manage.row);
|
||||
grid.append(tagSurface, categorySurface);
|
||||
main.append(grid);
|
||||
|
||||
const tags = mountList(scope, api, 'tags', {content: wrap, rowsHost: body, renderRow: tagRow, after: wrap});
|
||||
const categories = mountList(scope, api, 'categories', {content: categoryRows, rowsHost: categoryRows, renderRow: categoryRow, after: categoryRows});
|
||||
scope.listen(body, 'click', event => {
|
||||
const button = event.target.closest('button[data-action="browse"]');
|
||||
const tag = button && tags.itemOf(button.closest('tr'));
|
||||
if (tag) browseLibrary(browseFilters('tags', tag.name));
|
||||
});
|
||||
scope.listen(categoryRows, 'click', event => {
|
||||
const button = event.target.closest('button[data-action="browse"]');
|
||||
const category = button && categories.itemOf(button.closest('.operation'));
|
||||
if (category) browseLibrary(browseFilters('categories', category.value));
|
||||
});
|
||||
|
||||
let settled = Promise.resolve();
|
||||
/** Re-read both lists from page one: after any command, rows can move between pages. */
|
||||
function reload() {
|
||||
settled = Promise.all([tags.stream.reset(), categories.stream.reset()]).then(() => undefined);
|
||||
return settled;
|
||||
}
|
||||
/** A guarded command for one dialog; any outcome that may have changed storage re-reads the lists and the Library shelf. */
|
||||
function createCommand(onChange) {
|
||||
return createVocabularyCommand({api, onChange, onInvalidate: event => {
|
||||
invalidateLibraryAfterWrite({kind: 'vocabulary', receipt: event.receipt || {phase: event.phase}});
|
||||
reload();
|
||||
}});
|
||||
}
|
||||
const h1 = main.querySelector('h1');
|
||||
const actionOf = (row, action) => row?.querySelector(`button[data-action="${action}"]`) ?? null;
|
||||
const dialog = createVocabularyDialog({api, scope, createCommand,
|
||||
epoch: () => tags.stream.state.epoch ?? categories.stream.state.epoch,
|
||||
categories: {stream: categories.stream, subscribe: categories.subscribe},
|
||||
reload,
|
||||
// Rows are replaced on every reset, so a result's row exists only once the re-read has landed.
|
||||
settled: () => settled,
|
||||
heading: () => h1,
|
||||
tagAction: id => actionOf(tags.rowFor(item => item.id === id), 'rename'),
|
||||
categoryAction: value => actionOf(categories.rowFor(item => item.value === value), 'browse')});
|
||||
scope.listen(create, 'click', () => dialog.createTag(create));
|
||||
scope.listen(manage.button, 'click', () => dialog.manageCategories(manage.button));
|
||||
scope.listen(body, 'click', event => {
|
||||
const button = event.target.closest('button[data-action="rename"]');
|
||||
const tag = button && tags.itemOf(button.closest('tr'));
|
||||
if (tag) dialog.renameTag(tag, button);
|
||||
});
|
||||
reload();
|
||||
return {create, manage: manage.button, tags: tags.stream, categories: categories.stream, reload, createCommand, dialog};
|
||||
}
|
||||
@@ -12,7 +12,6 @@
|
||||
<link rel="stylesheet" href="js/workspace/library.css">
|
||||
<link rel="stylesheet" href="js/workspace/inspector.css">
|
||||
<link rel="stylesheet" href="js/workspace/library-editor.css">
|
||||
<link rel="stylesheet" href="js/workspace/vocabulary.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>
|
||||
|
||||
Reference in New Issue
Block a user