diff --git a/TESTING.md b/TESTING.md index 354e648..ec44240 100644 --- a/TESTING.md +++ b/TESTING.md @@ -50,6 +50,13 @@ stdin open and the server (`--exit-on-stdin-eof`) shuts down cleanly, removing i temp root, when that pipe closes, so a worker killed without teardown leaves no server behind. A test that fails gets the stderr its server wrote during the test attached to the report as `server-stderr` (an aiohttp traceback behind a 500). +A `/__test__/.../reset` still running after 2 s writes a report to the job log, +passing test or not: every thread's stack taken while it waits, the default +executor's threads and queue, the open connections and WAL size, then the total +and how long each executor call queued and ran. Its lines start with +`pm-slow-reset| ` (`tests/browser/slow_reset_watch.py`). The report never reads a +connection another thread is using: one that is busy inside SQLite would block +that read with the GIL held and freeze the whole server. The coverage run can be split the way CI splits it, and each shard keeps the raw V8 files it writes under `coverage/js/raw`: @@ -398,6 +405,13 @@ naming it, here and in a detailer's image ancestry; a latent that is not a link 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. +A UI workflow node whose `type` is a list or an object (read only for the overwritten +PromptManager check) is skipped like a non-object node, since it can be neither a +subgraph instance nor a PromptManager node, so a prompt that resolves still resolves +and the other nodes still feed the check; a list or object `class_type` is never a +preview output. Before #286 both raised and the inspect route answered 500 +(`test_unhashable_*` in `tests/test_api_prompt_inspector.py`, the `workflow-type-*` +golden cases on both sides). 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 @@ -861,9 +875,10 @@ Recorded deviations on this page (spec §8): - **D7**: the star is a real persisted favorite with "Added to favorites." / "Removed from favorites." and a rollback toast on failure. - **D8**: every Images card has the concept's select checkbox before the star; the - `#bulkbar` shows "N selected" and only the real actions in the concept's order (Set - category, Export metadata, Clear). Add tags and Add to collection wait for image tags - and collections; "Remove links…" is dropped. The selection survives Load more and + `#bulkbar` shows "N selected" and only the real actions in the concept's order (Add + tags, then D85's Auto-tag selected, Set category, Export metadata, Clear). Add to + collection waits for collections; "Remove links…" is dropped. (Amended by #249 PR D: + Add tags ships, see "Bulk tagging from the selection bar".) The selection survives Load more and bounded re-mounting and is cleared whenever the collection is replaced (search, category, sort, Filters, source change, rescan, reload). A return to Images restores the saved collection unreplaced and keeps its selection; a full page reload starts @@ -1201,6 +1216,142 @@ production-versus-concept difference of PR C and its record: | Retry leads the actions when the controller allows it | D116 (ruling T5) | | States the concept lacks (busy, conflict, changed storage or file, unconfirmed save, unavailable model, video) | Global Constraints: `#tag-generation-status` text only | +### Bulk tagging from the selection bar (#249 PR D) + +The Images selection bar shows the concept's **Add tags** after the count, then +**Auto-tag selected** (D85), before Set category. Both take the checked files when +pressed, in listing order, as their reveal identity (`{root_index, root_scope, path}`); +more than 500 toasts "Select at most 500 images." and opens nothing. The selection +stays after every start, refusal and review. The job's start body is bounded so 500 +targets of the longest legal path (1024 code points, even escaped as `\uXXXX`) fit: +`500 × (1024 × 6 + 256) + 64 KiB`; a larger body answers 413 +(`tests/test_image_tag_jobs.py` "test_start_body_bounds"). + +- **Add tags** opens the concept's `simpleInput` (app.js a1378–1383) as Set category + does: "Tag selected images", "Tags, separated by commas" (empty: the concept's + "cinematic, concept" is sample text), Cancel / Apply. The names are parsed as the Tag + image dialog's manual field parses them (trimmed, case-insensitive repeats dropped, + first spelling wins); a field that parses to no names keeps the dialog open with the + typed text and reports "Enter at least one tag." on the field (its native validity, + cleared on the next edit), and nothing is sent. Apply starts the + `image-tags` job (`POST /prompt_manager/jobs/image-tags`, `{mode: "add", targets, + tags}`) and toasts "Tagging started."; Activity shows its progress. +- A refused start toasts why and changes nothing: another record-changing job (a scan) + gives "Another job is running: ." with a link to it (D57's toast), a held model + gives the server's `model-busy` sentence, any other coded refusal its own sentence, + and anything else "Tagging could not start. Try again." / "Auto-tagging could not + start. Try again.". +- When a job this page started ends having tagged files, those files are read again + once: the selected file's inspection (its chips, D88) and, under a Tags filter or a + search, the loaded range, as after a Tag save. + +- **D85** (plan D62, ruling L1, beyond the concept): **Auto-tag selected** opens + "Auto-tag selected images" in the Tag image dialog's shell (`tagging-modal`) with + its model panel, headed "Auto-tag <N> image(s)" (the panel's "Auto-tag this image" + for N files), without the preview, the manual field, the suggestions or Generate. + Its fields show the server's defaults (`GET /prompt_manager/autotag/models`, the + D91 Suggestions count included) once that read lands; Start waits for it. A model + the status lists as not downloaded keeps Start `aria-disabled` and + `#tag-generation-status` says "<model> is not downloaded. Download it from the + legacy Auto-tag page." (the server's own sentence), so no job starts only to fail. + Start validates the fields as Generate does (native validity; nothing is sent). + The concept comparison (`images-concept.spec.mjs`) uses the concept page itself as + the oracle, with Add to collection and Remove links… removed and a copy of its Add + tags button reading "Auto-tag selected" inserted after it. +- **D90** (plan D67, ruling T3-bulk): the dialog's **How to apply** radio group + (`.field[role=radiogroup]`, radios beside their labels): **Apply automatically** + (checked; primary "Start auto-tagging") starts the `image-tags` job + (`{mode: "auto", targets, model_type, options}`) and toasts "Auto-tagging started."; + **Review each image** (primary "Start review") walks the selection in the browser + (`images-tagging-review.mjs`). Each image opens the Tag image dialog titled "Tag + image · Image <i> of <M>" on the chosen model and options with Generate already + running; its actions are Stop reviewing, Skip and Approve and next (Approve and + finish on the last). Approve saves the manual field plus the remaining chips as Save + does, without its per-image toast, and opens the next image; Skip writes nothing and + advances; Stop reviewing, Escape and × end the session. While Approve's save is in + flight, Skip and Stop reviewing are `aria-disabled` and Escape and × do nothing, so the + closing count includes that save. When no suggestions are on screen (a model change + cleared them, or Generate was refused) the primary saves the manual field only and + reads "Save and next" ("Save and finish" on the last image). Each image is one gated + `suggest` call, so the session holds no model between images; a `model-busy` answer + stays on that image with its sentence in `#tag-generation-status` and D116's Retry + until the user retries or skips. Ending toasts "<n> of <M> images tagged." and + returns focus to Auto-tag selected. +- **D91** extends to the bulk dialog: its Suggestions field is the job's and the + review's `suggestion_limit`. +- **D118** (copy the concept has no line for; the concept's `bulk-apply` only toasts a + demo sentence): an Add tags field that parses to no names reports "Enter at least one + tag." on the field; a start refused for no coded reason toasts "Tagging could not start. Try + again." or "Auto-tagging could not start. Try again." (as D55's "The scan could not + start. Try again."); and the bulk dialog's model panel is headed "Auto-tag <N> + image(s)" where the Tag image dialog's reads "Auto-tag this image". Everything else + the selection bar's tagging shows is the concept's or the plan's (D85, D90, D91). +- **D89** (plan D66, Activity for the `image-tags` job): the job's Activity item and its + view are the scan's (D54, D56, D57, D59) with the job kind's own counters, from one + per-kind table (`JOB_VIEWS` in `activity-store.mjs`; a kind without one keeps the + scan's tiles under "Job log" and "Cancel job"). The titles are the server's: "Add + tags to images" and "Auto-tag images". + - The pill's verb is "Tagging" ("Tagging · 41% · 8,240 / 19,622"). + - The item line and the view's heading line read "Started HH:MM · N images · closing + this view does not cancel it" (the job's image count, grouped "en-US"), "Finished + HH:MM · N tagged · M errors", "Cancelled HH:MM at a file boundary · N tagged · + completed work is kept" or "Stopped on an error at HH:MM · N tagged · M errors". + - The view's actions are Download report and, while running, "Cancel tagging" + (`.danger`); the progress bar is "<title> progress". + - Four `dl.stat-row` tiles in the mockup's tones: Tagged (`.added`, green), + Unchanged, Skipped (`.warn`, warning), Errors (`.bad`, red). They keep the + mockup's five-column row (two columns on a phone), so a tile is the scan's width + and the fifth cell stays empty. + - The outcome list reads Tagged, Errors, Skipped, Unchanged (what got tagged and what + went wrong first, as the scan's), and the view opens on the first with files. + - Rows under Tagged and Unchanged are D57's `button.file-row`s that open the file in + the viewer by its listing identity; Skipped and Errors rows are plain text. + - The log is "● Tagging log — raw detail, click to show". + - The row and log wording is the job's (D1), since neither the concept nor the + mockup has a line for it: "Added N tag(s)", "No new tags", and the skip reasons + "Video: manual tags only", "Too large to verify", "File changed", "File not found", + "Not a supported still image" and "The file could not be read now. Run the job again + for it." (the capture store stayed at capacity, for example with Tag dialogs open in + other tabs, after a short backoff of retries). Log prefixes are `+` tagged, `=` + unchanged, `-` skipped and `!` error. + - The progress line is the scan's: "Processing file N of M · about X min left", the + estimate (D56's `_time_left`) shown once two files are done, each file one batch. + - Download report saves `prompt-manager-image-tags-<YYYYMMDD-HHMM>.json`. + - The start toasts are "Tagging started." / "Auto-tagging started." (D85); the end + toasts follow the design's pattern with the job's counts, as the scan's: "<title> + finished · N tagged · M errors", "<title> cancelled · N tagged" or "<title> failed · + N tagged · M errors". + - Tests: `tests/js/workspace-activity-store.test.js` ("image-tags jobs get their own + tiles outcomes and pill verb", "unknown kinds keep the generic view"); + `tests/browser/workspace-activity.spec.mjs` ("an image tagging job shows Tagged + Unchanged Skipped Errors and opens tagged rows", "cancel tagging stops at a file + boundary", "the report download names the kind", on fake `image-tags` jobs from + `workspace_activity_fixture.py`); `tests/browser/activity-concept.spec.mjs` ("image + tagging job view follows the approved mockup", dark and light, with a mutation + check). The scan's view is unchanged. + +Browser tests (`tests/browser/workspace-tagging-bulk.spec.mjs`, on the tagging +fixture): "selection bar shows Add tags then Auto-tag selected before Set category", +"Add tags runs a job and the cards show the tags after it finishes", "Auto-tag +selected with Apply automatically runs a job with the chosen model, thresholds and +suggestion count", "a model that is not downloaded keeps Start disabled and says +why", "Suggestions field prefills from the setting and caps chips at the entered +count", "Review each image walks the selection one dialog at a time and only approved +images gain tags", "Review holds Stop reviewing, Escape and × while a save is in +flight, and names a manual-only save" and "a running scan makes the start report +busy". Units: +`tests/js/workspace-images-tagging-bulk.test.js`, +`tests/js/workspace-images-tagging-review.test.js`. + +| Difference | Record | +| --- | --- | +| Add tags ships (Add to collection and Remove links… still omitted) | D8 (amended) | +| Auto-tag selected and its dialog | D85 (ruling L1) | +| How to apply and the Review each image session | D90 (ruling T3-bulk) | +| Suggestions count in the bulk dialog | D91 | +| Copy without a concept line: no tags, start fallbacks, the panel heading | D118 | +| Activity item and job view for image tagging jobs | D89 (plan D66) | + Native image tagging (#249 PR B routes, PR C UI): `POST /prompt_manager/image-annotations/observe` (capture one listed file, its saved set and `tag_revision`), `GET`/`DELETE /prompt_manager/image-annotations/capture/<token>` @@ -1444,7 +1595,43 @@ AVI/WMV preview only), chosen or dropped, previews inline. The heading's one act the shared viewer with the full inspector on a record read entirely in this browser (`web/js/workspace/inspect.mjs`, `local-session.mjs`, `local-inspection.mjs` and the L1 readers `png-metadata.js`, `local-video.mjs`, `local-projection.mjs`). Nothing is -uploaded, imported or stored. Only HTML and JS change, so no ComfyUI restart is needed. +uploaded, imported or stored. PRs L1 and L2 change only HTML and JS, so they need no +ComfyUI restart; PR L3 does (below). + +Local roles (#249 PR L3, decision LI-1): the prompts and resolution of a local record are +the server inspector's for the same records. `web/js/workspace/local-resolver.mjs` (with +`local-resolver-support.mjs`, `local-resolver-stages.mjs`, `local-parameters.mjs` and +`py-json.mjs`) ports `inspect_prompt_evidence` with `INSPECTION_PROFILE`: execution +evidence, graph tracing, every bound, several sampler candidates with the +identical-branch rule (#270), preview-only stages, detailer stages (multistage) and A1111 +`parameters`. It reads the server's own node table, `web/js/inspection-semantics.json`, +which the page fetches once through the API client as it mounts, before any selection +(`local-semantics.mjs`; `/prompt_manager/js/inspection-semantics.json` is the one JSON +file the static route serves, by exact path). A table that cannot be fetched answers +"The inspector's node table is unavailable." as the server does. The legacy fill in +`projectInspectionPayload` then applies to a local record exactly where it applies to the +Images answer for the same bytes. Python is the oracle: +`tests/test_local_inspect_parity.py` `LocalResolverParityTests` compares the local +evidence byte for byte with `inspect_prompt_evidence` for every parity PNG and inspection +vector (prompts, resolution and facts), the golden and selection record sets +(`tests/local_resolver_cases.py`, `tests/local_resolver_selection_cases.py`), every work +bound and 1,200 seeded fuzz cases; `tests/fixtures/local-inspect/resolver-vectors.json` +holds the committed answers the Node unit test reads. The browser spec `chooser shows the +Images roles and resolution for every parity PNG` compares the rail with Images for all +25 committed parity PNGs. The static route change and the resolver fixes (Python) need a +ComfyUI restart. + +Main thread (M4): the resolver runs on the page's thread, not a worker. On a desktop +(Node 26) `resolveLocalEvidence` on maximal 2 MiB graphs (a 19,990-sampler chain, 6,600 +independent samplers, a consumer-dense scan, a 100,000-node PromptManager workflow) took +130–455 ms; the server took 80–905 ms on the same inputs. `localFileAdapter.read` runs in +bounded steps (read, table, parse and resolve, project, fit) with a turn of the event +loop between them, every wait ends with the selection's signal (a stalled table request +included), and the inspector carries `aria-busy="true"` with "Loading file details…" +until the answer lands. `tests/js/workspace-local-inspection.test.js` pins a 2,500 ms +budget per step on maximal independent-sampler and stage-chain graphs, checks that the +stepped read answers byte for byte as one synchronous build, and that an abort at any +step ends the read. Bounds (the server's own budgets, ported by PR L1; see "Follow-up: local Inspect parity" under "Chronological Library history" for the known divergences): @@ -1463,7 +1650,8 @@ under "Chronological Library history" for the known divergences): Privacy, asserted by `local inspection makes no content request or storage write` and `local roles send exactly` in `tests/browser/workspace-inspect.spec.mjs`, and by the -managed-host spec below: no request carries the file's bytes, name or text (fetch, XHR, +managed-host spec below: the only static file Inspect reads is its node table, once per +page and before any selection; no request carries the file's bytes, name or text (fetch, XHR, beacon, WebSocket, EventSource); Inspect writes nothing to `localStorage`, `sessionStorage` or IndexedDB; only an explicit Send crosses the host bridge, and its payload carries neither the `blob:` URL nor the filename; no console message names the @@ -1490,19 +1678,27 @@ mapped differences"): `image/png,image/jpeg,image/webp` plus `video/*,.mp4,.mov,.m4v,.webm,.mkv,.avi,.wmv`. A video previews in the same `#local-preview` slot as `video.local-file-preview` with native `controls`, `playsInline`, `preload="metadata"` and `muted`, never autoplays - inline, and is paused, has its `src` removed and is removed from the page on replacement - or dispose, before its URL is revoked. Each preview owns its listeners, so the page never - pins a released node. A settled video (metadata read, or failed) is then reset with - `load()` at once; one still starting is reset only once it reports metadata or an error, - because WebKit (GStreamer) blocked the page when such a pipeline was reset (`replacing a - video that just started loading never blocks the page`, `a video replaced while still - starting is reset once it settles`). A WebKit load cut off by the revoked URL usually - stalls and never settles; a fallback then only drops the waiting listeners. The viewer's - own video teardown was checked the same way (`closing the viewer on a video that just - started never blocks the page`, 15 fresh pages on WebKit) and needed no change. - Follow-up: on WebKit, a video released exactly at its `loadstart` sometimes blocks the - page, with or without any reset and also before the deferred reset (2 of 24 runs in the - pinned image), so the `loadstart` spec is skipped on WebKit until that is investigated. + inline. On replacement or dispose it is paused, has its `src` removed, is reset with + `load()` (which frees its decoder) and is removed from the page, before its URL is + revoked. Each preview owns its listeners, so the page never pins a released node. + A video replaced while still starting (a source, but neither metadata nor an error yet) + stays where it is, inert and `aria-hidden`, with the new preview inserted after it, and + the session's revoke of its URL waits; once it reports metadata or an error (or after + 15 s) it is released as above and only then is its URL revoked. Page leave and dispose + release it at once. Cause, found with native stacks of the hung WPE web process in the + pinned image: WebKit (GStreamer) deadlocks when a video leaves the page or the viewport + before its pipeline has started. The main thread changes the pipeline state and waits + for the source's streaming thread, which is itself waiting on the main thread in a + synchronous bus handler. Removing the video, hiding it, or only pushing it below the + fold with a taller preview inserted before it all hung (7 to 21 of 120 fresh pages each, + replaced at `loadstart` + 0–7 ms), and none of the earlier steps (`pause()`, removing + `src`, `load()`, revoking the URL) was the cause on its own; pushing it out after + `loadedmetadata` hung 0 of 180. Specs: `replacing a video that just started loading + never blocks the page`, and `a video replaced while still starting is reset once it + settles`, which now runs on WebKit too (15 fresh pages, replaced at `loadstart` + 0–4 + ms). The viewer's own video teardown was checked the same way (`closing the viewer on a + video that just started never blocks the page`, 15 fresh pages on WebKit) and needed no + change. - **D63** (status line): one `p.small.spaced-16` in `#local-preview`, for states the concept never shows: "Drop one file at a time.", "Folders cannot be inspected. Choose one image or video file.", "This file type cannot be previewed or inspected here. @@ -1527,6 +1723,29 @@ mapped differences"): Modified with no Path row and no Show in folder, and ends with "Local file; no saved library association was checked." and Download original. There is no Prompt history action and no category or tag chips. +- **D117** (Copy settings summary, LI-4): the legacy metadata + page's COPY ALL, consolidated into the shared full inspector (the viewer's + `#viewer-details`, so Images, the recorded history's current file and Inspect all have + it). The concept has no such action. One plain button "Copy settings summary" follows + the six facts (before "File & provenance"); the Images side panel keeps the concept's + footer and has none. Its text is the legacy `copyAllData` template, line for line: + `Checkpoint`, `Positive Prompt`, `Negative Prompt`, `Steps`, `CFG Scale`, `Sampler`, + `Seed`, with the legacy placeholders ("Unknown", "No prompt found", "No negative prompt + found"). The values are the ones the inspector shows: a confirmed prompt and a known + fact verbatim, anything else (an explicitly empty prompt included, as the legacy parse + treats '') the placeholder. Against the legacy page, for the same file, only these + differ: numbers keep the server's spelling ("8.0" where the legacy page wrote 8); a fact + the legacy parse could not read can be known; and a negative the #249 ZeroOut guard + zeroed reads "No negative prompt found" where the legacy parse read the positive's text + through the zero-out. `workspace-file-inspector.test.js` runs the template taken from + `web/metadata.html` itself on the legacy parse of every parity PNG: 22 of 25 are + byte-equal, f01b and f23 differ by the ZeroOut guard and f17 by facts the legacy parse + missed. Copying works as the concept's prompt copy: the toast "Settings summary + copied.", or, without a clipboard or when it is refused, the select-to-copy dialog + titled "Copy settings summary". `.ca-tools #viewer-details > div > button` wraps + (`white-space: normal`) in the narrow rail at 200% zoom, as D20's buttons do. The concept + comparisons add the button to the concept's viewer at runtime (a copy of its own plain + button) and to the Inspect rail's section order. The chooser keeps naming the chosen file, as the concept's does: it is cleared as the picker opens (so choosing the same file again is a new selection), not after `change`, @@ -1540,9 +1759,8 @@ Legacy floor (`web/metadata.html`), dispositions: - Full text (the old "Show more"): the prompt blocks show the whole text. - Raw workflow view and Download workflow JSON: kept, in "Workflow & raw metadata". - Copy all summary and filename copy (LI-4): the filename is selectable text in the rail - and Download original saves the file under its name. The ruled "Copy settings summary" - button after the six facts of the shared full inspector is not part of PR L2; until it - lands, the legacy page keeps COPY ALL and stays reachable from Overview. + and Download original saves the file under its name. COPY ALL is the shared full + inspector's "Copy settings summary" (D117), with the legacy text. - Fully browser-local reading: kept and asserted (privacy above). - Case-variant keys `Workflow|comfy|ComfyUI|Prompt|Parameters` (LI-5): a separate backend issue; Inspect follows the server's readers, so the two never disagree. @@ -2258,12 +2476,41 @@ skip without ffmpeg). The remaining known divergences, checked against ffprobe 9 `unreadable`); ffprobe reads such files. - An EBML DocType other than `matroska`/`webm` is an invalid container locally; ffmpeg only warns and reads it. -- Envelope JSON deeper than 1,000 levels is refused locally as Python's default - recursion limit refuses it; the server's real limit depends on the interpreter and - call stack. A duplicate key in an object enclosing a nest deeper than the running +- Deep JSON (local roles, PR L3, and video envelopes): both browser CPython-JSON + parsers (`py-json.mjs`, `local-video.mjs`) share one cap, `JSON_MAX_DEPTH` = 1,000 + nested arrays or objects (each counts, an empty one too); past it a record is invalid + JSON locally. The server's `json.loads` raises RecursionError at a limit that depends + on the Python, measured on Linux x86-64: 993 levels from a bare script on 3.10 and + 3.11 (the 1,000 recursion limit minus the caller's frames, so about 990 inside the + server), 9,997 on 3.12 and 9,998 on 3.13 (the C recursion counter; lower on Windows + builds), and about 52,150 on 3.14 (stack-based, varies with the thread's stack). + The record kinds answer: a deep `prompt` graph (or `comfyassets_prompt_pairs` + envelope) is malformed; a deep `workflow` is ignored, so the overwritten PromptManager + check (R1) does not run and a prompt can resolve; a deep video `comment` or + `description` envelope makes the file unreadable on both sides (the server's + RecursionError reaches `read_inspection_file`). That gives two directions: + - 3.10/3.11, between the server's limit and 1,000: the server refuses and the local + page reads, so a deep prompt resolves locally while the server says malformed, and + a deep workflow can make R1 refuse locally where the server resolves. + - 3.12 and newer, from 1,001 up to the server's limit: the local page refuses and + the server reads, so a deep prompt is malformed locally while the server resolves, + and a deep workflow is ignored locally while the server can refuse it under R1. + Past both limits they agree. `test_local_json_depth_cap_at_its_boundary` checks the + local answer at 999, 1,000 and 1,001 for both kinds and that any server difference + is only one of the two directions above. +- Local roles (PR L3): `\d` in the A1111 settings footer is any Unicode decimal + digit on both sides, but the browser's Unicode version can be newer than the server + Python's, so a footer whose step count uses a digit added after Unicode 15 can differ. + `str.lower()` and `toLowerCase()` differ only for characters no node class name uses. +- A duplicate key in an object enclosing a nest deeper than the running Python's json limit is `malformed` locally and `limited` on that Python. - Decimal exponent limits assume CPython's C `_decimal`; on a `_pydecimal` interpreter the server would read the over-limit exponents the local projection refuses. +- Follow-up (two CPython-JSON emulations, review M6): `local-video.mjs` + (`pythonJsonLoads`/`pythonFloatRepr`, numbers kept as lexemes) and `py-json.mjs` + (`pyJsonLoads`/`pyFloatRepr`, Python value kinds) both emulate `json.loads`; only the + depth cap is shared so far. A fix to one parser can miss the other, so the video + reader should move onto `py-json.mjs` in a later PR. Preserve raw logs, failed traces, paired PNGs, geometry, runtime versions and source hashes before later managed commands clear their output directories. diff --git a/py/api/__init__.py b/py/api/__init__.py index 3d92bbc..32ac93c 100644 --- a/py/api/__init__.py +++ b/py/api/__init__.py @@ -7,6 +7,7 @@ Split into domain-specific mixins for maintainability: - LoggingRoutesMixin: log management endpoints - AutotagRoutesMixin: auto-tagging model management and tagging - JobRoutesMixin: the session job registry behind Activity (py/api/jobs.py) +- ImageTagJobsMixin: bulk Add tags / Auto-tag as image-tags jobs - ReviewRoutesMixin: the Needs review queue, item detail and decisions - ReviewRecordingMixin: Needs review recording for the scan and the monitor """ @@ -35,7 +36,9 @@ from .saved_views import SavedViewRoutesMixin from .history import HistoryRoutesMixin from .images import ImageRoutesMixin from .admin import AdminRoutesMixin +from .settings_routes import SettingsRoutesMixin from .job_routes import JobRoutesMixin +from .image_tag_jobs import ImageTagJobsMixin from .jobs import JobRegistry from .logging_routes import LoggingRoutesMixin from .autotag_routes import AutotagRoutesMixin @@ -109,16 +112,20 @@ STATIC_MIME_TYPES = { ".html": "text/html", } _STATIC_SEPARATORS = re.compile(r"[\\/]+") +# Served by exact request path beside the extension allow-list: the browser's +# copy of the inspection node table (local Inspect resolves with it, #249). +STATIC_EXACT_FILES = {("js", "inspection-semantics.json"): "application/json"} -def _resolve_static_file(root, filepath): +def _resolve_static_file(root, filepath, allowed=STATIC_MIME_TYPES): """Real path of ``filepath`` under ``root``, or None when it must not be served. The request path is untrusted. It is rejected when it is empty, carries a ``..`` segment, is absolute on any platform (a leading slash or backslash, a drive letter such as ``C:`` or a UNC ``\\\\server\\share`` prefix all re-anchor ``os.path.join`` on Windows), has an extension outside - STATIC_MIME_TYPES, or resolves (symlinks included) outside ``root``. + ``allowed`` (STATIC_MIME_TYPES), or resolves (symlinks included) outside + ``root``. """ if not isinstance(filepath, str) or not filepath: return None @@ -131,7 +138,7 @@ def _resolve_static_file(root, filepath): segments = _STATIC_SEPARATORS.split(filepath) if any(segment in ("", ".", "..") for segment in segments): return None - if os.path.splitext(filepath)[1].lower() not in STATIC_MIME_TYPES: + if os.path.splitext(filepath)[1].lower() not in allowed: return None try: root_real = Path(os.path.normcase(os.path.realpath(root))) @@ -265,7 +272,9 @@ class PromptManagerAPI( PromptRoutesMixin, ImageRoutesMixin, AdminRoutesMixin, + SettingsRoutesMixin, JobRoutesMixin, + ImageTagJobsMixin, LoggingRoutesMixin, AutotagRoutesMixin, LoraIntegrationMixin, @@ -366,6 +375,24 @@ class PromptManagerAPI( """409 envelope for a long job that is already running.""" return _json_error(f"{label} already running", 409) + async def _run_held(self, kind, title, admission, operation): + """Run a legacy-page operation under a registry hold (D80). + + While ``operation()`` runs, the registry lists it as a running, + non-cancellable job, so workspace jobs its admission class conflicts + with are refused, naming it. When the registry refuses the hold, the + legacy 409 envelope names the running job and nothing is done. A + response of 400 or above ends the hold ``failed``. + """ + busy = self._jobs.blocker(kind, admission=admission) + if busy is not None: + return self._long_job_busy_response(busy.title) + async with self._jobs.hold(kind, title, admission=admission) as job: + response = await operation() + if getattr(response, "status", 200) >= 400: + job.finish("failed") + return response + async def _run_in_executor(self, func, *args, **kwargs): """Run a blocking function in the default thread pool executor. @@ -462,6 +489,7 @@ class PromptManagerAPI( self._register_prompt_routes(routes) self._register_image_routes(routes) self._register_admin_routes(routes) + self._register_settings_routes(routes) self._register_job_routes(routes) self._register_logging_routes(routes) self._register_autotag_routes(routes) @@ -484,15 +512,20 @@ class PromptManagerAPI( def _serve_static_file(self, subdir, filepath): """Serve ``web/<subdir>/<filepath>``; 403 outside it, 404 when missing.""" root = os.path.join(_get_project_root(), "web", subdir) - file_path = _resolve_static_file(root, filepath) + exact = STATIC_EXACT_FILES.get((subdir, filepath)) + # An exact file passes the same containment check, its extension only. + allowed = STATIC_MIME_TYPES if exact is None else {".json"} + file_path = _resolve_static_file(root, filepath, allowed) if file_path is None: return web.Response(text="Forbidden", status=403) + content_type = ( + exact or STATIC_MIME_TYPES[os.path.splitext(file_path)[1].lower()] + ) if not os.path.isfile(file_path): return web.Response(text="Not Found", status=404) - ext = os.path.splitext(file_path)[1].lower() with open(file_path, "rb") as f: content = f.read() - return web.Response(body=content, content_type=STATIC_MIME_TYPES[ext]) + return web.Response(body=content, content_type=content_type) def _serve_html_page(self, filename, title, cache=True): """Serve ``web/<filename>``; 404 when missing, 500 when unreadable.""" diff --git a/py/api/admin.py b/py/api/admin.py index 6b3c522..223127f 100644 --- a/py/api/admin.py +++ b/py/api/admin.py @@ -22,16 +22,17 @@ from .jobs import ( SCAN_GROUP_NAMES, SCAN_GROUPS, SCAN_SCOPES, + DUPLICATE_CLEANUP_JOB_KIND, + MAINTENANCE_JOB_KIND, + RESTORE_JOB_KIND, Job, JobBusy, ) try: from ...utils.parallel import map_parallel - from ...utils.validators import validate_result_timeout, validate_worker_threads except ImportError: from utils.parallel import map_parallel - from utils.validators import validate_result_timeout, validate_worker_threads try: from ...database.operations import PromptDatabase @@ -198,13 +199,22 @@ def _plural(count, word): return f"{count} {word}" if count == 1 else f"{count} {word}s" +def _time_left(done, total, batches, elapsed): + """ " · about X min left" once enough batches ran, else "" (D56). + + Shared by every Activity job that reports "Processing file N of M": the + scan counts its batches, the image tagging job each file. + """ + if batches < MIN_BATCHES_FOR_ESTIMATE or done <= 0 or done >= total: + return "" + minutes = max(1, math.ceil(elapsed / done * (total - done) / 60)) + return f" · about {minutes} min left" + + def _scan_status_line(done, total, batches, elapsed): """ "Processing file N of M", with an estimate once enough batches ran.""" line = f"Processing file {done:,} of {total:,}" - if batches < MIN_BATCHES_FOR_ESTIMATE or done <= 0 or done >= total: - return line - minutes = max(1, math.ceil(elapsed / done * (total - done) / 60)) - return f"{line} · about {minutes} min left" + return line + _time_left(done, total, batches, elapsed) def _file_size(path): @@ -493,14 +503,6 @@ class AdminRoutesMixin: async def get_stats_route(request): return await self.get_statistics(request) - @routes.get("/prompt_manager/settings") - async def get_settings_route(request): - return await self.get_settings(request) - - @routes.post("/prompt_manager/settings") - async def save_settings_route(request): - return await self.save_settings(request) - @routes.get("/prompt_manager/backup") async def backup_database_route(request): return await self.backup_database(request) @@ -642,7 +644,15 @@ class AdminRoutesMixin: job.finish() async def cleanup_duplicates_endpoint(self, request): - """Cleanup duplicate prompts endpoint.""" + """Cleanup duplicate prompts endpoint (holds the registry: records).""" + return await self._run_held( + MAINTENANCE_JOB_KIND, + "Prompt cleanup (admin page)", + "records", + self._cleanup_duplicates, + ) + + async def _cleanup_duplicates(self): try: removed_count = await self._run_in_executor(self.db.cleanup_duplicates) @@ -757,26 +767,39 @@ class AdminRoutesMixin: return hash_sha256.hexdigest() async def delete_duplicate_images_endpoint(self, request): - """Delete duplicate image files from disk (single flight with the scan).""" - job = self._acquire_long_job(self.LONG_JOB_DUPLICATES) - if job is None: + """Delete duplicate image files from disk. + + Single flight with the duplicate scan, and a records hold on the + registry, so no scan, restore or tagging job runs meanwhile. A body + without paths is refused before the hold (it is no job). + """ + data, error_response = await _read_json(request) + if error_response is not None: + return error_response + image_paths = data.get("image_paths", []) + if not image_paths or not isinstance(image_paths, list): + return web.json_response( + {"success": False, "error": "No image paths provided"}, + status=400, + ) + guard = self._acquire_long_job(self.LONG_JOB_DUPLICATES) + if guard is None: return self._long_job_busy_response("Duplicate scan or delete") - async with job: - return await self._delete_duplicate_images(request) - async def _delete_duplicate_images(self, request): + async def delete(): + # No await since the check above: the lock is still free. + async with guard: + return await self._delete_duplicate_images(image_paths) + + return await self._run_held( + DUPLICATE_CLEANUP_JOB_KIND, + "Duplicate image delete (gallery page)", + "records", + delete, + ) + + async def _delete_duplicate_images(self, image_paths): try: - data, error_response = await _read_json(request) - if error_response is not None: - return error_response - image_paths = data.get("image_paths", []) - - if not image_paths or not isinstance(image_paths, list): - return web.json_response( - {"success": False, "error": "No image paths provided"}, - status=400, - ) - output_dir = await self._run_in_executor(self._find_comfyui_output_dir) result = await self._run_in_executor( self._delete_duplicate_images_sync, image_paths, output_dir @@ -873,208 +896,6 @@ class AdminRoutesMixin: status=500, ) - async def get_settings(self, request): - """Get current settings.""" - try: - from ..config import PromptManagerConfig, GalleryConfig - - root_paths = [ - self._public_path(d) for d in GalleryConfig.MONITORING_DIRECTORIES - ] - return web.json_response( - { - "success": True, - "settings": { - "result_timeout": PromptManagerConfig.RESULT_TIMEOUT, - "webui_display_mode": PromptManagerConfig.WEBUI_DISPLAY_MODE, - "infinite_scroll": PromptManagerConfig.INFINITE_SCROLL, - "worker_threads": PromptManagerConfig.WORKER_THREADS, - "cpu_count": PromptManagerConfig.max_worker_threads(), - "gallery_root_paths": root_paths, - "gallery_root_path": root_paths[0] if root_paths else "", - "monitored_directories": [ - self._public_path(d) for d in self._monitored_directories() - ], - }, - } - ) - except Exception as e: - self.logger.error(f"Get settings error: {e}", exc_info=True) - return web.json_response( - {"success": False, "error": "Failed to get settings"}, - status=500, - ) - - def _monitored_directories(self): - """Directories the running image monitor watches, else the configured roots.""" - from ..config import GalleryConfig - - monitor = _current_image_monitor() - if monitor is not None: - return list(getattr(monitor, "monitored_directories", [])) - return list(GalleryConfig.MONITORING_DIRECTORIES) - - async def save_settings(self, request): - """Save settings: the whole payload is validated before anything changes.""" - try: - from ..config import PromptManagerConfig - - data, error_response = await _read_json(request) - if error_response is not None: - return error_response - - try: - updates = self._validate_settings(data) - except ValueError as ve: - return web.json_response( - {"success": False, "error": str(ve)}, status=400 - ) - - new_roots, error = self._parse_gallery_roots(data) - if error: - return web.json_response({"success": False, "error": error}, status=400) - - for name, value in updates.items(): - setattr(PromptManagerConfig, name, value) - restart_required = False - if new_roots is not None: - restart_required = self._apply_gallery_roots(new_roots) - - self._persist_settings() - - return web.json_response( - { - "success": True, - "message": "Settings saved successfully", - "restart_required": restart_required, - } - ) - except Exception as e: - self.logger.error(f"Save settings error: {e}", exc_info=True) - return web.json_response( - {"success": False, "error": "Failed to save settings"}, - status=500, - ) - - @staticmethod - def _validate_settings(data): - """Scalar settings of a payload as ``{CONFIG_ATTR: value}``. - - Raises ValueError with a user-facing message on the first bad value; - nothing is applied until every value passed. - """ - from ..config import PromptManagerConfig - - updates = {} - if "result_timeout" in data: - updates["RESULT_TIMEOUT"] = validate_result_timeout(data["result_timeout"]) - if "webui_display_mode" in data: - mode = data["webui_display_mode"] - if mode not in ("popup", "newtab"): - raise ValueError("webui_display_mode must be 'popup' or 'newtab'") - updates["WEBUI_DISPLAY_MODE"] = mode - if "infinite_scroll" in data: - if not isinstance(data["infinite_scroll"], bool): - raise ValueError("infinite_scroll must be true or false") - updates["INFINITE_SCROLL"] = data["infinite_scroll"] - if "worker_threads" in data: - updates["WORKER_THREADS"] = validate_worker_threads( - data["worker_threads"], PromptManagerConfig.max_worker_threads() - ) - return updates - - def _parse_gallery_roots(self, data): - """Extract and validate gallery roots from a settings payload. - - Accepts ``gallery_root_paths`` (list, preferred) or the legacy single - ``gallery_root_path`` string. Blank entries are dropped. - - Returns: - (roots, error): ``roots`` is the validated list, or ``None`` when - the payload carries no gallery root key; ``error`` is a message - when validation failed (and ``roots`` is then ``None``). - """ - if "gallery_root_paths" in data: - raw = data["gallery_root_paths"] - if not isinstance(raw, list): - return None, "gallery_root_paths must be a list" - elif "gallery_root_path" in data: - raw = [data["gallery_root_path"]] - else: - return None, None - - from ..config import GalleryConfig - - roots = [] - for entry in raw: - if not isinstance(entry, str): - return None, "Gallery root paths must be strings" - entry = entry.strip() - if not entry: - continue - ok, reason = GalleryConfig.validate_gallery_root(entry) - if not ok: - return ( - None, - f"Invalid gallery root '{os.path.basename(entry)}': {reason}", - ) - roots.append(GalleryConfig.resolve_gallery_root(entry)) - return roots, None - - def _apply_gallery_roots(self, roots): - """Install validated gallery roots; returns True when they changed.""" - from ..config import GalleryConfig - - if roots == list(GalleryConfig.MONITORING_DIRECTORIES): - return False - GalleryConfig.MONITORING_DIRECTORIES = roots - self._cached_output_dir = None - self.reset_gallery_roots_cache() - return True - - def _persist_settings(self): - """Merge the user-editable settings into the configured config.json. - - Only the keys this endpoint owns are rewritten; every other section - (integrations, database, performance, ...) and every other key in - ``web_ui``/``gallery`` is kept as it was. A config file that cannot - be parsed is replaced. - """ - from ..config import GalleryConfig, PromptManagerConfig, write_private_json - - config_file = PromptManagerConfig.get_config_path() - existing = self._read_config_file(config_file) - web_ui = dict(existing.get("web_ui") or {}) - web_ui["result_timeout"] = PromptManagerConfig.RESULT_TIMEOUT - web_ui["webui_display_mode"] = PromptManagerConfig.WEBUI_DISPLAY_MODE - web_ui["infinite_scroll"] = PromptManagerConfig.INFINITE_SCROLL - gallery = dict(existing.get("gallery") or {}) - monitoring = dict(gallery.get("monitoring") or {}) - monitoring["directories"] = list(GalleryConfig.MONITORING_DIRECTORIES) - gallery["monitoring"] = monitoring - performance = dict(existing.get("performance") or {}) - performance["worker_threads"] = PromptManagerConfig.WORKER_THREADS - config_data = { - **existing, - "web_ui": web_ui, - "gallery": gallery, - "performance": performance, - } - try: - write_private_json(config_file, config_data) - self.logger.info(f"Settings saved to {config_file}") - except OSError as save_err: - self.logger.warning(f"Could not save config file: {save_err}") - - def _read_config_file(self, config_file): - """Current contents of config.json as a dict, or {} when unusable.""" - try: - with open(config_file, "r", encoding="utf-8") as f: - data = json.load(f) - except (OSError, ValueError): - return {} - return data if isinstance(data, dict) else {} - async def run_diagnostics(self, request): """Run comprehensive system diagnostics and health checks.""" try: @@ -1167,6 +988,11 @@ class AdminRoutesMixin: "vacuum", "cleanup_orphaned_images", ) + # Operations that only read; a run of only these holds the registry as + # "read", every other run as "records". + READ_ONLY_MAINTENANCE_OPERATIONS = frozenset( + {"check_hash_duplicates", "statistics", "check_consistency"} + ) async def run_maintenance(self, request): """Perform database maintenance operations and report each outcome.""" @@ -1179,7 +1005,27 @@ class AdminRoutesMixin: operations = data.get( "operations", list(self.DEFAULT_MAINTENANCE_OPERATIONS) ) + except Exception as e: + self.logger.error(f"Maintenance error: {e}", exc_info=True) + return web.json_response( + {"success": False, "error": "Maintenance failed"}, status=500 + ) + writes = not ( + isinstance(operations, list) + and all( + isinstance(name, str) and name in self.READ_ONLY_MAINTENANCE_OPERATIONS + for name in operations + ) + ) + return await self._run_held( + MAINTENANCE_JOB_KIND, + "Maintenance (admin page)", + "records" if writes else "read", + lambda: self._run_maintenance(operations), + ) + async def _run_maintenance(self, operations): + try: results = await self._run_in_executor( self._run_maintenance_operations, operations ) @@ -1333,8 +1179,18 @@ class AdminRoutesMixin: The upload is streamed to a temp file (capped at restore_max_bytes), verified with PromptModel.verify_database_file and only then copied - over the live database, which is backed up first. + over the live database, which is backed up first. The whole request + holds the registry (records): it is refused while a scan or another + record-changing job runs, and those are refused while it runs. """ + return await self._run_held( + RESTORE_JOB_KIND, + "Database restore (admin page)", + "records", + lambda: self._restore_database(request), + ) + + async def _restore_database(self, request): max_bytes = getattr(self, "restore_max_bytes", 100 * 1024 * 1024) temp_path = None try: diff --git a/py/api/autotag_routes.py b/py/api/autotag_routes.py index 999b941..f72db79 100644 --- a/py/api/autotag_routes.py +++ b/py/api/autotag_routes.py @@ -8,6 +8,7 @@ from pathlib import Path from typing import Optional from urllib.parse import quote +from .jobs import AUTOTAG_BATCH_JOB_KIND from .prompts import thumbnail_url_for from .. import model_gate @@ -63,6 +64,12 @@ def path_is_within(candidate, root) -> bool: return child != parent and child.is_relative_to(parent) +async def _refusal(title): + """The single error event of an auto-tag batch refused by another job.""" + message = f"{title} already running" + yield {"type": "error", "status": "error", "message": message} + + class AutotagRoutesMixin: """Mixin providing auto-tagging API endpoints.""" @@ -425,6 +432,8 @@ class AutotagRoutesMixin: "general_threshold and character_threshold must be numeric" ) + errors = [] # an error event ends the run; Activity lists it failed + async def events(): try: from ..autotag import get_autotag_service @@ -432,14 +441,40 @@ class AutotagRoutesMixin: async for event in self._autotag_batch_events( get_autotag_service(), opts ): + if event.get("type") == "error": + errors.append(event) yield event except Exception: self.logger.exception("AutoTag error") + errors.append(_INTERNAL_ERROR) yield { "type": "error", "message": _INTERNAL_ERROR, } + # The batch writes prompt tags: a records hold on the registry, so it + # never runs beside a scan, restore or the workspace tagging job. + busy = self._jobs.blocker(AUTOTAG_BATCH_JOB_KIND, admission="records") + if busy is not None: + if busy.kind == AUTOTAG_BATCH_JOB_KIND: # unchanged payload + return web.json_response( + {"success": False, "error": _BUSY_ERROR}, status=409 + ) + # Both legacy pages read this route as a stream, where a 409 reads + # as a lost connection: refuse with one error frame instead (the + # admin page keys on ``type``, the gallery on ``status``). + return await self._stream_sse(request, _refusal(busy.title)) + async with self._jobs.hold( + AUTOTAG_BATCH_JOB_KIND, + "Auto-tag images (legacy page)", + admission="records", + ) as job: + response = await self._start_autotag_batch(request, opts, events) + if response.status >= 400 or errors: + job.finish("failed") + return response + + async def _start_autotag_batch(self, request, opts, events): if not _autotag_batch_lock.acquire(blocking=False): return web.json_response( {"success": False, "error": _BUSY_ERROR}, status=409 diff --git a/py/api/image_tag_jobs.py b/py/api/image_tag_jobs.py new file mode 100644 index 0000000..38fa9b6 --- /dev/null +++ b/py/api/image_tag_jobs.py @@ -0,0 +1,509 @@ +"""Bulk image tagging as an Activity job: Add tags and Auto-tag (#249 D1). + +``POST /prompt_manager/jobs/image-tags`` starts one ``image-tags`` job over +1..500 listed images, named by the listing identity ``{root_index, +root_scope, path}``. The job is record-changing (it writes image tag sets and +new vocabulary names), so it never runs beside the metadata scan. + +- **Add tags** (``mode: "add"``) hashes each current file and unions a fixed + tag set into the record of exactly those bytes. +- **Auto-tag** (``mode: "auto"``) copies each still image into a private + capture, asks one legacy engine for its top N tags on the capture, re-hashes + the current file and unions the suggestions only when the bytes are still + the captured ones. It holds the shared model gate for the whole job, loads + the model once and never downloads one. + +Every step reuses the Tag dialog's pieces: the capture store, the model gate, +the suggestion worker and the storage call ``add_image_tags``. Files are +handled one at a time and the job stops at a file boundary when cancelled, +keeping what it already wrote. Rows and log lines carry root-relative paths +and public root labels only. +""" + +import asyncio +import functools +import os +import time +from dataclasses import dataclass, field + +from aiohttp import web + +from .. import model_gate +from .admin import _plural, _time_left +from .annotation_suggest import ( + OptionError, + unavailable_message, + validate_options, +) +from .annotations import ( + _MESSAGES, + MAX_TARGET_PATH, + _coded, + _error, + _pinned_suggestions, + _settled, + _submit, + _target, +) +from .autotag_routes import model_busy_response +from .images import _public_path +from .job_routes import busy_response +from .jobs import IMAGE_TAG_GROUP_NAMES, IMAGE_TAG_JOB_KIND, JobBusy +from .prompts import bad_request + +try: + from ...database.annotations import ImageTagLimitError, normalize_image_tags + from ...database.library_commands import LibraryCommandError + from ...utils.media_capture import ( + CaptureCapacity, + CaptureChanged, + CaptureDenied, + CaptureError, + CaptureTooLarge, + ) + from ...utils.video_metadata import is_video_path +except ImportError: + from database.annotations import ImageTagLimitError, normalize_image_tags + from database.library_commands import LibraryCommandError + from utils.media_capture import ( + CaptureCapacity, + CaptureChanged, + CaptureDenied, + CaptureError, + CaptureTooLarge, + ) + from utils.video_metadata import is_video_path + +MAX_JOB_TARGETS = 500 +# 500 maximal targets plus the options: a path of MAX_TARGET_PATH code points +# takes at most 6 bytes each on the wire (a \uXXXX escape; raw UTF-8 is at most +# 4), and the root index and scope fit in 256. The annotation routes' 64 KiB +# bound is for one target, too small for a full selection. +MAX_JOB_BODY_BYTES = MAX_JOB_TARGETS * (MAX_TARGET_PATH * 6 + 256) + 64 * 1024 +# A busy capture worker (a Tag dialog in another tab) is waited for this long, +# try by try, before the file is left for a rerun. +CAPACITY_BACKOFF = (0.1, 0.2, 0.4, 0.8) + +ADD_TITLE = "Add tags to images" +AUTO_TITLE = "Auto-tag images" +GATE_OWNER_KIND = "image-tag-job" + +_FIELDS = { + "add": frozenset({"mode", "targets", "tags"}), + "auto": frozenset({"mode", "targets", "model_type", "options"}), +} +_VERBS = {"add": "Add tags", "auto": "Auto-tag"} +_LOG_PREFIXES = {"tagged": "+", "unchanged": "=", "skipped": "-", "errors": "!"} + +NO_NEW_TAGS = "No new tags" +VIDEO_SKIPPED = "Video: manual tags only" +TOO_LARGE = "Too large to verify" +FILE_CHANGED = "File changed" +FILE_NOT_FOUND = "File not found" +NOT_A_STILL = "Not a supported still image" +INFERENCE_FAILED = "Auto-tagging failed. Check server logs for details." +SAVE_FAILED = "The tags could not be saved. Check server logs for details." +READ_FAILED = "The file could not be read." +SOURCE_CHANGED = "The source changed. Refresh the image collection." +STORAGE_RESTORED = "Prompt storage was restored; start the job again." +BUSY_RETRY = "The file could not be read now. Run the job again for it." + +_monotonic = time.monotonic # the job's clock (a test can fake it) + + +class _JobStopped(Exception): + """No later file can be written (prompt storage was restored).""" + + +@dataclass +class _Run: + """One running job: its request, the gate owner and the resolved roots. + + ``roots`` maps each target's ``(root_index, root_scope)`` to ``(root, + label)``, read once per job off the event loop; ``root`` is None when the + scope no longer names a current root. + """ + + job: object + spec: object + epoch: str + owner: object + service: object = None + roots: dict = field(default_factory=dict) + + +@dataclass(frozen=True) +class _Spec: + """A validated start request.""" + + mode: str + targets: tuple + tags: tuple = () + model_type: str | None = None + options: dict | None = None + + @property + def title(self) -> str: + return ADD_TITLE if self.mode == "add" else AUTO_TITLE + + def scope(self) -> dict: + scope = {"mode": self.mode, "images": len(self.targets)} + if self.mode == "auto": + scope["model_type"] = self.model_type + return scope + + +def _parse_targets(raw): + """Unique validated targets in request order, or ``(None, error)``.""" + if not isinstance(raw, list) or not 1 <= len(raw) <= MAX_JOB_TARGETS: + return None, bad_request(f"targets must list 1 to {MAX_JOB_TARGETS} images") + seen, targets = set(), [] + for item in raw: + target, error = _target(item) + if error is not None: + return None, error + if target not in seen: + seen.add(target) + targets.append(target) + return tuple(targets), None + + +def _parse_add(targets, data): + try: + tags = normalize_image_tags(data["tags"]) + except ImageTagLimitError as exc: + return None, _error(400, "tag-limit", str(exc)) + except ValueError as exc: + return None, bad_request(str(exc)) + if not tags: + return None, bad_request("tags must name at least one tag") + return _Spec("add", targets, tags=tuple(tags)), None + + +def _parse_auto(targets, data): + from ..config import MAX_SUGGESTION_LIMIT, AutotagConfig + + try: + model_type, options = validate_options( + data["model_type"], + data["options"], + default_limit=AutotagConfig.suggestion_limit(), + max_limit=MAX_SUGGESTION_LIMIT, + ) + except OptionError as exc: + return None, bad_request(str(exc)) + return _Spec("auto", targets, model_type=model_type, options=options), None + + +def parse_image_tag_request(data: dict): + """A :class:`_Spec` for a start body, or ``(None, error response)``. + + ``{mode:"add", targets, tags}`` or ``{mode:"auto", targets, model_type, + options}`` exactly; options follow the Tag dialog's Generate rules. + """ + fields = _FIELDS.get(data.get("mode")) + if fields is None: + return None, bad_request('mode must be "add" or "auto"') + if set(data) != fields: + return None, bad_request( + "Request must contain exactly " + ", ".join(sorted(fields)) + ) + targets, error = _parse_targets(data["targets"]) + if error is not None: + return None, error + if data["mode"] == "add": + return _parse_add(targets, data) + return _parse_auto(targets, data) + + +def _capture_outcome(exc: CaptureError, root, relative: str): + """The (group, detail) of a file the capture store refused to read.""" + if isinstance(exc, CaptureTooLarge): + return "skipped", TOO_LARGE + if isinstance(exc, CaptureDenied): + return "skipped", FILE_NOT_FOUND + if isinstance(exc, CaptureChanged): + path = os.path.join(root, *relative.split("/")) + return "skipped", FILE_CHANGED if os.path.lexists(path) else FILE_NOT_FOUND + if isinstance(exc, CaptureCapacity): + return "skipped", BUSY_RETRY + return "errors", READ_FAILED + + +async def _capacity_retried(job, attempt): + """``await attempt()``, tried again while the capture store is at capacity. + + Raises the last ``CaptureCapacity`` once ``CAPACITY_BACKOFF`` is spent or the + job was cancelled meanwhile. + """ + for delay in CAPACITY_BACKOFF: + try: + return await attempt() + except CaptureCapacity: + if job.cancelled: + raise + await asyncio.sleep(delay) + return await attempt() + + +def _load_model(service, model_type: str, use_gpu: bool) -> None: + """Worker thread: load (or swap to) *model_type*; never downloads.""" + if service.is_model_loaded() and service.get_loaded_model_type() == model_type: + return + if service.load_model(model_type, use_gpu) is False: + raise RuntimeError(f"{model_type} could not be loaded") + + +class ImageTagJobsMixin: + """The ``image-tags`` job kind: start route and runner.""" + + async def start_image_tag_job(self, request: web.Request) -> web.Response: + """Start bulk tagging (202), or 409 ``busy`` / ``model-busy``. + + Every target's root scope is checked before the job starts + (``source-changed``). Auto-tag takes the model gate here, so a start + while anything else holds it is refused at once. + """ + data, error = await self._read_json_body(request, max_bytes=MAX_JOB_BODY_BYTES) + if error is not None: + return error + spec, error = parse_image_tag_request(data) + if error is not None: + return error + roots = await self._run_in_executor(self._job_roots, spec.targets) + if any(root is None for root, _label in roots.values()): + return _coded("source-changed") + epoch = await self._run_in_executor(self.db.get_execution_epoch) + # A running job in the way answers first: it says which job to wait for. + blocker = self._jobs.blocker(IMAGE_TAG_JOB_KIND, record_changing=True) + if blocker is not None: + return busy_response(JobBusy(blocker)) + owner = None + if spec.mode == "auto": + owner = model_gate.new_owner(GATE_OWNER_KIND) + if not model_gate.try_acquire(owner): + return model_busy_response() + try: + job = self._jobs.start( + IMAGE_TAG_JOB_KIND, + spec.title, + record_changing=True, + scope=spec.scope(), + runner=functools.partial(self._run_image_tag_job, spec, epoch, owner), + groups=IMAGE_TAG_GROUP_NAMES, + ) + except JobBusy as busy: + if owner is not None: + model_gate.release(owner) + return busy_response(busy) + if owner is not None: + # Also when the task is cancelled before its runner ever starts. + job.task.add_done_callback(lambda _task: model_gate.release(owner)) + return web.json_response({"success": True, "job": job.summary()}, status=202) + + # -- runner ---------------------------------------------------------------- + + def _job_roots(self, targets) -> dict: + """Worker thread: ``{(index, scope): (root or None, label)}`` (see _Run).""" + roots = list(self._get_all_output_dirs()) + resolved = {} + for index, scope in dict.fromkeys((t[0], t[1]) for t in targets): + label = _public_path(roots[index]) if index < len(roots) else "" + error = self._gallery_scope_error( + {"root_scope": scope, "root": str(index)}, roots + ) + resolved[(index, scope)] = (roots[index] if error is None else None, label) + return resolved + + async def _run_image_tag_job(self, spec: _Spec, epoch: str, owner, job) -> None: + verb = _VERBS[spec.mode] + total = len(spec.targets) + job.update(total=total, status_line=f"Processing file 0 of {total:,}") + started = f"{verb} started · {_plural(total, 'image')}" + if spec.mode == "auto": + started += f" · {spec.model_type}" + job.log_line(started) + run = _Run(job, spec, epoch, owner) + try: + if spec.mode == "auto": + run.service = await self._prepare_tagging_model(job, spec, owner) + if run.service is None: + job.finish("failed") + return + if not await self._tag_each_file(run): + job.log_line(f"{verb} stopped · {STORAGE_RESTORED}") + job.finish("failed") + return + state = "cancelled" if job.cancelled else "finished" + job.log_line(f"{verb} {state} · {_plural(job.processed, 'file')} processed") + job.publish({"type": state, "progress": job.progress}) + job.finish(state) + finally: + if owner is not None: + model_gate.release(owner) + + async def _tag_each_file(self, run: _Run) -> bool: + """Tag the targets in order; False when the job had to stop.""" + job, total = run.job, len(run.spec.targets) + run.roots = await self._run_in_executor(self._job_roots, run.spec.targets) + started = _monotonic() + for number, target in enumerate(run.spec.targets, 1): + if job.cancelled: + break + # The scan's estimate (D56), with each finished file as one batch. + left = _time_left(number - 1, total, number - 1, _monotonic() - started) + job.update(status_line=f"Processing file {number:,} of {total:,}{left}") + job.publish({"type": "progress", "progress": job.progress}) + stopped = False + try: + group, detail = await self._tag_one_file(run, target) + except _JobStopped: + stopped, group, detail = True, "errors", STORAGE_RESTORED + except Exception: + self.logger.exception("Image tagging failed for one file") + group, detail = "errors", SAVE_FAILED + self._record_tag_outcome(run, target, group, detail) + job.update(processed=number, progress=number * 100 // total) + if stopped: + return False + return True + + async def _prepare_tagging_model(self, job, spec: _Spec, owner): + """The loaded service, or None (logged) when the job cannot run.""" + from ..autotag import MODELS, get_autotag_service + + service = get_autotag_service() + status = await self._run_in_executor(service.get_models_status) + if not status.get(spec.model_type, {}).get("downloaded"): + name = MODELS.get(spec.model_type, {}).get("name", spec.model_type) + message = unavailable_message(name) + job.update(status_line=message) + job.log_line(f"! {message}") + return None + try: + await model_gate.run( + owner, + _load_model, + service, + spec.model_type, + spec.options["use_gpu"], + ) + except Exception: + self.logger.exception("The auto-tag job could not load its model") + job.update(status_line=INFERENCE_FAILED) + job.log_line(f"! {INFERENCE_FAILED}") + return None + return service + + async def _verified(self, run: _Run, store, root, relative): + """The current file's facts, waiting out a busy capture store.""" + return await _capacity_retried( + run.job, lambda: self._run_in_executor(store.verify, root, relative) + ) + + async def _tag_one_file(self, run: _Run, target): + """The (group, detail) of one target; raises _JobStopped on a restore.""" + index, scope, relative = target + root, _label = run.roots[(index, scope)] + if root is None: + return "errors", SOURCE_CHANGED + store = self._capture_store() + if run.spec.mode == "add": + try: + facts = await self._verified(run, store, root, relative) + except CaptureError as exc: + return _capture_outcome(exc, root, relative) + return await self._union_image_tags(facts, list(run.spec.tags), run.epoch) + if is_video_path(relative): + return "skipped", VIDEO_SKIPPED + try: + capture = await _capacity_retried( + run.job, + lambda: _settled( + _submit( + store.capture, + root, + relative, + epoch=run.epoch, + scope=scope, + root_index=index, + ), + lambda made: made is not None and store.release(made.token), + ), + ) + except CaptureError as exc: + return _capture_outcome(exc, root, relative) + try: + return await self._auto_tag_capture(run, store, capture, root, relative) + finally: + store.release(capture.token) + + async def _auto_tag_capture(self, run: _Run, store, capture, root, relative): + """Suggest on the capture, then union only onto the same current bytes.""" + if not capture.inferable: + return "skipped", NOT_A_STILL + if store.acquire(capture.token) is None: + raise _JobStopped # every capture was invalidated (restore) + spec = run.spec + try: + pairs = await model_gate.run( + run.owner, + _pinned_suggestions, + store, + capture.token, + run.service, + capture, + spec.model_type, + spec.options, + on_skipped=functools.partial(store.release_use, capture.token), + ) + except Exception: + self.logger.exception("Auto-tagging failed for one file") + return "errors", INFERENCE_FAILED + try: + facts = await self._verified(run, store, root, relative) + except CaptureError as exc: + return _capture_outcome(exc, root, relative) + if facts.locator != capture.locator or facts.sha256 != capture.sha256: + return "skipped", FILE_CHANGED + names = [str(name) for name, _score in pairs] + if not names: + return "unchanged", NO_NEW_TAGS + return await self._union_image_tags(facts, names, run.epoch) + + async def _union_image_tags(self, facts, tags: list, epoch: str): + """``add_image_tags`` on the verified bytes, as a (group, detail).""" + try: + receipt = await self._run_in_executor( + self.db.add_image_tags, + locator=facts.locator, + sha256=facts.sha256, + size=facts.size, + mtime_ns=facts.mtime_ns, + expected_epoch=epoch, + tags=tags, + ) + except LibraryCommandError as exc: + if exc.code == "storage-changed": + raise _JobStopped from None + if exc.code == "tag-limit": + return "errors", _MESSAGES["tag-limit"] + self.logger.warning(f"Image tags not saved ({exc.code})") + return "errors", SAVE_FAILED + except ValueError: + self.logger.exception("Image tags were refused") + return "errors", SAVE_FAILED + added = receipt.get("added", 0) + if added: + return "tagged", f"Added {_plural(added, 'tag')}" + return "unchanged", NO_NEW_TAGS + + def _record_tag_outcome(self, run: _Run, target, group: str, detail: str) -> None: + index, scope, relative = target + _root, label = run.roots[(index, scope)] + where = f"{label}/{relative}" if label else relative + run.job.record( + group, + {"path": relative, "root": label, "root_index": index, "detail": detail}, + f"{_LOG_PREFIXES[group]} {where} · {detail}", + ) diff --git a/py/api/images.py b/py/api/images.py index 6edfdc1..69ad96f 100644 --- a/py/api/images.py +++ b/py/api/images.py @@ -19,6 +19,7 @@ from typing import Callable, NamedTuple from aiohttp import web from PIL import Image, UnidentifiedImageError +from .jobs import THUMBNAILS_JOB_KIND from .prompts import bad_request, parse_page_params, publish_image_paths from .prompts import thumbnail_url_for from .prompts import safe_error_message as _safe_error @@ -104,6 +105,10 @@ def _card_header(path: Path) -> tuple: return inspection.width, inspection.height, records +# The legacy gallery page's thumbnail generation, as Activity lists it (D80). +THUMBNAIL_GENERATION_TITLE = "Thumbnail generation (gallery page)" + + class _CardFacts(NamedTuple): """One image file's card facts, derived from its header alone. @@ -1736,11 +1741,24 @@ class ImageRoutesMixin: return cls.THUMBNAIL_SIZES.get(quality, cls.THUMBNAIL_SIZES["medium"]) async def generate_thumbnails(self, request): - """Generate every thumbnail in one executor job (POST, blocking).""" + """Generate every thumbnail in one executor job (POST, blocking). + + Holds the registry as guarded work: refused while a record-changing + job runs, and such jobs are refused while it runs. A body that cannot + be read is refused before the hold, so it is never listed as a job. + """ + data, error = await _json_body_or_error(request) + if error is not None: + return error + return await self._run_held( + THUMBNAILS_JOB_KIND, + THUMBNAIL_GENERATION_TITLE, + "guarded", + lambda: self._generate_thumbnails(data), + ) + + async def _generate_thumbnails(self, data): try: - data, error = await _json_body_or_error(request) - if error is not None: - return error thumbnail_size = self._thumbnail_size(data.get("quality", "medium")) output_dirs = self._get_all_output_dirs() @@ -1935,14 +1953,32 @@ class ImageRoutesMixin: "Connection": "keep-alive", }, ) - await response.prepare(request) + busy = self._jobs.blocker(THUMBNAILS_JOB_KIND, admission="guarded") + if busy is not None: + # The gallery reads this stream: refuse with its error event. + await response.prepare(request) + message = f"{busy.title} already running" + await self._emit_sse( + response, "error", {"error": message, "message": message} + ) + return response + async with self._jobs.hold( + THUMBNAILS_JOB_KIND, THUMBNAIL_GENERATION_TITLE, admission="guarded" + ) as job: + await response.prepare(request) + if not await self._thumbnail_progress_stream(response, thumbnail_size): + job.finish("failed") # an error event ended the stream + return response + + async def _thumbnail_progress_stream(self, response, thumbnail_size) -> bool: + """Stream one generation run; False when it ended with an error event.""" try: output_dirs = self._get_all_output_dirs() if not output_dirs: await self._emit_sse( response, "error", {"error": "ComfyUI output directory not found"} ) - return response + return False await self._emit_sse( response, @@ -1960,6 +1996,7 @@ class ImageRoutesMixin: remaining=plan["remaining"], ) self.invalidate_gallery_cache() + return True except Exception: self.logger.exception("Thumbnail generation failed") await self._emit_sse( @@ -1970,7 +2007,7 @@ class ImageRoutesMixin: "message": "Thumbnail generation failed. Check server logs.", }, ) - return response + return False async def _stream_thumbnails( self, response, targets, thumbnail_size, skipped=0, remaining=0 @@ -2179,8 +2216,17 @@ class ImageRoutesMixin: """Clear generated thumbnails across gallery roots, preserving originals. Invalid thumbnail directories return 400; unavailable gallery roots - return 404, and unexpected filesystem failures return 500. + return 404, and unexpected filesystem failures return 500. Holds the + registry as guarded work, like generation. """ + return await self._run_held( + THUMBNAILS_JOB_KIND, + "Thumbnail clear (gallery page)", + "guarded", + self._clear_thumbnails, + ) + + async def _clear_thumbnails(self) -> web.Response: try: output_dirs = self._get_all_output_dirs() if not output_dirs: diff --git a/py/api/job_routes.py b/py/api/job_routes.py index 71b35cb..f855660 100644 --- a/py/api/job_routes.py +++ b/py/api/job_routes.py @@ -1,8 +1,9 @@ """Session job routes (Activity): list, inspect, follow and cancel jobs. -The registry itself is ``py/api/jobs.py``; the scan job's runner is in -``py/api/admin.py``. Every response leaves out absolute server paths: job -rows already carry root-relative paths and public root labels. +The registry itself is ``py/api/jobs.py``. The scan job's runner is in +``py/api/admin.py``, the image tagging job's in ``py/api/image_tag_jobs.py``. +Every response leaves out absolute server paths: job rows already carry +root-relative paths and public root labels. """ import contextlib @@ -42,6 +43,20 @@ async def _scan_scope(request): return scope if scope in SCAN_SCOPES else None +def busy_response(busy: JobBusy): + """``409 busy``, naming the running job that holds the slot.""" + running = busy.running + return web.json_response( + { + "success": False, + "code": "busy", + "error": str(busy), + "running": {"id": running.id, "title": running.title}, + }, + status=409, + ) + + def _summary_event(job): return f"data: {json.dumps(job.summary())}\n\n".encode("utf-8") @@ -58,6 +73,10 @@ class JobRoutesMixin: async def start_scan_job_route(request): return await self.start_scan_job(request) + @routes.post("/prompt_manager/jobs/image-tags") + async def start_image_tag_job_route(request): + return await self.start_image_tag_job(request) + @routes.get("/prompt_manager/jobs/{job_id}") async def job_detail_route(request): return await self.job_detail(request) @@ -121,16 +140,7 @@ class JobRoutesMixin: try: job = self._start_scan_job(files) except JobBusy as busy: - running = busy.running - return web.json_response( - { - "success": False, - "code": "busy", - "error": str(busy), - "running": {"id": running.id, "title": running.title}, - }, - status=409, - ) + return busy_response(busy) return web.json_response({"success": True, "job": job.summary()}, status=202) async def job_events(self, request): diff --git a/py/api/jobs.py b/py/api/jobs.py index a5a4b0e..568a99e 100644 --- a/py/api/jobs.py +++ b/py/api/jobs.py @@ -9,8 +9,21 @@ as a snapshot and then every event published after it, until the job ends. Jobs live only in server memory and are gone after a restart. The registry keeps the running jobs and this session's last ``MAX_FINISHED_JOBS`` finished -ones. Record-changing jobs (the metadata scan, and later maintenance, cleanup -and restore) never run together, and one job of a kind runs at a time. +ones. One job of a kind runs at a time, and each job has an admission class +(``ADMISSIONS``) that decides what may run beside it: + +- ``records`` changes library records or files (the metadata scan, bulk image + tagging, and the legacy restore, maintenance, cleanup, duplicate delete, + LoRA import and batch auto-tag routes). It is refused while any + ``records`` or ``guarded`` job runs. +- ``guarded`` must not overlap record changes but may overlap other guarded + work (thumbnail generation and clearing, later backup). It is refused while + a ``records`` job runs. +- ``read`` only reads; it is refused only by a running job of its own kind. + +A short legacy-page operation takes a ``hold``: a running, non-cancellable +job that lasts as long as the request, so the Activity page lists it and a +workspace job is refused while it runs (and the other way round). """ import asyncio @@ -54,6 +67,46 @@ SCAN_GROUPS = { "failed": "errors", } +# Bulk image tagging (#249 D1): Add tags and Auto-tag over selected images. +# Record-changing: it writes image tag sets and new vocabulary names. +IMAGE_TAG_JOB_KIND = "image-tags" +IMAGE_TAG_GROUP_NAMES = ("tagged", "unchanged", "skipped", "errors") + +ADMISSIONS = ("read", "guarded", "records") +# What each admission class is refused beside (besides its own kind). +_REFUSED_BESIDE = { + "read": frozenset(), + "guarded": frozenset({"records"}), + "records": frozenset({"records", "guarded"}), +} + +# Kinds of the legacy-page operations that hold the registry (D80; titles in +# their handlers end in "(admin page)", "(gallery page)" or "(legacy page)"). +RESTORE_JOB_KIND = "restore" +MAINTENANCE_JOB_KIND = "maintenance" +DUPLICATE_CLEANUP_JOB_KIND = "duplicate_cleanup" +THUMBNAILS_JOB_KIND = "thumbnails" +LORA_IMPORT_JOB_KIND = "lora_import" +AUTOTAG_BATCH_JOB_KIND = "autotag" + + +def resolve_admission(admission=None, record_changing=None) -> str: + """The admission class for a start call; ``record_changing`` is an alias. + + ``record_changing=True`` means ``records`` and ``False`` means ``read``; + with neither given the class is ``read``. Raises ValueError for an + unknown class or one that contradicts ``record_changing``. + """ + if admission is None: + return "records" if record_changing else "read" + if admission not in ADMISSIONS: + raise ValueError(f"Unknown job admission {admission!r}") + if record_changing is not None and record_changing != (admission == "records"): + raise ValueError( + f"record_changing={record_changing!r} contradicts admission {admission!r}" + ) + return admission + def _relative_path(path) -> str: """``path`` as a root-relative POSIX path; an absolute one keeps its name.""" @@ -79,13 +132,23 @@ class Job: """One tracked operation: state, counters, outcomes, log and subscribers.""" def __init__( - self, kind, title, *, record_changing=False, scope=None, groups=(), clock=None + self, + kind, + title, + *, + admission=None, + record_changing=None, + cancellable=True, + scope=None, + groups=(), + clock=None, ): self._clock = clock or time.time self.id = uuid.uuid4().hex self.kind = kind self.title = title - self.record_changing = record_changing + self.admission = resolve_admission(admission, record_changing) + self.cancellable = bool(cancellable) self.scope = dict(scope or {}) self.state = "running" self.started_at = self._clock() @@ -104,6 +167,10 @@ class Job: self.task = None self._subscribers = set() + @property + def record_changing(self): + return self.admission == "records" + @property def running(self): return self.state == "running" @@ -234,6 +301,9 @@ class Job: # What detail() lists, so a view re-reads it only when it changed. "listed": {group: len(rows) for group, rows in self.outcomes.items()}, "log_length": len(self.log), + # A view offers Cancel only when the server can stop the job. + "cancellable": self.cancellable, + "admission": self.admission, } def detail(self): @@ -285,27 +355,89 @@ class JobRegistry: self._logger = logger_ or logger self._jobs = {} - def start(self, kind, title, *, record_changing, scope, runner, groups=()) -> Job: + def start( + self, + kind, + title, + *, + admission=None, + record_changing=None, + cancellable=True, + scope=None, + runner, + groups=(), + ) -> Job: """Create a job and run ``runner(job)`` as a task. - Raises JobBusy, naming the job in the way, when a job of ``kind`` is - running or when ``record_changing`` and another record-changing job - runs. Must be called from the event loop. + ``admission`` is one of ``ADMISSIONS``; ``record_changing=True`` is + the older spelling of ``records`` and ``False`` of ``read``. One of + the two is required (TypeError otherwise), so no new job runs as + ``read`` beside a scan by omission. Raises JobBusy, naming the job in + the way, when the admission class refuses it (see the module + docstring). Must be called from the event loop. + """ + if admission is None and record_changing is None: + raise TypeError( + f"JobRegistry.start({kind!r}) needs an admission class " + "(admission= or record_changing=)" + ) + job = self._admit( + kind, + title, + admission=admission, + record_changing=record_changing, + cancellable=cancellable, + scope=scope, + groups=groups, + ) + job.task = asyncio.ensure_future(self._run(job, runner)) + return job + + def hold(self, kind, title, *, admission, scope=None): + """A running, non-cancellable job for a legacy-page operation. + + Returns an async context manager. Entering it registers the job and + yields it, or raises JobBusy at once, without waiting, when the + admission class refuses it; nothing is registered until then, so a + hold that is never entered leaves no job behind. The job ends when + the block exits: ``finished``, or ``failed`` when it raised (unless + the body already ended it). A caller that answers a refusal in its + own way checks ``blocker(kind, admission=...)`` first, with no await + before the ``async with``. + """ + resolve_admission(admission) # an unknown class fails at the call + return _Hold( + lambda: self._admit( + kind, title, admission=admission, cancellable=False, scope=scope + ) + ) + + def _admit(self, kind, title, *, admission, record_changing=None, **job_args): + resolved = resolve_admission(admission, record_changing) + blocker = self.blocker(kind, admission=resolved) + if blocker is not None: + raise JobBusy(blocker) + self._prune() + job = Job(kind, title, admission=resolved, **job_args) + self._jobs[job.id] = job + return job + + def blocker(self, kind, *, admission=None, record_changing=None): + """The running job a new ``kind`` job would be refused for, or None. + + The same kind refuses first; then the oldest running job whose class + the new job's admission class may not run beside. """ same_kind = self.active(kind) if same_kind is not None: - raise JobBusy(same_kind) - if record_changing: - for job in self._jobs.values(): - if job.running and job.record_changing: - raise JobBusy(job) - self._prune() - job = Job( - kind, title, record_changing=record_changing, scope=scope, groups=groups - ) - self._jobs[job.id] = job - job.task = asyncio.ensure_future(self._run(job, runner)) - return job + return same_kind + refused = _REFUSED_BESIDE[resolve_admission(admission, record_changing)] + running = [job for job in self._jobs.values() if job.running] + running.sort(key=lambda job: job._started_seq) + for job in running: + if job.admission in refused: + return job + return None async def _run(self, job, runner): try: @@ -349,11 +481,28 @@ class JobRegistry: def cancel(self, job_id) -> bool: """Ask a running job to stop at its next safe boundary. - Returns whether a cancellation was requested: False for a finished or - unknown job, so a cancel after the end is a no-op. + Returns whether a cancellation was requested: False for a finished, + unknown or non-cancellable job, so a cancel after the end is a no-op. """ job = self._jobs.get(job_id) - if job is None or job.finished: + if job is None or job.finished or not job.cancellable: return False job.cancelled = True return True + + +class _Hold: + """The async context manager ``JobRegistry.hold`` returns.""" + + def __init__(self, admit): + self._admit = admit + self._job = None + + async def __aenter__(self): + self._job = self._admit() + return self._job + + async def __aexit__(self, exc_type, exc, tb): + if self._job.running: + self._job.finish("finished" if exc_type is None else "failed") + return False diff --git a/py/api/lora_integration.py b/py/api/lora_integration.py index 537b35c..7e66c2d 100644 --- a/py/api/lora_integration.py +++ b/py/api/lora_integration.py @@ -5,12 +5,47 @@ from pathlib import Path from aiohttp import web +from ..config_store import ConfigUnreadable, RevisionConflict, get_config_store +from .jobs import LORA_IMPORT_JOB_KIND + +# Flags of POST /lora/enable; each must be a JSON boolean when present. +_LORA_FLAGS = ("enabled", "trigger_words_enabled", "clear_civitai_api_key") + _LORA_PATH_ERROR = ( "LoraManager path must be a directory under ComfyUI's custom_nodes " "whose name contains 'lora'" ) +def _lora_settings_mutation(values): + """The config commit for a LoRA save: only ``integrations.lora_manager``. + + The submitted keys replace their values; other LoRA keys, other + integrations and every other section stay as they are on disk. + """ + + def mutate(config): + integrations = config.get("integrations") + integrations = dict(integrations) if isinstance(integrations, dict) else {} + lora = integrations.get("lora_manager") + lora = dict(lora) if isinstance(lora, dict) else {} + lora.update(values) + integrations["lora_manager"] = lora + config["integrations"] = integrations + return config + + return mutate + + +def _lora_error_frame(message): + """An import stream's error event, as the admin page's modal reads it. + + The modal shows ``status`` and ``progress`` from every frame and closes + on ``type: error``, naming ``message``. + """ + return {"type": "error", "status": message, "progress": 0, "message": message} + + class LoraIntegrationMixin: """Mixin providing LoraManager detection, scanning, and trigger word endpoints.""" @@ -131,14 +166,25 @@ class LoraIntegrationMixin: return allowed, None async def lora_enable(self, request): - """Enable or disable LoraManager integration and save to config.json.""" + """Enable or disable LoraManager integration and save it to config.json. + + The values are committed through the config store first (#220) and + published at runtime only once config.json holds them; a save that + cannot be written reports ``persisted: false`` and changes nothing. + The CivitAI key is never echoed back. + """ try: - data = await request.json() + data, error = await self._read_json_body(request) + if error is not None: + return error + for flag in _LORA_FLAGS: + if flag in data and not isinstance(data[flag], bool): + return self._bad_lora_request(f"{flag} must be true or false") enabled = data.get("enabled", False) path = data.get("path", "") trigger_words = data.get("trigger_words_enabled", False) - from ..config import IntegrationConfig, PromptManagerConfig + from ..config import IntegrationConfig from ..lora_utils import get_trigger_cache # The key is never echoed back to the UI, so a blank or absent @@ -161,20 +207,40 @@ class LoraIntegrationMixin: if error is not None: return error - # Update in-memory config - IntegrationConfig.LORA_MANAGER_ENABLED = enabled - IntegrationConfig.LORA_MANAGER_PATH = path - IntegrationConfig.LORA_TRIGGER_WORDS_ENABLED = trigger_words - IntegrationConfig.CIVITAI_API_KEY = civitai_key + values = { + "enabled": enabled, + "path": path, + "trigger_words_enabled": trigger_words, + "civitai_api_key": civitai_key, + } - # Persist to the configured config.json - PromptManagerConfig.save_to_file() + def publish(_config): + # Under the commit lock, so the last save written is the one + # the runtime keeps. + IntegrationConfig.LORA_MANAGER_ENABLED = enabled + IntegrationConfig.LORA_MANAGER_PATH = path + IntegrationConfig.LORA_TRIGGER_WORDS_ENABLED = trigger_words + IntegrationConfig.CIVITAI_API_KEY = civitai_key + + try: + _saved, revision = await self._run_in_executor( + get_config_store().commit, + _lora_settings_mutation(values), + expected_revision=None, + publish=publish, + ) + except (ConfigUnreadable, RevisionConflict, OSError) as exc: + return self._config_commit_failure(exc) - # Load trigger word cache if enabling cache = get_trigger_cache() if enabled and trigger_words and path: - count = await self._run_in_executor(cache.load, path) - self.logger.info(f"Trigger word cache loaded: {count} LoRAs") + try: + count = await self._run_in_executor(cache.load, path) + self.logger.info(f"Trigger word cache loaded: {count} LoRAs") + except Exception as e: + # Saved and enabled; only the trigger words are missing. + self.logger.warning(f"Trigger word cache could not be loaded: {e}") + cache.clear() elif not enabled: cache.clear() @@ -184,6 +250,10 @@ class LoraIntegrationMixin: "enabled": enabled, "path": self._public_path(path), "trigger_words_enabled": trigger_words, + "persisted": True, + "revision": revision, + "runtime_applied": True, + "cache_loaded": cache.is_loaded, } ) except Exception as e: @@ -218,19 +288,33 @@ class LoraIntegrationMixin: reason="OK", headers={"Content-Type": "text/event-stream"}, ) - await response.prepare(request) async def send_progress(data): line = f"data: {json.dumps(data)}\n\n" await response.write(line.encode("utf-8")) - try: - await self._lora_scan_stream(allowed, send_progress) - except Exception as e: - self.logger.exception("LoRA scan failed") - await send_progress({"type": "error", "message": self._public_error(e)}) + # The import replaces prompts and images: a records hold, so it + # never runs beside a scan, restore or tagging job. + busy = self._jobs.blocker(LORA_IMPORT_JOB_KIND, admission="records") + if busy is not None: + await response.prepare(request) + message = f"{busy.title} already running" + await send_progress(_lora_error_frame(message)) + await response.write_eof() + return response - await response.write_eof() + async with self._jobs.hold( + LORA_IMPORT_JOB_KIND, "LoRA import (admin page)", admission="records" + ) as job: + await response.prepare(request) + try: + await self._lora_scan_stream(allowed, send_progress) + except Exception as e: + self.logger.exception("LoRA scan failed") + job.finish("failed") + await send_progress(_lora_error_frame(self._public_error(e))) + + await response.write_eof() return response except Exception as e: diff --git a/py/api/settings_routes.py b/py/api/settings_routes.py new file mode 100644 index 0000000..258ad2a --- /dev/null +++ b/py/api/settings_routes.py @@ -0,0 +1,339 @@ +"""Settings API routes: GET and POST /prompt_manager/settings (#220). + +A save validates the payload, commits it to config.json through the config +store and publishes it at runtime inside that commit; a save that cannot be +written changes nothing at runtime and says so. ``_config_commit_failure`` +is shared with the LoRA integration's save. +""" + +import os + +from aiohttp import web + +from ..config_store import ( + ConfigUnreadable, + RevisionConflict, + get_config_store, +) +from .admin import _current_image_monitor + +try: + from ...utils.validators import validate_result_timeout, validate_worker_threads +except ImportError: + from utils.validators import validate_result_timeout, validate_worker_threads + + +# Settings owned by POST /settings: config attribute -> (section, key). +_SETTINGS_FILE_KEYS = { + "RESULT_TIMEOUT": ("web_ui", "result_timeout"), + "WEBUI_DISPLAY_MODE": ("web_ui", "webui_display_mode"), + "INFINITE_SCROLL": ("web_ui", "infinite_scroll"), + "WORKER_THREADS": ("performance", "worker_threads"), +} + + +def _section(config, name): + """``config[name]`` as a fresh dict (a non-object section starts empty).""" + value = config.get(name) + return dict(value) if isinstance(value, dict) else {} + + +def _settings_mutation(updates, new_roots): + """The config commit for a settings save: only the submitted keys change. + + Every other section and key (integrations and the CivitAI key, database, + unknown sections) is kept as it is on disk. + """ + + def mutate(config): + for name, value in updates.items(): + section, key = _SETTINGS_FILE_KEYS[name] + merged = _section(config, section) + merged[key] = value + config[section] = merged + if new_roots is not None: + gallery = _section(config, "gallery") + monitoring = _section(gallery, "monitoring") + monitoring["directories"] = list(new_roots) + gallery["monitoring"] = monitoring + config["gallery"] = gallery + return config + + return mutate + + +async def _read_json(request): + """Parse a JSON object body through the package's capped reader.""" + from . import _read_json_body + + return await _read_json_body(request) + + +class SettingsRoutesMixin: + """Mixin providing the settings read and save endpoints.""" + + def _register_settings_routes(self, routes): + @routes.get("/prompt_manager/settings") + async def get_settings_route(request): + return await self.get_settings(request) + + @routes.post("/prompt_manager/settings") + async def save_settings_route(request): + return await self.save_settings(request) + + async def get_settings(self, request): + """Get current settings.""" + try: + from ..config import PromptManagerConfig, GalleryConfig + + root_paths = [ + self._public_path(d) for d in GalleryConfig.MONITORING_DIRECTORIES + ] + revision = await self._run_in_executor(self._config_revision) + return web.json_response( + { + "success": True, + **revision, + "settings": { + "result_timeout": PromptManagerConfig.RESULT_TIMEOUT, + "webui_display_mode": PromptManagerConfig.WEBUI_DISPLAY_MODE, + "infinite_scroll": PromptManagerConfig.INFINITE_SCROLL, + "worker_threads": PromptManagerConfig.WORKER_THREADS, + "cpu_count": PromptManagerConfig.max_worker_threads(), + "gallery_root_paths": root_paths, + "gallery_root_path": root_paths[0] if root_paths else "", + "monitored_directories": [ + self._public_path(d) for d in self._monitored_directories() + ], + }, + } + ) + except Exception as e: + self.logger.error(f"Get settings error: {e}", exc_info=True) + return web.json_response( + {"success": False, "error": "Failed to get settings"}, + status=500, + ) + + def _monitored_directories(self): + """Directories the running image monitor watches, else the configured roots.""" + from ..config import GalleryConfig + + monitor = _current_image_monitor() + if monitor is not None: + return list(getattr(monitor, "monitored_directories", [])) + return list(GalleryConfig.MONITORING_DIRECTORIES) + + async def save_settings(self, request): + """Save settings: validate, commit to config.json, then publish (#220). + + Nothing changes at runtime until config.json holds the new values, so + a save that cannot be written reports ``persisted: false`` and leaves + the running settings as they were. ``expected_revision`` (the + ``revision`` from GET) refuses to overwrite a file changed meanwhile; + without it the payload merges into what is on disk (legacy page). + """ + try: + data, error_response = await _read_json(request) + if error_response is not None: + return error_response + + expected_revision = data.get("expected_revision") + if expected_revision is not None and not isinstance(expected_revision, str): + return web.json_response( + {"success": False, "error": "expected_revision must be a string"}, + status=400, + ) + try: + updates = self._validate_settings(data) + except ValueError as ve: + return web.json_response( + {"success": False, "error": str(ve)}, status=400 + ) + + new_roots, error = self._parse_gallery_roots(data) + if error: + return web.json_response({"success": False, "error": error}, status=400) + + published = {} + + def publish(_config): + # Under the commit lock: saves reach the runtime in the order + # they were written, so the runtime ends on the last one. + published.update(self._publish_settings(updates, new_roots)) + + try: + _saved, revision = await self._run_in_executor( + get_config_store().commit, + _settings_mutation(updates, new_roots), + expected_revision=expected_revision, + publish=publish, + ) + except (ConfigUnreadable, RevisionConflict, OSError) as exc: + return self._config_commit_failure(exc) + + return web.json_response( + { + "success": True, + "message": "Settings saved successfully", + "restart_required": bool(published["restart_required_fields"]), + "persisted": True, + "revision": revision, + **published, + } + ) + except Exception as e: + self.logger.error(f"Save settings error: {e}", exc_info=True) + return web.json_response( + {"success": False, "error": "Failed to save settings"}, + status=500, + ) + + def _publish_settings(self, updates, new_roots): + """Install committed settings at runtime (inside the config commit). + + The values are already durable. A publication that fails is reported + as ``runtime_applied: false`` with the fields that need a restart; + nothing is retried or rolled back. Never raises. + """ + from ..config import PromptManagerConfig + + for name, value in updates.items(): + setattr(PromptManagerConfig, name, value) + restart_fields = [] + runtime_applied = True + if new_roots is not None: + try: + if self._apply_gallery_roots(new_roots): + restart_fields.append("gallery_root_paths") + except Exception as e: + self.logger.error( + f"Saved gallery roots could not be applied: {e}", exc_info=True + ) + runtime_applied = False + restart_fields.append("gallery_root_paths") + return { + "runtime_applied": runtime_applied, + "restart_required_fields": restart_fields, + } + + def _config_commit_failure(self, exc): + """The response for a config commit that wrote nothing.""" + not_saved = {"success": False, "persisted": False, "runtime_applied": False} + if isinstance(exc, ConfigUnreadable): + self.logger.warning(f"Settings not saved: {exc}") + return web.json_response( + { + **not_saved, + "code": "config-unreadable", + "error": ( + "config.json cannot be read, so nothing was saved. " + "Fix or remove the file, then save again." + ), + }, + status=409, + ) + if isinstance(exc, RevisionConflict): + return web.json_response( + { + **not_saved, + "code": "settings-changed", + "error": "Settings changed since they were loaded. Reload them.", + "revision": exc.current, + }, + status=409, + ) + self.logger.error(f"Could not write config.json: {exc}") + return web.json_response( + { + **not_saved, + "code": "config-write-failed", + "error": "Settings could not be written to config.json.", + }, + status=500, + ) + + @staticmethod + def _config_revision(): + """``{"revision": ...}``, or a null revision with ``config_error``.""" + try: + _data, revision = get_config_store().read() + except ConfigUnreadable: + return {"revision": None, "config_error": "config-unreadable"} + return {"revision": revision} + + @staticmethod + def _validate_settings(data): + """Scalar settings of a payload as ``{CONFIG_ATTR: value}``. + + Raises ValueError with a user-facing message on the first bad value; + nothing is applied until every value passed. + """ + from ..config import PromptManagerConfig + + updates = {} + if "result_timeout" in data: + updates["RESULT_TIMEOUT"] = validate_result_timeout(data["result_timeout"]) + if "webui_display_mode" in data: + mode = data["webui_display_mode"] + if mode not in ("popup", "newtab"): + raise ValueError("webui_display_mode must be 'popup' or 'newtab'") + updates["WEBUI_DISPLAY_MODE"] = mode + if "infinite_scroll" in data: + if not isinstance(data["infinite_scroll"], bool): + raise ValueError("infinite_scroll must be true or false") + updates["INFINITE_SCROLL"] = data["infinite_scroll"] + if "worker_threads" in data: + updates["WORKER_THREADS"] = validate_worker_threads( + data["worker_threads"], PromptManagerConfig.max_worker_threads() + ) + return updates + + def _parse_gallery_roots(self, data): + """Extract and validate gallery roots from a settings payload. + + Accepts ``gallery_root_paths`` (list, preferred) or the legacy single + ``gallery_root_path`` string. Blank entries are dropped. + + Returns: + (roots, error): ``roots`` is the validated list, or ``None`` when + the payload carries no gallery root key; ``error`` is a message + when validation failed (and ``roots`` is then ``None``). + """ + if "gallery_root_paths" in data: + raw = data["gallery_root_paths"] + if not isinstance(raw, list): + return None, "gallery_root_paths must be a list" + elif "gallery_root_path" in data: + raw = [data["gallery_root_path"]] + else: + return None, None + + from ..config import GalleryConfig + + roots = [] + for entry in raw: + if not isinstance(entry, str): + return None, "Gallery root paths must be strings" + entry = entry.strip() + if not entry: + continue + ok, reason = GalleryConfig.validate_gallery_root(entry) + if not ok: + return ( + None, + f"Invalid gallery root '{os.path.basename(entry)}': {reason}", + ) + roots.append(GalleryConfig.resolve_gallery_root(entry)) + return roots, None + + def _apply_gallery_roots(self, roots): + """Install validated gallery roots; returns True when they changed.""" + from ..config import GalleryConfig + + if roots == list(GalleryConfig.MONITORING_DIRECTORIES): + return False + GalleryConfig.MONITORING_DIRECTORIES = roots + self._cached_output_dir = None + self.reset_gallery_roots_cache() + return True diff --git a/py/config_store.py b/py/config_store.py new file mode 100644 index 0000000..949423a --- /dev/null +++ b/py/config_store.py @@ -0,0 +1,197 @@ +"""The one serialized writer of config.json (#220). + +Every save that changes config.json goes through ``ConfigStore.commit``: the +settings page, the LoRA integration and, later, every System section. A +commit runs under one process-wide lock, re-reads the file inside it, applies +the caller's change to a copy and replaces the file atomically. So two saves +never overwrite each other's keys, sections this process does not know are +kept byte-for-byte in meaning, and a write that fails leaves the old file in +place and raises instead of being logged and reported as saved. + +A revision names the file content a client last saw: ``"sha256:<hex>"`` of +the file bytes, or ``"absent"`` when there is no file. A commit that names a +revision refuses to write over different content (``RevisionConflict``). + +Reads take the same lock, so a read handle is never open while the file is +replaced (Windows refuses that), and a caller's ``publish`` runs inside the +lock right after its write, so runtimes publish saves in the order they were +written. A symlinked config.json is written beside its real target. + +config.json can hold the CivitAI key, so the file is written owner-only. +""" + +import copy +import hashlib +import json +import os +import tempfile +import threading +import time +from typing import Callable, Optional, Tuple + +ABSENT_REVISION = "absent" + +# One lock for the process: every ConfigStore writes the same file. It is +# re-entrant so a commit's ``publish`` callback may read the config again. +_COMMIT_LOCK = threading.RLock() + +# Windows: an antivirus, indexer or backup handle on config.json makes +# os.replace fail with PermissionError for a moment, so it is retried briefly. +_REPLACE_ATTEMPTS = 5 if os.name == "nt" else 1 +_REPLACE_BACKOFF_SECONDS = 0.05 + + +class ConfigUnreadable(Exception): + """config.json exists but is not a readable JSON object; it is left alone.""" + + +class RevisionConflict(Exception): + """config.json changed since the revision the caller saw.""" + + def __init__(self, current: str): + super().__init__("config.json changed since it was read") + self.current = current + + +def revision_of(data: Optional[bytes]) -> str: + """The revision of a file's bytes; ``None`` means the file is absent.""" + if data is None: + return ABSENT_REVISION + return "sha256:" + hashlib.sha256(data).hexdigest() + + +def _default_path() -> str: + from .config import PromptManagerConfig + + return PromptManagerConfig.get_config_path() + + +def _replace(source: str, target: str) -> None: + """``os.replace`` with a short retry on a transient Windows PermissionError.""" + for attempt in range(1, _REPLACE_ATTEMPTS + 1): + try: + os.replace(source, target) + return + except PermissionError: + if attempt == _REPLACE_ATTEMPTS: + raise + time.sleep(_REPLACE_BACKOFF_SECONDS) + + +def _write_atomically(path: str, payload: bytes) -> None: + """Replace ``path`` with ``payload``: temp file beside it, fsync, replace. + + The descriptor is closed before ``os.replace`` (Windows refuses to replace + over an open handle), and on any failure the temp file is closed and + removed, so a failed write leaves the old file and no stray temp file. + ``path`` is resolved first: a symlink keeps pointing at the updated + target. A single-file bind mount cannot be replaced (EBUSY) and fails as + an OSError, which callers report as not saved. + """ + path = os.path.realpath(path) + parent = os.path.dirname(path) or "." + os.makedirs(parent, exist_ok=True) + fd, temp_path = tempfile.mkstemp(dir=parent, prefix=".config-", suffix=".tmp") + try: + try: + handle = os.fdopen(fd, "wb") + except BaseException: + os.close(fd) + raise + with handle: # closed before the replace, also when a write fails + handle.write(payload) + handle.flush() + os.fsync(handle.fileno()) + if os.name == "posix": + os.chmod(temp_path, 0o600) + _replace(temp_path, path) + except BaseException: + try: + os.unlink(temp_path) + except OSError: + pass + raise + + +class ConfigStore: + """Serialized, atomic reads and commits of config.json.""" + + def __init__(self, path_getter: Optional[Callable[[], str]] = None): + self._path_getter = path_getter or _default_path + + @property + def path(self) -> str: + """The config file this store reads and writes (resolved per call).""" + return self._path_getter() + + def read(self) -> Tuple[dict, str]: + """``(config, revision)``; a missing file is ``({}, "absent")``. + + Raises ConfigUnreadable when the file cannot be read, is not JSON, or + is not a JSON object. Waits for a running commit (see the module + docstring). + """ + with _COMMIT_LOCK: + return self._read(self.path) + + @staticmethod + def _read(path: str) -> Tuple[dict, str]: + try: + with open(path, "rb") as handle: + raw = handle.read() + except (FileNotFoundError, NotADirectoryError): + # No file there (a parent that is a file cannot hold one either); + # a commit then fails on the write, as config-write-failed. + return {}, ABSENT_REVISION + except OSError as exc: + raise ConfigUnreadable(f"config.json cannot be read: {exc}") from exc + try: + data = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, ValueError) as exc: + raise ConfigUnreadable("config.json is not valid JSON") from exc + if not isinstance(data, dict): + raise ConfigUnreadable("config.json is not a JSON object") + return data, revision_of(raw) + + def commit( + self, + mutate: Callable[[dict], dict], + *, + expected_revision: Optional[str], + publish: Optional[Callable[[dict], None]] = None, + ) -> Tuple[dict, str]: + """Apply ``mutate`` to the current config and write the result. + + ``mutate`` receives a deep copy of the file's content and returns the + whole new config (an object). ``expected_revision`` None merges into + whatever is on disk (legacy partial updates); a revision refuses to + write over different content. Returns ``(config, revision)`` as + written. Raises ConfigUnreadable, RevisionConflict, TypeError (a + non-object result) or OSError (the write failed; the old file stays). + + ``publish(config)``, when given, runs under the lock once the file + holds the new config, so concurrent saves reach the runtime in the + order they were written. It must handle its own errors: the values + are already durable when it runs. + """ + with _COMMIT_LOCK: + path = self.path + current, revision = self._read(path) + if expected_revision is not None and expected_revision != revision: + raise RevisionConflict(revision) + candidate = mutate(copy.deepcopy(current)) + if not isinstance(candidate, dict): + raise TypeError("A config commit must produce a JSON object") + payload = json.dumps(candidate, indent=2).encode("utf-8") + _write_atomically(path, payload) + if publish is not None: + publish(candidate) + return candidate, revision_of(payload) + + +_store = ConfigStore() + + +def get_config_store() -> ConfigStore: + """The process's config store (on ``PromptManagerConfig.get_config_path``).""" + return _store diff --git a/tests/browser/activity-concept.spec.mjs b/tests/browser/activity-concept.spec.mjs index 2f4ef4d..9dd4e64 100644 --- a/tests/browser/activity-concept.spec.mjs +++ b/tests/browser/activity-concept.spec.mjs @@ -249,14 +249,16 @@ const EXPECTED_JOB_VIEW = { pressed: { border: true, background: true, added: true, bad: true, warn: true }, }; -async function openRunningJob(page, request, theme, viewport = { width: 1600, height: 1100 }) { +const SCAN_START = { start: { total: 19622 }, step: { processed: 8240, + counters: { known: 6511, review: 37, no_metadata: 254, errors: 18 }, + records: [{ group: 'added', path: 'CCC/mabes9sans-dataset-v1/IMG_GEN_00369_.png', detail: 'Added to the Library as “Mabes portrait”' }] } }; + +async function openRunningJob(page, request, theme, viewport = { width: 1600, height: 1100 }, data = SCAN_START) { await page.setViewportSize(viewport); - const started = await request.post('/__test__/jobs/start', { data: { total: 19622 } }); + const started = await request.post('/__test__/jobs/start', { data: data.start }); await expect(started).toBeOK(); const { job } = await started.json(); - await expect(await request.post(`/__test__/jobs/${job.id}/step`, { data: { processed: 8240, - counters: { known: 6511, review: 37, no_metadata: 254, errors: 18 }, - records: [{ group: 'added', path: 'CCC/mabes9sans-dataset-v1/IMG_GEN_00369_.png', detail: 'Added to the Library as “Mabes portrait”' }] } })).toBeOK(); + await expect(await request.post(`/__test__/jobs/${job.id}/step`, { data: data.step })).toBeOK(); await openProduction(page, `activity/${job.id}`, theme); await expect(page.locator('#workspace-main .file-row')).toHaveCount(1); return job; @@ -278,6 +280,30 @@ for (const theme of THEMES) test(`job view follows the approved mockup ${theme}` expect(await page.evaluate(measureJobView)).toEqual(EXPECTED_JOB_VIEW); }); +// D89 (plan D66): an image tagging job's view is the same mockup with its own four tiles (Tagged green, Unchanged, +// Skipped warning, Errors red) in the mockup's five-column row, its own outcome list and "Tagging log". +const TAG_START = { start: { kind: 'image-tags', title: 'Auto-tag images', mode: 'auto', images: 4, total: 4 }, step: { processed: 2, + counters: { unchanged: 1, errors: 1 }, records: [{ group: 'tagged', path: 'CCC/mabes9sans-dataset-v1/IMG_GEN_00369_.png', detail: 'Added 3 tags' }] } }; +const EXPECTED_TAG_VIEW = { + ...EXPECTED_JOB_VIEW, + progress: [MOCKUP_PROGRESS[0], MOCKUP_PROGRESS[1], 'dl.stat-row(div.added.stat,div.stat,div.stat.warn,div.bad.stat)'], + summary: '● Tagging log — raw detail, click to show', +}; + +for (const theme of THEMES) test(`image tagging job view follows the approved mockup ${theme}`, async ({ page, request }) => { + await seedConcept(request); + await openRunningJob(page, request, theme, undefined, TAG_START); + expect(await page.evaluate(measureJobView)).toEqual(EXPECTED_TAG_VIEW); + await expect(page.locator('#workspace-main .outcome-list > button')).toHaveText(['Tagged1', 'Errors1', 'Skipped0', 'Unchanged1']); + // Mutation check: the scan's five tiles and the scan's log label are both noticed. + expect(diffInventory(EXPECTED_JOB_VIEW, await page.evaluate(measureJobView)).map(item => item.path).sort()) + .toEqual(['progress.2', 'summary']); + const undoCss = await mutate(page, { css: '#workspace-main .stat-row .stat.warn dd { color: red; }' }); + expect(diffInventory(EXPECTED_TAG_VIEW, await page.evaluate(measureJobView)).map(item => item.path)).toEqual(['pressed.warn']); + await undoCss(); + expect(await page.evaluate(measureJobView)).toEqual(EXPECTED_TAG_VIEW); +}); + test('job view fits a phone without page scroll', async ({ page, request }) => { await seedConcept(request); await openRunningJob(page, request, 'dark', { width: 390, height: 844 }); diff --git a/tests/browser/concept-inventory.mjs b/tests/browser/concept-inventory.mjs index 862ad30..f7cba70 100644 --- a/tests/browser/concept-inventory.mjs +++ b/tests/browser/concept-inventory.mjs @@ -78,6 +78,17 @@ export async function seedConcept(request,data={}) { return response.json(); } +/** + * Seed the concept fixture and fix the browser's clock at the fixture's own instant (`manifest.now`), so the page and + * the fixture read one "now": a prompt used now reads "Used today" and the caption names the fixture's day even when + * the run crosses midnight between seeding and rendering. Timers keep running; only `Date` stands still. + */ +export async function seedConceptAt(page,request,data={}) { + const manifest=await seedConcept(request,data); + await page.clock.setFixedTime(new Date(manifest.now*1000)); + return manifest; +} + /** * Save `tags` as the current image tag set of the listed file at `path` (root 0) through the real observe and tag * routes (#249 PR C), releasing the capture afterwards; returns the receipt's saved names. @@ -138,10 +149,12 @@ export async function hoverStateOf(page,locator) { /** * Mutate the open page so a comparison can be shown to notice a change (the A7 mutation check): `css` is appended as * the last stylesheet, so it wins over equally specific concept rules, and every element matching a `remove` selector - * is detached. Returns an async undo that restores the stylesheet and the removed elements in place. + * is detached; each `clone` ({selector, text}) inserts a copy of the first match after it, holding `text` and no + * `data-action` (a control production adds beside a concept one, D85). Returns an async undo that restores the + * stylesheet and the removed elements in place and drops the copies. */ -export async function mutate(page,{css=null,remove=[]}={}) { - const token=await page.evaluate(({css,remove})=>{ +export async function mutate(page,{css=null,remove=[],clone=[]}={}) { + const token=await page.evaluate(({css,remove,clone})=>{ const token=`mutation-${Math.random().toString(36).slice(2)}`; const undo=[]; if(css) { @@ -156,9 +169,15 @@ export async function mutate(page,{css=null,remove=[]}={}) { node.remove();undo.push(()=>parent.insertBefore(node,next)); } } + for(const {selector,text} of clone) { + const node=document.querySelector(selector); + if(!node)throw new Error(`mutate: nothing matches ${selector}`); + const copy=node.cloneNode(true);copy.textContent=text;copy.removeAttribute('data-action'); + node.after(copy);undo.push(()=>copy.remove()); + } (window.__conceptMutations??=new Map()).set(token,undo); return token; - },{css,remove}); + },{css,remove,clone}); return ()=>page.evaluate(token=>{ const undo=window.__conceptMutations?.get(token); if(!undo)throw new Error('mutate: this mutation was already undone or the page reloaded'); diff --git a/tests/browser/full-inspector.spec.mjs b/tests/browser/full-inspector.spec.mjs index f1268b1..e4efc5a 100644 --- a/tests/browser/full-inspector.spec.mjs +++ b/tests/browser/full-inspector.spec.mjs @@ -237,9 +237,12 @@ const viewerStructure=details=>details.evaluate(node=>{ test('viewer details hold provenance, raw records and downloads',async({page,request})=>{ await page.setViewportSize({width:1600,height:1100});const reads=await patchInspection(page); await fullFixture(page,request);let details=await viewerDetails(page,'complete.png'); - // The panel content without the cover, then the two disclosures, then the pair send and history buttons (D20). + // The panel content without the cover, D117's Copy settings summary after the facts, then the two disclosures, + // then the pair send and history buttons (D20). expect(await viewerStructure(details)).toEqual({outer:['div','button.send','button'], - inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','div.tags','dl.meta-grid','details','details']}); + inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','div.tags','dl.meta-grid','button.settings-summary','details','details']}); + await expect(details.locator(':scope > div > button')).toHaveText('Copy settings summary'); + await expect(details.locator(':scope > div > button.settings-summary')).toHaveCSS('white-space','normal'); await expect(details.locator('.detail-cover,.detail-inner,.detail-footer')).toHaveCount(0); await expect(details.locator('.panel-heading h2')).toHaveText(POSITIVE_TITLE);await expect(details.locator('.panel-heading .chip')).toHaveText('PNG'); await expect(details.locator(':scope > div > p.small')).toHaveText('complete.png'); diff --git a/tests/browser/images-concept.spec.mjs b/tests/browser/images-concept.spec.mjs index e7e219c..08479b3 100644 --- a/tests/browser/images-concept.spec.mjs +++ b/tests/browser/images-concept.spec.mjs @@ -1,7 +1,7 @@ import {writeFile} from 'node:fs/promises'; import {test,expect} from './fixtures.mjs'; import {captureCanonicalState} from './history-canonical-comparison.mjs'; -import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,mutate,stripExact,countCopy,reflow,withoutItems} from './concept-inventory.mjs'; +import {SIZES,THEMES,MOTIONS,seedConceptAt,openConcept,openProduction,inventoryOf,diffInventory,mutate,stripExact,countCopy,reflow,withoutItems} from './concept-inventory.mjs'; // Task C7 of #249: the production Images page against the immutable concept, on the concept-shaped real fixture (24 // images of the concept's six prompts, its seeds, sizes and favorites). Heading, toolbar, filter row, caption, grid, @@ -30,9 +30,11 @@ function captionDay(seconds,now=new Date()) { return `${name} · ${date.toLocaleDateString('en-US',options)}`; } -/** D8: the selection bar's real actions only; Add tags, Add to collection and Remove links… are left out. */ -const OMITTED_BULK=Object.freeze(['Add tags','Add to collection','Remove links…']); -const OMITTED_BULK_SELECTORS=Object.freeze(['bulk-tags','bulk-collection','bulk-remove'].map(action=>`#bulkbar [data-action="${action}"]`)); +/** D8 (amended by #249 PR D): the selection bar's real actions only; Add to collection and Remove links… are left out. */ +const OMITTED_BULK=Object.freeze(['Add to collection','Remove links…']); +const OMITTED_BULK_SELECTORS=Object.freeze(['bulk-collection','bulk-remove'].map(action=>`#bulkbar [data-action="${action}"]`)); +/** D85: Auto-tag selected follows the concept's Add tags, a concept button of its own text. */ +const AUTO_TAG_CLONE=Object.freeze([{selector:'#bulkbar [data-action="bulk-tags"]',text:'Auto-tag selected'}]); /** * `withoutItems` for a row with padding and a border (the `.bulkbar`): its items flow again inside the content box, @@ -83,7 +85,7 @@ function listInfo(card,actionsWidth) { /** Recorded Images deviations only (spec §8); everything else in the concept inventory must equal production. */ const nocase=(a,b)=>{const x=a.toLowerCase(),y=b.toLowerCase();return x<y?-1:x>y?1:a<b?-1:a>b?1:0;}; -export function mapImagesConcept(reference,{manifest,now=new Date()}) { +export function mapImagesConcept(reference,{manifest,now=new Date(manifest.now*1000)}) { const paths=new Map(manifest.images.map(image=>[image.filename,image.path])); // D14: the sort appends production's two sorts. Save view is the concept's own (saved views, #249 D16 retired). const toolbar={...reference.toolbar}; @@ -316,7 +318,7 @@ async function behaviorsOf(page,scope) { return {hover,opened,viewerTitle,focusAfterViewer,selected,layouts,search,single}; } -test.beforeEach(async({request},testInfo)=>{testInfo.manifest=await seedConcept(request);}); +test.beforeEach(async({page,request},testInfo)=>{testInfo.manifest=await seedConceptAt(page,request);}); for(const [width,height] of SIZES)for(const theme of THEMES)test(`images page geometry, tokens, controls and behaviors match the concept ${width}x${height} ${theme}`,async({page,request},testInfo)=>{ test.setTimeout(240_000); @@ -346,7 +348,7 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`images page ge importDiff:diffInventory(mapImportConcept(referenceImport),actualImport),referenceBehaviors,actualBehaviors}; } // Load more in production: a fixture past one listing page appends one batch and shows the concept's toast. - testInfo.manifest=await seedConcept(request,{extra_images:LOAD_MORE_EXTRA}); + testInfo.manifest=await seedConceptAt(page,request,{extra_images:LOAD_MORE_EXTRA}); for(const motion of MOTIONS) { const total=FIXTURE_IMAGES+LOAD_MORE_EXTRA; await openProductionLoaded(page,theme,motion,total); @@ -443,6 +445,10 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`images selecti const restore=await mutate(page,{remove:[...OMITTED_BULK_SELECTORS]}); const removed=(await inventoryOf(page,'#main')).bulkbar; await restore(); + // The oracle for production's bar: the concept page itself without the omitted actions and with D85's button. + const restoreProduction=await mutate(page,{remove:[...OMITTED_BULK_SELECTORS],clone:AUTO_TAG_CLONE}); + const oracle=await inventoryOf(page,'#main'); + await restoreProduction(); await captureCanonicalState(page,testInfo,`concept-images-selected-${width}-${theme}`); await openProductionLoaded(page,theme,'no-preference'); const emptyActual=await inventoryOf(page,'#workspace-main'); @@ -454,12 +460,13 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`images selecti expect.soft(reference.bulkbar.bar.items.map(item=>item.text),'concept bar').toEqual(['1 selected','Add tags','Set category', 'Add to collection','Export metadata','Remove links…','Clear']); expect.soft(diffInventory(stripExact(mapBulkbar(reference.bulkbar,0).bulkbar.bar),stripExact(removed.bar)),'bar without the omitted actions').toEqual([]); - expect.soft(actual.bulkbar.bar.items.map(item=>item.text),'production bar').toEqual(['1 selected','Set category','Export metadata','Clear']); + expect.soft(actual.bulkbar.bar.items.map(item=>item.text),'production bar').toEqual(['1 selected','Add tags','Auto-tag selected', + 'Set category','Export metadata','Clear']); expect.soft(compare(empty,emptyActual,manifest,['bulkbar','cards','panel']),'nothing checked').toEqual([]); - expect.soft(compare(reference,actual,manifest,['bulkbar','cards','panel']),'one card checked').toEqual([]); + expect.soft(compare(oracle,actual,manifest,['bulkbar','cards','panel']),'one card checked').toEqual([]); // The bar comparison notices a change to the production bar. await mutate(page,{css:'.bulkbar { padding: 4px }'}); - expect(compare(reference,await inventoryOf(page,'#workspace-main'),manifest,['bulkbar']).map(item=>item.path)).toContain('bulkbar.bar.paddingTop'); + expect(compare(oracle,await inventoryOf(page,'#workspace-main'),manifest,['bulkbar']).map(item=>item.path)).toContain('bulkbar.bar.paddingTop'); }); // The comparison is only worth something if it notices a change (spec §7 mutation check): one CSS mutation (the @@ -507,7 +514,7 @@ test('one appended image reads in the singular',async({page,request})=>{ test.setTimeout(120_000); await page.setViewportSize({width:1600,height:1100}); const total=PRODUCTION_BATCH+1; - await seedConcept(request,{extra_images:total-FIXTURE_IMAGES}); + await seedConceptAt(page,request,{extra_images:total-FIXTURE_IMAGES}); await openProductionLoaded(page,'dark','no-preference',total); await expect(page.locator('.infinite-continuation > p').first()).toHaveText(countCopy(`${PRODUCTION_BATCH} of ${total} images · more load as you scroll`)); // A dispatched press: scrolling the button into view lets the page's own observer load the one image first. @@ -523,7 +530,7 @@ test('one image appended by scrolling reads in the singular',async({page,request test.setTimeout(120_000); await page.setViewportSize({width:1600,height:1100}); const total=PRODUCTION_BATCH+1; - await seedConcept(request,{extra_images:total-FIXTURE_IMAGES}); + await seedConceptAt(page,request,{extra_images:total-FIXTURE_IMAGES}); await openProductionLoaded(page,'dark','no-preference',total); await expect(page.locator('#result-count')).toHaveText(new RegExp(`^${PRODUCTION_BATCH} of ${total} images`)); await page.locator('.infinite-continuation').scrollIntoViewIfNeeded(); @@ -544,7 +551,7 @@ test('one footer height for the collection (D3)',async({page,request})=>{ const reference=await inventoryOf(page,'#main'); expect(reference.grid.columns).toBe(2); expect(reference.cards.every(card=>card.info.p.height>parseFloat(card.info.p.lineHeight))).toBe(true); - const manifest=await seedConcept(request,{seedless_images:2,short_seedless:true}); + const manifest=await seedConceptAt(page,request,{seedless_images:2,short_seedless:true}); await openProductionLoaded(page,'dark','no-preference',FIXTURE_IMAGES+2); const actual=await inventoryOf(page,'#workspace-main'); const box=card=>({left:card.left,top:card.top,width:card.width,height:card.height}); diff --git a/tests/browser/inspect-concept.spec.mjs b/tests/browser/inspect-concept.spec.mjs index 509cc44..48be585 100644 --- a/tests/browser/inspect-concept.spec.mjs +++ b/tests/browser/inspect-concept.spec.mjs @@ -117,7 +117,7 @@ function measureViewer() { ['dl.meta-grid', node => node.closest('details') ? null : 'facts'], ['details > summary', node => text(node)], ['button.send', node => node.closest('.prompt-section') ? `send ${node.closest('.prompt-section').matches('.positive') ? 'positive' : 'negative'}` : 'send pair'], - ['button', node => /Prompt history/.test(node.textContent) ? 'Prompt history' : null], + ['button', node => /Prompt history/.test(node.textContent) ? 'Prompt history' : text(node) === 'Copy settings summary' ? 'settings summary' : null], ]; const sections = [...rail.querySelectorAll('*')].map(node => { for (const [selector, label] of SECTIONS) if (node.matches(selector)) return label(node); @@ -158,13 +158,18 @@ async function withProductionCopy(page) { }, { COPY, ACCEPT }); } -/** D66: the viewer of a local file is titled by its filename and has no tag chips and no Prompt history. */ +/** + * D66: the viewer of a local file is titled by its filename and has no tag chips and no Prompt history. D117 (LI-4): + * "Copy settings summary" follows the six facts. + */ function mapViewerConcept(reference) { + const sections = reference.sections.filter(section => section !== 'tag chips' && section !== 'Prompt history'); + const facts = sections.indexOf('facts'); return { ...reference, title: FILE, railTitle: FILE, - sections: reference.sections.filter(section => section !== 'tag chips' && section !== 'Prompt history'), + sections: [...sections.slice(0, facts + 1), 'settings summary', ...sections.slice(facts + 1)], }; } @@ -269,9 +274,9 @@ for (const [width, height] of SIZES) for (const theme of THEMES) { // The chooser names the chosen file after a real setInputFiles, as the concept's does. expect(production.initial.chosen).toEqual({ name: '', files: [] }); expect(production.selected.chosen).toEqual({ name: FILE, files: [FILE] }); - // D66 in the rail: no tag chips and no Prompt history; the title is the filename. + // D66 in the rail: no tag chips and no Prompt history; the title is the filename. D117 follows the facts. expect(production.viewer.sections).toEqual(['heading', 'filename', 'badges', 'positive', 'send positive', 'negative', - 'send negative', 'facts', 'File & provenance', 'Workflow & raw metadata', 'send pair']); + 'send negative', 'facts', 'settings summary', 'File & provenance', 'Workflow & raw metadata', 'send pair']); }); } diff --git a/tests/browser/inspector-concept.spec.mjs b/tests/browser/inspector-concept.spec.mjs index 1c8f77e..0a43d7f 100644 --- a/tests/browser/inspector-concept.spec.mjs +++ b/tests/browser/inspector-concept.spec.mjs @@ -45,10 +45,10 @@ function withCategoryScope(inventory) { } /** - * Recorded inspector deviations only (spec §8, TESTING.md D3, D20–D22, D34 and D38); everything else must equal - * production. `reference.viewerPlain` is the concept's own full details - * with its tag chips made plain (D20) and, under a coarse pointer, D34's targets, both at runtime: the browser lays out - * what follows them, as the Images spec's removed toolbar items do. + * Recorded inspector deviations only (spec §8, TESTING.md D3, D20–D22, D34, D38 and D117); everything else must + * equal production. `reference.viewerPlain` is the concept's own full details + * with its tag chips made plain (D20), D117's summary button added and, under a coarse pointer, D34's targets, all at + * runtime: the browser lays out what follows them, as the Images spec's removed toolbar items do. */ export function mapInspectorConcept(reference,{viewport}) { const {panel,viewerPlain,fileControls}=reference; @@ -105,7 +105,11 @@ async function openProductionPanel(page,theme) { await settle(page); } -/** The concept's tag chips in the open viewer as plain `span.chip`s (D20) and D34's coarse targets; returns an undo. */ +/** + * The concept's tag chips in the open viewer as plain `span.chip`s (D20), D34's coarse targets and D117's "Copy + * settings summary" after the six facts (LI-4), drawn as the concept's own plain button (its "Download sample JSON", + * without its action); returns an undo. + */ async function plainViewerChips(page) { const restoreTargets=await mutate(page,{css:D34_COARSE_TARGETS}); await page.evaluate(()=>{ @@ -113,10 +117,15 @@ async function plainViewerChips(page) { const span=document.createElement('span');span.className='chip';span.textContent=button.textContent; button.replaceWith(span);return [span,button]; }); + const summary=document.querySelector('#viewer-details [data-action="download-workflow"]').cloneNode(false); + for(const key of Object.keys(summary.dataset))delete summary.dataset[key]; + summary.textContent='Copy settings summary';summary.classList.add('settings-summary'); + document.querySelector('#viewer-details > div > dl.meta-grid').after(summary);window.__summaryButton=summary; }); await settle(page); return async()=>{ - await page.evaluate(()=>{for(const [span,button] of window.__plainChips)span.replaceWith(button);delete window.__plainChips;}); + await page.evaluate(()=>{for(const [span,button] of window.__plainChips)span.replaceWith(button);delete window.__plainChips; + window.__summaryButton.remove();delete window.__summaryButton;}); await restoreTargets(); }; } @@ -342,7 +351,7 @@ for(const [width,height] of SIZES)test(`image tags saved on the concept image ke // The comparison is only worth something if it notices a change (spec §7 mutation check): on the panel one CSS // mutation (the role label's letter spacing) and one DOM mutation (the footer removed); in the viewer (final review m5) -// its pair actions removed and D38's Show in folder removed; each applied after an exact match. +// its pair actions removed, D38's Show in folder removed and D117's summary removed; each applied after an exact match. test('inspector comparison detects mutations',async({page})=>{ test.setTimeout(120_000); const viewport={width:1600,height:1100}; @@ -366,6 +375,9 @@ test('inspector comparison detects mutations',async({page})=>{ expect(()=>assertInspectorMatch(reference,withoutActions,viewport)).toThrow(/viewerDetails\.actions/); const withoutReveal=await viewerMutated({remove:['#viewer-details button[aria-label$=" in folder"]']}); expect(()=>assertInspectorMatch(reference,withoutReveal,viewport)).toThrow(/fileControls/); + // D117: the summary action after the facts is part of the mapped concept, so losing it is a difference. + const withoutSummary=await viewerMutated({remove:['#viewer-details > div > button']}); + expect(()=>assertInspectorMatch(reference,withoutSummary,viewport)).toThrow(/viewerDetails\.structure\.inner/); assertInspectorMatch(reference,await viewerMutated(),viewport); await mutate(page,{remove:['#image-inspector > .detail-footer']}); diff --git a/tests/browser/library-concept.spec.mjs b/tests/browser/library-concept.spec.mjs index eeab9b9..8e85bae 100644 --- a/tests/browser/library-concept.spec.mjs +++ b/tests/browser/library-concept.spec.mjs @@ -1,7 +1,7 @@ import {writeFile} from 'node:fs/promises'; import {test,expect} from './fixtures.mjs'; import {captureCanonicalState} from './history-canonical-comparison.mjs'; -import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,mutate,stripExact,countCopy,reflow} from './concept-inventory.mjs'; +import {SIZES,THEMES,MOTIONS,seedConceptAt,openConcept,openProduction,inventoryOf,diffInventory,mutate,stripExact,countCopy,reflow} from './concept-inventory.mjs'; // Task E3 of #249: the production Prompt library shelf against the immutable concept (app.js renderLibrary a572–586, // style.css s648–708), on the concept-shaped real fixture (the concept's six prompts with their negatives, categories, @@ -37,7 +37,8 @@ export function mapLibraryConcept(reference) { // D43: tag chips come in the store's alphabetical order; the row flows again from the concept's own chips. tags:sortedTags(card.tags), // D23: the stars are aria-hidden, with a visually hidden "Rated N out of 5" spoken in their place. The fixture's - // run_count (4) and last_used_at (now) give the concept's own "4 runs" and "Used today". The two-line clamp is + // run_count (4) and last_used_at (its own "now", where the page's clock stands) give the concept's own "4 runs" + // and "Used today". The two-line clamp is // applied below through `clampedCards`, measured on the concept with `CLAMP_CSS` (its samples wrap at 390px). footer:{...card.footer,facts:{...card.footer.facts,spoken:card.footer.facts.spoken.replace(/^(★+)/,stars=>`Rated ${stars.length} out of 5`)}}})); return {...reference,library:{...library,toolbar, @@ -238,7 +239,8 @@ async function openHost(context) { } test.beforeEach(async({page,request},testInfo)=>{ - testInfo.manifest=await seedConcept(request); + // The page's clock stands at the fixture's own "now", so its last use reads "Used today" on either side of midnight. + testInfo.manifest=await seedConceptAt(page,request); // Both documents copy through the clipboard; the stub records what they wrote. await page.addInitScript(()=>{window.copied=[];Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async text=>{window.copied.push(text);}}});}); }); @@ -322,6 +324,27 @@ for(const theme of THEMES)test(`library grid and cards match the concept 1800x11 expect(compare(reference,actual,{sections:['library'],library:['grid','cards','trailing']})).toEqual([]); }); +// PR #284's CI run: the fixture recorded the last use at 23:59:5x UTC and the shelf rendered at 00:00:05, so production +// read "Used yesterday" against the concept's "Used today". Here the browser's wall clock starts at 23:59:58 UTC, the +// fixture records its use at that instant, and midnight passes before either document opens. +const BEFORE_MIDNIGHT=Date.UTC(2026,9,22,23,59,58); +const PAST_MIDNIGHT_MS=5_000; +test('a run that crosses midnight still reads the concept fixture\'s day',async({page,request})=>{ + await page.setViewportSize({width:1800,height:1100}); + await page.clock.install({time:BEFORE_MIDNIGHT}); + const manifest=await seedConceptAt(page,request,{now:BEFORE_MIDNIGHT/1000}); + expect(manifest.now*1000,'the fixture\'s now').toBe(BEFORE_MIDNIGHT); + const used=(await (await request.get('/prompt_manager/library')).json()).items.map(item=>Date.parse(item.last_used_at)); + expect(used,'every last use at the fixture\'s now').toEqual(Array(CONCEPT_PROMPTS).fill(BEFORE_MIDNIGHT)); + await page.clock.fastForward(PAST_MIDNIGHT_MS); + await openConceptLibrary(page,'dark','no-preference'); + const reference=await conceptInventory(page); + await openProductionLibrary(page,'dark','no-preference'); + const actual=await inventoryOf(page,'#workspace-main'); + expect(actual.library.cards.map(card=>card.footer.facts.text.replace(/^★+ · /,''))).toEqual(Array(CONCEPT_PROMPTS).fill('Used today')); + expect(compare(reference,actual,{sections:['library'],library:['grid','cards','trailing']})).toEqual([]); +}); + // The comparison is only worth something if it notices a change (spec §7 mutation check): one CSS mutation (the footer // padding) and one DOM mutation (the negative-variants chip removed), each applied after an exact match. test('library comparison detects mutations',async({page})=>{ diff --git a/tests/browser/request_hold.py b/tests/browser/request_hold.py new file mode 100644 index 0000000..eef5fd0 --- /dev/null +++ b/tests/browser/request_hold.py @@ -0,0 +1,106 @@ +"""Hold real requests at this test server, so a page can leave while one is in flight. + +A Playwright ``page.route`` hold answers inside the browser's interception layer, +which WebKit cancels differently from a request the server has not answered yet. +These commands hold the request at the server itself: + +- ``POST /__test__/hold`` with ``{"path": "/prompt_manager/..."}`` holds every + later request for that path until it is released. With ``"stage": "body"`` + the server answers first and holds after sending the headers and the first + half of the body, so the browser has a response whose body is still loading. +- ``GET /__test__/hold`` answers ``{"held": {path: count}}``: how many requests + for each held path have arrived and are waiting. +- ``POST /__test__/release`` lets every held request go on, and stops holding. +""" + +import asyncio + +from aiohttp import web + +HOLDABLE_PREFIX = "/prompt_manager/" + + +class RequestHolds: + def __init__(self) -> None: + self._gates: dict[str, asyncio.Event] = {} + self._waiting: dict[str, int] = {} + self._stages: dict[str, str] = {} + + def hold(self, path: str, stage: str = "request") -> None: + self._gates.setdefault(path, asyncio.Event()) + self._waiting.setdefault(path, 0) + self._stages[path] = stage + + def stage(self, path: str) -> str | None: + return self._stages.get(path) if path in self._gates else None + + def held(self) -> dict[str, int]: + return dict(self._waiting) + + def release(self) -> None: + for gate in self._gates.values(): + gate.set() + self._gates.clear() + self._waiting.clear() + self._stages.clear() + + async def wait(self, path: str) -> None: + gate = self._gates.get(path) + if gate is None: + return + self._waiting[path] = self._waiting.get(path, 0) + 1 + try: + await gate.wait() + finally: + if path in self._waiting: + self._waiting[path] -= 1 + + +def register_request_holds(app: web.Application, routes) -> RequestHolds: + holds = RequestHolds() + + @web.middleware + async def hold_middleware(request: web.Request, handler): + stage = holds.stage(request.path) + if stage == "request": + await holds.wait(request.path) + response = await handler(request) + if stage != "body" or not isinstance(response, web.Response): + return response + body = response.body if isinstance(response.body, bytes) else b"" + streamed = web.StreamResponse(status=response.status, headers=response.headers) + streamed.content_length = len(body) + await streamed.prepare(request) + await streamed.write(body[: len(body) // 2]) + await holds.wait(request.path) + await streamed.write(body[len(body) // 2 :]) + await streamed.write_eof() + return streamed + + app.middlewares.append(hold_middleware) + + @routes.post("/__test__/hold") + async def hold(request: web.Request) -> web.Response: + data = await request.json() if request.can_read_body else {} + path = data.get("path") if isinstance(data, dict) else None + stage = data.get("stage", "request") if isinstance(data, dict) else None + if ( + not isinstance(path, str) + or not path.startswith(HOLDABLE_PREFIX) + or set(data) - {"path", "stage"} + or stage not in ("request", "body") + ): + raise web.HTTPBadRequest() + holds.hold(path, stage) + return web.json_response({"success": True}) + + @routes.get("/__test__/hold") + async def held(request: web.Request) -> web.Response: + return web.json_response({"success": True, "held": holds.held()}) + + @routes.post("/__test__/release") + async def release(request: web.Request) -> web.Response: + holds.release() + return web.json_response({"success": True}) + + return holds diff --git a/tests/browser/server.py b/tests/browser/server.py index e820a5c..d41b838 100644 --- a/tests/browser/server.py +++ b/tests/browser/server.py @@ -18,10 +18,13 @@ from aiohttp.web_runner import GracefulExit from PIL import Image from PIL.PngImagePlugin import PngInfo +from tests.browser.request_hold import register_request_holds +from tests.browser import slow_reset_watch from tests.browser.workspace_activity_fixture import register_activity_commands from tests.browser.workspace_concept_fixture import ( MAX_EXTRA_IMAGES as CONCEPT_MAX_EXTRA_IMAGES, MAX_SEEDLESS_IMAGES as CONCEPT_MAX_SEEDLESS_IMAGES, + MAX_EPOCH_SECONDS as CONCEPT_MAX_EPOCH_SECONDS, seed_workspace_concept, ) from tests.browser.workspace_visual_fixture import seed_workspace_visual @@ -195,6 +198,8 @@ def create_app(root: Path) -> web.Application: register_tagging_commands(routes, api, root, model) reveal_stub = register_reveal_stub(routes, api, root) register_activity_commands(app, routes, api) + request_holds = register_request_holds(app, routes) + slow_reset_watch.install(app, api) async def canonical_asset(request: web.Request) -> web.Response: filename, content_type = CANONICAL_ASSETS[request.path] @@ -376,6 +381,7 @@ def create_app(root: Path) -> web.Application: @routes.post("/__test__/reset") async def reset(request: web.Request) -> web.Response: nonlocal history_fixture + request_holds.release() library_editor_commands.reset() data = await request.json() if request.can_read_body else {} if not isinstance(data, dict): @@ -488,8 +494,17 @@ def create_app(root: Path) -> web.Application: if ( request.query or not isinstance(data, dict) - or set(data) - set(limits) - {"short_seedless"} + or set(data) - set(limits) - {"short_seedless", "now"} or not isinstance(data.get("short_seedless", False), bool) + # A chosen "now" (epoch seconds) pins every time the fixture writes. + or ( + "now" in data + and ( + isinstance(data["now"], bool) + or not isinstance(data["now"], (int, float)) + or not 0 < data["now"] < CONCEPT_MAX_EPOCH_SECONDS + ) + ) or any( isinstance(data.get(key, 0), bool) or not isinstance(data.get(key, 0), int) @@ -509,6 +524,7 @@ def create_app(root: Path) -> web.Application: extra_images=data.get("extra_images", 0), seedless_images=data.get("seedless_images", 0), short_seedless=data.get("short_seedless", False), + now=data.get("now"), ) ) return web.json_response({"success": True, **manifest}) diff --git a/tests/browser/shell-concept.spec.mjs b/tests/browser/shell-concept.spec.mjs index a4076d7..1497631 100644 --- a/tests/browser/shell-concept.spec.mjs +++ b/tests/browser/shell-concept.spec.mjs @@ -178,27 +178,28 @@ async function captureFooter(page,testInfo,name) { test.beforeEach(async({request})=>{await seedConcept(request);}); -for(const [width,height] of SIZES)for(const theme of THEMES)test(`shell geometry, tokens and controls match the concept ${width}x${height} ${theme}`,async({page},testInfo)=>{ +// One test per route, size, theme and motion. All of them in one test per size and theme (12 page loads, 12 captures, +// 36 hovers) steadily took 22 s median and up to 33 s on CI WebKit, past the 30 s budget with no step stalled; one +// route in one motion is a sixth of that work under the same budget. +for(const [route,breadcrumb] of ROUTES)for(const [width,height] of SIZES)for(const theme of THEMES)for(const motion of MOTIONS)test(`shell geometry, tokens and controls match the concept on ${route} ${width}x${height} ${theme} ${motion}`,async({page},testInfo)=>{ await page.setViewportSize({width,height});const result={}; - for(const motion of MOTIONS)for(const [route,breadcrumb] of ROUTES) { - await openConcept(page,route,theme,motion);await page.mouse.move(0,0); - const reference=await inventoryOf(page,'#main'); - if(motion==='no-preference')await captureCanonicalState(page,testInfo,`concept-shell-${route}-${width}-${theme}`); - const referenceRings=FULL_HEADING.has(route)?await ringsOf(page,'#main'):null; - const referenceHovers=await hoversOf(page); - if(motion==='no-preference')await captureFooter(page,testInfo,`concept-footer-${route}-${width}-${theme}`); - await openProduction(page,route,theme,motion);await page.mouse.move(0,0); - const actual=await inventoryOf(page,'#workspace-main'); - if(motion==='no-preference')await captureCanonicalState(page,testInfo,`production-shell-${route}-${width}-${theme}`); - const actualRings=FULL_HEADING.has(route)?await ringsOf(page,'#workspace-main'):null; - const actualHovers=await hoversOf(page); - if(motion==='no-preference')await captureFooter(page,testInfo,`production-footer-${route}-${width}-${theme}`); - result[`${motion} ${route}`]={route,breadcrumb,reference,actual,diff:compare(route,reference,actual),referenceRings,actualRings, - referenceHovers,actualHovers}; - } - const path=testInfo.outputPath('shell-geometry.json');await writeFile(path,JSON.stringify(result,null,2)); - await testInfo.attach('shell-geometry',{path,contentType:'application/json'}); - for(const [key,{route,breadcrumb,reference,actual,diff,referenceRings,actualRings,referenceHovers,actualHovers}] of Object.entries(result)) { + await openConcept(page,route,theme,motion);await page.mouse.move(0,0); + const reference=await inventoryOf(page,'#main'); + if(motion==='no-preference')await captureCanonicalState(page,testInfo,`concept-shell-${route}-${width}-${theme}`); + const referenceRings=FULL_HEADING.has(route)?await ringsOf(page,'#main'):null; + const referenceHovers=await hoversOf(page); + if(motion==='no-preference')await captureFooter(page,testInfo,`concept-footer-${route}-${width}-${theme}`); + await openProduction(page,route,theme,motion);await page.mouse.move(0,0); + const actual=await inventoryOf(page,'#workspace-main'); + if(motion==='no-preference')await captureCanonicalState(page,testInfo,`production-shell-${route}-${width}-${theme}`); + const actualRings=FULL_HEADING.has(route)?await ringsOf(page,'#workspace-main'):null; + const actualHovers=await hoversOf(page); + if(motion==='no-preference')await captureFooter(page,testInfo,`production-footer-${route}-${width}-${theme}`); + result[`${motion} ${route}`]={reference,actual,diff:compare(route,reference,actual),referenceRings,actualRings, + referenceHovers,actualHovers}; + const path=testInfo.outputPath(`shell-geometry-${route}-${motion}.json`);await writeFile(path,JSON.stringify(result,null,2)); + await testInfo.attach(`shell-geometry-${route}-${motion}`,{path,contentType:'application/json'}); + for(const [key,{reference,actual,diff,referenceRings,actualRings,referenceHovers,actualHovers}] of Object.entries(result)) { // The concept itself is what this comparison assumes: its sample counts and its shell controls. expect.soft(reference.chrome.navs[0].rows.map(row=>[row.text,row.count?.text??null]),`${key} concept WORKSPACE rows`) .toEqual([['Images','24'],['Prompt library','6'],['Tags & categories','15'],['Inspect a file',null]]); diff --git a/tests/browser/slow_reset_watch.py b/tests/browser/slow_reset_watch.py new file mode 100644 index 0000000..e3b453b --- /dev/null +++ b/tests/browser/slow_reset_watch.py @@ -0,0 +1,237 @@ +"""Report a slow test reset with what the server was doing while it waited. + +A reset normally answers within about a second. Once one runs past +SLOW_RESET_SECONDS, this writes to stderr, while the reset is still waiting: + +- every thread's stack, so a reset waiting on a lock, a full executor or a + slow disk shows where it waits and who holds it; +- the default executor's threads and queued work; +- how many database connections are open, and the WAL size; +- on Linux, each thread's scheduler state and the kernel function it sleeps + in (``D`` with a journal or writeback wait is a slow disk flush, a nanosleep + is SQLite's busy handler, a futex is a Python lock or an idle worker). + +When the reset ends it writes how long it took and, for each executor call it +made, how long the call queued and how long it ran. + +Every report line starts with REPORT_PREFIX. The worker fixture +(tools/vendor/start-browser-server.mjs) copies those lines to the job log, +so a slow reset is visible even in a test that still passes. +""" + +import asyncio +import contextvars +import faulthandler +import os +import sys +import tempfile +import threading +import time +from typing import Callable, TextIO + +from aiohttp import web + +SLOW_RESET_SECONDS = 2.0 +REPORT_PREFIX = "pm-slow-reset| " + +_current: contextvars.ContextVar["ResetWatch | None"] = contextvars.ContextVar( + "pm_reset_watch", default=None +) + + +def is_reset_path(path: str) -> bool: + return path.startswith("/__test__/") and path.endswith("/reset") + + +class ResetWatch: + """Time one reset; dump the server's state if it passes the threshold.""" + + def __init__( + self, + label: str, + describe: Callable[[], list[str]], + *, + threshold: float = SLOW_RESET_SECONDS, + stream: TextIO | None = None, + ) -> None: + self.label = label + self.threshold = threshold + self._describe = describe + self._stream = stream + self._start = time.monotonic() + self._calls: list[str] = [] + self._calls_lock = threading.Lock() + self._stalled = threading.Event() + self._timer = threading.Timer(threshold, self._report_stall) + self._timer.daemon = True + self._timer.name = "pm-slow-reset-watch" + + def start(self) -> "ResetWatch": + self._timer.start() + return self + + def elapsed(self) -> float: + return time.monotonic() - self._start + + def record_call(self, name: str, queued: float, ran: float) -> None: + with self._calls_lock: + self._calls.append( + f"executor call {name}: queued {queued:.3f}s, ran {ran:.3f}s" + ) + + def finish(self, outcome: str) -> None: + self._timer.cancel() + elapsed = self.elapsed() + # A coarse clock (Windows) can fire the timer just short of the + # threshold; a reset that was reported always gets its summary. + if elapsed < self.threshold and not self._stalled.is_set(): + return + with self._calls_lock: + calls = list(self._calls) + self._write([f"{self.label} {outcome} after {elapsed:.3f}s", *calls]) + + def _report_stall(self) -> None: + self._stalled.set() + lines = [f"{self.label} still running after {self.elapsed():.3f}s"] + try: + lines.extend(self._describe()) + except Exception as error: # a diagnostic never breaks the reset + lines.append(f"state unavailable: {error!r}") + lines.extend(thread_stacks()) + self._write(lines) + + def _write(self, lines: list[str]) -> None: + stream = self._stream or sys.stderr + text = "".join( + f"{REPORT_PREFIX}{part}\n" for line in lines for part in line.splitlines() + ) + try: + stream.write(text) + stream.flush() + except (OSError, ValueError): + pass + + +def thread_stacks() -> list[str]: + """Every thread's stack, from faulthandler. + + Formatting sys._current_frames() showed a thread blocked in SQLite as idle + (Python 3.14); faulthandler reports where each thread really is. + """ + # Before Python 3.14 faulthandler prints ids only; name each id here. + names = [ + f"thread 0x{thread.ident:016x} is {thread.name}" + for thread in threading.enumerate() + if thread.ident is not None + ] + with tempfile.TemporaryFile() as dump: + faulthandler.dump_traceback(file=dump, all_threads=True) + dump.seek(0) + return [*names, *dump.read().decode("utf-8", "replace").splitlines()] + + +def executor_state(loop: asyncio.AbstractEventLoop) -> list[str]: + executor = getattr(loop, "_default_executor", None) + if executor is None: + return ["default executor: not started"] + threads = getattr(executor, "_threads", ()) + queue = getattr(executor, "_work_queue", None) + queued = queue.qsize() if queue is not None else "?" + return [ + f"default executor: {len(threads)} of " + f"{getattr(executor, '_max_workers', '?')} threads, {queued} queued" + ] + + +def database_state(model) -> list[str]: + """How many connections are open, and the WAL size. + + Never touches another thread's connection: a connection busy in SQLite + holds its mutex, and reading even ``in_transaction`` from here would block + on it while holding the GIL, freezing every thread until that query ends. + """ + with model._conn_lock: + count = len(model._connections) + wal = model.db_path + "-wal" + size = os.path.getsize(wal) if os.path.exists(wal) else 0 + return [f"database: {count} connections, WAL {size} bytes"] + + +def kernel_wait_state() -> list[str]: + """Each thread's state and kernel wait channel, where /proc has them.""" + lines = [] + for thread in threading.enumerate(): + task = f"/proc/self/task/{thread.native_id}" + try: + with open(f"{task}/stat", encoding="ascii") as stat: + state = stat.read().rsplit(")", 1)[1].split()[0] + with open(f"{task}/wchan", encoding="ascii") as wchan: + channel = wchan.read().strip() or "0" + except (OSError, IndexError): + continue + # The id matches the "Thread 0x..." headers of the stacks below. + lines.append( + f"kernel: {thread.name} (0x{thread.ident:016x}) state {state}, " + f"waiting in {channel}" + ) + return lines + + +def install(app: web.Application, api) -> None: + """Watch every reset route, and time the executor calls each one makes.""" + run_in_executor = api._run_in_executor + + async def timed_run_in_executor(func, *args, **kwargs): + watch = _current.get() + if watch is None: + return await run_in_executor(func, *args, **kwargs) + queued_at = time.monotonic() + started: list[float] = [] + + def timed(*call_args, **call_kwargs): + started.append(time.monotonic()) + return func(*call_args, **call_kwargs) + + try: + return await run_in_executor(timed, *args, **kwargs) + finally: + end = time.monotonic() + begin = started[0] if started else end + watch.record_call( + getattr(func, "__qualname__", repr(func)), + begin - queued_at, + end - begin, + ) + + api._run_in_executor = timed_run_in_executor + + @web.middleware + async def watch_resets(request: web.Request, handler): + if not is_reset_path(request.path): + return await handler(request) + loop = asyncio.get_running_loop() + + def describe() -> list[str]: + return [ + *executor_state(loop), + *database_state(api.db.model), + *kernel_wait_state(), + ] + + watch = ResetWatch( + f"{request.method} {request.path}", describe, threshold=SLOW_RESET_SECONDS + ).start() + token = _current.set(watch) + outcome = "failed" + try: + response = await handler(request) + outcome = f"answered {response.status}" + return response + except web.HTTPException as error: + outcome = f"answered {error.status}" + raise + finally: + _current.reset(token) + watch.finish(outcome) + + app.middlewares.append(watch_resets) diff --git a/tests/browser/workflows.spec.mjs b/tests/browser/workflows.spec.mjs index ab5d842..0ee4620 100644 --- a/tests/browser/workflows.spec.mjs +++ b/tests/browser/workflows.spec.mjs @@ -43,6 +43,55 @@ test('actual gallery form saves a prompt that the admin page can edit and reload expect((await saved.json()).prompt_id).not.toBe(Number(id)); }); +// WebKit fails a fetch that a leaving page starts ("Fetch API cannot load … due to access control checks."), and +// Playwright reports that console error as a page error. A save's follow-up reads must not start once the page hides: +// here the statistics read after an in-place edit is held at the server while the page reloads. +test('an in-place edit whose follow-up reads outlive the page starts no read while it leaves', async ({ page, request }) => { + await page.goto('/prompt_manager/admin'); + const text = page.locator('.prompt-text').filter({ hasText: 'Cat portrait' }); + const id = await text.getAttribute('data-id'); + await expect(await request.post('/__test__/hold', { data: { path: '/prompt_manager/stats' } })).toBeOK(); + await page.locator(`[data-action="edit"][data-prompt-id="${id}"]`).click(); + const editor = page.locator(`.prompt-text[data-id="${id}"]`); + await editor.fill('Cat portrait at dusk'); + await editor.press('Enter'); + const held = async () => (await (await request.get('/__test__/hold')).json()).held['/prompt_manager/stats']; + await expect.poll(held).toBe(1); + await page.reload({ waitUntil: 'commit' }); + await expect(await request.post('/__test__/release')).toBeOK(); + await expect(page.locator('.prompt-text').filter({ hasText: 'Cat portrait at dusk' })).toBeVisible(); +}); + +// A leave that never happens (a stopped navigation, or one answered with a download) must not leave the list dead. +test('a page whose leave was called off reads its list again on the next input', async ({ page }) => { + await page.goto('/prompt_manager/admin'); + await expect(page.locator('.prompt-text').first()).toBeVisible(); + await page.evaluate(() => window.dispatchEvent(new Event('beforeunload'))); + await page.mouse.click(1, 1); + const reread = page.waitForResponse(response => new URL(response.url()).pathname === '/prompt_manager/recent'); + await page.locator('#sortBy').selectOption('created_asc'); + expect(new URL((await reread).url()).searchParams.get('sort')).toBe('created_asc'); + await expect(page.locator('#resultsTitle')).toHaveText('Recent Prompts'); +}); + +// The gallery's startup thumbnail check runs a second after load. When that second ends while the page is leaving, +// the check must start no read: WebKit fails it with "Fetch API cannot load … due to access control checks." and +// Playwright reports a page error (seen in CI on PR #289). The page clock makes the second end after the leave began. +for (const leave of ['beforeunload', 'pagehide']) { + test(`the gallery startup check starts no read after ${leave}`, async ({ page }) => { + await page.clock.install(); + const statusReads = []; + page.on('request', req => { if (req.url().includes('/images/thumbnails/status')) statusReads.push(req.url()); }); + await page.goto('/prompt_manager/gallery'); + await expect(page.locator('#galleryGrid .image-item')).toHaveCount(2); + await page.evaluate(type => window.dispatchEvent(new Event(type)), leave); + await page.clock.runFor(1500); + // A read made after the clock ran is listed, so the listener saw every status read. + await page.evaluate(() => fetch('/prompt_manager/images/thumbnails/status?probe=1')); + expect(statusReads.map(url => new URL(url).search)).toEqual(['?probe=1']); + }); +} + test('actual gallery review generates and persists tags for the selected root', async ({ page, request }) => { await page.goto('/prompt_manager/gallery'); await expect(page.locator('#galleryGrid .image-item')).toHaveCount(2); diff --git a/tests/browser/workspace-activity.spec.mjs b/tests/browser/workspace-activity.spec.mjs index 05cb2d7..cbd848c 100644 --- a/tests/browser/workspace-activity.spec.mjs +++ b/tests/browser/workspace-activity.spec.mjs @@ -2,6 +2,13 @@ import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage import { test, expect } from './fixtures.mjs'; import { seedConcept, openConcept, openProduction } from './concept-inventory.mjs'; +// The Activity store's periodic list read (activity-store.mjs POLL_MS) and the detail view's read throttle +// (activity-job-view.mjs DETAIL_INTERVAL_MS). +const POLL_MS = 15_000; +const DETAIL_INTERVAL_MS = 1_000; +// A request is reported to the test a little after the browser sends it; that delay is not a shorter wait. +const REPORT_SLACK_MS = 100; + // Activity (#249 Activity-1): the concept's Activity page (app.js renderActivity 732–738), its top-bar button // (index.html 51–52) and sidebar row (app.js ~341), with the recorded D52 (counts of running jobs), D53 (the progress // pill) and D54 (real job items). Fake jobs come from tests/browser/workspace_activity_fixture.py. @@ -278,6 +285,8 @@ test('reload during a scan reattaches to the running job', async ({ page, reques }); test('lost server clears the pill and marks jobs ended', async ({ page, request }) => { + // A poll interval after the restart, then failed re-reads backing off for 1 + 2 + 4 s. + test.setTimeout(90_000); await page.setViewportSize({ width: 1600, height: 1100 }); const job = await startJob(request); await step(request, job.id, { processed: 4000 }); @@ -294,9 +303,12 @@ test('lost server clears the pill and marks jobs ended', async ({ page, request await expect(items(page).first().locator('p')).toHaveText(LOST); await expect(items(page).first().locator('progress, .row-actions')).toHaveCount(0); await expect(navRow(page).locator('.nav-count')).toHaveCount(0); - // One re-read found the restart; there is no retry storm and no finish toast for a job that only vanished. - await page.waitForTimeout(2500); - expect(reads.length).toBeLessThanOrEqual(2); + // One re-read found the restart; there is no retry storm and no finish toast for a job that only vanished. The + // read after it is the periodic one, a whole poll interval later: any extra read lands in that gap and fails it. + const found = reads.length; + expect(found, 'reads that found the restart').toBeGreaterThanOrEqual(1); + await expect.poll(() => reads.length, { timeout: POLL_MS + 10_000 }).toBeGreaterThan(found); + expect(reads[found] - reads[found - 1], 'the next read waits for the poll').toBeGreaterThanOrEqual(POLL_MS - REPORT_SLACK_MS); await expect(toast(page)).toHaveText(''); // A server that cannot be read at all also ends the running job, then is read again with a growing backoff. @@ -308,9 +320,12 @@ test('lost server clears the pill and marks jobs ended', async ({ page, request await expect(await request.post('/__test__/jobs/restart')).toBeOK(); await expect(pill(page)).toBeHidden(); await expect(items(page).filter({ hasText: LOST })).toHaveCount(1); - await page.waitForTimeout(3500); - // 1 s, then 2 s: at most two re-reads in 3.5 s. - expect(failed.length).toBeLessThanOrEqual(2); + // 1 s, then 2 s, then 4 s: each failed re-read waits twice as long as the one before. The gaps between the + // reads themselves are the evidence, so a slow runner noticing the ended job late cannot add a read to a window. + await expect.poll(() => failed.length, { timeout: 20_000 }).toBeGreaterThanOrEqual(3); + const gaps = failed.slice(1, 3).map((at, index) => at - failed[index]); + expect(gaps[0]).toBeGreaterThanOrEqual(2000 - REPORT_SLACK_MS); + expect(gaps[1]).toBeGreaterThanOrEqual(4000 - REPORT_SLACK_MS); await page.unroute('**/prompt_manager/jobs**'); expect(second.id).not.toBe(job.id); }); @@ -607,6 +622,8 @@ test('filter narrows the selected outcome', async ({ page, request }) => { test('job view renders 200-row cap note', async ({ page, request }) => { await page.setViewportSize({ width: 1600, height: 1100 }); + // The page's timers are Playwright's, running at real speed, so the M12 check below can run a read throttle out. + await page.clock.install(); const job = await startJob(request); await step(request, job.id, { processed: 260, bulk: { group: 'added', count: 250 }, records: [ ...Array.from({ length: 10 }, (_, index) => ({ group: 'no_metadata', path: `misc/plain_${index}.png`, detail: 'No prompt metadata in the file' })), @@ -646,11 +663,15 @@ test('job view renders 200-row cap note', async ({ page, request }) => { page.on('request', item => { if (new URL(item.url()).pathname === `/prompt_manager/jobs/${job.id}`) reads.push(Date.now()); }); await step(request, job.id, { processed: 300, bulk: { group: 'added', count: 40 } }); await expect(tiles(page).first().locator('dd')).toHaveText('290'); - await page.waitForTimeout(1500); + // Every read the counter update could have scheduled is due within one throttle interval: run it out, so any such + // read is sent before the log opens. Requests reach the test in the order they were sent, so once the log's read + // has answered, a read sent before it would already be listed. + await page.clock.runFor(DETAIL_INTERVAL_MS); expect(reads).toEqual([]); await scanLog(page).locator('summary').click(); await expect.poll(() => reads.length).toBe(1); await expect(scanLog(page).locator('pre')).toContainText('bulk/file_00039.png'); + expect(reads, 'only the log\'s own read').toHaveLength(1); }); test('cancel scan ends cancelled with partial counts', async ({ page, request }) => { @@ -678,16 +699,44 @@ test('cancel scan ends cancelled with partial counts', async ({ page, request }) await expect(heading(page).getByRole('button', { name: 'Cancel scan', exact: true })).not.toHaveAttribute('aria-disabled', 'true'); }); +// Truth rule: a held legacy-page job (a restore from the admin page) cannot be cancelled, so neither the Activity +// list nor the job's view offers Cancel; the cancellable scan beside it keeps its own (review I3). +test('a held legacy job shows no Cancel in the list or its view', async ({ page, request }) => { + await page.setViewportSize({ width: 1600, height: 1100 }); + const response = await request.post('/__test__/jobs/hold', { data: { kind: 'restore', title: 'Database restore (admin page)' } }); + await expect(response).toBeOK(); + const held = (await response.json()).job; + expect(held.cancellable).toBe(false); + await page.goto(`${workspace}#/activity`); + const item = items(page).filter({ has: page.getByRole('link', { name: /Database restore \(admin page\)$/ }) }); + await expect(item.locator('.chip')).toHaveText('Running'); + await expect(item.locator('progress')).toHaveCount(1); + await expect(main(page).getByRole('button', { name: 'Cancel job', exact: true })).toHaveCount(0); + + await item.getByRole('link').click(); + await expect(heading(page).locator('h1')).toHaveText('Database restore (admin page)'); + await expect(heading(page).locator('.heading-actions > button')).toHaveText(['Download report']); + await expect(heading(page).getByRole('button', { name: /^Cancel/ })).toHaveCount(0); + + // When the hold ends the view shows the end, still without a Cancel. + await finish(request, held.id); + await expect(heading(page).locator('p.muted')).toHaveText(/^Finished/); + await expect(heading(page).locator('.heading-actions > button')).toHaveText(['Download report']); +}); + test('busy start names the running job', async ({ page, request }) => { await page.setViewportSize({ width: 1600, height: 1100 }); + // The page's timers are Playwright's, so the toast's lifetime below is run out rather than waited for. + await page.clock.install(); const running = await startJob(request, { title: 'Database maintenance', kind: 'maintenance' }); await startFromImport(page); await expect(toast(page)).toHaveText('Another job is running: Database maintenance.'); await expect(toast(page).getByRole('link', { name: 'Database maintenance', exact: true })).toHaveAttribute('href', `#/activity/${running.id}`); await expect(page).toHaveURL(/#\/images$/); expect((await listed(request)).map(item => item.id)).toEqual([running.id]); - // Review M14: a toast with a link outlasts the plain 6.5 s, so the link stays reachable. - await page.waitForTimeout(7000); + // Review M14: a toast with a link outlasts the plain 6.5 s, so the link stays reachable. Running the page's timers + // 7 s on fires a 6.5 s clear for certain, however slowly this runner reached this line. + await page.clock.runFor(7000); await expect(toast(page).getByRole('link', { name: 'Database maintenance', exact: true })).toBeVisible(); await toast(page).getByRole('link', { name: 'Database maintenance', exact: true }).click(); await expect(heading(page).locator('h1')).toHaveText('Database maintenance'); @@ -799,3 +848,114 @@ test('a failed report download says so and leaves the button usable', async ({ p await expect(toast(page)).toHaveText('The report could not be downloaded. Try again.'); await expect(button).not.toHaveAttribute('aria-disabled', 'true'); }); + +// ── #249 PR D Task D3 (D89, plan D66): image tagging jobs in Activity ───────────────────────── + +const TAG_JOB = { kind: 'image-tags', title: 'Auto-tag images', mode: 'auto', images: 4, total: 4 }; + +test('an image tagging job shows Tagged Unchanged Skipped Errors and opens tagged rows', async ({ page, request }) => { + await page.setViewportSize({ width: 1600, height: 1100 }); + const job = await startJob(request, TAG_JOB); + await step(request, job.id, { processed: 3, records: [ + { group: 'tagged', path: 'shared/same.png', root: 'output', detail: 'Added 2 tags' }, + { group: 'unchanged', path: 'shared/same.png', root: 'input', detail: 'No new tags' }, + { group: 'skipped', path: 'video/clip_0004.webm', root: 'output', detail: 'Video: manual tags only' }, + ] }); + await page.goto(`${workspace}#/images`); + // The pill names the kind's verb. + await expect(pill(page)).toHaveText('Tagging · 75% · 3 / 4'); + await openJob(page, job.id); + await expect(heading(page).locator('h1')).toHaveText('Auto-tag images'); + await expect(page).toHaveTitle('Auto-tag images · Prompt Manager'); + // The header's scope is the number of images, and the job cancels as "Cancel tagging" (danger). + await expect(heading(page).locator('p.muted')).toHaveText(/^Started \d\d:\d\d · 4 images · closing this view does not cancel it$/); + await expect(heading(page).locator('.heading-actions > button')).toHaveText(['Download report', 'Cancel tagging']); + await expect(heading(page).getByRole('button', { name: 'Cancel tagging', exact: true })).toHaveClass(/danger/); + await expect(progressSection(page).locator('.panel-heading > span.small')).toHaveText('Processing file 3 of 4'); + await expect(progressSection(page).locator('progress')).toHaveAttribute('aria-label', 'Auto-tag images progress'); + // Four tiles in the scan's tones: Tagged green, Unchanged plain, Skipped warning, Errors red. + expect(await tileValues(page)).toEqual([ + ['stat added', 'Tagged', '1'], ['stat', 'Unchanged', '1'], ['stat warn', 'Skipped', '1'], ['stat bad', 'Errors', '0'], + ]); + // What got tagged and what went wrong first, as the scan's list does. + await expect(outcomeButtons(page)).toHaveText(['Tagged1', 'Errors0', 'Skipped1', 'Unchanged1']); + await expect(outcomeButtons(page).first()).toHaveAttribute('aria-pressed', 'true'); + + // Tagged rows open the viewer on that file (D57's identity). + const tagged = fileRows(page).first(); + expect(await tagged.evaluate(node => node.localName)).toBe('button'); + await expect(tagged.locator('.path')).toHaveText('shared/same.png'); + await expect(tagged.locator('.why')).toHaveText('Added 2 tags'); + await expect(resultsNote(page)).toHaveText('Each row opens the image in the viewer.'); + await tagged.click(); + const viewer = page.locator('dialog#viewer-dialog[open]'); + await expect(viewer.locator('#viewer-stage img').first()).toHaveAttribute('src', /[?&]root=0(&|$)/); + await page.keyboard.press('Escape'); + await expect(viewer).toHaveCount(0); + await expect(tagged).toBeFocused(); + // Unchanged rows open too, here the input root's file. + await outcomeButtons(page).filter({ hasText: 'Unchanged' }).click(); + await fileRows(page).first().click(); + await expect(page.locator('dialog#viewer-dialog[open] #viewer-stage img').first()).toHaveAttribute('src', /[?&]root=1(&|$)/); + await page.keyboard.press('Escape'); + // Skipped rows are plain text with their reason. + await outcomeButtons(page).filter({ hasText: 'Skipped' }).click(); + expect(await fileRows(page).first().evaluate(node => node.localName)).toBe('div'); + await expect(fileRows(page).first().locator('.why')).toHaveText('Video: manual tags only'); + await expect(resultsNote(page)).toHaveCount(0); + // The log is the job's own: "Tagging log", with the runner's prefixes. + await expect(scanLog(page).locator('summary')).toHaveText('● Tagging log — raw detail, click to show'); + await scanLog(page).locator('summary').click(); + await expect(scanLog(page).locator('pre')).toContainText('+ shared/same.png · Added 2 tags'); + + // An error, then the end: the header and the Activity item read "N tagged · M errors". + await step(request, job.id, { processed: 4, records: [ + { group: 'errors', path: 'broken/truncated.png', root: 'output', detail: 'Auto-tagging failed. Check server logs for details.' }, + ] }); + await expect(tiles(page).last().locator('dd')).toHaveText('1'); + await finish(request, job.id); + await expect(toast(page)).toHaveText('Auto-tag images finished · 1 tagged · 1 error'); + await expect(heading(page).locator('p.muted')).toHaveText(/^Finished \d\d:\d\d · 1 tagged · 1 error$/); + await expect(heading(page).locator('.heading-actions > button')).toHaveText(['Download report']); + await expect(progressSection(page).locator('.panel-heading > span.small')).toHaveText('4 of 4 files processed'); + await page.goto(`${workspace}#/activity`); + await expect(items(page).first().locator('h3')).toHaveText('✓ Auto-tag images'); + await expect(items(page).first().locator('p')).toHaveText(/^Finished \d\d:\d\d · 1 tagged · 1 error$/); +}); + +test('cancel tagging stops at a file boundary', async ({ page, request }) => { + await page.setViewportSize({ width: 1600, height: 1100 }); + const job = await startJob(request, { ...TAG_JOB, title: 'Add tags to images', mode: 'add', images: 1250, total: 1250 }); + await step(request, job.id, { processed: 2, counters: { tagged: 2 } }); + await page.goto(`${workspace}#/activity`); + await expect(items(page).first().locator('p')).toHaveText(/^Started \d\d:\d\d · 1,250 images · closing this view does not cancel it$/); + await openJob(page, job.id); + await heading(page).getByRole('button', { name: 'Cancel tagging', exact: true }).focus(); + await page.keyboard.press('Enter'); + await expect(toast(page)).toHaveText('Add tags to images cancelled · 2 tagged'); + await expect(heading(page).locator('h1')).toBeFocused(); + await expect(heading(page).locator('p.muted')).toHaveText(/^Cancelled \d\d:\d\d at a file boundary · 2 tagged · completed work is kept$/); + await expect(heading(page).getByRole('button', { name: 'Cancel tagging', exact: true })).toHaveCount(0); + await expect(progressSection(page).locator('.panel-heading > span.small')).toHaveText('Cancelled after 2 of 1,250 files'); + expect((await tileValues(page)).map(([, , value]) => value)).toEqual(['2', '0', '0', '0']); + expect((await listed(request))[0].state).toBe('cancelled'); +}); + +test('the report download names the kind', async ({ page, request }) => { + await page.setViewportSize({ width: 1600, height: 1100 }); + const job = await startJob(request, TAG_JOB); + await step(request, job.id, { processed: 1, records: [ + { group: 'tagged', path: 'sub/a.png', root: 'output', detail: 'Added 1 tag' }, + ] }); + await finish(request, job.id); + await openJob(page, job.id); + const button = heading(page).getByRole('button', { name: 'Download report', exact: true }); + const [download] = await Promise.all([page.waitForEvent('download'), button.click()]); + expect(download.suggestedFilename()).toMatch(/^prompt-manager-image-tags-\d{8}-\d{4}\.json$/); + const report = JSON.parse(await (await import('node:fs/promises')).readFile(await download.path(), 'utf8')); + expect(report.job.kind).toBe('image-tags'); + expect(report.job.scope).toEqual({ mode: 'auto', images: 4 }); + expect(Object.keys(report.outcomes)).toEqual(['tagged', 'unchanged', 'skipped', 'errors']); + expect(report.outcomes.tagged.rows.map(row => [row.path, row.detail])).toEqual([['sub/a.png', 'Added 1 tag']]); + await expect(toast(page)).toHaveText('Job report downloaded.'); +}); diff --git a/tests/browser/workspace-history.spec.mjs b/tests/browser/workspace-history.spec.mjs index e08a1a9..48f8107 100644 --- a/tests/browser/workspace-history.spec.mjs +++ b/tests/browser/workspace-history.spec.mjs @@ -494,6 +494,23 @@ test('the inline current file names its prompts apart from the frozen run fields expect(await exact(dialog(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('p.prompt-text'))).toBe(before); }); +// LI-4 (D117): the recorded history's current-file viewer is the shared full inspector, so it copies the legacy summary. +test('the current file viewer copies its settings summary',async({page})=>{ + await page.addInitScript(()=>{window.__copied=[];Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async text=>{window.__copied.push(text);}}});}); + 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).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click(); + const details=page.locator('dialog#viewer-dialog #viewer-details');await expect(details.locator('.panel-heading')).toBeVisible(); + const facts=await details.locator(':scope > div > dl.meta-grid dd').allTextContents(); + await details.getByRole('button',{name:'Copy settings summary',exact:true}).click(); + await expect(page.locator('#workspace-toast')).toHaveText('Settings summary copied.'); + const [seed,steps,cfg,sampler,,model]=facts; + expect(await page.evaluate(()=>window.__copied)).toEqual([[`Checkpoint: ${model}`,'Positive Prompt: current file positive','Negative Prompt: current file negative', + `Steps: ${steps}`,`CFG Scale: ${cfg}`,`Sampler: ${sampler}`,`Seed: ${seed}`].join('\n').replace(/: (Not recorded|Unresolved|Ambiguous|Unavailable within inspection limits)(?=\n|$)/g,': Unknown')]); +}); + 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'}}; diff --git a/tests/browser/workspace-images.spec.mjs b/tests/browser/workspace-images.spec.mjs index bd07349..4f544cf 100644 --- a/tests/browser/workspace-images.spec.mjs +++ b/tests/browser/workspace-images.spec.mjs @@ -787,7 +787,12 @@ test('density changes and resize preserve identity focus and selection', async ( await page.getByRole('button',{name:'Grid',exact:true}).click(); for (const [width,columns] of [[951,3],[950,3],[700,2],[390,2]]) { await page.setViewportSize({width,height:844}); - await expect.poll(()=>cards(page).nth(columns).evaluate(node=>node.offsetLeft)).toBe(0); + // 700px and 390px both lay out two columns, so a card at the row start alone is also true of the previous + // width's cards before WebKit runs the first frame after the resize; wait for the row to fit the new grid. + await expect.poll(()=>page.locator('.image-grid').evaluate((grid,columns)=>{ + const row=[...grid.querySelectorAll(':scope > article[data-image-key]')]; + return row[columns].offsetLeft===0&&row[columns-1].getBoundingClientRect().right<=grid.getBoundingClientRect().right+1; + },columns)).toBe(true); await first.getByRole('button',{name:/^Open /}).focus();await page.keyboard.press('ArrowDown'); await expect(cards(page).nth(columns).getByRole('button',{name:/^Open /})).toBeFocused(); expect(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1); @@ -902,6 +907,10 @@ test('browser Back closes the open Images dialog and leaves the next page usable test('count line shows images and positive prompts', async ({ page, request }) => { await page.addInitScript(() => { window.IntersectionObserver = undefined; }); + // D17's "Today" is the newest file's day: the page's clock stands at that file's mtime, so the fixture's day and + // the page's agree on either side of midnight. + const { images } = await (await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json(); + await page.clock.setFixedTime(new Date(Math.max(...images.map(image => image.modified_time)) * 1000)); await page.goto(workspace); await expect(cards(page)).toHaveCount(2); await expect(caption(page).locator('#result-count')).toHaveText('2 of 2 images · 2 positive prompts'); @@ -1702,7 +1711,8 @@ test('card checkboxes select images into the concept bar and Clear empties it', const selectedBefore = await cards(page).evaluateAll(nodes => nodes.map(node => node.classList.contains('selected'))); await checkbox(first, 'same.png').check(); await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('1 selected'); - await expect(barButtons(page)).toHaveText(['Set category', 'Export metadata', 'Clear']); + // D85: Add tags (the concept's) and Auto-tag selected lead the real actions. + await expect(barButtons(page)).toHaveText(['Add tags', 'Auto-tag selected', 'Set category', 'Export metadata', 'Clear']); // Toggling neither opens the viewer, nor inspects, nor moves focus. await expect(page.locator('dialog[open]')).toHaveCount(0); await expect(checkbox(first, 'same.png')).toBeFocused(); diff --git a/tests/browser/workspace-inspect.spec.mjs b/tests/browser/workspace-inspect.spec.mjs index 5923259..5d19793 100644 --- a/tests/browser/workspace-inspect.spec.mjs +++ b/tests/browser/workspace-inspect.spec.mjs @@ -207,6 +207,40 @@ test('chooser opens the shared full inspector without a second read', async ({ p expect(await page.evaluate(() => window.__slices)).toBe(0); }); +// PR L3 Task 10: local roles are the server inspector's (local-resolver.mjs), so for every parity PNG the rail shows the +// prompts, their status lines and the facts Images shows for the same bytes in the output folder. +const PARITY_PNGS = ['f01b', 'f02', 'f03', 'f06a', 'f06b', 'f06c', 'f06d', 'f06e', 'f07a', 'f07b', 'f08', 'f09', 'f10', 'f11', 'f12', + 'f14', 'f15', 'f16', 'f17', 'f19', 'f20', 'f21', 'f22', 'f23', 'f24']; +const roleSections = locator => locator.evaluate(node => [...node.querySelectorAll('.prompt-section')].map(item => item.textContent)); + +test('chooser shows the Images roles and resolution for every parity PNG', async ({ page, request }) => { + test.slow(); + for (const name of PARITY_PNGS) { + await expect(await request.post('/__test__/add-local-inspect-file', { data: { name: `${name}.png` } })).toBeOK(); + } + const served = {}; + await page.goto(`${workspace}#/images`); + for (const name of PARITY_PNGS) { + await page.locator(`article[data-image-key="0:local-inspect/${name}.png"] .image-open`).click(); + await expect(viewer(page).locator('#viewer-title')).toHaveText(`${name}.png`); + await expect(rail(page).locator('.panel-heading')).toBeVisible(); + await expect(rail(page).locator('.prompt-section')).not.toHaveCount(0); + served[name] = { content: await railContent(rail(page)), roles: await roleSections(rail(page)) }; + await page.keyboard.press('Escape'); + await expect(viewer(page)).toHaveCount(0); + } + await openInspect(page); + for (const name of PARITY_PNGS) { + await choose(page, parityPng(`${name}.png`)); + await openViewer(page); + await expect(viewer(page).locator('#viewer-title')).toHaveText(`${name}.png`); + await expect(rail(page).locator('.prompt-section')).not.toHaveCount(0); + expect({ content: await railContent(rail(page)), roles: await roleSections(rail(page)) }, name).toEqual(served[name]); + await page.keyboard.press('Escape'); + await expect(viewer(page)).toHaveCount(0); + } +}); + test('Import → Inspect a local image lands here', async ({ page }) => { await page.goto(`${workspace}#/images`); await page.getByRole('button', { name: 'Import', exact: true }).click(); @@ -481,15 +515,13 @@ test('replacing a video that just started loading never blocks the page', async }); test('a video replaced while still starting is reset once it settles', async ({ context, browserName }) => { - // A WebKit (GStreamer) video released exactly at its `loadstart` sometimes blocks the page, with or without any - // reset and also with the code before the deferred reset (2 of 24 runs); tracked in TESTING.md (D62). WebKit's - // no-hang guard is `replacing a video that just started loading never blocks the page`. - test.skip(browserName === 'webkit', 'WebKit release at loadstart is a tracked follow-up (D62)'); - test.setTimeout(120000); - // The video is replaced from its own `loadstart` (readyState HAVE_NOTHING). It leaves the page with no src before - // its URL is revoked, and is reset (load() with no src, which frees its decoder) only once it reports metadata or - // an error, so the page never blocks. Fresh pages share one browser process. - for (let attempt = 0; attempt < 5; attempt++) { + test.setTimeout(180000); + // The video is replaced from its own `loadstart` (readyState HAVE_NOTHING), or a few milliseconds after it. On + // WebKit (GStreamer) a video that left the page or the viewport before its pipeline had started deadlocked the + // page (TESTING.md D62), so it stays in place, inert and still playing its URL, with the new preview after it. + // Once it reports metadata or an error it is paused, loses its src, is reset (load() with no src, which frees its + // decoder) and leaves the page, and only then is its URL revoked. Fresh pages share one browser process. + for (let attempt = 0; attempt < 15; attempt++) { const page = await context.newPage(); try { if (attempt === 0) await startCoverage(page, browserName); @@ -510,10 +542,12 @@ test('a video replaced while still starting is reset once it settles', async ({ const video = slot.querySelector('video'); if (!video) return; observer.disconnect(); - video.addEventListener('loadstart', () => { + const replace = () => { window.__starting = { node: video, url: video.getAttribute('src'), readyState: video.readyState }; input.files = window.__pngFiles; input.dispatchEvent(new Event('change', { bubbles: true })); - }, { once: true }); + window.__starting.replaced = { connected: video.isConnected, src: video.hasAttribute('src'), inert: video.inert, resets: video.__resets ?? 0 }; + }; + video.addEventListener('loadstart', () => { if (window.__delay) setTimeout(replace, window.__delay); else replace(); }, { once: true }); }).observe(slot, { childList: true }); }); const png = parityPng('f02.png'); @@ -522,13 +556,17 @@ test('a video replaced while still starting is reset once it settles', async ({ transfer.items.add(new File([new Uint8Array(bytes)], 'f02.png', { type: 'image/png' })); window.__pngFiles = transfer.files; }, [...png.buffer]); + await page.evaluate(delay => { window.__delay = delay; }, attempt % 5); await choose(page, localFixture('core-tags.webm', 'video/webm')); await expect(preview(page).locator('img.local-file-preview')).toBeVisible({ timeout: 8000 }); await expect(preview(page).locator('video')).toHaveCount(0); - // Released while starting, and never reset synchronously: the URL went once the node had no src. - expect(await page.evaluate(() => ({ readyState: window.__starting.readyState, revoked: window.__starting.revoked }))) - .toEqual({ readyState: 0, revoked: { src: false, connected: false, resets: 0 } }); - await expect.poll(() => page.evaluate(() => window.__starting.node.__resets ?? 0), { timeout: 15000 }).toBe(1); + // Replaced while starting (always so at `loadstart` itself): kept in place and untouched. A video that had + // settled is released at once. Either way it is reset once, and its URL goes last. + await expect.poll(() => page.evaluate(() => window.__starting.revoked ?? null)).toEqual({ src: false, connected: false, resets: 1 }); + const { readyState, replaced } = await page.evaluate(() => ({ readyState: window.__starting.readyState, replaced: window.__starting.replaced })); + if (attempt % 5 === 0) expect(readyState).toBe(0); + expect(replaced).toEqual(readyState === 0 ? { connected: true, src: true, inert: true, resets: 0 } : { connected: false, src: false, inert: false, resets: 1 }); + expect(await page.evaluate(() => window.__starting.node.__resets)).toBe(1); // The page still answers. await chooser(page).setInputFiles(parityPng('f02.png', 'after.png'), { timeout: 8000 }); await expect(preview(page).locator('img.local-file-preview')).toBeVisible({ timeout: 8000 }); @@ -652,13 +690,19 @@ test('local roles send exactly', async ({ page, context }) => { test('local inspection makes no content request or storage write', async ({ page }) => { await instrument(page); - await openInspect(page); - // The shell's own reads (counts, Activity) settle before the baseline. - await expect(page.locator('aside.sidebar nav [data-route="images"] .nav-count')).toBeAttached(); - const baseline = (await urls(page)).writes.length; const requests = []; page.on('request', request => requests.push({ url: request.url(), method: request.method(), body: request.postData() })); page.on('websocket', socket => requests.push({ url: socket.url(), method: 'WS', body: null })); + const table = /\/prompt_manager\/js\/inspection-semantics\.json$/; + const tableRead = page.waitForResponse(response => table.test(response.url())); + await openInspect(page); + // The one static file Inspect reads, its node table, is fetched as the page mounts: before any selection. + expect((await tableRead).ok()).toBe(true); + // The shell's own reads (counts, Activity) settle before the baseline. + await expect(page.locator('aside.sidebar nav [data-route="images"] .nav-count')).toBeAttached(); + const baseline = (await urls(page)).writes.length; + const before = requests.length; + expect(requests.filter(entry => table.test(entry.url))).toHaveLength(1); for (const file of [sendablePng('private-name.png'), localFixture('core-tags.webm', 'video/webm', 'private-clip.webm')]) { await choose(page, file); await openViewer(page); @@ -666,7 +710,8 @@ test('local inspection makes no content request or storage write', async ({ page await page.keyboard.press('Escape'); await expect(viewer(page)).toHaveCount(0); } - const network = requests.filter(entry => !entry.url.startsWith('blob:') && !entry.url.startsWith('data:')); + const network = requests.slice(before).filter(entry => !entry.url.startsWith('blob:') && !entry.url.startsWith('data:')); + expect(requests.filter(entry => table.test(entry.url)), 'the table is read once per page').toHaveLength(1); expect(network.filter(entry => /private|lighthouse|fox/.test(`${entry.url} ${entry.body ?? ''}`))).toEqual([]); expect(network.filter(entry => entry.method !== 'GET')).toEqual([]); expect(network.filter(entry => !/\/prompt_manager\/(activity|images\/output|library|vocabulary)/.test(entry.url))).toEqual([]); @@ -685,3 +730,80 @@ test('touch layout at 390 has no horizontal overflow at 200% zoom', async ({ pag expect(area).toBe(true); } }); + +// LI-4 (D117): "Copy settings summary" after the six facts is the legacy metadata page's COPY ALL, in the shared full +// inspector, so a file served in Images and the same bytes chosen here copy the same summary of what the rail shows. +/** Records clipboard writes in the page, or (`clipboard: false`) leaves the page without a clipboard. */ +async function stubClipboard(page, { clipboard = true } = {}) { + await page.addInitScript(available => { + window.__copied = []; + Object.defineProperty(navigator, 'clipboard', { configurable: true, + value: available ? { writeText: async text => { window.__copied.push(text); } } : undefined }); + }, clipboard); +} +/** The summary the rail's own prompts and facts make, in the legacy COPY ALL lines and placeholders. */ +const expectedSummary = locator => locator.evaluate(node => { + const facts = Object.fromEntries([...node.querySelectorAll(':scope .meta-grid:not(details .meta-grid) > div')] + .map(item => [item.querySelector('dt').textContent, item.querySelector('dd').textContent])); + // A confirmed prompt has its Copy action; an explicitly empty one takes the legacy placeholder too. + const prompt = role => { + const text = node.querySelector(`.prompt-section.${role} [data-copy-role]`) ? node.querySelector(`.prompt-section.${role} p.prompt-text`).textContent : null; + return text === `No ${role} prompt (explicitly empty)` ? null : text; + }; + return [`Checkpoint: ${facts.Model}`, `Positive Prompt: ${prompt('positive') ?? 'No prompt found'}`, + `Negative Prompt: ${prompt('negative') ?? 'No negative prompt found'}`, `Steps: ${facts.Steps}`, `CFG Scale: ${facts.CFG}`, + `Sampler: ${facts.Sampler}`, `Seed: ${facts.Seed}`].join('\n'); +}); +async function copySummary(page) { + const button = rail(page).getByRole('button', { name: 'Copy settings summary', exact: true }); + // It follows the six facts, before "File & provenance". + expect(await button.evaluate(node => [node.previousElementSibling?.localName, node.nextElementSibling?.localName])).toEqual(['dl', 'details']); + await button.click(); +} + +test('Copy settings summary copies the same legacy summary in Images and Inspect', async ({ page, request }) => { + await stubClipboard(page); + await expect(await request.post('/__test__/add-local-inspect-file', { data: { name: 'f02.png' } })).toBeOK(); + await page.goto(`${workspace}#/images`); + await page.locator('article[data-image-key="0:local-inspect/f02.png"] .image-open').click(); + await expect(rail(page).locator('.prompt-section.positive p.prompt-text')).toBeVisible(); + const served = await expectedSummary(rail(page)); + expect(served).toMatch(/^Checkpoint: .+\nPositive Prompt: [\s\S]+\nNegative Prompt: [\s\S]+\nSteps: .+\nCFG Scale: .+\nSampler: .+\nSeed: .+$/); + expect(served).not.toContain('Unknown'); + await copySummary(page); + await expect(page.locator('#workspace-toast')).toHaveText('Settings summary copied.'); + expect(await page.evaluate(() => window.__copied)).toEqual([served]); + await page.keyboard.press('Escape'); + await expect(viewer(page)).toHaveCount(0); + + await openInspect(page); + await choose(page, parityPng('f02.png')); + await openViewer(page); + expect(await expectedSummary(rail(page))).toBe(served); + await copySummary(page); + await expect.poll(() => page.evaluate(() => window.__copied)).toEqual([served, served]); + // The panel variant (the Images side panel) keeps the concept's footer: no summary action there. + await page.keyboard.press('Escape'); + await page.goto(`${workspace}#/images`); + await expect(page.locator('#image-inspector .panel-heading')).toBeVisible(); + await expect(page.locator('#image-inspector').getByRole('button', { name: 'Copy settings summary', exact: true })).toHaveCount(0); +}); + +test('Copy settings summary without a clipboard opens the select-to-copy dialog', async ({ page }) => { + await stubClipboard(page, { clipboard: false }); + await openInspect(page); + await choose(page, sendablePng()); + await openViewer(page); + const summary = await expectedSummary(rail(page)); + expect(summary).toContain(`Positive Prompt: ${POSITIVE_LINES}`); + await copySummary(page); + const fallback = page.getByRole('dialog', { name: 'Copy settings summary', exact: true }); + await expect(fallback).toBeVisible(); + await expect(fallback.getByRole('textbox', { name: 'Select and copy this text', exact: true })).toHaveValue(summary); + await expect(fallback.getByRole('textbox', { name: 'Select and copy this text', exact: true })).toBeFocused(); + await page.keyboard.press('Escape'); + await expect(fallback).toHaveCount(0); + // Escape closes only the copy dialog; focus returns to the action in the still-open viewer. + await expect(viewer(page)).toBeVisible(); + await expect(rail(page).getByRole('button', { name: 'Copy settings summary', exact: true })).toBeFocused(); +}); diff --git a/tests/browser/workspace-library.spec.mjs b/tests/browser/workspace-library.spec.mjs index 7055766..5ef235f 100644 --- a/tests/browser/workspace-library.spec.mjs +++ b/tests/browser/workspace-library.spec.mjs @@ -610,7 +610,9 @@ test('card matches the concept and never wraps the category mid-word',async({pag await expect(await request.post('/prompt_manager/bulk/category',{data:{prompt_ids:[dual.id],category:''}})).toBeOK(); // A long saved title fills the title row, so only the chip's own min-content keeps "Uncategorized" and "asian" whole. const wide='A remarkably long saved positive prompt title that fills the whole card title row'; - await patchShelf(page,item=>item.id===cat.id?{run_count:4,last_used_at:new Date().toISOString(),rating:5}:item.id===dual.id||item.id===dog.id?{title:wide,run_count:1,last_used_at:null,rating:null}:{}); + // One "now" for the patched last use and the page's clock, so it reads "Used today" on either side of midnight. + const usedAt=new Date();await page.clock.setFixedTime(usedAt); + await patchShelf(page,item=>item.id===cat.id?{run_count:4,last_used_at:usedAt.toISOString(),rating:5}:item.id===dual.id||item.id===dog.id?{title:wide,run_count:1,last_used_at:null,rating:null}:{}); for(const [width,height] of [[1600,1100],[390,844]]) { await page.setViewportSize({width,height});await page.goto(route);await expect(cards(page)).toHaveCount(3); const card=page.locator(`article[data-prompt-id="${cat.id}"]`); diff --git a/tests/browser/workspace-tagging-bulk.spec.mjs b/tests/browser/workspace-tagging-bulk.spec.mjs new file mode 100644 index 0000000..d356105 --- /dev/null +++ b/tests/browser/workspace-tagging-bulk.spec.mjs @@ -0,0 +1,323 @@ +// The Images selection bar's Add tags and Auto-tag selected (#249 PR D, plan Task D2, Mock inventory M3; D85, D90, +// D91) against the real `image-tags` job route and the injected fake model (tests/browser/workspace_tagging_fixture.py). +// No model is ever downloaded or loaded for real: the fake answers, and every test checks no download request was sent. +import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs'; +import { test, expect } from './fixtures.mjs'; + +const images = '/prompt_manager/workspace#/images'; +const FIRST = 'tagging/dunes_00001.png', SECOND = 'tagging/dunes_00002.png', UNLINKED = 'loose/unlinked.png'; +const card = (page, path) => page.locator(`article[data-image-key="0:${path}"]`); +const bar = page => page.locator('#workspace-main > #bulkbar > .bulkbar'); +const barButton = (page, name) => bar(page).getByRole('button', { name, exact: true }); +const modal = page => page.locator('dialog#modal[open]'); +const title = page => modal(page).locator('#modal-title'); +const toast = page => page.locator('#workspace-toast'); +const toasts = page => page.evaluate(() => window.__toasts); +const chips = page => modal(page).locator('#tag-suggestions .chip.tag-suggestion'); +const actionButtons = page => modal(page).locator('.dialog-actions button'); +const panel = page => page.getByRole('complementary', { name: 'Selected image details', exact: true }); +const tagRow = page => panel(page).locator('.detail-inner > .tags').last(); +const NOT_DOWNLOADED = 'WD14 SwinV2 is not downloaded. Download it from the legacy Auto-tag page.'; + +test.beforeEach(async ({ page, request, browserName }) => { + await startCoverage(page, browserName); + // Every toast text in order: a fast job's finish toast may replace its start toast before an assertion reads it. + await page.addInitScript(() => { + window.__toasts = []; + new MutationObserver(() => { + const text = document.getElementById('workspace-toast')?.textContent ?? ''; + if (text && window.__toasts.at(-1) !== text) window.__toasts.push(text); + }).observe(document, { subtree: true, childList: true, characterData: true }); + }); + await expect(await request.post('/__test__/tagging/reset', { data: {} })).toBeOK(); + page.taggingErrors = []; + page.on('pageerror', error => page.taggingErrors.push(error.message)); + page.downloads = []; + page.on('request', item => { if (/\/autotag\/download\//.test(item.url())) page.downloads.push(item.url()); }); +}); +test.afterEach(async ({ page, browserName }) => { + await saveCoverage(page, browserName); + expect(page.taggingErrors).toEqual([]); + expect(page.downloads).toEqual([]); +}); + +/** Images with `paths` checked, in that order. */ +async function select(page, ...paths) { + await page.goto(images); + for (const path of paths) await card(page, path).getByRole('checkbox').check(); + await expect(bar(page).locator('strong')).toHaveText(`${paths.length} selected`); +} +async function scopeOf(request) { + const listing = await (await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json(); + return listing.images[0].root_scope; +} +/** The saved tags of `path` through the real observe route, then that capture's release. */ +async function savedTags(request, path) { + const target = { root_index: 0, root_scope: await scopeOf(request), path }; + const observed = await (await request.post('/prompt_manager/image-annotations/observe', { data: { target } })).json(); + expect(observed.success).toBe(true); + await request.delete(`/prompt_manager/image-annotations/capture/${observed.capture.token}`); + return observed.tags; +} +/** Wait until the session's job `id` has ended; returns its summary. */ +async function ended(request, id) { + let job; + await expect.poll(async () => { + const { jobs } = await (await request.get('/prompt_manager/jobs')).json(); + job = jobs.find(item => item.id === id); + return job?.state; + }).toBe('finished'); + return job; +} +const jobStart = page => page.waitForRequest(item => item.method() === 'POST' && new URL(item.url()).pathname === '/prompt_manager/jobs/image-tags'); + +test('selection bar shows Add tags then Auto-tag selected before Set category', async ({ page }) => { + await select(page, FIRST); + await expect(bar(page).locator(':scope > *')).toHaveText(['1 selected', 'Add tags', 'Auto-tag selected', 'Set category', 'Export metadata', 'Clear']); +}); + +test('Add tags runs a job and the cards show the tags after it finishes', async ({ page, request }) => { + await select(page, SECOND, FIRST); + await expect(card(page, FIRST)).toHaveClass(/\bselected\b/); + await barButton(page, 'Add tags').click(); + await expect(title(page)).toHaveText('Tag selected images'); + const field = modal(page).getByRole('textbox', { name: 'Tags, separated by commas', exact: true }); + await expect(field).toHaveValue(''); + await expect(actionButtons(page)).toHaveText(['Cancel', 'Apply']); + let posted = 0; + page.on('request', item => { if (new URL(item.url()).pathname === '/prompt_manager/jobs/image-tags') posted += 1; }); + // A field that parses to no names keeps the dialog and its text, and reports why on the field (D118). + await field.fill(' , ,'); + await actionButtons(page).last().click(); + await expect.poll(() => field.evaluate(node => node.validationMessage)).toBe('Enter at least one tag.'); + await expect(title(page)).toHaveText('Tag selected images'); + await expect(field).toHaveValue(' , ,'); + await field.fill('sunset, Sunset , blue_hour,,'); + expect(await field.evaluate(node => node.validity.valid)).toBe(true); + const started = jobStart(page); + const answer = page.waitForResponse(item => new URL(item.url()).pathname === '/prompt_manager/jobs/image-tags'); + await actionButtons(page).last().click(); + const scope = await scopeOf(request); + // Listing order (the newer FIRST before SECOND), the reveal identity, the dialog's dedupe. + expect((await started).postDataJSON()).toEqual({ mode: 'add', tags: ['sunset', 'blue_hour'], targets: [ + { root_index: 0, root_scope: scope, path: FIRST }, { root_index: 0, root_scope: scope, path: SECOND }] }); + const { job } = await (await answer).json(); + // The refused Apply sent nothing: only the valid one was posted. + expect(posted).toBe(1); + await expect.poll(() => toasts(page)).toContain('Tagging started.'); + expect(await toasts(page)).not.toContain('Enter at least one tag.'); + await expect(modal(page)).toHaveCount(0); + await expect(bar(page).locator('strong')).toHaveText('2 selected'); + expect((await ended(request, job.id)).counters).toEqual({ tagged: 2, unchanged: 0, skipped: 0, errors: 0 }); + // The finished job's files are read again: the selected card's panel shows its new image tags. + await expect(tagRow(page).locator('button.chip')).toHaveText(['sunset', 'blue_hour']); + expect(await savedTags(request, SECOND)).toEqual(['sunset', 'blue_hour']); + expect(await savedTags(request, UNLINKED)).toEqual([]); +}); + +test('Auto-tag selected with Apply automatically runs a job with the chosen model, thresholds and suggestion count', async ({ page, request }) => { + await select(page, FIRST, UNLINKED); + await barButton(page, 'Auto-tag selected').click(); + await expect(title(page)).toHaveText('Auto-tag selected images'); + await expect(modal(page)).toHaveClass(/(^| )tagging-modal( |$)/); + const body = modal(page).locator('#modal-body'); + expect(await body.evaluate(node => [...node.children].map(child => child.className))).toEqual(['tag-model-panel', 'field', 'small', 'dialog-actions']); + await expect(body.locator('.tag-model-panel h3')).toHaveText('Auto-tag 2 images'); + // No preview, manual field, suggestions or Generate: only the model panel's fields. + await expect(body.getByRole('button', { name: 'Generate suggestions', exact: true })).toHaveCount(0); + await expect(body.locator('#manual-image-tags, .tag-suggestion-panel, .tag-image-preview')).toHaveCount(0); + const group = body.getByRole('radiogroup', { name: 'How to apply', exact: true }); + await expect(group.getByRole('radio', { name: 'Apply automatically', exact: true })).toBeChecked(); + await expect(group.getByRole('radio', { name: 'Review each image', exact: true })).not.toBeChecked(); + await expect(actionButtons(page)).toHaveText(['Cancel', 'Start auto-tagging']); + await body.getByRole('combobox', { name: 'Tagging model', exact: true }).selectOption('wd14-vit'); + await body.getByRole('spinbutton', { name: 'General confidence', exact: true }).fill('0.5'); + await body.getByRole('spinbutton', { name: 'Character confidence', exact: true }).fill('0.9'); + const limit = body.getByRole('spinbutton', { name: 'Suggestions', exact: true }); + // An invalid count reports its validity and sends nothing. + await limit.fill('0'); + let posted = 0; + page.on('request', item => { if (new URL(item.url()).pathname === '/prompt_manager/jobs/image-tags') posted += 1; }); + await actionButtons(page).last().click(); + expect(await limit.evaluate(node => node.validity.valid)).toBe(false); + await expect(title(page)).toHaveText('Auto-tag selected images'); + await limit.fill('2'); + const started = jobStart(page); + const answer = page.waitForResponse(item => new URL(item.url()).pathname === '/prompt_manager/jobs/image-tags'); + await actionButtons(page).last().click(); + const scope = await scopeOf(request); + expect((await started).postDataJSON()).toEqual({ mode: 'auto', model_type: 'wd14-vit', + options: { general_threshold: 0.5, character_threshold: 0.9, suggestion_limit: 2 }, + targets: [{ root_index: 0, root_scope: scope, path: FIRST }, { root_index: 0, root_scope: scope, path: UNLINKED }] }); + const { job } = await (await answer).json(); + // The invalid press sent nothing: only this Start was posted. + expect(posted).toBe(1); + await expect.poll(() => toasts(page)).toContain('Auto-tagging started.'); + await expect(modal(page)).toHaveCount(0); + await expect(bar(page).locator('strong')).toHaveText('2 selected'); + expect((await ended(request, job.id)).counters).toEqual({ tagged: 2, unchanged: 0, skipped: 0, errors: 0 }); + expect(await savedTags(request, FIRST)).toEqual(['sandstone', 'desert']); + expect(await savedTags(request, UNLINKED)).toEqual(['sandstone', 'desert']); + expect(await savedTags(request, SECOND)).toEqual([]); +}); + +test('a model that is not downloaded keeps Start disabled and says why', async ({ page, request }) => { + await expect(await request.post('/__test__/tagging/models', { data: { unavailable: ['wd14-swinv2'] } })).toBeOK(); + await select(page, FIRST); + await barButton(page, 'Auto-tag selected').click(); + const status = modal(page).locator('#tag-generation-status'); + await expect(status).toHaveAttribute('role', 'status'); + await expect(status).toHaveText(NOT_DOWNLOADED); + const start = actionButtons(page).last(); + await expect(start).toHaveAttribute('aria-disabled', 'true'); + let posted = 0; + page.on('request', item => { if (new URL(item.url()).pathname === '/prompt_manager/jobs/image-tags') posted += 1; }); + // aria-disabled: a press does nothing. + await start.dispatchEvent('click'); + await expect(title(page)).toHaveText('Auto-tag selected images'); + await modal(page).getByRole('combobox', { name: 'Tagging model', exact: true }).selectOption('wd14-vit'); + await expect(status).toHaveText(''); + await expect(start).not.toHaveAttribute('aria-disabled', 'true'); + await modal(page).getByRole('combobox', { name: 'Tagging model', exact: true }).selectOption('wd14-swinv2'); + await expect(start).toHaveAttribute('aria-disabled', 'true'); + await actionButtons(page).first().click(); + await expect(modal(page)).toHaveCount(0); + await expect(barButton(page, 'Auto-tag selected')).toBeFocused(); + expect(posted).toBe(0); +}); + +test('Suggestions field prefills from the setting and caps chips at the entered count', async ({ page, request }) => { + await expect(await request.post('/__test__/tagging/defaults', { data: { general: 0.5, character: 0.9, limit: 7 } })).toBeOK(); + await select(page, FIRST); + await barButton(page, 'Auto-tag selected').click(); + const limit = modal(page).getByRole('spinbutton', { name: 'Suggestions', exact: true }); + await expect(limit).toHaveValue('7'); + await expect(modal(page).getByRole('spinbutton', { name: 'General confidence', exact: true })).toHaveValue('0.5'); + await expect(modal(page).getByRole('spinbutton', { name: 'Character confidence', exact: true })).toHaveValue('0.9'); + await modal(page).getByRole('radio', { name: 'Review each image', exact: true }).check(); + await expect(actionButtons(page)).toHaveText(['Cancel', 'Start review']); + await limit.fill('3'); + const suggest = page.waitForRequest(item => item.url().endsWith('/image-annotations/suggest')); + await actionButtons(page).last().click(); + await expect(title(page)).toHaveText('Tag image · Image 1 of 1'); + expect((await suggest).postDataJSON().options).toEqual({ general_threshold: 0.5, character_threshold: 0.9, suggestion_limit: 3 }); + await expect(chips(page)).toHaveCount(3); + await expect(modal(page).getByRole('spinbutton', { name: 'Suggestions', exact: true })).toHaveValue('3'); + await expect(actionButtons(page)).toHaveText(['Stop reviewing', 'Skip', 'Approve and finish']); + await page.keyboard.press('Escape'); + await expect(modal(page)).toHaveCount(0); + await expect(toast(page)).toHaveText('0 of 1 images tagged.'); + expect(await savedTags(request, FIRST)).toEqual([]); +}); + +test('Review each image walks the selection one dialog at a time and only approved images gain tags', async ({ page, request }) => { + await select(page, FIRST, SECOND, UNLINKED); + // The second image's first Generate finds the model busy (a legacy batch): the dialog waits for Retry or Skip. + let suggests = 0; + await page.route('**/prompt_manager/image-annotations/suggest', async route => { + suggests += 1; + if (suggests === 2) { + await route.fulfill({ status: 409, json: { success: false, code: 'model-busy', error: 'The tagging model is busy. Try again when it finishes.' } }); + return; + } + await route.continue(); + }); + const puts = []; + page.on('request', item => { if (item.method() === 'PUT' && item.url().endsWith('/image-annotations/tags')) puts.push(item.postDataJSON()); }); + await barButton(page, 'Auto-tag selected').click(); + await modal(page).getByRole('radio', { name: 'Review each image', exact: true }).check(); + await modal(page).getByRole('spinbutton', { name: 'Suggestions', exact: true }).fill('2'); + await actionButtons(page).last().click(); + await expect(title(page)).toHaveText('Tag image · Image 1 of 3'); + await expect(modal(page).getByRole('combobox', { name: 'Tagging model', exact: true })).toHaveValue('wd14-swinv2'); + await expect(chips(page)).toHaveCount(2); + await expect(actionButtons(page)).toHaveText(['Stop reviewing', 'Skip', 'Approve and next']); + await expect(actionButtons(page).last()).toHaveClass(/ca-primary/); + // Approve saves the dialog's manual field plus its remaining chips. + await modal(page).getByRole('textbox', { name: 'Image tags · edit manually', exact: true }).fill('mine'); + await modal(page).getByRole('button', { name: 'Remove desert', exact: true }).click(); + await actionButtons(page).last().click(); + await expect(title(page)).toHaveText('Tag image · Image 2 of 3'); + await expect(modal(page).locator('#tag-generation-status')).toHaveText('The tagging model is busy. Try again when it finishes.'); + // No suggestions on screen: the primary would save the manual field only (D90). + await expect(actionButtons(page)).toHaveText(['Retry', 'Stop reviewing', 'Skip', 'Save and next']); + await actionButtons(page).nth(2).click(); + await expect(title(page)).toHaveText('Tag image · Image 3 of 3'); + await expect(chips(page)).toHaveCount(2); + await expect(actionButtons(page)).toHaveText(['Stop reviewing', 'Skip', 'Approve and finish']); + await actionButtons(page).last().click(); + await expect(modal(page)).toHaveCount(0); + await expect(toast(page)).toHaveText('2 of 3 images tagged.'); + await expect(barButton(page, 'Auto-tag selected')).toBeFocused(); + await expect(bar(page).locator('strong')).toHaveText('3 selected'); + expect(puts.map(body => [body.target.path, body.tags])).toEqual([[FIRST, ['mine', 'sandstone']], [UNLINKED, ['sandstone', 'desert']]]); + expect(await savedTags(request, FIRST)).toEqual(['mine', 'sandstone']); + expect(await savedTags(request, SECOND)).toEqual([]); + expect(await savedTags(request, UNLINKED)).toEqual(['sandstone', 'desert']); + // The reviewed file's panel shows its saved tags without reselecting. + await expect(tagRow(page).locator('button.chip')).toHaveText(['mine', 'sandstone']); + await expect.poll(async () => (await (await request.get('/__test__/tagging/captures')).json()).captures).toBe(0); +}); + +test('Review holds Stop reviewing, Escape and × while a save is in flight, and names a manual-only save', async ({ page, request }) => { + await select(page, FIRST, SECOND); + let release; + const held = new Promise(resolve => { release = resolve; }); + await page.route('**/prompt_manager/image-annotations/tags', async route => { + if (route.request().method() === 'PUT') await held; + await route.continue(); + }); + await barButton(page, 'Auto-tag selected').click(); + await modal(page).getByRole('radio', { name: 'Review each image', exact: true }).check(); + await actionButtons(page).last().click(); + await expect(title(page)).toHaveText('Tag image · Image 1 of 2'); + await expect(chips(page)).not.toHaveCount(0); + await expect(actionButtons(page)).toHaveText(['Stop reviewing', 'Skip', 'Approve and next']); + // D90: a model change clears the suggestions, so the primary saves the manual field only and says so. + await modal(page).getByRole('combobox', { name: 'Tagging model', exact: true }).selectOption('wd14-vit'); + await expect(chips(page)).toHaveCount(0); + await expect(actionButtons(page)).toHaveText(['Stop reviewing', 'Skip', 'Save and next']); + await modal(page).getByRole('textbox', { name: 'Image tags · edit manually', exact: true }).fill('mine'); + const put = page.waitForRequest(item => item.method() === 'PUT' && item.url().endsWith('/image-annotations/tags')); + await actionButtons(page).last().click(); + await put; + const stop = actionButtons(page).first(); + await expect(stop).toHaveAttribute('aria-disabled', 'true'); + await expect(actionButtons(page).nth(1)).toHaveAttribute('aria-disabled', 'true'); + // None of these ends the session while the save is in flight; the count then includes it. + await page.keyboard.press('Escape'); + await modal(page).locator('#modal-close').click(); + await stop.dispatchEvent('click'); // aria-disabled: Playwright's click would wait for it + release(); + await expect(title(page)).toHaveText('Tag image · Image 2 of 2'); + await expect(chips(page)).not.toHaveCount(0); + await expect(actionButtons(page)).toHaveText(['Stop reviewing', 'Skip', 'Approve and finish']); + await expect(actionButtons(page).first()).not.toHaveAttribute('aria-disabled', 'true'); + await actionButtons(page).first().click(); + await expect(modal(page)).toHaveCount(0); + await expect(toast(page)).toHaveText('1 of 2 images tagged.'); + expect(await savedTags(request, FIRST)).toEqual(['mine']); + expect(await savedTags(request, SECOND)).toEqual([]); +}); + +test('a running scan makes the start report busy', async ({ page, request }) => { + const scan = await (await request.post('/__test__/jobs/start', { data: { kind: 'scan', title: 'Scan for metadata' } })).json(); + try { + await select(page, FIRST, SECOND); + await barButton(page, 'Add tags').click(); + await modal(page).getByRole('textbox', { name: 'Tags, separated by commas', exact: true }).fill('sunset'); + await actionButtons(page).last().click(); + await expect(toast(page)).toHaveText('Another job is running: Scan for metadata.'); + await expect(toast(page).getByRole('link', { name: 'Scan for metadata', exact: true })).toHaveAttribute('href', `#/activity/${scan.job.id}`); + await expect(bar(page).locator('strong')).toHaveText('2 selected'); + // Auto-tag is refused the same way. + await barButton(page, 'Auto-tag selected').click(); + await expect(actionButtons(page).last()).not.toHaveAttribute('aria-disabled', 'true'); + await actionButtons(page).last().click(); + await expect(toast(page)).toHaveText('Another job is running: Scan for metadata.'); + await expect(bar(page).locator('strong')).toHaveText('2 selected'); + expect(await savedTags(request, FIRST)).toEqual([]); + } finally { + await request.post(`/__test__/jobs/${scan.job.id}/finish`, { data: { state: 'finished' } }); + } +}); diff --git a/tests/browser/workspace-tagging.spec.mjs b/tests/browser/workspace-tagging.spec.mjs index 06d24a6..15de079 100644 --- a/tests/browser/workspace-tagging.spec.mjs +++ b/tests/browser/workspace-tagging.spec.mjs @@ -340,6 +340,9 @@ test('Tag opens the dialog without selecting the card', async ({ page }) => { const panel = page.getByRole('complementary', { name: 'Selected image details', exact: true }); await page.goto(images); await expect(card(page, FIRST)).toHaveClass(/\bselected\b/); + // Capture the panel once it shows the first card's details, not while it is still reading them. + await expect(panel.getByRole('heading', { level: 2 })).toBeVisible(); + await expect(panel).not.toContainText('Loading file details'); const before = await panel.innerText(); await openTag(page, SECOND, { goto: false }); await page.keyboard.press('Escape'); diff --git a/tests/browser/workspace_activity_fixture.py b/tests/browser/workspace_activity_fixture.py index 77f102a..41dba88 100644 --- a/tests/browser/workspace_activity_fixture.py +++ b/tests/browser/workspace_activity_fixture.py @@ -4,7 +4,9 @@ A fake job is a real registry job whose runner waits for commands instead of reading files, so a test sets its progress and counters exactly: - ``POST /__test__/jobs/start`` starts one (``kind``, ``title``, ``total``, - ``output_folders``) and returns its summary, or 409 while one runs; + ``output_folders``; for ``kind: "image-tags"`` the image tagging job's + groups and its ``{mode, images}`` scope instead) and returns its summary, + or 409 while one runs; - ``POST /__test__/jobs/{id}/step`` records ``records`` (``{group, path, root, root_index, detail}`` file outcomes, counted, listed and logged as the scan records them) and ``bulk`` (``{group, count, root}``: ``count`` numbered @@ -12,6 +14,9 @@ reading files, so a test sets its progress and counters exactly: the progress to every subscriber; - ``POST /__test__/jobs/{id}/finish`` ends it as ``finished``, ``cancelled`` or ``failed``; a cancel through the real route ends it ``cancelled``; +- ``POST /__test__/jobs/hold`` opens a legacy-page hold (``kind``, ``title``): + a running, non-cancellable ``records`` job, as a legacy restore or + maintenance request takes, until ``finish`` ends it; - ``POST /__test__/jobs/restart`` acts out a ComfyUI restart: the open job streams drop without a final event and the registry starts empty. """ @@ -28,6 +33,8 @@ POLL_SECONDS = 0.02 PREFIXES = {"added": "+", "known": "=", "review": "?", "errors": "!"} PREFIXES["no_metadata"] = "-" +# The image tagging job's groups (#249 PR D), logged with the runner's prefixes. +PREFIXES.update({"tagged": "+", "unchanged": "=", "skipped": "-"}) ROOT_INDEXES = {"output": 0, "input": 1} @@ -72,10 +79,17 @@ def outcome_records(data): def register_activity_commands(app, routes, api): - from py.api.jobs import SCAN_GROUP_NAMES, JobBusy, JobRegistry + from py.api.jobs import ( + IMAGE_TAG_GROUP_NAMES, + IMAGE_TAG_JOB_KIND, + SCAN_GROUP_NAMES, + JobBusy, + JobRegistry, + ) streams = set() endings = {} + holders = set() @web.middleware async def track_streams(request, handler): @@ -123,17 +137,29 @@ def register_activity_commands(app, routes, api): async def start_job(request): data = await body(request) ending = asyncio.get_running_loop().create_future() + kind = str(data.get("kind", "scan")) + if kind == IMAGE_TAG_JOB_KIND: + title = "Add tags to images" + scope = { + "mode": str(data.get("mode", "add")), + "images": count(data.get("images"), 0), + } + groups = IMAGE_TAG_GROUP_NAMES + else: + title = "Scan for metadata" + scope = { + "files": str(data.get("files", "new")), + "output_folders": count(data.get("output_folders"), 2), + } + groups = SCAN_GROUP_NAMES try: job = api._jobs.start( - str(data.get("kind", "scan")), - str(data.get("title", "Scan for metadata")), + kind, + str(data.get("title", title)), record_changing=True, - scope={ - "files": str(data.get("files", "new")), - "output_folders": count(data.get("output_folders"), 2), - }, + scope=scope, runner=fake_runner(ending), - groups=SCAN_GROUP_NAMES, + groups=groups, ) except JobBusy: raise web.HTTPConflict() @@ -141,6 +167,36 @@ def register_activity_commands(app, routes, api): endings[job.id] = ending return web.json_response({"success": True, "job": job.summary()}) + @routes.post("/__test__/jobs/hold") + async def hold_job(request): + data = await body(request) + loop = asyncio.get_running_loop() + ending = loop.create_future() + entered = loop.create_future() + + async def hold(): + async with api._jobs.hold( + str(data.get("kind", "restore")), + str(data.get("title", "Database restore (admin page)")), + admission="records", + ) as job: + entered.set_result(job) + state = await ending + if state != "finished": + job.finish(state) + + holder = asyncio.ensure_future(hold()) + holders.add(holder) + holder.add_done_callback(holders.discard) + await asyncio.wait({entered, holder}, return_when=asyncio.FIRST_COMPLETED) + if not entered.done(): + if isinstance(holder.exception(), JobBusy): + raise web.HTTPConflict() + raise holder.exception() + job = entered.result() + endings[job.id] = ending + return web.json_response({"success": True, "job": job.summary()}) + @routes.post("/__test__/jobs/{job_id}/step") async def step_job(request): job = job_or_404(request) @@ -188,6 +244,7 @@ def register_activity_commands(app, routes, api): for transport in list(streams): transport.close() tasks = [job.task for job in old.list() if job.task and not job.task.done()] + tasks += [holder for holder in holders if not holder.done()] for task in tasks: task.cancel() await asyncio.gather(*tasks, return_exceptions=True) diff --git a/tests/browser/workspace_concept_fixture.py b/tests/browser/workspace_concept_fixture.py index 1b1b2fc..1cf0192 100644 --- a/tests/browser/workspace_concept_fixture.py +++ b/tests/browser/workspace_concept_fixture.py @@ -11,6 +11,7 @@ the same sidebar counts (24 images, 6 prompts, 15 tags). Nothing is mocked. from __future__ import annotations from dataclasses import dataclass +import datetime import json import math import os @@ -157,6 +158,8 @@ CONCEPT_MODEL = "illustrative-model.safetensors" MAX_EXTRA_IMAGES = 120 # Unlinked, seedless images (D11 "W × H · PNG") newer than the concept's, for D3 checks. MAX_SEEDLESS_IMAGES = 2 +# A test's chosen "now" (epoch seconds) stays before the year 3000. +MAX_EPOCH_SECONDS = 32_503_680_000 SEEDLESS_SIZE = (1536, 1152) # The same 4:3 shape with a short "96 × 72 · PNG" subtitle that stays on one line at # 390px beside the card's Info and Tag (the D3 footer check). @@ -344,6 +347,17 @@ def _bounded(name: str, value: int, maximum: int) -> None: raise ValueError(f"{name} is out of range") +def _instant(now: float | None) -> float: + """The fixture's one "now": ``now`` itself, or the wall clock when omitted.""" + if now is None: + return time.time() + if isinstance(now, bool) or not isinstance(now, (int, float)): + raise TypeError("now must be a number of epoch seconds") + if not 0 < now < MAX_EPOCH_SECONDS: + raise ValueError("now is out of range") + return float(now) + + def seed_workspace_concept( api: PromptManagerAPI, root: Path, @@ -351,9 +365,16 @@ def seed_workspace_concept( extra_images: int = 0, seedless_images: int = 0, short_seedless: bool = False, + now: float | None = None, ) -> dict: """Replace this server's disposable rows and files with the concept's data. + Every relative time the fixture writes (the prompts' last use, the files' + mtimes, the runs' observation times) is taken from one instant, ``now`` + (epoch seconds, the wall clock when omitted), returned as ``manifest["now"]``. + A browser whose clock is fixed at that instant reads the concept's "Used + today" and caption day however close to midnight the run starts. + ``extra_images`` continues the concept's rounds (same prompts, dimensions and seed rule) after its 24 images, for bounded-rendering and Load more checks. ``seedless_images`` adds unlinked PNGs without metadata, newest of all; @@ -363,6 +384,10 @@ def seed_workspace_concept( raise TypeError("short_seedless must be a boolean") _bounded("extra_images", extra_images, MAX_EXTRA_IMAGES) _bounded("seedless_images", seedless_images, MAX_SEEDLESS_IMAGES) + now = _instant(now) + used_at = datetime.datetime.fromtimestamp(now, datetime.timezone.utc).isoformat( + timespec="microseconds" + ) _clear(api, root) db = api.db epoch = db.get_execution_epoch() @@ -387,8 +412,15 @@ def seed_workspace_concept( db.update_prompt_pair_metadata(pair_ids[0], title=first_name) for name, text in prompt.negatives[1:]: pair_ids.append(db.save_prompt_pair(prompt.positive, text, title=name)) - # Real queue-hook counting (D23): four runs, the last one now. + # Real queue-hook counting (D23): four runs, the last one now, at the + # fixture's own instant rather than the database's later wall clock. db.record_prompt_use(positive_id, times=CONCEPT_RUN_COUNT) + with db.model.get_connection() as conn: + conn.execute( + "UPDATE prompts SET last_used_at = ? WHERE id = ?", + (used_at, positive_id), + ) + conn.commit() prompts.insert( 0, {"id": prompt.id, "positive_id": positive_id, "pair_ids": pair_ids} ) @@ -402,7 +434,6 @@ def seed_workspace_concept( favorite_root = _favorite_root_key(list(api._get_all_output_dirs())[0]) total = len(CONCEPT_PROMPTS) * CONCEPT_IMAGE_ROUNDS + extra_images rounds = math.ceil(total / len(CONCEPT_PROMPTS)) - now = time.time() images, runs = [], [] slots = [ (round_index, prompt_index, prompt) @@ -486,6 +517,8 @@ def seed_workspace_concept( api.invalidate_gallery_cache() return { "epoch": epoch, + # The fixture's "now" (epoch seconds), for the browser's fixed clock. + "now": now, # The newest file's mtime (epoch seconds), for the D17 caption date. "newest_modified": now - 10 if seedless_images else now - 60, "seedless": seedless, diff --git a/tests/fixtures/local-inspect/resolver-vectors.json b/tests/fixtures/local-inspect/resolver-vectors.json new file mode 100644 index 0000000..55e7a71 --- /dev/null +++ b/tests/fixtures/local-inspect/resolver-vectors.json @@ -0,0 +1 @@ +{"vectors": [{"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "blurry"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "a fox"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "encode", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Positive prompt is empty.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Positive prompt is empty.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Positive prompt is empty", "reason_code": null, "sampler_id": "3", "state": "unresolved"}}, "name": "empty-positive", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \" …\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "blurry"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": ""}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "feff-positive", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "a fox"}}, "resolution": {"node": null, "reason": "Expected a typed graph link or text input", "reason_code": "malformed", "sampler_id": "3", "state": "unresolved"}}, "name": "no-negative", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": null, \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}}"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "absent", "text": null}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "a fox"}}, "resolution": {"node": null, "reason": "Negative input is absent", "reason_code": null, "sampler_id": "3", "state": "absent"}}, "name": "negative-missing-input", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Expected a typed graph link or text input", "reason_code": "malformed", "sampler_id": "3", "state": "unresolved"}}, "name": "float-slot", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0.0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Expected a typed graph link or text input", "reason_code": "malformed", "sampler_id": "3", "state": "unresolved"}}, "name": "bool-slot", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", true], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "blurry"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "a fox"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "int-id", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [6, 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Missing or malformed graph node", "reason_code": "malformed", "sampler_id": "3", "state": "unresolved"}}, "name": "dangling", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"60\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt uses a node the inspector does not support (CLIPTextEncode).", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt uses a node the inspector does not support (CLIPTextEncode).", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": {"class_type": "CLIPTextEncode", "id": "6"}, "reason": "Unsupported node semantics or output slot", "reason_code": "unsupported-node", "sampler_id": "3", "state": "unresolved"}}, "name": "wrong-slot", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 3], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Cyclic prompt graph", "reason_code": "malformed", "sampler_id": "3", "state": "unresolved"}}, "name": "cycle", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"8\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"8\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Reroute input is not uniquely identifiable", "reason_code": "malformed", "sampler_id": "3", "state": "unresolved"}}, "name": "reroute-ambiguous", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"8\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"6\", 0], \"in\": [\"6\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "blurry"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "last wins"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "duplicate-key", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"last wins\", \"clip\": [\"4\", 1]}}}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "blurry"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "a fox"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "nan-input", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": NaN, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt uses a node the inspector does not support.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt uses a node the inspector does not support.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": {"class_type": null, "id": "6"}, "reason": "Unsupported node semantics or output slot", "reason_code": "unsupported-node", "sampler_id": "3", "state": "unresolved"}}, "name": "class-type-number", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": 1.5, \"inputs\": {\"text\": \"x\"}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt uses a node the inspector does not support.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt uses a node the inspector does not support.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": {"class_type": null, "id": "6"}, "reason": "Unsupported node semantics or output slot", "reason_code": "unsupported-node", "sampler_id": "3", "state": "unresolved"}}, "name": "class-type-missing", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"inputs\": {\"text\": \"x\"}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Missing or malformed graph node", "reason_code": "malformed", "sampler_id": "3", "state": "unresolved"}}, "name": "inputs-list", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": [\"x\"]}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "API graph is missing or malformed", "reason_code": "malformed", "sampler_id": null, "state": "unresolved"}}, "name": "prompt-list", "records": {"prompt": "[1, 2]"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "API graph is missing or malformed", "reason_code": "malformed", "sampler_id": null, "state": "unresolved"}}, "name": "prompt-empty", "records": {"prompt": "{}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Prompt evidence is malformed", "reason_code": "malformed", "sampler_id": null, "state": "unresolved"}}, "name": "prompt-bom", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Prompt evidence is malformed", "reason_code": "malformed", "sampler_id": null, "state": "unresolved"}}, "name": "huge-int", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 99999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Prompt evidence is malformed", "reason_code": "malformed", "sampler_id": null, "state": "unresolved"}}, "name": "only-execution", "records": {"comfyassets_prompt_pairs": "{\"version\": 1, \"nodes\": {\"8\": {\"records\": [{\"text\": \"fox\", \"negative_text\": \"blur\", \"positive_text\": \"fox executed\", \"negative_effective_text\": \"blur\", \"prepend_text\": \"\", \"append_text\": \"\"}]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "No supported sampler was found.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "No supported sampler was found.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "No supported sampler pair found (saw XSampler, YGuider)", "reason_code": "no-sampler", "sampler_id": null, "state": "unresolved"}}, "name": "no-sampler", "records": {"prompt": "{\"1\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"x\", \"clip\": [\"4\", 1]}}, \"2\": {\"class_type\": \"XSampler\", \"inputs\": {\"positive\": [\"1\", 0]}}, \"9\": {\"class_type\": \"YGuider\", \"inputs\": {\"guider\": [\"1\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "No supported sampler was found.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "No supported sampler was found.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "No supported sampler pair found", "reason_code": "no-sampler", "sampler_id": null, "state": "unresolved"}}, "name": "no-sampler-plain", "records": {"prompt": "{\"1\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"x\", \"clip\": [\"4\", 1]}}}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "blurry"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "a"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "sdxl-equal", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"8\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": \"a\", \"text_l\": \"a\"}}}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "blurry"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "a"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "sdxl-one", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"8\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": \"a\", \"text_l\": \"\"}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt combines several texts (CLIPTextEncodeSDXL).", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt combines several texts (CLIPTextEncodeSDXL).", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": {"class_type": "CLIPTextEncodeSDXL", "id": "8"}, "reason": "Encoder parts have different texts", "reason_code": "composite", "sampler_id": "3", "state": "unresolved"}}, "name": "sdxl-differ", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"8\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": \"a\", \"text_l\": \"b\"}}}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "blurry"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "t"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "smz-false", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"8\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"smZ CLIPTextEncode\", \"inputs\": {\"text\": \"t\", \"text_g\": \"g\", \"text_l\": \"g\", \"with_SDXL\": false}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt is chosen at run time (smZ CLIPTextEncode).", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt is chosen at run time (smZ CLIPTextEncode).", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": {"class_type": "smZ CLIPTextEncode", "id": "8"}, "reason": "Text is decided at run time", "reason_code": "runtime-value", "sampler_id": "3", "state": "unresolved"}}, "name": "smz-true", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"8\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"smZ CLIPTextEncode\", \"inputs\": {\"text\": \"t\", \"text_g\": \"g\", \"text_l\": \"g\", \"with_SDXL\": true}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Malformed string node setting", "reason_code": "malformed", "sampler_id": "3", "state": "unresolved"}}, "name": "smz-1", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"8\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"smZ CLIPTextEncode\", \"inputs\": {\"text\": \"t\", \"text_g\": \"g\", \"text_l\": \"g\", \"with_SDXL\": 1}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt uses a node the inspector does not support (smZ CLIPTextEncode).", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt uses a node the inspector does not support (smZ CLIPTextEncode).", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": {"class_type": "smZ CLIPTextEncode", "id": "8"}, "reason": "Linked string node setting", "reason_code": "unsupported-node", "sampler_id": "3", "state": "unresolved"}}, "name": "smz-[\"9\", 0]", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"8\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"smZ CLIPTextEncode\", \"inputs\": {\"text\": \"t\", \"text_g\": \"g\", \"text_l\": \"g\", \"with_SDXL\": [\"9\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": "zeroed-conditioning", "status": "resolved", "text": ""}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "a fox"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "zeroed", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"8\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"ConditioningZeroOut\", \"inputs\": {\"conditioning\": [\"6\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "blurry"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "a fox"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "passthrough", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"8\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"6\", 0], \"guidance\": 3.5}}}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "blurry"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "a fox"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "controlnet-slot1", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"8\", 0], \"negative\": [\"8\", 1], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"ControlNetApplyAdvanced\", \"inputs\": {\"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "blurry"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "a fox"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "combine-equal", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"8\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"9\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"6\", 0], \"conditioning_2\": [\"9\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt combines several texts (ConditioningCombine).", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt combines several texts (ConditioningCombine).", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": {"class_type": "ConditioningCombine", "id": "8"}, "reason": "Combined conditioning has different texts", "reason_code": "composite", "sampler_id": "3", "state": "unresolved"}}, "name": "combine-differ", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"8\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"6\", 0], \"conditioning_2\": [\"7\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "neg"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "a fox"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "select-true", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"8\", 0], \"negative\": [\"10\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": true, \"on_true\": [\"6\", 0], \"on_false\": [\"7\", 0]}}, \"10\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"neg\", \"clip\": [\"4\", 1]}}, \"9\": {\"class_type\": \"PrimitiveBoolean\", \"inputs\": {\"value\": true}}}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "neg"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "blurry"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "select-false", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"8\", 0], \"negative\": [\"10\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": false, \"on_true\": [\"6\", 0], \"on_false\": [\"7\", 0]}}, \"10\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"neg\", \"clip\": [\"4\", 1]}}, \"9\": {\"class_type\": \"PrimitiveBoolean\", \"inputs\": {\"value\": true}}}"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "ambiguous", "text": null}, "positive": {"source": null, "status": "ambiguous", "text": null}}, "resolution": {"node": null, "reason": "Selector branches have different prompts", "reason_code": null, "sampler_id": "3", "state": "ambiguous"}}, "name": "select-[\"9\", 0]", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"8\", 0], \"negative\": [\"10\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": [\"9\", 0], \"on_true\": [\"6\", 0], \"on_false\": [\"7\", 0]}}, \"10\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"neg\", \"clip\": [\"4\", 1]}}, \"9\": {\"class_type\": \"PrimitiveBoolean\", \"inputs\": {\"value\": true}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Malformed selector condition", "reason_code": "malformed", "sampler_id": "3", "state": "unresolved"}}, "name": "select-1", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"8\", 0], \"negative\": [\"10\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": 1, \"on_true\": [\"6\", 0], \"on_false\": [\"7\", 0]}}, \"10\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"neg\", \"clip\": [\"4\", 1]}}, \"9\": {\"class_type\": \"PrimitiveBoolean\", \"inputs\": {\"value\": true}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Malformed selector condition", "reason_code": "malformed", "sampler_id": "3", "state": "unresolved"}}, "name": "select-\"true\"", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"8\", 0], \"negative\": [\"10\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": \"true\", \"on_true\": [\"6\", 0], \"on_false\": [\"7\", 0]}}, \"10\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"neg\", \"clip\": [\"4\", 1]}}, \"9\": {\"class_type\": \"PrimitiveBoolean\", \"inputs\": {\"value\": true}}}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "blurry"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "a fox"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "select-converge", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"8\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": [\"9\", 0], \"on_true\": [\"6\", 0], \"on_false\": [\"11\", 0]}}, \"11\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}}"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "ambiguous", "text": null}, "positive": {"source": null, "status": "ambiguous", "text": null}}, "resolution": {"node": null, "reason": "Selector branches have different prompts", "reason_code": null, "sampler_id": "3", "state": "ambiguous"}}, "name": "select-shared", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"8\", 0], \"negative\": [\"12\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": [\"9\", 0], \"on_true\": [\"6\", 0], \"on_false\": [\"7\", 0]}}, \"12\": {\"class_type\": \"Switch conditioning [Crystools]\", \"inputs\": {\"boolean\": [\"9\", 0], \"on_true\": [\"7\", 0], \"on_false\": [\"6\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt is chosen at run time (PromptSearchList).", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt is chosen at run time (PromptSearchList).", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": {"class_type": "PromptSearchList", "id": "8"}, "reason": "Text is decided at run time", "reason_code": "runtime-value", "sampler_id": "3", "state": "unresolved"}}, "name": "runtime", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"8\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"PromptSearchList\", \"inputs\": {}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt uses a node the inspector does not support (FancyEncoderxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx).", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt uses a node the inspector does not support (FancyEncoderxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx).", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": {"class_type": "FancyEncoderxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "id": "8"}, "reason": "Unsupported node semantics or output slot", "reason_code": "unsupported-node", "sampler_id": "3", "state": "unresolved"}}, "name": "unknown-encoder", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"8\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"FancyEncoderxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\", \"inputs\": {\"text\": \"x\"}}}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "blurry"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": " multi\nline "}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "primitive", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": [\"8\", 0], \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"PrimitiveStringMultiline\", \"inputs\": {\"value\": \" multi\\nline \"}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Output slot has an incompatible type", "reason_code": "malformed", "sampler_id": "3", "state": "unresolved"}}, "name": "string-kind-mismatch", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"8\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"PrimitiveString\", \"inputs\": {\"value\": \"x\"}}}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "blurry"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": " a , "}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "string-concatenate", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": [\"8\", 0], \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"StringConcatenate\", \"inputs\": {\"string_a\": \" a \", \"string_b\": \"\", \"delimiter\": \", \"}}}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "blurry"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "a b"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "text-concatenate-\"true\"", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": [\"8\", 0], \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"Text Concatenate\", \"inputs\": {\"text_b\": \" b \", \"text_a\": \" a\", \"text_aa\": \"x\", \"text_c\": \"\", \"clean_whitespace\": \"true\"}}}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "blurry"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": " a b "}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "text-concatenate-\"False\"", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": [\"8\", 0], \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"Text Concatenate\", \"inputs\": {\"text_b\": \" b \", \"text_a\": \" a\", \"text_aa\": \"x\", \"text_c\": \"\", \"clean_whitespace\": \"False\"}}}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "blurry"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "a b"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "text-concatenate-true", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": [\"8\", 0], \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"Text Concatenate\", \"inputs\": {\"text_b\": \" b \", \"text_a\": \" a\", \"text_aa\": \"x\", \"text_c\": \"\", \"clean_whitespace\": true}}}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "blurry"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "a b"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "text-concatenate-\"TRUE\"", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": [\"8\", 0], \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"Text Concatenate\", \"inputs\": {\"text_b\": \" b \", \"text_a\": \" a\", \"text_aa\": \"x\", \"text_c\": \"\", \"clean_whitespace\": \"TRUE\"}}}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "blurry"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": " a b "}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "text-concatenate-0", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": [\"8\", 0], \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"Text Concatenate\", \"inputs\": {\"text_b\": \" b \", \"text_a\": \" a\", \"text_aa\": \"x\", \"text_c\": \"\", \"clean_whitespace\": 0}}}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "blurry"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "ab"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "strip-newlines-true", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": [\"8\", 0], \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"StringConstantMultiline\", \"inputs\": {\"string\": \"\\n a\\nb \\n\", \"strip_newlines\": true}}}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "blurry"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "\n a\nb \n"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "strip-newlines-false", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": [\"8\", 0], \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"StringConstantMultiline\", \"inputs\": {\"string\": \"\\n a\\nb \\n\", \"strip_newlines\": false}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Malformed string node setting", "reason_code": "malformed", "sampler_id": "3", "state": "unresolved"}}, "name": "strip-newlines-1", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": [\"8\", 0], \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"StringConstantMultiline\", \"inputs\": {\"string\": \"\\n a\\nb \\n\", \"strip_newlines\": 1}}}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "blurry"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": " | b"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "join-multi-3-false", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": [\"8\", 0], \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"JoinStringMulti\", \"inputs\": {\"string_1\": \"\", \"string_2\": \"b\", \"string_3\": \"\", \"string_4\": \"d\", \"inputcount\": 3, \"delimiter\": \" | \", \"return_list\": false}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Positive prompt is empty.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Positive prompt is empty.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Positive prompt is empty", "reason_code": null, "sampler_id": "3", "state": "unresolved"}}, "name": "join-multi-1-false", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": [\"8\", 0], \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"JoinStringMulti\", \"inputs\": {\"string_1\": \"\", \"string_2\": \"b\", \"string_3\": \"\", \"string_4\": \"d\", \"inputcount\": 1, \"delimiter\": \" | \", \"return_list\": false}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Malformed string node setting", "reason_code": "malformed", "sampler_id": "3", "state": "unresolved"}}, "name": "join-multi-0-false", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": [\"8\", 0], \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"JoinStringMulti\", \"inputs\": {\"string_1\": \"\", \"string_2\": \"b\", \"string_3\": \"\", \"string_4\": \"d\", \"inputcount\": 0, \"delimiter\": \" | \", \"return_list\": false}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Malformed string node setting", "reason_code": "malformed", "sampler_id": "3", "state": "unresolved"}}, "name": "join-multi-1001-false", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": [\"8\", 0], \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"JoinStringMulti\", \"inputs\": {\"string_1\": \"\", \"string_2\": \"b\", \"string_3\": \"\", \"string_4\": \"d\", \"inputcount\": 1001, \"delimiter\": \" | \", \"return_list\": false}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt is chosen at run time (JoinStringMulti).", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt is chosen at run time (JoinStringMulti).", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": {"class_type": "JoinStringMulti", "id": "8"}, "reason": "Text is decided at run time", "reason_code": "runtime-value", "sampler_id": "3", "state": "unresolved"}}, "name": "join-multi-2-true", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": [\"8\", 0], \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"JoinStringMulti\", \"inputs\": {\"string_1\": \"\", \"string_2\": \"b\", \"string_3\": \"\", \"string_4\": \"d\", \"inputcount\": 2, \"delimiter\": \" | \", \"return_list\": true}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Malformed string node setting", "reason_code": "malformed", "sampler_id": "3", "state": "unresolved"}}, "name": "join-multi-2.0-false", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": [\"8\", 0], \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"JoinStringMulti\", \"inputs\": {\"string_1\": \"\", \"string_2\": \"b\", \"string_3\": \"\", \"string_4\": \"d\", \"inputcount\": 2.0, \"delimiter\": \" | \", \"return_list\": false}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Malformed string node setting", "reason_code": "malformed", "sampler_id": "3", "state": "unresolved"}}, "name": "join-multi-true-false", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": [\"8\", 0], \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"JoinStringMulti\", \"inputs\": {\"string_1\": \"\", \"string_2\": \"b\", \"string_3\": \"\", \"string_4\": \"d\", \"inputcount\": true, \"delimiter\": \" | \", \"return_list\": false}}}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "blurry"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "before main"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "prompt-manager", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"8\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"PromptManager\", \"inputs\": {\"text\": \"main\", \"prepend_text\": \" before \", \"append_text\": \"\", \"clip\": [\"4\", 1]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Expected a typed graph link or text input", "reason_code": "malformed", "sampler_id": "3", "state": "unresolved"}}, "name": "prompt-manager-null-prepend", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"8\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"PromptManager\", \"inputs\": {\"text\": \"main\", \"prepend_text\": null, \"append_text\": \"\", \"clip\": [\"4\", 1]}}}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "blurry"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "lead,main"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "historical-PromptManagerV2-0", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"8\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"PromptManagerV2\", \"inputs\": {\"text\": \" main \", \"negative_text\": \" neg \", \"prepend_text\": \"lead,\", \"append_text\": null}}}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "blurry"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "neg"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "historical-PromptManagerV2-1", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"8\", 1], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"PromptManagerV2\", \"inputs\": {\"text\": \" main \", \"negative_text\": \" neg \", \"prepend_text\": \"lead,\", \"append_text\": null}}}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "blurry"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "lead,main"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "historical-PromptManagerPositive-0", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"8\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"PromptManagerPositive\", \"inputs\": {\"text\": \" main \", \"negative_text\": \" neg \", \"prepend_text\": \"lead,\", \"append_text\": null}}}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "blurry"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "main"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "historical-PromptManagerNegative-0", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"8\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"PromptManagerNegative\", \"inputs\": {\"text\": \" main \", \"negative_text\": \" neg \", \"prepend_text\": \"lead,\", \"append_text\": null}}}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "blur"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "fox"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "pair-graph", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"8\", 0], \"negative\": [\"8\", 1], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"fox\", \"negative_text\": \"blur\", \"prepend_text\": \"\", \"append_text\": \"\"}}}"}}, {"evidence": {"prompts": {"negative": {"source": "execution", "status": "resolved", "text": "blur"}, "positive": {"source": "execution", "status": "resolved", "text": "fox executed"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "pair-execution", "records": {"comfyassets_prompt_pairs": "{\"version\": 1, \"nodes\": {\"8\": {\"records\": [{\"text\": \"fox\", \"negative_text\": \"blur\", \"positive_text\": \"fox executed\", \"negative_effective_text\": \"blur\", \"prepend_text\": \"\", \"append_text\": \"\"}]}}}", "prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"8\", 0], \"negative\": [\"8\", 1], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"fox\", \"negative_text\": \"blur\", \"prepend_text\": \"\", \"append_text\": \"\"}}}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "blur"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "fox"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "pair-execution-other-node", "records": {"comfyassets_prompt_pairs": "{\"version\": 1, \"nodes\": {\"99\": {\"records\": [{\"text\": \"fox\", \"negative_text\": \"blur\", \"positive_text\": \"fox executed\", \"negative_effective_text\": \"blur\", \"prepend_text\": \"\", \"append_text\": \"\"}]}}}", "prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"8\", 0], \"negative\": [\"8\", 1], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"fox\", \"negative_text\": \"blur\", \"prepend_text\": \"\", \"append_text\": \"\"}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Execution evidence does not match recorded node inputs", "reason_code": "malformed", "sampler_id": "3", "state": "unresolved"}}, "name": "pair-execution-mismatch", "records": {"comfyassets_prompt_pairs": "{\"version\": 1, \"nodes\": {\"8\": {\"records\": [{\"text\": \"other\", \"negative_text\": \"blur\", \"positive_text\": \"fox executed\", \"negative_effective_text\": \"blur\", \"prepend_text\": \"\", \"append_text\": \"\"}]}}}", "prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"8\", 0], \"negative\": [\"8\", 1], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"fox\", \"negative_text\": \"blur\", \"prepend_text\": \"\", \"append_text\": \"\"}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Negative execution text must be unchanged", "reason_code": "malformed", "sampler_id": "3", "state": "unresolved"}}, "name": "pair-execution-negative-changed", "records": {"comfyassets_prompt_pairs": "{\"version\": 1, \"nodes\": {\"8\": {\"records\": [{\"text\": \"fox\", \"negative_text\": \"blur\", \"positive_text\": \"fox executed\", \"negative_effective_text\": \"x\", \"prepend_text\": \"\", \"append_text\": \"\"}]}}}", "prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"8\", 0], \"negative\": [\"8\", 1], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"fox\", \"negative_text\": \"blur\", \"prepend_text\": \"\", \"append_text\": \"\"}}}"}}, {"evidence": {"prompts": {"negative": {"source": "execution", "status": "resolved", "text": "blur"}, "positive": {"source": "execution", "status": "resolved", "text": "fox executed"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "pair-execution-version-true", "records": {"comfyassets_prompt_pairs": "{\"version\": true, \"nodes\": {\"8\": {\"records\": [{\"text\": \"fox\", \"negative_text\": \"blur\", \"positive_text\": \"fox executed\", \"negative_effective_text\": \"blur\", \"prepend_text\": \"\", \"append_text\": \"\"}]}}}", "prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"8\", 0], \"negative\": [\"8\", 1], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"fox\", \"negative_text\": \"blur\", \"prepend_text\": \"\", \"append_text\": \"\"}}}"}}, {"evidence": {"prompts": {"negative": {"source": "execution", "status": "resolved", "text": "blur"}, "positive": {"source": "execution", "status": "resolved", "text": "fox executed"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "pair-execution-version-float", "records": {"comfyassets_prompt_pairs": "{\"version\": 1.0, \"nodes\": {\"8\": {\"records\": [{\"text\": \"fox\", \"negative_text\": \"blur\", \"positive_text\": \"fox executed\", \"negative_effective_text\": \"blur\", \"prepend_text\": \"\", \"append_text\": \"\"}]}}}", "prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"8\", 0], \"negative\": [\"8\", 1], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"fox\", \"negative_text\": \"blur\", \"prepend_text\": \"\", \"append_text\": \"\"}}}"}}, {"evidence": {"prompts": {"negative": {"source": "execution", "status": "resolved", "text": "blur"}, "positive": {"source": "execution", "status": "resolved", "text": "fox executed"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "pair-execution-truncated", "records": {"comfyassets_prompt_pairs": "{\"version\": 1, \"truncated\": [], \"nodes\": {\"8\": {\"records\": [{\"text\": \"fox\", \"negative_text\": \"blur\", \"positive_text\": \"fox executed\", \"negative_effective_text\": \"blur\", \"prepend_text\": \"\", \"append_text\": \"\"}]}}}", "prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"8\", 0], \"negative\": [\"8\", 1], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"fox\", \"negative_text\": \"blur\", \"prepend_text\": \"\", \"append_text\": \"\"}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Malformed execution evidence JSON", "reason_code": "malformed", "sampler_id": "3", "state": "unresolved"}}, "name": "pair-execution-bad-json", "records": {"comfyassets_prompt_pairs": "{nope", "prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"8\", 0], \"negative\": [\"8\", 1], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"fox\", \"negative_text\": \"blur\", \"prepend_text\": \"\", \"append_text\": \"\"}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Multiple distinct executions have no image anchor", "reason_code": "malformed", "sampler_id": "3", "state": "unresolved"}}, "name": "pair-execution-two-records", "records": {"comfyassets_prompt_pairs": "{\"version\": 1, \"nodes\": {\"8\": {\"records\": [{\"text\": \"fox\", \"negative_text\": \"blur\", \"positive_text\": \"fox executed\", \"negative_effective_text\": \"blur\", \"prepend_text\": \"\", \"append_text\": \"\"}, {\"text\": \"fox\", \"negative_text\": \"blur\", \"positive_text\": \"other\", \"negative_effective_text\": \"blur\", \"prepend_text\": \"\", \"append_text\": \"\"}]}}}", "prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"8\", 0], \"negative\": [\"8\", 1], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"fox\", \"negative_text\": \"blur\", \"prepend_text\": \"\", \"append_text\": \"\"}}}"}}, {"evidence": {"prompts": {"negative": {"source": "execution", "status": "resolved", "text": "blur"}, "positive": {"source": "execution", "status": "resolved", "text": "fox executed"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "pair-execution-runtime-input", "records": {"comfyassets_prompt_pairs": "{\"version\": 1, \"nodes\": {\"8\": {\"records\": [{\"text\": \"fox\", \"negative_text\": \"blur\", \"positive_text\": \"fox executed\", \"negative_effective_text\": \"blur\", \"prepend_text\": \"\", \"append_text\": \"\"}]}}}", "prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"8\", 0], \"negative\": [\"8\", 1], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": [\"9\", 0], \"negative_text\": \"blur\", \"prepend_text\": \"\", \"append_text\": \"\"}}, \"9\": {\"class_type\": \"PromptSearchList\", \"inputs\": {}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt uses a node the inspector does not support (PromptManagerText).", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt uses a node the inspector does not support (PromptManagerText).", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": {"class_type": "PromptManagerText", "id": "9"}, "reason": "Unsupported node semantics or output slot", "reason_code": "unsupported-node", "sampler_id": "3", "state": "unresolved"}}, "name": "pair-execution-runtime-wrong-slot", "records": {"comfyassets_prompt_pairs": "{\"version\": 1, \"nodes\": {\"8\": {\"records\": [{\"text\": \"fox\", \"negative_text\": \"blur\", \"positive_text\": \"fox executed\", \"negative_effective_text\": \"blur\", \"prepend_text\": \"\", \"append_text\": \"\"}]}}}", "prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"8\", 0], \"negative\": [\"8\", 1], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": [\"9\", 2], \"negative_text\": \"blur\", \"prepend_text\": \"\", \"append_text\": \"\"}}, \"9\": {\"class_type\": \"PromptManagerText\", \"inputs\": {\"text\": \"x\"}}}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "blurry"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "a fox"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "custom-cfg", "records": {"prompt": "{\"3\": {\"class_type\": \"SamplerCustomAdvanced\", \"inputs\": {\"noise\": [\"20\", 0], \"guider\": [\"21\", 0], \"sampler\": [\"22\", 0], \"sigmas\": [\"23\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"21\": {\"class_type\": \"CFGGuider\", \"inputs\": {\"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"model\": [\"4\", 0], \"cfg\": 5}}}"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "absent", "text": null}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "a fox"}}, "resolution": {"node": null, "reason": "Guider has no negative input", "reason_code": null, "sampler_id": "3", "state": "absent"}}, "name": "custom-basic", "records": {"prompt": "{\"3\": {\"class_type\": \"SamplerCustomAdvanced\", \"inputs\": {\"noise\": [\"20\", 0], \"guider\": [\"21\", 0], \"sampler\": [\"22\", 0], \"sigmas\": [\"23\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"21\": {\"class_type\": \"BasicGuider\", \"inputs\": {\"conditioning\": [\"6\", 0], \"model\": [\"4\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "absent", "text": null}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "a fox"}}, "resolution": {"node": null, "reason": "Guider has no negative input", "reason_code": null, "sampler_id": "3", "state": "absent"}}, "name": "custom-reroute-guider", "records": {"prompt": "{\"3\": {\"class_type\": \"SamplerCustomAdvanced\", \"inputs\": {\"noise\": [\"20\", 0], \"guider\": [\"21\", 0], \"sampler\": [\"22\", 0], \"sigmas\": [\"23\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"21\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"24\", 0]}}, \"24\": {\"class_type\": \"BasicGuider\", \"inputs\": {\"conditioning\": [\"6\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt uses a node the inspector does not support (DualCFGGuider).", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt uses a node the inspector does not support (DualCFGGuider).", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": {"class_type": "DualCFGGuider", "id": "21"}, "reason": "Unsupported guider", "reason_code": "unsupported-node", "sampler_id": "3", "state": "unresolved"}}, "name": "custom-unknown-guider", "records": {"prompt": "{\"3\": {\"class_type\": \"SamplerCustomAdvanced\", \"inputs\": {\"noise\": [\"20\", 0], \"guider\": [\"21\", 0], \"sampler\": [\"22\", 0], \"sigmas\": [\"23\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"21\": {\"class_type\": \"DualCFGGuider\", \"inputs\": {\"cond1\": [\"6\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt uses a node the inspector does not support (KSamplerPreview).", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt uses a node the inspector does not support (KSamplerPreview).", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": {"class_type": "KSamplerPreview", "id": "30"}, "reason": "Unsupported latent ancestry", "reason_code": "unsupported-node", "sampler_id": "3", "state": "unresolved"}}, "name": "latent-stage", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"LatentUpscale\", \"inputs\": {\"samples\": [\"30\", 0]}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"30\": {\"class_type\": \"KSamplerPreview\", \"inputs\": {\"model\": [\"4\", 0], \"positive\": [\"6\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "blurry"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "a fox"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "latent-encode", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"VAEEncode\", \"inputs\": {\"pixels\": [\"31\", 0], \"vae\": [\"4\", 2]}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"31\": {\"class_type\": \"LoadImage\", \"inputs\": {\"image\": \"x.png\"}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Cyclic latent ancestry", "reason_code": "malformed", "sampler_id": "3", "state": "unresolved"}}, "name": "latent-cycle", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"LatentUpscale\", \"inputs\": {\"samples\": [\"3\", 0]}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "blurry"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "a fox"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "override-ignored-\"\"", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0], \"wildcard\": \"\"}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "blurry"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "a fox"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "override-ignored-\"wild\"", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0], \"wildcard\": \"wild\"}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "blurry"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "a fox"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "override-ignored-[\"6\", 0]", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0], \"wildcard\": [\"6\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt exceeds inspection limits.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt exceeds inspection limits.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Resolution depth limit reached", "reason_code": "depth-limit", "sampler_id": "3", "state": "unresolved"}}, "name": "depth-limit", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"r69\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"deep\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"r0\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"6\", 0]}}, \"r1\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r0\", 0]}}, \"r2\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r1\", 0]}}, \"r3\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r2\", 0]}}, \"r4\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r3\", 0]}}, \"r5\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r4\", 0]}}, \"r6\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r5\", 0]}}, \"r7\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r6\", 0]}}, \"r8\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r7\", 0]}}, \"r9\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r8\", 0]}}, \"r10\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r9\", 0]}}, \"r11\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r10\", 0]}}, \"r12\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r11\", 0]}}, \"r13\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r12\", 0]}}, \"r14\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r13\", 0]}}, \"r15\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r14\", 0]}}, \"r16\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r15\", 0]}}, \"r17\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r16\", 0]}}, \"r18\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r17\", 0]}}, \"r19\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r18\", 0]}}, \"r20\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r19\", 0]}}, \"r21\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r20\", 0]}}, \"r22\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r21\", 0]}}, \"r23\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r22\", 0]}}, \"r24\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r23\", 0]}}, \"r25\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r24\", 0]}}, \"r26\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r25\", 0]}}, \"r27\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r26\", 0]}}, \"r28\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r27\", 0]}}, \"r29\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r28\", 0]}}, \"r30\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r29\", 0]}}, \"r31\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r30\", 0]}}, \"r32\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r31\", 0]}}, \"r33\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r32\", 0]}}, \"r34\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r33\", 0]}}, \"r35\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r34\", 0]}}, \"r36\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r35\", 0]}}, \"r37\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r36\", 0]}}, \"r38\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r37\", 0]}}, \"r39\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r38\", 0]}}, \"r40\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r39\", 0]}}, \"r41\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r40\", 0]}}, \"r42\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r41\", 0]}}, \"r43\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r42\", 0]}}, \"r44\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r43\", 0]}}, \"r45\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r44\", 0]}}, \"r46\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r45\", 0]}}, \"r47\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r46\", 0]}}, \"r48\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r47\", 0]}}, \"r49\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r48\", 0]}}, \"r50\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r49\", 0]}}, \"r51\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r50\", 0]}}, \"r52\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r51\", 0]}}, \"r53\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r52\", 0]}}, \"r54\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r53\", 0]}}, \"r55\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r54\", 0]}}, \"r56\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r55\", 0]}}, \"r57\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r56\", 0]}}, \"r58\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r57\", 0]}}, \"r59\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r58\", 0]}}, \"r60\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r59\", 0]}}, \"r61\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r60\", 0]}}, \"r62\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r61\", 0]}}, \"r63\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r62\", 0]}}, \"r64\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r63\", 0]}}, \"r65\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r64\", 0]}}, \"r66\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r65\", 0]}}, \"r67\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r66\", 0]}}, \"r68\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r67\", 0]}}, \"r69\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r68\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "blurry"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "deep"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "depth-near", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"r60\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"deep\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"r0\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"6\", 0]}}, \"r1\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r0\", 0]}}, \"r2\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r1\", 0]}}, \"r3\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r2\", 0]}}, \"r4\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r3\", 0]}}, \"r5\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r4\", 0]}}, \"r6\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r5\", 0]}}, \"r7\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r6\", 0]}}, \"r8\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r7\", 0]}}, \"r9\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r8\", 0]}}, \"r10\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r9\", 0]}}, \"r11\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r10\", 0]}}, \"r12\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r11\", 0]}}, \"r13\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r12\", 0]}}, \"r14\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r13\", 0]}}, \"r15\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r14\", 0]}}, \"r16\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r15\", 0]}}, \"r17\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r16\", 0]}}, \"r18\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r17\", 0]}}, \"r19\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r18\", 0]}}, \"r20\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r19\", 0]}}, \"r21\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r20\", 0]}}, \"r22\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r21\", 0]}}, \"r23\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r22\", 0]}}, \"r24\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r23\", 0]}}, \"r25\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r24\", 0]}}, \"r26\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r25\", 0]}}, \"r27\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r26\", 0]}}, \"r28\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r27\", 0]}}, \"r29\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r28\", 0]}}, \"r30\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r29\", 0]}}, \"r31\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r30\", 0]}}, \"r32\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r31\", 0]}}, \"r33\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r32\", 0]}}, \"r34\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r33\", 0]}}, \"r35\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r34\", 0]}}, \"r36\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r35\", 0]}}, \"r37\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r36\", 0]}}, \"r38\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r37\", 0]}}, \"r39\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r38\", 0]}}, \"r40\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r39\", 0]}}, \"r41\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r40\", 0]}}, \"r42\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r41\", 0]}}, \"r43\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r42\", 0]}}, \"r44\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r43\", 0]}}, \"r45\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r44\", 0]}}, \"r46\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r45\", 0]}}, \"r47\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r46\", 0]}}, \"r48\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r47\", 0]}}, \"r49\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r48\", 0]}}, \"r50\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r49\", 0]}}, \"r51\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r50\", 0]}}, \"r52\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r51\", 0]}}, \"r53\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r52\", 0]}}, \"r54\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r53\", 0]}}, \"r55\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r54\", 0]}}, \"r56\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r55\", 0]}}, \"r57\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r56\", 0]}}, \"r58\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r57\", 0]}}, \"r59\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r58\", 0]}}, \"r60\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"r59\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Saved PromptManager text was overwritten by another node", "reason_code": "malformed", "sampler_id": "3", "state": "unresolved"}}, "name": "overwritten", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"shared\", \"clip\": [\"4\", 1], \"search_text\": \"\"}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"shared\", \"clip\": [\"4\", 1]}}}", "workflow": "{\"nodes\": [{\"id\": 6, \"type\": \"PromptManager\", \"widgets_values\": [\"own text\"]}, {\"id\": 7, \"type\": \"PromptManager\", \"widgets_values\": [\"other text\"]}]}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Saved PromptManager text was overwritten by another node", "reason_code": "malformed", "sampler_id": "3", "state": "unresolved"}}, "name": "overwritten-subgraph", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"1:6\", 0], \"negative\": [\"1:7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"1:6\": {\"class_type\": \"PromptManager\", \"inputs\": {\"text\": \"same\", \"clip\": [\"4\", 1]}}, \"1:7\": {\"class_type\": \"PromptManager\", \"inputs\": {\"text\": \"same\"}}}", "workflow": "{\"nodes\": [{\"id\": 1, \"type\": \"sub-a\", \"widgets_values\": []}], \"definitions\": {\"subgraphs\": [{\"id\": \"sub-a\", \"nodes\": [{\"id\": 6, \"type\": \"PromptManagerFocused\", \"widgets_values\": {\"text\": \"a\"}}, {\"id\": 7, \"type\": \"PromptManagerFocused\", \"widgets_values\": [\"b\"]}]}]}}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "shared"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "shared"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "overwritten-bad-workflow", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"shared\", \"clip\": [\"4\", 1], \"search_text\": \"\"}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"shared\", \"clip\": [\"4\", 1]}}}", "workflow": "{bad"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "shared"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "shared"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "workflow-type-list", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"shared\", \"clip\": [\"4\", 1], \"search_text\": \"\"}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"shared\", \"clip\": [\"4\", 1]}}}", "workflow": "{\"nodes\": [{\"id\": 6, \"type\": \"PromptManager\"}, {\"id\": 8, \"type\": [\"PromptManager\"]}]}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "shared"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "shared"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "workflow-type-dict", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"shared\", \"clip\": [\"4\", 1], \"search_text\": \"\"}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"shared\", \"clip\": [\"4\", 1]}}}", "workflow": "{\"nodes\": [{\"id\": 6, \"type\": \"PromptManager\"}, {\"id\": 8, \"type\": {\"a\": 1}}]}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "same"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "same"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "workflow-type-in-subgraph", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"1:6\", 0], \"negative\": [\"1:7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"1:6\": {\"class_type\": \"PromptManager\", \"inputs\": {\"text\": \"same\", \"clip\": [\"4\", 1]}}, \"1:7\": {\"class_type\": \"PromptManager\", \"inputs\": {\"text\": \"same\"}}}", "workflow": "{\"nodes\": [{\"id\": 1, \"type\": \"sub-a\", \"widgets_values\": []}], \"definitions\": {\"subgraphs\": [{\"id\": \"sub-a\", \"nodes\": [{\"id\": 2, \"type\": [1]}]}]}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Saved PromptManager text was overwritten by another node", "reason_code": "malformed", "sampler_id": "3", "state": "unresolved"}}, "name": "workflow-type-skipped-keeps-overwritten", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"shared\", \"clip\": [\"4\", 1], \"search_text\": \"\"}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"shared\", \"clip\": [\"4\", 1]}}}", "workflow": "{\"nodes\": [{\"id\": 6, \"type\": \"PromptManager\", \"widgets_values\": [\"own text\"]}, {\"id\": 8, \"type\": [\"PromptManager\"]}, {\"id\": 7, \"type\": \"PromptManager\", \"widgets_values\": [\"other text\"]}]}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "shared"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "shared"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "workflow-type-without-id", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"shared\", \"clip\": [\"4\", 1], \"search_text\": \"\"}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"shared\", \"clip\": [\"4\", 1]}}}", "workflow": "{\"nodes\": [{\"id\": true, \"type\": [\"x\"]}, \"skipped\"]}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "blurry"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "a fox"}}, "resolution": {"node": null, "reason": "Every sampler branch uses the same prompt pair", "reason_code": null, "sampler_id": null, "state": "resolved"}}, "name": "several-samplers", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"9\": {\"class_type\": \"KSampler\", \"inputs\": {\"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "blurry"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "a fox"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "detailer", "records": {"prompt": "{\"3\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"5\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"wildcard\": \"\"}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}}"}}, {"evidence": {"prompts": {"negative": {"source": "embedded-parameters", "status": "resolved", "text": "blur"}, "positive": {"source": "embedded-parameters", "status": "resolved", "text": "a fox"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": null, "state": "resolved"}}, "name": "parameters-only", "records": {"parameters": "a fox\nNegative prompt: blur\nSteps: 20, Sampler: euler"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "blurry"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "a fox"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "prompt-and-parameters", "records": {"parameters": "ignored", "prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "blurry"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "a fox"}}, "resolution": {"node": null, "reason": "Every sampler branch uses the same prompt pair", "reason_code": null, "sampler_id": null, "state": "resolved"}}, "name": "select-two-identical", "records": {"prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a cat\", \"clip\": [\"4\", 1]}}, \"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"9\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "ambiguous", "text": null}, "positive": {"source": null, "status": "ambiguous", "text": null}}, "resolution": {"node": null, "reason": "Multiple sampler branches", "reason_code": null, "sampler_id": null, "state": "ambiguous"}}, "name": "select-two-differ", "records": {"prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a cat\", \"clip\": [\"4\", 1]}}, \"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"9\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"8\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "ambiguous", "text": null}, "positive": {"source": null, "status": "ambiguous", "text": null}}, "resolution": {"node": null, "reason": "Multiple sampler branches", "reason_code": null, "sampler_id": null, "state": "ambiguous"}}, "name": "select-two-negative-differs", "records": {"prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a cat\", \"clip\": [\"4\", 1]}}, \"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"9\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"8\", 0], \"latent_image\": [\"5\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "ambiguous", "text": null}, "positive": {"source": null, "status": "ambiguous", "text": null}}, "resolution": {"node": null, "reason": "Multiple sampler branches", "reason_code": null, "sampler_id": null, "state": "ambiguous"}}, "name": "select-identical-without-negative", "records": {"prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a cat\", \"clip\": [\"4\", 1]}}, \"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"latent_image\": [\"5\", 0]}}, \"9\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"latent_image\": [\"5\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "ambiguous", "text": null}, "positive": {"source": null, "status": "ambiguous", "text": null}}, "resolution": {"node": null, "reason": "Multiple sampler branches", "reason_code": null, "sampler_id": null, "state": "ambiguous"}}, "name": "select-one-unresolved", "records": {"prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a cat\", \"clip\": [\"4\", 1]}}, \"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"9\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"missing\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "ambiguous", "text": null}, "positive": {"source": null, "status": "ambiguous", "text": null}}, "resolution": {"node": null, "reason": "Multiple sampler branches", "reason_code": null, "sampler_id": null, "state": "ambiguous"}}, "name": "select-both-unresolved", "records": {"prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a cat\", \"clip\": [\"4\", 1]}}, \"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"x\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"9\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"y\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "blurry"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "a fox"}}, "resolution": {"node": null, "reason": "Every sampler branch uses the same prompt pair", "reason_code": null, "sampler_id": null, "state": "resolved"}}, "name": "select-three-identical-one-advanced", "records": {"prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a cat\", \"clip\": [\"4\", 1]}}, \"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"9\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"10\": {\"class_type\": \"KSamplerAdvanced\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "ambiguous", "text": null}, "positive": {"source": null, "status": "ambiguous", "text": null}}, "resolution": {"node": null, "reason": "Multiple sampler branches", "reason_code": null, "sampler_id": null, "state": "ambiguous"}}, "name": "select-same-text-other-recipe", "records": {"prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a cat\", \"clip\": [\"4\", 1]}}, \"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"9\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"11\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"11\": {\"class_type\": \"PromptManager\", \"inputs\": {\"text\": \"fox\", \"prepend_text\": \"a\", \"append_text\": \"\", \"clip\": [\"4\", 1]}}}"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "ambiguous", "text": null}, "positive": {"source": null, "status": "ambiguous", "text": null}}, "resolution": {"node": null, "reason": "Multiple sampler branches", "reason_code": null, "sampler_id": null, "state": "ambiguous"}}, "name": "select-multistage-latent", "records": {"prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a cat\", \"clip\": [\"4\", 1]}}, \"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"9\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"3\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "ambiguous", "text": null}, "positive": {"source": null, "status": "ambiguous", "text": null}}, "resolution": {"node": null, "reason": "Multiple sampler branches", "reason_code": null, "sampler_id": null, "state": "ambiguous"}}, "name": "select-selector-and-plain", "records": {"prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a cat\", \"clip\": [\"4\", 1]}}, \"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"9\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"12\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"12\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": [\"4\", 0], \"on_true\": [\"6\", 0], \"on_false\": [\"8\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "blurry"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "a fox"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "select-preview-dropped", "records": {"prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a cat\", \"clip\": [\"4\", 1]}}, \"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"9\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"8\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"13\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"3\", 0]}}, \"14\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"9\", 0]}}, \"15\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"13\", 0]}}, \"16\": {\"class_type\": \"PreviewImage\", \"inputs\": {\"images\": [\"14\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "ambiguous", "text": null}, "positive": {"source": null, "status": "ambiguous", "text": null}}, "resolution": {"node": null, "reason": "Multiple sampler branches", "reason_code": null, "sampler_id": null, "state": "ambiguous"}}, "name": "select-all-previews-kept", "records": {"prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a cat\", \"clip\": [\"4\", 1]}}, \"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"9\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"8\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"13\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"3\", 0]}}, \"14\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"9\", 0]}}, \"15\": {\"class_type\": \"PreviewImage\", \"inputs\": {\"images\": [\"13\", 0]}}, \"16\": {\"class_type\": \"Image Comparer (rgthree)\", \"inputs\": {\"images\": [\"14\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "ambiguous", "text": null}, "positive": {"source": null, "status": "ambiguous", "text": null}}, "resolution": {"node": null, "reason": "Multiple sampler branches", "reason_code": null, "sampler_id": null, "state": "ambiguous"}}, "name": "select-preview-and-file", "records": {"prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a cat\", \"clip\": [\"4\", 1]}}, \"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"9\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"8\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"13\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"3\", 0]}}, \"14\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"9\", 0]}}, \"15\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"13\", 0]}}, \"16\": {\"class_type\": \"PreviewImage\", \"inputs\": {\"images\": [\"14\", 0]}}, \"17\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"14\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "blurry"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "a fox"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "select-unconsumed-stage-kept", "records": {"prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a cat\", \"clip\": [\"4\", 1]}}, \"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"9\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"8\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"14\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"9\", 0]}}, \"16\": {\"class_type\": \"PreviewImage\", \"inputs\": {\"images\": [\"14\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "ambiguous", "text": null}, "positive": {"source": null, "status": "ambiguous", "text": null}}, "resolution": {"node": null, "reason": "Multiple sampler branches", "reason_code": null, "sampler_id": null, "state": "ambiguous"}}, "name": "select-preview-unhashable-class", "records": {"prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a cat\", \"clip\": [\"4\", 1]}}, \"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"9\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"8\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"14\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"9\", 0]}}, \"16\": {\"class_type\": [\"PreviewImage\"], \"inputs\": {\"images\": [\"14\", 0]}}, \"17\": {\"class_type\": {\"a\": 1}, \"inputs\": {\"images\": [\"3\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "blurry"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "a fox"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "select-preview-through-bad-node", "records": {"prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a cat\", \"clip\": [\"4\", 1]}}, \"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"9\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"8\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"13\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"3\", 0]}}, \"15\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"13\", 0]}}, \"16\": {\"class_type\": \"PreviewImage\", \"inputs\": {\"images\": [\"9\", 0]}}, \"18\": \"not a node\", \"19\": {\"class_type\": \"Note\", \"inputs\": {\"x\": [\"18\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "blurry"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "a fox"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "11", "state": "resolved"}}, "name": "select-detailer-same-prompt", "records": {"prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a cat\", \"clip\": [\"4\", 1]}}, \"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"10\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"3\", 0]}}, \"11\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"10\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"12\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"11\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "ambiguous", "text": null}, "positive": {"source": null, "status": "ambiguous", "text": null}}, "resolution": {"node": null, "reason": "Sampler stages have different prompts", "reason_code": "multistage-differs", "sampler_id": "11", "state": "ambiguous"}}, "name": "select-detailer-differs", "records": {"prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a cat\", \"clip\": [\"4\", 1]}}, \"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"10\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"3\", 0]}}, \"11\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"10\", 0], \"model\": [\"4\", 0], \"positive\": [\"8\", 0], \"negative\": [\"7\", 0]}}, \"12\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"11\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "ambiguous", "text": null}, "positive": {"source": null, "status": "ambiguous", "text": null}}, "resolution": {"node": null, "reason": "Multiple sampler branches", "reason_code": null, "sampler_id": null, "state": "ambiguous"}}, "name": "select-detailer-and-own-output", "records": {"prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a cat\", \"clip\": [\"4\", 1]}}, \"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"10\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"3\", 0]}}, \"11\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"10\", 0], \"model\": [\"4\", 0], \"positive\": [\"8\", 0], \"negative\": [\"7\", 0]}}, \"12\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"11\", 0]}}, \"13\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"10\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Expected an image link", "reason_code": "malformed", "sampler_id": "11", "state": "unresolved"}}, "name": "select-detailer-image-literal", "records": {"prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a cat\", \"clip\": [\"4\", 1]}}, \"11\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": \"not a link\", \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"12\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"11\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Cyclic sampler stage ancestry", "reason_code": "malformed", "sampler_id": "11", "state": "unresolved"}}, "name": "select-detailer-cycle", "records": {"prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a cat\", \"clip\": [\"4\", 1]}}, \"11\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"10\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"10\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"11\", 0]}}, \"12\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"11\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "ambiguous", "text": null}, "positive": {"source": null, "status": "ambiguous", "text": null}}, "resolution": {"node": null, "reason": "Sampler stages have different prompts", "reason_code": "multistage-differs", "sampler_id": "21", "state": "ambiguous"}}, "name": "select-detailer-chain", "records": {"prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a cat\", \"clip\": [\"4\", 1]}}, \"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"10\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"3\", 0]}}, \"11\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"10\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"21\": {\"class_type\": \"UltimateSDUpscale\", \"inputs\": {\"image\": [\"11\", 0], \"model\": [\"4\", 0], \"positive\": [\"8\", 0], \"negative\": [\"7\", 0]}}, \"12\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"21\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "blurry"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "a fox"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "21", "state": "resolved"}}, "name": "select-detailer-chain-same", "records": {"prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a cat\", \"clip\": [\"4\", 1]}}, \"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"10\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"3\", 0]}}, \"11\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"10\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"21\": {\"class_type\": \"DetailerForEach\", \"inputs\": {\"image\": [\"11\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"12\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"21\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "ambiguous", "text": null}, "positive": {"source": null, "status": "ambiguous", "text": null}}, "resolution": {"node": null, "reason": "Sampler stages have different prompts", "reason_code": "multistage-differs", "sampler_id": "11", "state": "ambiguous"}}, "name": "select-detailer-two-upstream", "records": {"prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a cat\", \"clip\": [\"4\", 1]}}, \"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"9\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"8\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"10\": {\"class_type\": \"ImageBlend\", \"inputs\": {\"image1\": [\"13\", 0], \"image2\": [\"14\", 0]}}, \"13\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"3\", 0]}}, \"14\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"9\", 0]}}, \"11\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"10\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"12\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"11\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt uses a node the inspector does not support (MysterySampler).", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt uses a node the inspector does not support (MysterySampler).", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": {"class_type": "MysterySampler", "id": "10"}, "reason": "Unsupported sampler stage ancestry", "reason_code": "unsupported-node", "sampler_id": "11", "state": "unresolved"}}, "name": "select-detailer-unsupported-stage", "records": {"prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a cat\", \"clip\": [\"4\", 1]}}, \"10\": {\"class_type\": \"MysterySampler\", \"inputs\": {\"positive\": [\"6\", 0], \"model\": [\"4\", 0]}}, \"11\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"10\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"12\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"11\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Missing or malformed graph node", "reason_code": "malformed", "sampler_id": "11", "state": "unresolved"}}, "name": "select-detailer-upstream-unresolved", "records": {"prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a cat\", \"clip\": [\"4\", 1]}}, \"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"gone\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"10\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"3\", 0]}}, \"11\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"10\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"12\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"11\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "ambiguous", "text": null}, "positive": {"source": null, "status": "ambiguous", "text": null}}, "resolution": {"node": null, "reason": "Selector branches have different prompts", "reason_code": null, "sampler_id": "11", "state": "ambiguous"}}, "name": "select-detailer-upstream-selector", "records": {"prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a cat\", \"clip\": [\"4\", 1]}}, \"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"15\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"15\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": [\"4\", 0], \"on_true\": [\"6\", 0], \"on_false\": [\"8\", 0]}}, \"10\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"3\", 0]}}, \"11\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"10\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"12\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"11\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt is chosen at run time (FaceDetailer).", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt is chosen at run time (FaceDetailer).", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": {"class_type": "FaceDetailer", "id": "11"}, "reason": "Text is decided at run time", "reason_code": "runtime-value", "sampler_id": "11", "state": "unresolved"}}, "name": "select-detailer-wildcard", "records": {"prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a cat\", \"clip\": [\"4\", 1]}}, \"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"10\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"3\", 0]}}, \"11\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"10\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"wildcard\": \"[x|y]\"}}, \"12\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"11\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "ambiguous", "text": null}, "positive": {"source": null, "status": "ambiguous", "text": null}}, "resolution": {"node": null, "reason": "Multiple sampler branches", "reason_code": null, "sampler_id": null, "state": "ambiguous"}}, "name": "select-detailer-no-upscaled-image", "records": {"prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a cat\", \"clip\": [\"4\", 1]}}, \"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"10\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"3\", 0]}}, \"11\": {\"class_type\": \"UltimateSDUpscaleNoUpscale\", \"inputs\": {\"image\": [\"10\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"12\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"11\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "blurry"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "a fox"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "11", "state": "resolved"}}, "name": "select-detailer-no-sampler-upstream", "records": {"prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a cat\", \"clip\": [\"4\", 1]}}, \"10\": {\"class_type\": \"LoadImage\", \"inputs\": {\"image\": \"a.png\"}}, \"11\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"10\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"12\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"11\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "ambiguous", "text": null}, "positive": {"source": null, "status": "ambiguous", "text": null}}, "resolution": {"node": null, "reason": "Multiple sampler branches", "reason_code": null, "sampler_id": null, "state": "ambiguous"}}, "name": "select-detailer-deep-ancestry", "records": {"prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a cat\", \"clip\": [\"4\", 1]}}, \"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"d0\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"3\", 0]}}, \"d1\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d0\", 0]}}, \"d2\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d1\", 0]}}, \"d3\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d2\", 0]}}, \"d4\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d3\", 0]}}, \"d5\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d4\", 0]}}, \"d6\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d5\", 0]}}, \"d7\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d6\", 0]}}, \"d8\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d7\", 0]}}, \"d9\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d8\", 0]}}, \"d10\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d9\", 0]}}, \"d11\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d10\", 0]}}, \"d12\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d11\", 0]}}, \"d13\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d12\", 0]}}, \"d14\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d13\", 0]}}, \"d15\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d14\", 0]}}, \"d16\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d15\", 0]}}, \"d17\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d16\", 0]}}, \"d18\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d17\", 0]}}, \"d19\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d18\", 0]}}, \"d20\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d19\", 0]}}, \"d21\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d20\", 0]}}, \"d22\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d21\", 0]}}, \"d23\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d22\", 0]}}, \"d24\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d23\", 0]}}, \"d25\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d24\", 0]}}, \"d26\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d25\", 0]}}, \"d27\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d26\", 0]}}, \"d28\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d27\", 0]}}, \"d29\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d28\", 0]}}, \"d30\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d29\", 0]}}, \"d31\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d30\", 0]}}, \"d32\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d31\", 0]}}, \"d33\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d32\", 0]}}, \"d34\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d33\", 0]}}, \"d35\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d34\", 0]}}, \"d36\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d35\", 0]}}, \"d37\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d36\", 0]}}, \"d38\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d37\", 0]}}, \"d39\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d38\", 0]}}, \"d40\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d39\", 0]}}, \"d41\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d40\", 0]}}, \"d42\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d41\", 0]}}, \"d43\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d42\", 0]}}, \"d44\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d43\", 0]}}, \"d45\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d44\", 0]}}, \"d46\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d45\", 0]}}, \"d47\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d46\", 0]}}, \"d48\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d47\", 0]}}, \"d49\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d48\", 0]}}, \"d50\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d49\", 0]}}, \"d51\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d50\", 0]}}, \"d52\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d51\", 0]}}, \"d53\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d52\", 0]}}, \"d54\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d53\", 0]}}, \"d55\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d54\", 0]}}, \"d56\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d55\", 0]}}, \"d57\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d56\", 0]}}, \"d58\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d57\", 0]}}, \"d59\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d58\", 0]}}, \"d60\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d59\", 0]}}, \"d61\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d60\", 0]}}, \"d62\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d61\", 0]}}, \"d63\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d62\", 0]}}, \"d64\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d63\", 0]}}, \"d65\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d64\", 0]}}, \"d66\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d65\", 0]}}, \"d67\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d66\", 0]}}, \"d68\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d67\", 0]}}, \"d69\": {\"class_type\": \"ImageScale\", \"inputs\": {\"image\": [\"d68\", 0]}}, \"11\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"d69\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"12\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"11\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt exceeds inspection limits.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt exceeds inspection limits.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Resolution depth limit reached", "reason_code": "depth-limit", "sampler_id": "s66", "state": "unresolved"}}, "name": "select-detailer-stacked-past-depth", "records": {"prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a cat\", \"clip\": [\"4\", 1]}}, \"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0], \"latent_image\": [\"5\", 0]}}, \"s0\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"3\", 0]}}, \"s1\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s0\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s2\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s1\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s3\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s2\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s4\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s3\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s5\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s4\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s6\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s5\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s7\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s6\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s8\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s7\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s9\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s8\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s10\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s9\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s11\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s10\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s12\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s11\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s13\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s12\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s14\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s13\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s15\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s14\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s16\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s15\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s17\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s16\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s18\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s17\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s19\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s18\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s20\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s19\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s21\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s20\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s22\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s21\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s23\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s22\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s24\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s23\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s25\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s24\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s26\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s25\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s27\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s26\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s28\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s27\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s29\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s28\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s30\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s29\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s31\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s30\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s32\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s31\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s33\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s32\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s34\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s33\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s35\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s34\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s36\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s35\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s37\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s36\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s38\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s37\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s39\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s38\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s40\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s39\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s41\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s40\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s42\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s41\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s43\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s42\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s44\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s43\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s45\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s44\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s46\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s45\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s47\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s46\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s48\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s47\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s49\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s48\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s50\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s49\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s51\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s50\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s52\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s51\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s53\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s52\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s54\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s53\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s55\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s54\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s56\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s55\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s57\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s56\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s58\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s57\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s59\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s58\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s60\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s59\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s61\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s60\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s62\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s61\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s63\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s62\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s64\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s63\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s65\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s64\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"s66\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"s65\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"12\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"s66\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Cyclic sampler stage ancestry", "reason_code": "malformed", "sampler_id": "21", "state": "unresolved"}}, "name": "select-detailer-mutual-cycle", "records": {"prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"6\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a fox\", \"clip\": [\"4\", 1]}}, \"7\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"blurry\", \"clip\": [\"4\", 1]}}, \"8\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a cat\", \"clip\": [\"4\", 1]}}, \"11\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"21\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"21\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"11\", 0], \"model\": [\"4\", 0], \"positive\": [\"6\", 0], \"negative\": [\"7\", 0]}}, \"12\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"21\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": "embedded-parameters", "status": "resolved", "text": "blur"}, "positive": {"source": "embedded-parameters", "status": "resolved", "text": "a fox"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": null, "state": "resolved"}}, "name": "parameters-basic", "records": {"parameters": "a fox\nNegative prompt: blur\nSteps: 20, Sampler: euler, CFG scale: 7"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "absent", "text": null}, "positive": {"source": "embedded-parameters", "status": "resolved", "text": "a fox"}}, "resolution": {"node": null, "reason": "Negative prompt delimiter is absent", "reason_code": null, "sampler_id": null, "state": "absent"}}, "name": "parameters-no-negative", "records": {"parameters": "a fox\nSteps: 20, Sampler: euler, CFG scale: 7"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "ambiguous", "text": null}, "positive": {"source": null, "status": "ambiguous", "text": null}}, "resolution": {"node": null, "reason": "Multiple negative prompt delimiters", "reason_code": null, "sampler_id": null, "state": "ambiguous"}}, "name": "parameters-two-delimiters", "records": {"parameters": "a\nNegative prompt: b\nNegative prompt: c\nSteps: 20, Sampler: euler, CFG scale: 7"}}, {"evidence": {"prompts": {"negative": {"message": "Positive prompt is empty.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Positive prompt is empty.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Positive prompt is empty", "reason_code": null, "sampler_id": null, "state": "unresolved"}}, "name": "parameters-leading-delimiter", "records": {"parameters": "Negative prompt: blur\nSteps: 20, Sampler: euler, CFG scale: 7"}}, {"evidence": {"prompts": {"negative": {"message": "Positive prompt is empty.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Positive prompt is empty.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Positive prompt is empty", "reason_code": null, "sampler_id": null, "state": "unresolved"}}, "name": "parameters-blank-positive", "records": {"parameters": " …\nNegative prompt: blur\nSteps: 20, Sampler: euler, CFG scale: 7"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "A1111 settings footer is not unique", "reason_code": "malformed", "sampler_id": null, "state": "unresolved"}}, "name": "parameters-footer-not-last", "records": {"parameters": "a fox\nSteps: 20, Sampler: euler, CFG scale: 7\nmore text"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "A1111 settings footer is not unique", "reason_code": "malformed", "sampler_id": null, "state": "unresolved"}}, "name": "parameters-two-footers", "records": {"parameters": "a\nSteps: 20, Sampler: euler, CFG scale: 7\nSteps: 20, Sampler: euler, CFG scale: 7"}}, {"evidence": {"prompts": {"negative": {"source": "embedded-parameters", "status": "resolved", "text": "blur"}, "positive": {"source": "embedded-parameters", "status": "resolved", "text": "a fox"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": null, "state": "resolved"}}, "name": "parameters-bare-footer", "records": {"parameters": "a fox\nNegative prompt: blur\nSteps: 20"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "absent", "text": null}, "positive": {"source": "embedded-parameters", "status": "resolved", "text": "a fox\nSteps: 5"}}, "resolution": {"node": null, "reason": "Negative prompt delimiter is absent", "reason_code": null, "sampler_id": null, "state": "absent"}}, "name": "parameters-bare-and-full", "records": {"parameters": "a fox\nSteps: 5\nSteps: 20, Sampler: euler, CFG scale: 7"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "A1111 settings footer is not unique", "reason_code": "malformed", "sampler_id": null, "state": "unresolved"}}, "name": "parameters-two-bare", "records": {"parameters": "a\nSteps: 1\nSteps: 2"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "absent", "text": null}, "positive": {"source": "embedded-parameters", "status": "resolved", "text": "a fox"}}, "resolution": {"node": null, "reason": "Negative prompt delimiter is absent", "reason_code": null, "sampler_id": null, "state": "absent"}}, "name": "parameters-template", "records": {"parameters": "a fox\nSteps: 20, Sampler: euler, CFG scale: 7\nTemplate: {a|b}"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "absent", "text": null}, "positive": {"source": "embedded-parameters", "status": "resolved", "text": "a"}}, "resolution": {"node": null, "reason": "Negative prompt delimiter is absent", "reason_code": null, "sampler_id": null, "state": "absent"}}, "name": "parameters-both-templates", "records": {"parameters": "a\nSteps: 20, Sampler: euler, CFG scale: 7\nTemplate: x\nNegative Template: y"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "A1111 settings footer is not unique", "reason_code": "malformed", "sampler_id": null, "state": "unresolved"}}, "name": "parameters-templates-reversed", "records": {"parameters": "a\nSteps: 20, Sampler: euler, CFG scale: 7\nNegative Template: y\nTemplate: x"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "A1111 settings footer is not unique", "reason_code": "malformed", "sampler_id": null, "state": "unresolved"}}, "name": "parameters-two-templates", "records": {"parameters": "a\nSteps: 20, Sampler: euler, CFG scale: 7\nTemplate: x\nTemplate: y"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "A1111 settings footer is not unique", "reason_code": "malformed", "sampler_id": null, "state": "unresolved"}}, "name": "parameters-template-multiline", "records": {"parameters": "a\nSteps: 20, Sampler: euler, CFG scale: 7\nTemplate: x\nmore"}}, {"evidence": {"prompts": {"negative": {"source": "embedded-parameters", "status": "resolved", "text": "blur"}, "positive": {"source": "embedded-parameters", "status": "resolved", "text": "a fox"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": null, "state": "resolved"}}, "name": "parameters-crlf", "records": {"parameters": "a fox\r\nNegative prompt: blur\r\nSteps: 20, Sampler: euler, CFG scale: 7\r\n"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "A1111 settings footer is not unique", "reason_code": "malformed", "sampler_id": null, "state": "unresolved"}}, "name": "parameters-cr-only", "records": {"parameters": "a fox\rNegative prompt: blur\rSteps: 20, Sampler: euler, CFG scale: 7"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "absent", "text": null}, "positive": {"source": "embedded-parameters", "status": "resolved", "text": "a"}}, "resolution": {"node": null, "reason": "Negative prompt delimiter is absent", "reason_code": null, "sampler_id": null, "state": "absent"}}, "name": "parameters-cr-template", "records": {"parameters": "a\nSteps: 20, Sampler: euler, CFG scale: 7\rTemplate: x"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "A1111 settings footer is not unique", "reason_code": "malformed", "sampler_id": null, "state": "unresolved"}}, "name": "parameters-line-separator", "records": {"parameters": "a fox
Steps: 20, Sampler: euler, CFG scale: 7"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "A1111 settings footer is not unique", "reason_code": "malformed", "sampler_id": null, "state": "unresolved"}}, "name": "parameters-next-line", "records": {"parameters": "a fox…Steps: 20, Sampler: euler, CFG scale: 7"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "absent", "text": null}, "positive": {"source": "embedded-parameters", "status": "resolved", "text": "a fox"}}, "resolution": {"node": null, "reason": "Negative prompt delimiter is absent", "reason_code": null, "sampler_id": null, "state": "absent"}}, "name": "parameters-spaced-steps", "records": {"parameters": "a fox\nSteps:\n 20 \n, x"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "absent", "text": null}, "positive": {"source": "embedded-parameters", "status": "resolved", "text": "a fox"}}, "resolution": {"node": null, "reason": "Negative prompt delimiter is absent", "reason_code": null, "sampler_id": null, "state": "absent"}}, "name": "parameters-steps-newline-bare", "records": {"parameters": "a fox\nSteps:\n20\n"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "absent", "text": null}, "positive": {"source": "embedded-parameters", "status": "resolved", "text": "a fox"}}, "resolution": {"node": null, "reason": "Negative prompt delimiter is absent", "reason_code": null, "sampler_id": null, "state": "absent"}}, "name": "parameters-arabic-digits", "records": {"parameters": "a fox\nSteps: ٢٠, Sampler: euler"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "absent", "text": null}, "positive": {"source": "embedded-parameters", "status": "resolved", "text": "a fox"}}, "resolution": {"node": null, "reason": "Negative prompt delimiter is absent", "reason_code": null, "sampler_id": null, "state": "absent"}}, "name": "parameters-fullwidth-digits", "records": {"parameters": "a fox\nSteps: 2, x"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "A1111 settings footer is not unique", "reason_code": "malformed", "sampler_id": null, "state": "unresolved"}}, "name": "parameters-no-digits", "records": {"parameters": "a fox\nSteps: , x"}}, {"evidence": {"prompts": {"negative": {"source": "embedded-parameters", "status": "resolved", "text": "blur"}, "positive": {"source": "embedded-parameters", "status": "resolved", "text": "a fox"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": null, "state": "resolved"}}, "name": "parameters-trailing-spaces", "records": {"parameters": "a fox \nNegative prompt: blur \nSteps: 20, Sampler: euler, CFG scale: 7 \n \n"}}, {"evidence": {"prompts": {"negative": {"source": "embedded-parameters", "status": "resolved", "text": ""}, "positive": {"source": "embedded-parameters", "status": "resolved", "text": "a fox"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": null, "state": "resolved"}}, "name": "parameters-empty-negative", "records": {"parameters": "a fox\nNegative prompt:\nSteps: 20, Sampler: euler, CFG scale: 7"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "absent", "text": null}, "positive": {"source": null, "status": "absent", "text": null}}, "resolution": {"node": null, "reason": "No A1111 parameters", "reason_code": null, "sampler_id": null, "state": "absent"}}, "name": "parameters-empty", "records": {"parameters": ""}}, {"evidence": {"prompts": {"negative": {"message": "Positive prompt is empty.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Positive prompt is empty.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Positive prompt is empty", "reason_code": null, "sampler_id": null, "state": "unresolved"}}, "name": "parameters-only-footer", "records": {"parameters": "Steps: 20, Sampler: euler, CFG scale: 7"}}, {"evidence": {"prompts": {"negative": {"source": "embedded-parameters", "status": "resolved", "text": "blur"}, "positive": {"source": "embedded-parameters", "status": "resolved", "text": "a fox"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": null, "state": "resolved"}}, "name": "parameters-with-execution", "records": {"comfyassets_prompt_pairs": "{\"version\": 1, \"nodes\": {}}", "parameters": "a fox\nNegative prompt: blur\nSteps: 20, Sampler: euler, CFG scale: 7"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt combines several texts (ConditioningCombine).", "source": null, "status": "unresolved", "text": null}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "
   café\u001csnow"}}, "resolution": {"node": {"class_type": "ConditioningCombine", "id": "102"}, "reason": "Combined conditioning has different texts", "reason_code": "composite", "sampler_id": "3", "state": "unresolved"}}, "name": "fuzz-0-0", "records": {"comfyassets_prompt_pairs": "{\"version\": 1, \"nodes\": {\"103\": {\"records\": [{\"text\": \"\\u001csnow \", \"negative_text\": \"\\u0085\\u2028 detailed fur\", \"prepend_text\": \"\\u732b\", \"append_text\": \"\\ud83d\\ude00\", \"positive_text\": \"snow\\u3000\", \"negative_effective_text\": \"\\u0085\\u2028 detailed fur\"}]}}}", "prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"102\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"101\": {\"class_type\": \"PromptManagerFocused\", \"inputs\": {\"text\": \"
  \", \"prepend_text\": \"\\u001c…\", \"append_text\": \"\\ncafé\\u001csnow\", \"clip\": [\"4\", 1]}}, \"103\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"\\u001csnow \", \"negative_text\": \"…
 detailed fur\", \"prepend_text\": \"…猫
 \", \"append_text\": \"
 😀\"}}, \"105\": {\"class_type\": \"PromptManagerPositive\", \"inputs\": {\"text\": \"…snow\\n\", \"prepend_text\": \"\\n猫\\t\", \"negative_text\": \"snow \"}}, \"104\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"105\", 0]}}, \"102\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"103\", 0], \"conditioning_2\": [\"104\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": "zeroed-conditioning", "status": "resolved", "text": ""}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "猫, snow"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "fuzz-0-1", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"102\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"101\": {\"class_type\": \"PromptManagerV2\", \"inputs\": {\"text\": \"\\u001csnow
 \", \"prepend_text\": \"
 猫
 \", \"negative_text\": \"snow \"}}, \"105\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"\\u001c café\", \"clip\": [\"4\", 1]}}, \"104\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"105\", 0]}}, \"103\": {\"class_type\": \"ConditioningZeroOut\", \"inputs\": {\"conditioning\": [\"104\", 0]}}, \"102\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"103\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Expected a typed graph link or text input", "reason_code": "malformed", "sampler_id": "3", "state": "unresolved"}}, "name": "fuzz-0-2", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"106\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"105\": {\"class_type\": \"PrimitiveString\", \"inputs\": {\"value\": \" a, \"}}, \"104\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"105\", 0]}}, \"103\": {\"class_type\": \"TextInput\", \"inputs\": {\"text\": [\"104\", 0]}}, \"102\": {\"class_type\": \"TextInput\", \"inputs\": {\"string\": [\"103\", 0]}}, \"101\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": [\"102\", 0], \"text_l\": \"\"}}, \"108\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"\\t猫\", \"clip\": [\"4\", 1]}}, \"109\": {\"class_type\": \"PromptSearchList\", \"inputs\": {\"text\": \"snow\\n\"}}, \"107\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": [\"4\", 1], \"on_true\": [\"108\", 0], \"on_false\": [\"109\", 0]}}, \"111\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": \"…snow\", \"text_l\": \"…snow\"}}, \"110\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"111\", 0]}}, \"106\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": false, \"on_true\": [\"107\", 0], \"on_false\": [\"110\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "café, 😀"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "café, snow\u001c \tfox
  猫"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "fuzz-0-3", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"105\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"104\": {\"class_type\": \"StringConcatenate\", \"inputs\": {\"string_a\": \"\\nsnow\\u001c\", \"string_b\": \"\\tfox\\u2028 \", \"delimiter\": \" \"}}, \"103\": {\"class_type\": \"StringConcatenate\", \"inputs\": {\"string_a\": [\"104\", 0], \"string_b\": \"\\u3000\\u732b\\u001c\", \"delimiter\": \" \"}}, \"102\": {\"class_type\": \"PrimitiveString\", \"inputs\": {\"value\": [\"103\", 0]}}, \"101\": {\"class_type\": \"PromptManagerV2\", \"inputs\": {\"text\": [\"102\", 0], \"prepend_text\": \"\\ufeffcaf\\u00e9\\n\", \"negative_text\": \"caf\\u00e9\\t\"}}, \"106\": {\"class_type\": \"PromptManagerV2\", \"inputs\": {\"text\": \"\\u3000\\ud83d\\ude00\\u0085\", \"prepend_text\": \"\\u001ccaf\\u00e9\\u2028 \", \"negative_text\": \"\\ufeffsnow\\u0085\"}}, \"105\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"106\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Missing or malformed graph node", "reason_code": "malformed", "sampler_id": "3", "state": "unresolved"}}, "name": "fuzz-0-4", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"107\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"104\": {\"class_type\": \"StringConcatenate\"}, \"105\": {\"class_type\": \"Text Concatenate\", \"inputs\": {\"text_a\": \"\\t\\u2028 \", \"text_b\": \"\\u2028 fox\", \"clean_whitespace\": \"false\"}}, \"106\": {\"class_type\": \"PrimitiveStringMultiline\", \"inputs\": {\"value\": \"\\nsnow\\t\"}}, \"103\": {\"class_type\": \"Text Concatenate\", \"inputs\": {\"text_c\": [\"104\", 0], \"text_b\": [\"105\", 0], \"text_d\": [\"106\", 0], \"clean_whitespace\": \"false\"}}, \"102\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"103\", 0]}}, \"101\": {\"class_type\": \"PromptManagerPositive\", \"inputs\": {\"text\": [\"102\", 0], \"prepend_text\": \"\\u3000a,fox\", \"negative_text\": \"\\u2028 snow\\n\\u732b\"}}, \"110\": {\"class_type\": \"StringConstant\", \"inputs\": {\"string\": \"\\u001c \"}}, \"109\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": [\"110\", 0], \"text_l\": [\"110\", 0]}}, \"108\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"109\", 0]}}, \"111\": {\"class_type\": \"FancyEncoder\", \"inputs\": {\"text\": \" caf\\u00e9\\u001c\"}}, \"107\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"108\", 0], \"conditioning_2\": [\"111\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "ambiguous", "text": null}, "positive": {"source": null, "status": "ambiguous", "text": null}}, "resolution": {"node": null, "reason": "Selector branches have different prompts", "reason_code": null, "sampler_id": "3", "state": "ambiguous"}}, "name": "fuzz-0-5", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"112\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"104\": {\"class_type\": \"PromptManagerText\", \"inputs\": {\"text\": \"\\ufeffdetailed fur\\u0085\", \"prepend_text\": \"\\t\\u732b\\u3000\", \"append_text\": \"caf\\u00e9\\ta,\"}}, \"103\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": [\"104\", 0], \"clip\": [\"4\", 1]}}, \"102\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"103\", 0]}}, \"107\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"\\u001ccaf\\u00e9\\t\", \"clip\": [\"4\", 1]}}, \"109\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"\\u0085detailed fur\\u2028 snow\", \"clip\": [\"4\", 1]}}, \"108\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"109\", -1], \"conditioning_2\": [\"109\", -1]}}, \"106\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": true, \"on_true\": [\"107\", 0], \"on_false\": [\"108\", 0]}}, \"111\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"\\u001ccaf\\u00e9\\u2028 \", \"clip\": [\"4\", 1]}}, \"110\": {\"class_type\": \"ConditioningZeroOut\", \"inputs\": {\"conditioning\": [\"111\", 0]}}, \"105\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": true, \"on_true\": [\"106\", 0], \"on_false\": [\"110\", 0]}}, \"101\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": [\"4\", 0], \"on_true\": [\"102\", 0], \"on_false\": [\"105\", 0]}}, \"113\": {\"class_type\": \"PromptManager\", \"inputs\": {\"text\": \"\\ufeff\\u0085\", \"prepend_text\": \"\\u0085snow\\ufeff\", \"append_text\": \"\\u3000fox\\u001c\", \"clip\": [\"4\", 1]}}, \"114\": {\"class_type\": \"PromptSearchList\", \"inputs\": {\"text\": \"\\tdetailed fur snow\"}}, \"112\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": [\"4\", 0], \"on_true\": [\"113\", 0], \"on_false\": [\"114\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "a, 猫\t"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "…猫\u001c"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "fuzz-0-6", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 1], \"negative\": [\"102\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"101\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"\\ufeff\\n\", \"negative_text\": \"\\u0085\\u732b\\u001c\", \"prepend_text\": \"\\tdetailed fur\\n\", \"append_text\": \" fox\\u2028 \"}}, \"102\": {\"class_type\": \"PromptManagerFocused\", \"inputs\": {\"text\": \"\\u732b\\t\", \"prepend_text\": \"\\u3000a,\\u2028 \", \"append_text\": \" \\t\", \"clip\": [\"4\", 1]}}}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "😀"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "\n猫"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "fuzz-0-7", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 1], \"negative\": [\"102\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"101\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"…\", \"negative_text\": \"\\n猫\", \"prepend_text\": \"…a,\", \"append_text\": \"\\u001ccafé \"}}, \"103\": {\"class_type\": \"PrimitiveString\", \"inputs\": {\"value\": \"😀\"}}, \"102\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": [\"103\", 0], \"text_l\": [\"103\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt uses a node the inspector does not support (FancyEncoder).", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt uses a node the inspector does not support (FancyEncoder).", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": {"class_type": "FancyEncoder", "id": "101"}, "reason": "Unsupported node semantics or output slot", "reason_code": "unsupported-node", "sampler_id": "3", "state": "unresolved"}}, "name": "fuzz-0-8", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"102\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"101\": {\"class_type\": \"FancyEncoder\", \"inputs\": {\"text\": \"…detailed fur
 \"}}, \"102\": {\"class_type\": \"PromptSearchList\", \"inputs\": {\"text\": \" 猫\\u001c\"}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "fox, snow  detailed fur café"}}, "resolution": {"node": null, "reason": "Missing or malformed graph node", "reason_code": "malformed", "sampler_id": "3", "state": "unresolved"}}, "name": "fuzz-0-9", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"105\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"104\": {\"class_type\": \"JoinStringMulti\", \"inputs\": {\"string_1\": \"…snow\", \"string_3\": \" detailed fur café\", \"inputcount\": 3, \"delimiter\": \" \", \"return_list\": false}}, \"103\": {\"class_type\": \"PrimitiveString\", \"inputs\": {\"value\": [\"104\", 0]}}, \"102\": {\"class_type\": \"StringConstantMultiline\", \"inputs\": {\"string\": [\"103\", 0], \"strip_newlines\": false}}, \"101\": {\"class_type\": \"PromptManagerPositive\", \"inputs\": {\"text\": [\"102\", 0], \"prepend_text\": \"fox\", \"negative_text\": \"snow\\t\"}}, \"106\": {\"class_type\": \"TextInput\"}, \"105\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": [\"106\", 0], \"clip\": [\"4\", 1]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt uses a node the inspector does not support (FancyEncoder).", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt uses a node the inspector does not support (FancyEncoder).", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": {"class_type": "FancyEncoder", "id": "101"}, "reason": "Unsupported node semantics or output slot", "reason_code": "unsupported-node", "sampler_id": "3", "state": "unresolved"}}, "name": "fuzz-0-10", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"102\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"101\": {\"class_type\": \"FancyEncoder\", \"inputs\": {\"text\": \"\\ndetailed fur\\ufefffox\"}}, \"105\": {\"class_type\": \"StringConstantMultiline\", \"inputs\": {\"string\": \"detailed fur\\ufeff\", \"strip_newlines\": true}}, \"104\": {\"class_type\": \"TextInput\", \"inputs\": {\"string\": [\"105\", 0]}}, \"107\": {\"class_type\": \"PrimitiveStringMultiline\", \"inputs\": {\"value\": \"\\u001cfox\\u2028 \"}}, \"108\": {\"class_type\": \"StringConstantMultiline\", \"inputs\": {\"string\": \"\\nsnow\\u2028 \", \"strip_newlines\": false}}, \"106\": {\"class_type\": \"StringConcatenate\", \"inputs\": {\"string_a\": [\"107\", 0], \"string_b\": [\"108\", 0], \"delimiter\": \" \"}}, \"103\": {\"class_type\": \"JoinStringMulti\", \"inputs\": {\"string_1\": [\"104\", 0], \"string_2\": [\"106\", 0], \"inputcount\": 2, \"delimiter\": \",\", \"return_list\": false}}, \"102\": {\"class_type\": \"PromptManagerPositive\", \"inputs\": {\"text\": [\"103\", 0], \"prepend_text\": \"\\tdetailed fur\\u3000\", \"negative_text\": \" detailed fur caf\\u00e9\"}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Cyclic prompt graph", "reason_code": "malformed", "sampler_id": "3", "state": "unresolved"}}, "name": "fuzz-0-11", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"104\": {\"class_type\": \"StringConcatenate\", \"inputs\": {\"string_a\": \"\\u0085snow \", \"string_b\": \"a,\\u0085\\u732b\", \"delimiter\": \", \"}}, \"103\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"104\", 0]}}, \"102\": {\"class_type\": \"PrimitiveStringMultiline\", \"inputs\": {\"value\": [\"102\", 0]}}, \"101\": {\"class_type\": \"PromptManagerPositive\", \"inputs\": {\"text\": [\"102\", 0], \"prepend_text\": \" caf\\u00e9\\nsnow\", \"negative_text\": \"\\ud83d\\ude00\\u0085\"}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Missing or malformed graph node", "reason_code": "malformed", "sampler_id": "3", "state": "unresolved"}}, "name": "fuzz-0-12", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"105\": {\"class_type\": \"PrimitiveString\"}, \"104\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"105\", 0]}}, \"103\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": [\"104\", 0], \"clip\": [\"4\", 1]}}, \"102\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"103\", 0]}}, \"101\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"102\", 0], \"conditioning_2\": [\"102\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Missing or malformed graph node", "reason_code": "malformed", "sampler_id": "3", "state": "unresolved"}}, "name": "fuzz-0-13", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"107\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\"}, \"105\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"\\ncaf\\u00e9\\u001c\", \"clip\": [\"4\", 1]}}, \"106\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"caf\\u00e9\", \"clip\": [\"4\", 1]}}, \"104\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": false, \"on_true\": [\"105\", 0], \"on_false\": [\"106\", 0]}}, \"103\": {\"class_type\": \"ConditioningZeroOut\", \"inputs\": {\"conditioning\": [\"104\", 0]}}, \"102\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"103\", 0]}}, \"101\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"102\", 0]}}, \"110\": {\"class_type\": \"StringConcatenate\", \"inputs\": {\"string_a\": \"\\tsnow\\u001ccaf\\u00e9\", \"string_b\": \"\\ufeffdetailed fur\\t\", \"delimiter\": \", \"}}, \"109\": {\"class_type\": \"Text Concatenate\", \"inputs\": {\"text_d\": \"\\ufeffa,\\u2028 detailed fur\", \"text_b\": \"\\u0085\\n\", \"text_c\": [\"110\", 0], \"clean_whitespace\": \"true\"}}, \"108\": {\"class_type\": \"PromptManagerFocused\", \"inputs\": {\"text\": [\"109\", 0], \"prepend_text\": \"\\n\\u0085\", \"append_text\": \"\\ta,\\ufeff\", \"clip\": [\"4\", 1]}}, \"107\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"108\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Missing or malformed graph node", "reason_code": "malformed", "sampler_id": "3", "state": "unresolved"}}, "name": "fuzz-0-14", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"102\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\"}, \"101\": {\"class_type\": \"PromptManagerPositive\", \"inputs\": {\"text\": \"\\u001csnow\\u001c\", \"prepend_text\": \"\\tdetailed fur\\ufeff\", \"negative_text\": \"\\ndetailed fur\\u001c\"}}, \"103\": {\"class_type\": \"PromptSearchList\", \"inputs\": {\"text\": \"\\ud83d\\ude00\\u3000\"}}, \"104\": {\"class_type\": \"PromptManagerFocused\", \"inputs\": {\"text\": \"\\u001ca,\\u001c\", \"prepend_text\": \"\\nsnow\\ud83d\\ude00\", \"append_text\": \"\\u001ca, \", \"clip\": [\"4\", 1]}}, \"102\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"103\", 0], \"conditioning_2\": [\"104\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt combines several texts (ConditioningCombine).", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt combines several texts (ConditioningCombine).", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": {"class_type": "ConditioningCombine", "id": "101"}, "reason": "Combined conditioning has different texts", "reason_code": "composite", "sampler_id": "3", "state": "unresolved"}}, "name": "fuzz-0-15", "records": {"comfyassets_prompt_pairs": "{\"version\": 1, \"nodes\": {\"103\": {\"records\": [{\"text\": \"\\u2028 \\u732b\\u2028 detailed fur\", \"negative_text\": \"\\u001c\\ud83d\\ude00\\ufeff\", \"prepend_text\": \"caf\\u00e9\", \"append_text\": \"detailed fur\\ufeff\\ud83d\\ude00\", \"positive_text\": \"\\ufeffdetailed fur\\n\", \"negative_effective_text\": \"\\u001c\\ud83d\\ude00\\ufeff\"}]}}}", "prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"105\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"103\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"\\u2028 \\u732b\\u2028 detailed fur\", \"negative_text\": \"\\u001c\\ud83d\\ude00\\ufeff\", \"prepend_text\": \"\\tcaf\\u00e9 \", \"append_text\": \"\\u3000detailed fur\\ufeff\\ud83d\\ude00\"}}, \"102\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"103\", 0], \"conditioning_2\": [\"103\", 0]}}, \"104\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"\\t\", \"clip\": [\"4\", 1]}}, \"101\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"102\", 0], \"conditioning_2\": [\"104\", 0]}}, \"108\": {\"class_type\": \"StringConcatenate\", \"inputs\": {\"string_a\": \"\\u3000fox\\u0085fox\", \"string_b\": \"\\u001c\\ud83d\\ude00\\n\\ud83d\\ude00\", \"delimiter\": \"\"}}, \"107\": {\"class_type\": \"StringConcatenate\", \"inputs\": {\"string_a\": [\"108\", 0], \"string_b\": \"\\ufeffa,\\u3000detailed fur\", \"delimiter\": \" \"}}, \"106\": {\"class_type\": \"PromptManager\", \"inputs\": {\"text\": [\"107\", 0], \"prepend_text\": \"\\u3000caf\\u00e9\\ufeff\", \"append_text\": \"\\ta,\\tcaf\\u00e9\", \"clip\": [\"4\", 1]}}, \"110\": {\"class_type\": \"TextInput\", \"inputs\": {\"text\": \"\\n\\u732b\\ufeff\"}}, \"109\": {\"class_type\": \"PromptManagerPositive\", \"inputs\": {\"text\": [\"110\", 0], \"prepend_text\": \"detailed fursnow\", \"negative_text\": \"\\ta,\"}}, \"105\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": [\"4\", 1], \"on_true\": [\"106\", 0], \"on_false\": [\"109\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt uses a node the inspector does not support (Unknown).", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt uses a node the inspector does not support (Unknown).", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": {"class_type": "Unknown", "id": "104"}, "reason": "Unsupported node semantics or output slot", "reason_code": "unsupported-node", "sampler_id": "3", "state": "unresolved"}}, "name": "fuzz-0-16", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"105\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"104\": {\"class_type\": \"Unknown\", \"inputs\": {\"string_1\": \" \", \"string_2\": \"
 detailed fur\", \"string_3\": \"\\na,\\u001cfox\", \"inputcount\": 3, \"delimiter\": \" \", \"return_list\": false}}, \"103\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": [\"104\", 0], \"text_l\": [\"104\", 0]}}, \"102\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"103\", 0]}}, \"101\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"102\", 0], \"conditioning_2\": [\"102\", 0]}}, \"106\": {\"class_type\": \"StringConstant\", \"inputs\": {\"text\": \" snow…\"}}, \"105\": {\"class_type\": \"PromptManager\", \"inputs\": {\"text\": [\"106\", 0], \"prepend_text\": \"a,\\u001cfox\", \"append_text\": \"\\na, a,\", \"clip\": [\"4\", 1]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt combines several texts (ConditioningCombine).", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt combines several texts (ConditioningCombine).", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": {"class_type": "ConditioningCombine", "id": "102"}, "reason": "Combined conditioning has different texts", "reason_code": "composite", "sampler_id": "3", "state": "unresolved"}}, "name": "fuzz-0-17", "records": {"comfyassets_prompt_pairs": "{\"version\": 1, \"nodes\": {\"103\": {\"records\": [{\"text\": \"\\u001c\\u0085\", \"negative_text\": \"\\nfox\\u001c\", \"prepend_text\": \"fox\\ufeff\", \"append_text\": \"\\ufefffox\", \"positive_text\": \" snow caf\\u00e9\", \"negative_effective_text\": \"\\nfox\\u001c\"}]}}}", "prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"107\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"103\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"\\u001c\\u0085\", \"negative_text\": \"\\nfox\\u001c\", \"prepend_text\": \" fox\\ufeff\", \"append_text\": \"\\ufefffox\\u3000\"}}, \"105\": {\"class_type\": \"StringConstant\", \"inputs\": {\"string\": \"\\n\\n\\ud83d\\ude00\"}}, \"104\": {\"class_type\": \"PromptManagerV2\", \"inputs\": {\"text\": [\"105\", 0], \"prepend_text\": \"\\u0085a,\\u3000\\ud83d\\ude00\", \"negative_text\": \"\\u2028 \\u732b\\u001c\"}}, \"102\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"103\", 1], \"conditioning_2\": [\"104\", 0]}}, \"106\": {\"class_type\": \"PromptManagerPositive\", \"inputs\": {\"text\": \"snow detailed fur\", \"prepend_text\": \"\\n\\u732b\\u001c\", \"negative_text\": \"detailed fur\\ud83d\\ude00\"}}, \"101\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"102\", 0]}}, \"109\": {\"class_type\": \"FancyEncoder\", \"inputs\": {\"text\": \"\\n\\u732b \"}}, \"110\": {\"class_type\": \"FancyEncoder\", \"inputs\": {\"text\": \"\\na,\\ufeffcaf\\u00e9\"}}, \"108\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": false, \"on_true\": [\"109\", 0], \"on_false\": [\"110\", 0]}}, \"107\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"108\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt is chosen at run time (PromptSearchList).", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt is chosen at run time (PromptSearchList).", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": {"class_type": "PromptSearchList", "id": "101"}, "reason": "Text is decided at run time", "reason_code": "runtime-value", "sampler_id": "3", "state": "unresolved"}}, "name": "fuzz-0-18", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"102\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"101\": {\"class_type\": \"PromptSearchList\", \"inputs\": {\"text\": \"\\tdetailed fur\\t\"}}, \"102\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": \"\\n\\ncaf\\u00e9\", \"text_l\": \"\"}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt is chosen at run time (PromptSearchList).", "source": null, "status": "unresolved", "text": null}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "a, detailed fur, café"}}, "resolution": {"node": {"class_type": "PromptSearchList", "id": "105"}, "reason": "Text is decided at run time", "reason_code": "runtime-value", "sampler_id": "3", "state": "unresolved"}}, "name": "fuzz-0-19", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"105\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"103\": {\"class_type\": \"PromptManagerPositive\", \"inputs\": {\"text\": \"\\ncafé\", \"prepend_text\": \"a, detailed fur\", \"negative_text\": \"\\na,\\u001c\"}}, \"104\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"…猫…\", \"clip\": [\"4\", 1]}}, \"102\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": [\"4\", 1], \"on_true\": [\"103\", 0], \"on_false\": [\"104\", 0]}}, \"101\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"102\", 0]}}, \"105\": {\"class_type\": \"PromptSearchList\", \"inputs\": {\"text\": \"\\u001ca,\\t\"}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt is chosen at run time (PromptSearchList).", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt is chosen at run time (PromptSearchList).", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": {"class_type": "PromptSearchList", "id": "101"}, "reason": "Text is decided at run time", "reason_code": "runtime-value", "sampler_id": "3", "state": "unresolved"}}, "name": "fuzz-0-20", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"102\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"101\": {\"class_type\": \"PromptSearchList\", \"inputs\": {\"text\": \"\\u0085fox\\ufeff\"}}, \"102\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"\\u0085snow\", \"clip\": [\"4\", 1]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Positive prompt is empty.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Positive prompt is empty.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Positive prompt is empty", "reason_code": null, "sampler_id": "3", "state": "unresolved"}}, "name": "fuzz-0-21", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"103\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"102\": {\"class_type\": \"PromptManagerPositive\", \"inputs\": {\"text\": \"\\u001ccaf\\u00e9\\u0085\", \"prepend_text\": \"\\u3000detailed fur\", \"negative_text\": \"\\u001c\"}}, \"101\": {\"class_type\": \"ConditioningZeroOut\", \"inputs\": {\"conditioning\": [\"102\", 0]}}, \"103\": {\"class_type\": \"PromptManager\", \"inputs\": {\"text\": \"\\tcaf\\u00e9 \", \"prepend_text\": \"\\t\\u732b\", \"append_text\": \"\\tcaf\\u00e9 \", \"clip\": [\"4\", 1]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt uses a node the inspector does not support (FancyEncoder).", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt uses a node the inspector does not support (FancyEncoder).", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": {"class_type": "FancyEncoder", "id": "101"}, "reason": "Unsupported node semantics or output slot", "reason_code": "unsupported-node", "sampler_id": "3", "state": "unresolved"}}, "name": "fuzz-0-22", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"102\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"101\": {\"class_type\": \"FancyEncoder\", \"inputs\": {\"text\": \"\\tfox\\u001c\"}}, \"103\": {\"class_type\": \"PrimitiveStringMultiline\", \"inputs\": {\"value\": \"\\u001ccaf\\u00e9\\ufeffa,\"}}, \"102\": {\"class_type\": \"PromptManager\", \"inputs\": {\"text\": [\"103\", 0], \"prepend_text\": \"\\tdetailed fur\\u0085\", \"append_text\": \"\\u3000fox\\u001c\", \"clip\": [\"4\", 1]}}}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "\u001c😀…"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "foxfox, \u001c猫
 "}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "fuzz-0-23", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"104\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"103\": {\"class_type\": \"StringConcatenate\", \"inputs\": {\"string_a\": \"\\ufefffoxfox\", \"string_b\": \"\\u001c\\u732b\\u2028 \", \"delimiter\": \", \"}}, \"102\": {\"class_type\": \"JoinStringMulti\", \"inputs\": {\"string_1\": [\"103\", 0], \"inputcount\": 1, \"delimiter\": \",\", \"return_list\": false}}, \"101\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": [\"102\", 0], \"clip\": [\"4\", 1]}}, \"106\": {\"class_type\": \"FancyEncoder\", \"inputs\": {\"text\": \"\\tcaf\\u00e9\\u0085\"}}, \"105\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"106\", 0]}}, \"110\": {\"class_type\": \"PrimitiveString\", \"inputs\": {\"value\": \"\\u001c\\ud83d\\ude00\\u0085\"}}, \"109\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"110\", 0]}}, \"108\": {\"class_type\": \"PrimitiveString\", \"inputs\": {\"value\": [\"109\", 0]}}, \"107\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": [\"108\", 0], \"clip\": [\"4\", 1]}}, \"104\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": false, \"on_true\": [\"105\", 0], \"on_false\": [\"107\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Positive prompt is empty.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Positive prompt is empty.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Positive prompt is empty", "reason_code": null, "sampler_id": "3", "state": "unresolved"}}, "name": "fuzz-0-24", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"104\", 1], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"103\": {\"class_type\": \"PrimitiveStringMultiline\", \"inputs\": {\"value\": \"\\u001c\"}}, \"102\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": [\"103\", 0], \"clip\": [\"4\", 1]}}, \"101\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"102\", 0]}}, \"104\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"\\u001ccafé
 café\", \"negative_text\": \" detailed fur 😀\", \"prepend_text\": \"detailed fur\\u001c\", \"append_text\": \"
 a,…detailed fur\"}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt uses a node the inspector does not support (FancyEncoder).", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt uses a node the inspector does not support (FancyEncoder).", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": {"class_type": "FancyEncoder", "id": "102"}, "reason": "Unsupported node semantics or output slot", "reason_code": "unsupported-node", "sampler_id": "3", "state": "unresolved"}}, "name": "fuzz-0-25", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"103\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"102\": {\"class_type\": \"FancyEncoder\", \"inputs\": {\"text\": \"…\"}}, \"101\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"102\", 0]}}, \"105\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"\\t猫…\", \"clip\": [\"4\", 1]}}, \"104\": {\"class_type\": \"ConditioningZeroOut\", \"inputs\": {\"conditioning\": [\"105\", 0]}}, \"103\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"104\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Positive prompt is empty.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Positive prompt is empty.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Positive prompt is empty", "reason_code": null, "sampler_id": "3", "state": "unresolved"}}, "name": "fuzz-0-26", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"106\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"105\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"\\tdetailed fur \", \"clip\": [\"4\", 1]}}, \"104\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"105\", 0], \"conditioning_2\": [\"105\", 0]}}, \"103\": {\"class_type\": \"ConditioningZeroOut\", \"inputs\": {\"conditioning\": [\"104\", 0]}}, \"102\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"103\", 0]}}, \"101\": {\"class_type\": \"ConditioningZeroOut\", \"inputs\": {\"conditioning\": [\"102\", 0]}}, \"106\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"\\ncaf\\u00e9\\ufeff\", \"negative_text\": \"\\u3000detailed fur\\u001ca,\", \"prepend_text\": \"\\u0085snow\\u001cfox\", \"append_text\": \"\\u001c\\u732b\\u3000\"}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Missing or malformed graph node", "reason_code": "malformed", "sampler_id": "3", "state": "unresolved"}}, "name": "fuzz-0-27", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"106\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"105\": {\"class_type\": \"PrimitiveString\", \"inputs\": {\"value\": \"a, \"}}, \"104\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"105\", 0]}}, \"103\": {\"class_type\": \"Text Concatenate\", \"inputs\": []}, \"102\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"103\", 0]}}, \"101\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": [\"102\", 0], \"clip\": [\"4\", 1]}}, \"106\": {\"class_type\": \"PromptSearchList\", \"inputs\": {\"text\": \"\\tdetailed fur\\u001c\"}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Missing or malformed graph node", "reason_code": "malformed", "sampler_id": "3", "state": "unresolved"}}, "name": "fuzz-0-28", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"102\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\"}, \"101\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"
 detailed fur\\t\", \"negative_text\": \" snow
 😀\", \"prepend_text\": \"\\n😀…\", \"append_text\": \"\\u001c😀
 \"}}, \"102\": {\"class_type\": \"PromptManagerV2\", \"inputs\": {\"text\": \"\\tfox…\", \"prepend_text\": \" 猫\", \"negative_text\": \"café…\"}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Positive prompt is empty.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Positive prompt is empty.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Positive prompt is empty", "reason_code": null, "sampler_id": "3", "state": "unresolved"}}, "name": "fuzz-0-29", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 1], \"negative\": [\"102\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"101\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"snow \", \"negative_text\": \"\\u0085\\u0085\", \"prepend_text\": \"\\u2028 a, fox\", \"append_text\": \"\\tcaf\\u00e9\"}}, \"102\": {\"class_type\": \"PromptSearchList\", \"inputs\": {\"text\": \"\\u001cdetailed fur\"}}}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "fox…detailed fur \u001c猫 snow a,"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "a,\u001c"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "fuzz-0-30", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"102\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"101\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": \"a,\\u001c\", \"text_l\": \"a,\\u001c\"}}, \"102\": {\"class_type\": \"PromptManager\", \"inputs\": {\"text\": \"\\u001c猫 snow\", \"prepend_text\": \" fox…detailed fur\", \"append_text\": \"a,\", \"clip\": [\"4\", 1]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Expected a typed graph link or text input", "reason_code": "malformed", "sampler_id": "3", "state": "unresolved"}}, "name": "fuzz-0-31", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"106\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"104\": {\"class_type\": \"StringConcatenate\", \"inputs\": {\"string_a\": \"\\u0085a, \", \"string_b\": \"caf\\u00e9\\u2028 \", \"delimiter\": \", \"}}, \"105\": {\"class_type\": \"PrimitiveStringMultiline\", \"inputs\": {\"value\": \"\\u0085caf\\u00e9\\u2028 \"}}, \"103\": {\"class_type\": \"StringConcatenate\", \"inputs\": {\"string_a\": [\"104\", 0], \"string_b\": [\"105\", 0], \"delimiter\": \"\"}}, \"102\": {\"class_type\": \"TextInput\", \"inputs\": {\"string\": [\"103\", 0]}}, \"101\": {\"class_type\": \"PromptManagerV2\", \"inputs\": {\"text\": [\"102\", 0], \"prepend_text\": \"\\tcaf\\u00e9\\n\", \"negative_text\": \"\\u0085fox\\ufeff\"}}, \"106\": {\"class_type\": \"PromptManager\", \"inputs\": {\"text\": \"\\u001ca,\\t\", \"prepend_text\": \"\\u0085\\u732b\\t\", \"append_text\": \"\\ufeffa,\\t\", \"clip\": [\"4\", 1]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt uses a node the inspector does not support (FancyEncoder).", "source": null, "status": "unresolved", "text": null}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "foxsnow"}}, "resolution": {"node": {"class_type": "FancyEncoder", "id": "105"}, "reason": "Unsupported node semantics or output slot", "reason_code": "unsupported-node", "sampler_id": "3", "state": "unresolved"}}, "name": "fuzz-0-32", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"104\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"103\": {\"class_type\": \"Text Concatenate\", \"inputs\": {\"text_a\": \"…foxsnow\", \"clean_whitespace\": true}}, \"102\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": [\"103\", 0], \"clip\": [\"4\", 1]}}, \"101\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"102\", 0]}}, \"105\": {\"class_type\": \"FancyEncoder\", \"inputs\": {\"text\": \"detailed fur \"}}, \"104\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"105\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "fox fox 猫 café…fox …a,\t fox café\ta, snow"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "snow, fox"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "fuzz-0-33", "records": {"comfyassets_prompt_pairs": "{\"version\": 1, \"nodes\": {\"103\": {\"records\": [{\"text\": \" caf\\u00e9\\u3000!\", \"negative_text\": \"\\u0085snow\\u3000\", \"prepend_text\": \"detailed fur\", \"append_text\": \"\", \"positive_text\": \"\\u001c\\ud83d\\ude00\\u2028 \", \"negative_effective_text\": \"\\u0085snow\\u3000\"}]}}}", "prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"106\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"103\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \" café \", \"negative_text\": \"…snow \", \"prepend_text\": \"\\n detailed fur\", \"append_text\": \"\\n \"}}, \"105\": {\"class_type\": \"PromptManagerV2\", \"inputs\": {\"text\": \"\\u001cfox…\", \"prepend_text\": \" snow
 \", \"negative_text\": \"café detailed fur\"}}, \"104\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"105\", 0]}}, \"102\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": false, \"on_true\": [\"103\", 1], \"on_false\": [\"104\", 0]}}, \"101\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"102\", 0]}}, \"109\": {\"class_type\": \"Text Concatenate\", \"inputs\": {\"text_c\": \"…a,\\t\", \"text_d\": \"fox\\n\", \"text_a\": \"\\u001ccafé…fox\", \"clean_whitespace\": \"false\"}}, \"108\": {\"class_type\": \"Text Concatenate\", \"inputs\": {\"text_c\": [\"109\", 0], \"clean_whitespace\": true}}, \"107\": {\"class_type\": \"PromptManagerText\", \"inputs\": {\"text\": [\"108\", 0], \"prepend_text\": \" fox 猫\", \"append_text\": \"…café\\ta,\"}}, \"106\": {\"class_type\": \"PromptManagerFocused\", \"inputs\": {\"text\": [\"107\", 0], \"prepend_text\": \"\\tfox \", \"append_text\": \"snow \", \"clip\": [\"4\", 1]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Positive prompt is empty.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Positive prompt is empty.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Positive prompt is empty", "reason_code": null, "sampler_id": "3", "state": "unresolved"}}, "name": "fuzz-0-34", "records": {"comfyassets_prompt_pairs": "{\"version\": 1, \"nodes\": {\"111\": {\"records\": [{\"text\": \"\\nsnow\\u001c\", \"negative_text\": \"\\tdetailed fur \", \"prepend_text\": \"snow\", \"append_text\": \"\\ufeffa,\", \"positive_text\": \"\\u001c\\n\", \"negative_effective_text\": \"\\tdetailed fur \"}]}}}", "prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"106\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"105\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"\\nfox\\u2028 \", \"clip\": [\"4\", 1]}}, \"104\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"105\", 0]}}, \"103\": {\"class_type\": \"ConditioningZeroOut\", \"inputs\": {\"conditioning\": [\"104\", 0]}}, \"102\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"103\", 0]}}, \"101\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"102\", 0]}}, \"109\": {\"class_type\": \"PromptManagerFocused\", \"inputs\": {\"text\": \"\\ta,\\u2028 \", \"prepend_text\": \"\\u2028 caf\\u00e9\\u3000\", \"append_text\": \"\\u2028 \\ud83d\\ude00\\n\", \"clip\": [\"4\", 1]}}, \"110\": {\"class_type\": \"PromptManagerFocused\", \"inputs\": {\"text\": \"\\u3000\\u2028 \", \"prepend_text\": \"\\u3000detailed fur \", \"append_text\": \"\\tcaf\\u00e9\", \"clip\": [\"4\", 1]}}, \"108\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": true, \"on_true\": [\"109\", 0], \"on_false\": [\"110\", 0]}}, \"111\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"\\nsnow\\u001c\", \"negative_text\": \"\\tdetailed fur \", \"prepend_text\": \"snow\\u0085\", \"append_text\": \"\\ufeffa, \"}}, \"107\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"108\", 0], \"conditioning_2\": [\"111\", 0]}}, \"106\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"107\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "\t😀\t"}, "positive": {"source": "execution", "status": "resolved", "text": "fox"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "fuzz-0-35", "records": {"comfyassets_prompt_pairs": "{\"version\": 1, \"nodes\": {\"101\": {\"records\": [{\"text\": \"caf\\u00e9\\u2028 \", \"negative_text\": \"\\n\\u3000caf\\u00e9\", \"prepend_text\": \"caf\\u00e9\\ufeff\", \"append_text\": \"snow\", \"positive_text\": \"\\ufefffox\\ufeff\", \"negative_effective_text\": \"\\n\\u3000caf\\u00e9\"}]}}}", "prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"102\", 1], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"101\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"caf\\u00e9\\u2028 \", \"negative_text\": \"\\n\\u3000caf\\u00e9\", \"prepend_text\": \"\\u3000caf\\u00e9\\ufeff\", \"append_text\": \"\\u3000snow\\u3000\"}}, \"102\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"\\ufeff\\ud83d\\ude00\\ta,\", \"negative_text\": \"\\t\\ud83d\\ude00\\t\", \"prepend_text\": \" \\u3000\", \"append_text\": \"\\u3000snow\\u001c\"}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt combines several texts (CLIPTextEncodeSDXL).", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt combines several texts (CLIPTextEncodeSDXL).", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": {"class_type": "CLIPTextEncodeSDXL", "id": "101"}, "reason": "Encoder parts have different texts", "reason_code": "composite", "sampler_id": "3", "state": "unresolved"}}, "name": "fuzz-0-36", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"102\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"101\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": \"café
 fox\", \"text_l\": \"
 \"}}, \"103\": {\"class_type\": \"PromptSearchList\", \"inputs\": {\"text\": \"\\n猫\"}}, \"107\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"…\", \"clip\": [\"4\", 1]}}, \"106\": {\"class_type\": \"ConditioningZeroOut\", \"inputs\": {\"conditioning\": [\"107\", 0]}}, \"109\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \" 😀 a,\", \"clip\": [\"4\", 1]}}, \"110\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \" café\\n猫\", \"clip\": [\"4\", 1]}}, \"108\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": true, \"on_true\": [\"109\", 0], \"on_false\": [\"110\", 0]}}, \"105\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"106\", 0], \"conditioning_2\": [\"108\", 0]}}, \"111\": {\"class_type\": \"PromptManagerPositive\", \"inputs\": {\"text\": \"\\tsnow \", \"prepend_text\": \"猫…\", \"negative_text\": \"…detailed fur\\u001c\"}}, \"104\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"105\", 0], \"conditioning_2\": [\"111\", 0]}}, \"102\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": true, \"on_true\": [\"103\", 0], \"on_false\": [\"104\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "…猫\t"}}, "resolution": {"node": null, "reason": "Execution evidence does not match recorded node inputs", "reason_code": "malformed", "sampler_id": "3", "state": "unresolved"}}, "name": "fuzz-0-37", "records": {"comfyassets_prompt_pairs": "{\"version\": 1, \"nodes\": {\"104\": {\"records\": [{\"text\": \"\\ndetailed fur\\u2028 !\", \"negative_text\": \"\\na,\\u2028 \", \"prepend_text\": \"\", \"append_text\": \"detailed fur\", \"positive_text\": \" \\ud83d\\ude00\\n\", \"negative_effective_text\": \"\\na,\\u2028 \"}]}}}", "prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"104\", 1], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": null, \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"103\": {\"class_type\": \"JoinStringMulti\", \"inputs\": {\"string_1\": \"\\u0085\\u732b\\t\", \"inputcount\": 1, \"delimiter\": \",\", \"return_list\": false}}, \"102\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": [\"103\", 0], \"text_l\": [\"103\", 0]}}, \"101\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"102\", 0], \"conditioning_2\": [\"102\", 0]}}, \"104\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"\\ndetailed fur\\u2028 \", \"negative_text\": \"\\na,\\u2028 \", \"prepend_text\": \" \\u0085\", \"append_text\": \"\\ndetailed fur\"}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "
 fox\t猫"}}, "resolution": {"node": null, "reason": "Missing or malformed graph node", "reason_code": "malformed", "sampler_id": "3", "state": "unresolved"}}, "name": "fuzz-0-38", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"102\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"101\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"\\u2028 fox\\t\\u732b\", \"clip\": [\"4\", 1]}}, \"105\": {\"class_type\": \"TextInput\", \"inputs\": {\"string\": \"\\u3000\\u732b\\u3000\"}}, \"104\": {\"class_type\": \"StringConstantMultiline\", \"inputs\": {\"string\": [\"105\", 0], \"strip_newlines\": true}}, \"103\": {\"class_type\": \"StringConstant\", \"inputs\": {\"string\": [\"104\", 0]}}, \"102\": {\"class_type\": \"PromptManagerV2\"}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt uses a node the inspector does not support (Unknown).", "source": null, "status": "unresolved", "text": null}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "
 snow
 \t"}}, "resolution": {"node": {"class_type": "Unknown", "id": "105"}, "reason": "Unsupported node semantics or output slot", "reason_code": "unsupported-node", "sampler_id": "3", "state": "unresolved"}}, "name": "fuzz-0-39", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"105\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"104\": {\"class_type\": \"PrimitiveString\", \"inputs\": {\"value\": \"\\u2028 snow\\u2028 \"}}, \"103\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"104\", 0]}}, \"102\": {\"class_type\": \"Text Concatenate\", \"inputs\": {\"text_b\": [\"103\", 0], \"text_c\": \"\\ufeff\\t\", \"clean_whitespace\": \"false\"}}, \"101\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": [\"102\", 0], \"clip\": [\"4\", 1]}}, \"108\": {\"class_type\": \"StringConstantMultiline\", \"inputs\": {\"string\": \"\\ufeff\\u732b\\u0085\", \"strip_newlines\": false}}, \"107\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"108\", 0]}}, \"106\": {\"class_type\": \"PromptManager\", \"inputs\": {\"text\": [\"107\", 0], \"prepend_text\": \"\", \"append_text\": \"\\ufeff\\u732b\\u3000\", \"clip\": [\"4\", 1]}}, \"105\": {\"class_type\": \"Unknown\", \"inputs\": {\"conditioning_1\": [\"106\", 0], \"conditioning_2\": [\"106\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Execution evidence does not match recorded node inputs", "reason_code": "malformed", "sampler_id": "3", "state": "unresolved"}}, "name": "fuzz-0-40", "records": {"comfyassets_prompt_pairs": "{\"version\": 1, \"nodes\": {\"102\": {\"records\": [{\"text\": \"\\n\\ud83d\\ude00\\u2028 !\", \"negative_text\": \" fox\\t\", \"prepend_text\": \"caf\\u00e9\\ufeff\", \"append_text\": \"detailed fur\\ufeffdetailed fur\", \"positive_text\": \"\\u0085\\u732b\\u0085detailed fur\", \"negative_effective_text\": \" fox\\t\"}]}}}", "prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"103\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"102\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"\\n\\ud83d\\ude00\\u2028 \", \"negative_text\": \" fox\\t\", \"prepend_text\": \"\\u0085caf\\u00e9\\ufeff\", \"append_text\": \"\\ndetailed fur\\ufeffdetailed fur\"}}, \"101\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"102\", 0]}}, \"106\": {\"class_type\": \"StringConstantMultiline\", \"inputs\": {\"string\": \"fox\", \"strip_newlines\": true}}, \"105\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"106\", 0]}}, \"104\": {\"class_type\": \"PrimitiveString\", \"inputs\": {\"value\": [\"105\", 0]}}, \"103\": {\"class_type\": \"PromptManagerV2\", \"inputs\": {\"text\": [\"104\", 0], \"prepend_text\": \" detailed fur\\u001c\", \"negative_text\": \"\\u0085\\na,\"}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt uses a node the inspector does not support (FancyEncoder).", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt uses a node the inspector does not support (FancyEncoder).", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": {"class_type": "FancyEncoder", "id": "101"}, "reason": "Unsupported node semantics or output slot", "reason_code": "unsupported-node", "sampler_id": "3", "state": "unresolved"}}, "name": "fuzz-0-41", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"102\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"101\": {\"class_type\": \"FancyEncoder\", \"inputs\": {\"text\": \"snow\\tsnow\"}}, \"104\": {\"class_type\": \"PrimitiveString\", \"inputs\": {\"value\": \" detailed fur \"}}, \"103\": {\"class_type\": \"StringConstantMultiline\", \"inputs\": {\"string\": [\"104\", 0], \"strip_newlines\": true}}, \"102\": {\"class_type\": \"PromptManagerFocused\", \"inputs\": {\"text\": [\"103\", 0], \"prepend_text\": \"\\t\\u001c\", \"append_text\": \"
 snow \", \"clip\": [\"4\", 1]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Positive prompt is empty.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Positive prompt is empty.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Positive prompt is empty", "reason_code": null, "sampler_id": "3", "state": "unresolved"}}, "name": "fuzz-0-42", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"102\": {\"class_type\": \"PromptManagerFocused\", \"inputs\": {\"text\": \"猫\", \"prepend_text\": \"
 a,\\t\", \"append_text\": \" \\u001c\", \"clip\": [\"4\", 1]}}, \"101\": {\"class_type\": \"ConditioningZeroOut\", \"inputs\": {\"conditioning\": [\"102\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "café, detailed fur snow a, café \t😀"}, "positive": {"source": "execution", "status": "resolved", "text": "snow "}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "3", "state": "resolved"}}, "name": "fuzz-0-43", "records": {"comfyassets_prompt_pairs": "{\"version\": 1, \"nodes\": {\"105\": {\"records\": [{\"text\": \"\\u3000a,\\u3000\", \"negative_text\": \"snow\\u3000\", \"prepend_text\": \"caf\\u00e9detailed fur\", \"append_text\": \"fox\\u001csnow\", \"positive_text\": \"\\u0085\\u732b\\u2028 \", \"negative_effective_text\": \"snow\\u3000\"}]}}}", "prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"106\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"104\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": \"café…\", \"text_l\": \"café…\"}}, \"103\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"104\", 0], \"conditioning_2\": [\"104\", 0]}}, \"102\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"103\", 0]}}, \"105\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \" a, \", \"negative_text\": \"snow \", \"prepend_text\": \"\\tcafédetailed fur\", \"append_text\": \"\\nfox\\u001csnow\"}}, \"101\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": false, \"on_true\": [\"102\", 0], \"on_false\": [\"105\", 1]}}, \"109\": {\"class_type\": \"Text Concatenate\", \"inputs\": {\"text_a\": \"\\na, \", \"text_c\": \"\\ncafé\\n\", \"clean_whitespace\": true}}, \"108\": {\"class_type\": \"JoinStringMulti\", \"inputs\": {\"string_1\": \" detailed fur snow\", \"string_2\": [\"109\", 0], \"string_3\": \"\\t😀…\", \"inputcount\": 3, \"delimiter\": \" \", \"return_list\": false}}, \"107\": {\"class_type\": \"PromptManagerV2\", \"inputs\": {\"text\": [\"108\", 0], \"prepend_text\": \"…café\\u001c\", \"negative_text\": \"snow…\"}}, \"106\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"107\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "absent", "text": null}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "😀 😀"}}, "resolution": {"node": null, "reason": "Negative input is absent", "reason_code": null, "sampler_id": "3", "state": "absent"}}, "name": "fuzz-0-44", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"101\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"\\ud83d\\ude00 \\ud83d\\ude00\", \"clip\": [\"4\", 1]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "café\u001cdetailed fur, detailed fur"}}, "resolution": {"node": null, "reason": "Missing or malformed graph node", "reason_code": "malformed", "sampler_id": "3", "state": "unresolved"}}, "name": "fuzz-0-45", "records": {"comfyassets_prompt_pairs": "{\"version\": 1, \"nodes\": {\"106\": {\"records\": [{\"text\": \"\\u001cfox\\u3000!\", \"negative_text\": \"\\n\\ud83d\\ude00\\u0085\", \"prepend_text\": \"\\ufeff\", \"append_text\": \"a,\", \"positive_text\": \"\\ufeffdetailed fur\\ud83d\\ude00\", \"negative_effective_text\": \"\\n\\ud83d\\ude00\\u0085\"}]}}}", "prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"102\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"101\": {\"class_type\": \"PromptManagerV2\", \"inputs\": {\"text\": \"
 detailed fur \", \"prepend_text\": \"…café\\u001cdetailed fur\", \"negative_text\": \"\\u001cfox\"}}, \"105\": {\"class_type\": \"PrimitiveString\", \"inputs\": {\"value\": \"
 😀\"}}, \"104\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"105\", 0]}}, \"103\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": []}, \"106\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"\\u001cfox \", \"negative_text\": \"\\n😀…\", \"prepend_text\": \"
 \", \"append_text\": \" a,\\t\"}}, \"102\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"103\", 0], \"conditioning_2\": [\"106\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt combines several texts (CLIPTextEncodeSDXL).", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt combines several texts (CLIPTextEncodeSDXL).", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": {"class_type": "CLIPTextEncodeSDXL", "id": "102"}, "reason": "Encoder parts have different texts", "reason_code": "composite", "sampler_id": "3", "state": "unresolved"}}, "name": "fuzz-0-46", "records": {"comfyassets_prompt_pairs": "{\"version\": 1, \"nodes\": {\"106\": {\"records\": [{\"text\": \"\\t\\u732b\\u2028 \", \"negative_text\": \"\\u001cfox\\ufeff\", \"prepend_text\": \"fox\\u3000detailed fur\", \"append_text\": \"detailed fur\", \"positive_text\": \" fox \\u732b\", \"negative_effective_text\": \"\\u001cfox\\ufeff\"}]}}}", "prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"107\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"103\": {\"class_type\": \"PromptManagerText\", \"inputs\": {\"text\": \" detailed fur\", \"prepend_text\": \"café
 \", \"append_text\": \"\\ncafé\\n\"}}, \"102\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": [\"103\", 0], \"text_l\": \"\\t😀
 猫\"}}, \"106\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"\\t猫
 \", \"negative_text\": \"\\u001cfox\", \"prepend_text\": \"\\nfox detailed fur\", \"append_text\": \"\\ndetailed fur
 \"}}, \"105\": {\"class_type\": \"ConditioningZeroOut\", \"inputs\": {\"conditioning\": [\"106\", 0]}}, \"104\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"105\", 0]}}, \"101\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": [\"4\", 1], \"on_true\": [\"102\", 0], \"on_false\": [\"104\", 0]}}, \"110\": {\"class_type\": \"Text Concatenate\", \"inputs\": {\"text_b\": \"fox
 \", \"text_c\": \"猫\", \"text_a\": \"\\t😀\", \"clean_whitespace\": \"false\"}}, \"109\": {\"class_type\": \"JoinStringMulti\", \"inputs\": {\"string_1\": [\"110\", 0], \"inputcount\": 1, \"delimiter\": \",\", \"return_list\": false}}, \"108\": {\"class_type\": \"PromptManagerFocused\", \"inputs\": {\"text\": [\"109\", 0], \"prepend_text\": \"\\u001c😀\\t猫\", \"append_text\": \" a,\", \"clip\": [\"4\", 1]}}, \"107\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"108\", 0], \"conditioning_2\": [\"108\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt uses a node the inspector does not support (FancyEncoder).", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt uses a node the inspector does not support (FancyEncoder).", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": {"class_type": "FancyEncoder", "id": "102"}, "reason": "Unsupported node semantics or output slot", "reason_code": "unsupported-node", "sampler_id": "3", "state": "unresolved"}}, "name": "fuzz-0-47", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"105\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"102\": {\"class_type\": \"FancyEncoder\", \"inputs\": {\"text\": \" detailed fur
 猫\"}}, \"104\": {\"class_type\": \"PromptManager\", \"inputs\": {\"text\": \"\\tcafé
 \", \"prepend_text\": \"
 …😀\", \"append_text\": \"fox\\n\", \"clip\": [\"4\", 1]}}, \"103\": {\"class_type\": \"ConditioningZeroOut\", \"inputs\": {\"conditioning\": [\"104\", 0]}}, \"101\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": [\"4\", 0], \"on_true\": [\"102\", 0], \"on_false\": [\"103\", 0]}}, \"105\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": \" 😀\", \"text_l\": \" 😀\"}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Missing or malformed graph node", "reason_code": "malformed", "sampler_id": "3", "state": "unresolved"}}, "name": "fuzz-0-48", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"102\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": []}, \"101\": {\"class_type\": \"FancyEncoder\", \"inputs\": {\"text\": \"\\u001cfoxcafé\"}}, \"106\": {\"class_type\": \"PrimitiveString\", \"inputs\": {\"value\": \" detailed fur \"}}, \"105\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"106\", 0]}}, \"104\": {\"class_type\": \"PromptManagerFocused\", \"inputs\": {\"text\": [\"105\", 0], \"prepend_text\": \"\\tfox
 猫\", \"append_text\": \" 猫…café\", \"clip\": [\"4\", 1]}}, \"103\": {\"class_type\": \"ConditioningZeroOut\", \"inputs\": {\"conditioning\": [\"104\", 0]}}, \"102\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"103\", 0], \"conditioning_2\": [\"103\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt is chosen at run time (PromptSearchList).", "source": null, "status": "unresolved", "text": null}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "café
 😀"}}, "resolution": {"node": {"class_type": "PromptSearchList", "id": "102"}, "reason": "Text is decided at run time", "reason_code": "runtime-value", "sampler_id": "3", "state": "unresolved"}}, "name": "fuzz-0-49", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"102\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"101\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"café
 😀\", \"clip\": [\"4\", 1]}}, \"102\": {\"class_type\": \"PromptSearchList\", \"inputs\": {\"text\": \"a,\\t\"}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt is chosen at run time (PromptSearchList).", "source": null, "status": "unresolved", "text": null}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "😀"}}, "resolution": {"node": {"class_type": "PromptSearchList", "id": "105"}, "reason": "Text is decided at run time", "reason_code": "runtime-value", "sampler_id": "3", "state": "unresolved"}}, "name": "fuzz-0-50", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"104\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"103\": {\"class_type\": \"PrimitiveString\", \"inputs\": {\"value\": \" \\t\"}}, \"102\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"103\", 0]}}, \"101\": {\"class_type\": \"PromptManagerPositive\", \"inputs\": {\"text\": [\"102\", 0], \"prepend_text\": \"\\u2028 \\ud83d\\ude00\\u2028 \", \"negative_text\": \"\\ufeffdetailed fur\\t\"}}, \"105\": {\"class_type\": \"PromptSearchList\", \"inputs\": {\"text\": \" caf\\u00e9\\ufeffsnow\"}}, \"104\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"105\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt combines several texts (ConditioningCombine).", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt combines several texts (ConditioningCombine).", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": {"class_type": "ConditioningCombine", "id": "101"}, "reason": "Combined conditioning has different texts", "reason_code": "composite", "sampler_id": "3", "state": "unresolved"}}, "name": "fuzz-0-51", "records": {"comfyassets_prompt_pairs": "{\"version\": 1, \"nodes\": {\"105\": {\"records\": [{\"text\": \"\\ufeffcaf\\u00e9\\u2028 \", \"negative_text\": \"\\t\\u732b\\t\", \"prepend_text\": \"\\ufeff\\ud83d\\ude00\\u001cdetailed fur\", \"append_text\": \"fox\", \"positive_text\": \"\\ud83d\\ude00\\t\", \"negative_effective_text\": \"\\t\\u732b\\t\"}]}}}", "prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"106\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"104\": {\"class_type\": \"PromptManagerText\", \"inputs\": {\"text\": \"a,\\tdetailed fur\", \"prepend_text\": \"\\nfox\\n\", \"append_text\": \"fox\\u3000\\ud83d\\ude00\"}}, \"103\": {\"class_type\": \"StringConcatenate\", \"inputs\": {\"string_a\": \"caf\\u00e9 \", \"string_b\": [\"104\", 0], \"delimiter\": \", \"}}, \"102\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": [\"103\", 0], \"text_l\": [\"103\", 0]}}, \"105\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"\\ufeffcaf\\u00e9\\u2028 \", \"negative_text\": \"\\t\\u732b\\t\", \"prepend_text\": \"\\ufeff\\ud83d\\ude00\\u001cdetailed fur\", \"append_text\": \"fox\\u2028 \"}}, \"101\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"102\", 0], \"conditioning_2\": [\"105\", 0]}}, \"109\": {\"class_type\": \"StringConcatenate\", \"inputs\": {\"string_a\": \" a,\\na,\", \"string_b\": \"\\ufeff\\u2028 \", \"delimiter\": \"\"}}, \"108\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"109\", 0]}}, \"107\": {\"class_type\": \"PrimitiveStringMultiline\", \"inputs\": {\"value\": [\"108\", 0]}}, \"106\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": [\"107\", 0], \"text_l\": [\"107\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt uses a node the inspector does not support (FancyEncoder).", "source": null, "status": "unresolved", "text": null}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "fox, a,"}}, "resolution": {"node": {"class_type": "FancyEncoder", "id": "103"}, "reason": "Unsupported node semantics or output slot", "reason_code": "unsupported-node", "sampler_id": "3", "state": "unresolved"}}, "name": "fuzz-0-52", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"103\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\"}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"102\": {\"class_type\": \"StringConstantMultiline\", \"inputs\": {\"string\": \" a,\\t\", \"strip_newlines\": true}}, \"101\": {\"class_type\": \"PromptManagerPositive\", \"inputs\": {\"text\": [\"102\", 0], \"prepend_text\": \"\\tfox \", \"negative_text\": \" snow
 😀\"}}, \"103\": {\"class_type\": \"FancyEncoder\", \"inputs\": {\"text\": \" a,\"}}}"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "ambiguous", "text": null}, "positive": {"source": null, "status": "ambiguous", "text": null}}, "resolution": {"node": null, "reason": "Selector branches have different prompts", "reason_code": null, "sampler_id": "3", "state": "ambiguous"}}, "name": "fuzz-0-53", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"106\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"102\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"\\nsnow\\ufeffdetailed fur\", \"negative_text\": \"\\nfox\\u2028 \", \"prepend_text\": \"a,\", \"append_text\": \"\\ta,\\u001c\"}}, \"105\": {\"class_type\": \"PromptManagerText\", \"inputs\": {\"text\": \"\\u0085caf\\u00e9\", \"prepend_text\": \"\\ufeffcaf\\u00e9\\u3000\", \"append_text\": \"\\ufeff\\u732b\"}}, \"104\": {\"class_type\": \"StringConstantMultiline\", \"inputs\": {\"string\": [\"105\", 0], \"strip_newlines\": true}}, \"103\": {\"class_type\": \"PromptManager\", \"inputs\": {\"text\": [\"104\", 0], \"prepend_text\": \"\\ufeffa,\\u0085\", \"append_text\": \"\\tdetailed fur\\u3000detailed fur\", \"clip\": [\"4\", 1]}}, \"101\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": [\"4\", 0], \"on_true\": [\"102\", 0], \"on_false\": [\"103\", 0]}}, \"109\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"\\ufeff\\u732b\\u0085\", \"negative_text\": \"\\ta,\\n\\ud83d\\ude00\", \"prepend_text\": \"\\u2028 caf\\u00e9\\u2028 \", \"append_text\": \" \\ufeffcaf\\u00e9\"}}, \"108\": {\"class_type\": \"ConditioningZeroOut\", \"inputs\": {\"conditioning\": [\"109\", 1]}}, \"111\": {\"class_type\": \"StringConstantMultiline\", \"inputs\": {\"string\": \"\\u001cdetailed fur\\u001c\", \"strip_newlines\": false}}, \"110\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": [\"111\", 0], \"text_l\": \"\"}}, \"107\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"108\", 0], \"conditioning_2\": [\"110\", 0]}}, \"106\": {\"class_type\": \"ConditioningZeroOut\", \"inputs\": {\"conditioning\": [\"107\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Execution evidence does not match recorded node inputs", "reason_code": "malformed", "sampler_id": "3", "state": "unresolved"}}, "name": "fuzz-0-54", "records": {"comfyassets_prompt_pairs": "{\"version\": 1, \"nodes\": {\"102\": {\"records\": [{\"text\": \"\\ufeffdetailed fur\\u0085!\", \"negative_text\": \"\\u3000fox \", \"prepend_text\": \"caf\\u00e9\", \"append_text\": \"\", \"positive_text\": \"\\ufeffa,\\n\", \"negative_effective_text\": \"\\u3000fox \"}]}}}", "prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"102\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"detailed fur…\", \"negative_text\": \" fox \", \"prepend_text\": \"…café\\u001c\", \"append_text\": \"
 \\u001c\"}}, \"101\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"102\", 1], \"conditioning_2\": [\"102\", 1]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "No supported sampler was found.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "No supported sampler was found.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "No supported sampler pair found", "reason_code": "no-sampler", "sampler_id": null, "state": "unresolved"}}, "name": "fuzz-0-55", "records": {"prompt": "{\"3\": {\"class_type\": null, \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"105\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"104\": {\"class_type\": \"StringConstantMultiline\", \"inputs\": {\"string\": \"fox \", \"strip_newlines\": false}}, \"103\": {\"class_type\": \"StringConcatenate\", \"inputs\": {\"string_a\": [\"104\", 0], \"string_b\": \"\\u3000caf\\u00e9\\u2028 \", \"delimiter\": \"\"}}, \"102\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"103\", 0]}}, \"101\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": [\"102\", 0], \"clip\": [\"4\", 1]}}, \"109\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"\\tcaf\\u00e9\\n\\ud83d\\ude00\", \"clip\": [\"4\", 1]}}, \"108\": {\"class_type\": \"ConditioningZeroOut\", \"inputs\": {\"conditioning\": [\"109\", 0]}}, \"107\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"108\", 0]}}, \"106\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"107\", 0]}}, \"105\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"106\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt uses a node the inspector does not support (FancyEncoder).", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt uses a node the inspector does not support (FancyEncoder).", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": {"class_type": "FancyEncoder", "id": "101"}, "reason": "Unsupported node semantics or output slot", "reason_code": "unsupported-node", "sampler_id": "3", "state": "unresolved"}}, "name": "fuzz-0-56", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"102\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"101\": {\"class_type\": \"FancyEncoder\", \"inputs\": {\"text\": \"\\t\\u732b\"}}, \"102\": {\"class_type\": \"PromptSearchList\", \"inputs\": {\"text\": \"\\u001csnow\\u001c\"}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Positive prompt is empty.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Positive prompt is empty.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Positive prompt is empty", "reason_code": null, "sampler_id": "3", "state": "unresolved"}}, "name": "fuzz-0-57", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"105\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": []}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"104\": {\"class_type\": \"PromptSearchList\", \"inputs\": {\"text\": \"\\u3000caf\\u00e9\\t\"}}, \"103\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"104\", 0]}}, \"102\": {\"class_type\": \"ConditioningZeroOut\", \"inputs\": {\"conditioning\": [\"103\", 0]}}, \"101\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"102\", 0]}}, \"106\": {\"class_type\": \"PromptSearchList\", \"inputs\": {\"text\": \"\\ufeffa,\\ufeff\\ud83d\\ude00\"}}, \"107\": {\"class_type\": \"PromptSearchList\", \"inputs\": {\"text\": \" a,\\ufeff\\ud83d\\ude00\"}}, \"105\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": false, \"on_true\": [\"106\", 0], \"on_false\": [\"107\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt combines several texts (CLIPTextEncodeSDXL).", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt combines several texts (CLIPTextEncodeSDXL).", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": {"class_type": "CLIPTextEncodeSDXL", "id": "101"}, "reason": "Encoder parts have different texts", "reason_code": "composite", "sampler_id": "3", "state": "unresolved"}}, "name": "fuzz-0-58", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"107\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"104\": {\"class_type\": \"StringConstant\", \"inputs\": {\"string\": \"😀\"}}, \"103\": {\"class_type\": \"Text Concatenate\", \"inputs\": {\"text_d\": \"
 猫\\t\", \"text_a\": \"…detailed fur
 detailed fur\", \"text_b\": [\"104\", 0], \"clean_whitespace\": true}}, \"106\": {\"class_type\": \"StringConstant\", \"inputs\": {\"string\": \"
 detailed fur \"}}, \"105\": {\"class_type\": \"StringConstantMultiline\", \"inputs\": {\"string\": [\"106\", 0], \"strip_newlines\": false}}, \"102\": {\"class_type\": \"StringConcatenate\", \"inputs\": {\"string_a\": [\"103\", 0], \"string_b\": [\"105\", 0], \"delimiter\": \" \"}}, \"101\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": [\"102\", 0], \"text_l\": \"猫\\ndetailed fur\"}}, \"107\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"
 猫\\n\", \"negative_text\": \"\\nsnow
 猫\", \"prepend_text\": \"
 detailed fur \", \"append_text\": \"\\u001c\"}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Expected a typed graph link or text input", "reason_code": "malformed", "sampler_id": "3", "state": "unresolved"}}, "name": "fuzz-0-59", "records": {"prompt": "{\"3\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"103\", 0], \"latent_image\": [\"5\", 0]}}, \"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"102\": {\"class_type\": \"PromptManagerText\", \"inputs\": {\"text\": \"\\nfox\\t\", \"prepend_text\": \" \\ud83d\\ude00\\u001c\", \"append_text\": \"\\u001csnow \"}}, \"101\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {}}, \"104\": {\"class_type\": \"PromptSearchList\", \"inputs\": {\"text\": \"\\ncaf\\u00e9\\u3000\"}}, \"103\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"104\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt combines several texts (ConditioningCombine).", "source": null, "status": "unresolved", "text": null}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "
   café\u001csnow"}}, "resolution": {"node": {"class_type": "ConditioningCombine", "id": "102"}, "reason": "Combined conditioning has different texts", "reason_code": "composite", "sampler_id": "106", "state": "unresolved"}}, "name": "select-fuzz-0-0", "records": {"comfyassets_prompt_pairs": "{\"version\": 1, \"nodes\": {\"103\": {\"records\": [{\"text\": \"\\u001csnow \", \"negative_text\": \"\\u0085\\u2028 detailed fur\", \"prepend_text\": \"\\u732b\", \"append_text\": \"\\ud83d\\ude00\", \"positive_text\": \"snow\\u3000\", \"negative_effective_text\": \"\\u0085\\u2028 detailed fur\"}]}}}", "prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"101\": {\"class_type\": \"PromptManager\", \"inputs\": {\"text\": \"\\u2028 \\u3000\", \"prepend_text\": \"\\u001c\\u0085\", \"append_text\": \"\\ncaf\\u00e9\\u001csnow\", \"clip\": [\"4\", 1]}}, \"103\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"\\u001csnow \", \"negative_text\": \"\\u0085\\u2028 detailed fur\", \"prepend_text\": \"\\u0085\\u732b\\u2028 \", \"append_text\": \"\\u2028 \\ud83d\\ude00\"}}, \"105\": {\"class_type\": \"PromptManagerPositive\", \"inputs\": {\"text\": \"\\u0085snow\\n\", \"prepend_text\": \"\\n\\u732b\\t\", \"negative_text\": \"\\ufeffsnow \"}}, \"104\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"105\", 0]}}, \"102\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"103\", 0], \"conditioning_2\": [\"104\", 0]}}, \"106\": {\"class_type\": \"SamplerCustom\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"102\", 0], \"latent_image\": [\"5\", 0]}}, \"107\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"106\", 0]}}, \"108\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"107\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "ambiguous", "text": null}, "positive": {"source": null, "status": "ambiguous", "text": null}}, "resolution": {"node": null, "reason": "Multiple sampler branches", "reason_code": null, "sampler_id": null, "state": "ambiguous"}}, "name": "select-fuzz-0-1", "records": {"prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"101\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"snow \", \"negative_text\": \"\\n😀\\tcafé\", \"prepend_text\": \"😀café\", \"append_text\": \"\\tfox \"}}, \"106\": {\"class_type\": \"PrimitiveString\", \"inputs\": {\"value\": \" a, \"}}, \"105\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"106\", 0]}}, \"104\": {\"class_type\": \"TextInput\", \"inputs\": {\"text\": [\"105\", 0]}}, \"103\": {\"class_type\": \"StringConstantMultiline\", \"inputs\": {\"string\": [\"104\", 0], \"strip_newlines\": false}}, \"102\": {\"class_type\": \"PromptManagerFocused\", \"inputs\": {\"text\": [\"103\", 0], \"prepend_text\": \"\\n猫\", \"append_text\": \" 猫…\", \"clip\": [\"4\", 1]}}, \"107\": {\"class_type\": \"KSamplerAdvanced\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 1], \"negative\": [\"102\", 0], \"latent_image\": [\"5\", 0]}}, \"109\": {\"class_type\": \"StringConstantMultiline\", \"inputs\": {\"string\": \"
 snow猫\", \"strip_newlines\": true}}, \"108\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": [\"109\", 0], \"text_l\": [\"109\", 0]}}, \"110\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": \"猫\\t\", \"text_l\": \"猫\\t\"}}, \"111\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"108\", 0], \"negative\": [\"110\", 0], \"latent_image\": [\"5\", 0]}}, \"112\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"107\", 0]}}, \"115\": {\"class_type\": \"StringConcatenate\", \"inputs\": {\"string_a\": \"\\u001ca, \", \"string_b\": \" detailed fur\\n\", \"delimiter\": \" \"}}, \"114\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": [\"115\", 0], \"text_l\": [\"115\", 0]}}, \"118\": {\"class_type\": \"StringConcatenate\", \"inputs\": {\"string_a\": \"café\", \"string_b\": \" 😀…\", \"delimiter\": \" \"}}, \"117\": {\"class_type\": \"Text Concatenate\", \"inputs\": {\"text_c\": [\"118\", 0], \"clean_whitespace\": \"false\"}}, \"116\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": [\"117\", 0], \"clip\": [\"4\", 1]}}, \"113\": {\"class_type\": \"DetailerForEach\", \"inputs\": {\"image\": [\"112\", 0], \"model\": [\"4\", 0], \"positive\": [\"114\", 0], \"negative\": [\"116\", 0]}}, \"119\": {\"class_type\": \"PreviewAny\", \"inputs\": {\"images\": [\"113\", 0]}}, \"120\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"113\", 0]}}, \"121\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"111\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt uses a node the inspector does not support (FancyEncoder).", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt uses a node the inspector does not support (FancyEncoder).", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": {"class_type": "FancyEncoder", "id": "102"}, "reason": "Unsupported node semantics or output slot", "reason_code": "unsupported-node", "sampler_id": "104", "state": "unresolved"}}, "name": "select-fuzz-0-2", "records": {"prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"102\": {\"class_type\": \"FancyEncoder\", \"inputs\": {\"text\": \"\\t\\ud83d\\ude00\\u3000\"}}, \"103\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": \"\\na,\\t\", \"text_l\": \"\\na,\\t\"}}, \"101\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"102\", 0], \"conditioning_2\": [\"103\", 0]}}, \"104\": {\"class_type\": \"SamplerCustom\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"latent_image\": [\"5\", 0]}}, \"105\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"104\", 0]}}, \"106\": {\"class_type\": \"PreviewAny\", \"inputs\": {\"images\": [\"105\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "A1111 settings footer is not unique", "reason_code": "malformed", "sampler_id": null, "state": "unresolved"}}, "name": "select-fuzz-0-3", "records": {"parameters": "Steps:٣, a\r\nfox\r"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "A1111 settings footer is not unique", "reason_code": "malformed", "sampler_id": null, "state": "unresolved"}}, "name": "select-fuzz-0-4", "records": {"parameters": "Negative prompt: blur \tfox 
 a,
Negative prompt:\n"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "A1111 settings footer is not unique", "reason_code": "malformed", "sampler_id": null, "state": "unresolved"}}, "name": "select-fuzz-0-5", "records": {"parameters": "\u001ccafé Negative Template: n\n"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt uses a node the inspector does not support (FancyEncoder).", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt uses a node the inspector does not support (FancyEncoder).", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": {"class_type": "FancyEncoder", "id": "109"}, "reason": "Unsupported node semantics or output slot", "reason_code": "unsupported-node", "sampler_id": "111", "state": "unresolved"}}, "name": "select-fuzz-0-6", "records": {"comfyassets_prompt_pairs": "{\"version\": 1, \"nodes\": {\"105\": {\"records\": [{\"text\": \"\\u001csnow\\ufeff\", \"negative_text\": \"\\ud83d\\ude00\\t\", \"prepend_text\": \"\\ud83d\\ude00\", \"append_text\": \"detailed fur\", \"positive_text\": \"\\u0085snow\\ufeff\", \"negative_effective_text\": \"\\ud83d\\ude00\\t\"}]}}}", "prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"104\": {\"class_type\": \"PrimitiveStringMultiline\", \"inputs\": {\"value\": \"detailed fur…\"}}, \"103\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"104\", 0]}}, \"102\": {\"class_type\": \"Text Concatenate\", \"inputs\": {\"text_c\": \"fox😀\", \"text_a\": [\"103\", 0], \"text_d\": \" 😀😀\", \"clean_whitespace\": \"false\"}}, \"101\": {\"class_type\": \"PromptManagerV2\", \"inputs\": {\"text\": [\"102\", 0], \"prepend_text\": \"…a, \", \"negative_text\": \"fox\\u001c😀\"}}, \"105\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"\\u001csnow\", \"negative_text\": \"😀\\t\", \"prepend_text\": \" 😀 \", \"append_text\": \"\\u001cdetailed fur \"}}, \"106\": {\"class_type\": \"KSamplerAdvanced\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"105\", 0], \"latent_image\": [\"5\", 0]}}, \"109\": {\"class_type\": \"FancyEncoder\", \"inputs\": {\"text\": \" snow 猫\"}}, \"108\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"109\", 0]}}, \"107\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"108\", 0]}}, \"110\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"
 😀\", \"clip\": [\"4\", 1]}}, \"111\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"107\", 0], \"negative\": [\"110\", 0], \"latent_image\": [\"5\", 0]}}, \"112\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"106\", 0]}}, \"117\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \" 猫…fox\", \"clip\": [\"4\", 1]}}, \"116\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"117\", 0]}}, \"115\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"116\", 0]}}, \"118\": {\"class_type\": \"PromptManagerV2\", \"inputs\": {\"text\": \" 😀\\n\", \"prepend_text\": \"\\ncafé detailed fur\", \"negative_text\": \"…café \"}}, \"114\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"115\", 0], \"conditioning_2\": [\"118\", 0]}}, \"121\": {\"class_type\": \"PromptManagerFocused\", \"inputs\": {\"text\": \"
 …\", \"prepend_text\": \"猫
 \", \"append_text\": \"\\nfox…\", \"clip\": [\"4\", 1]}}, \"120\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"121\", 0]}}, \"119\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"120\", 0]}}, \"113\": {\"class_type\": \"UltimateSDUpscaleNoUpscale\", \"inputs\": {\"model\": [\"4\", 0], \"positive\": [\"114\", 0], \"negative\": [\"119\", 0], \"upscaled_image\": [\"112\", 0]}}, \"122\": {\"class_type\": \"PreviewImage\", \"inputs\": {\"images\": [\"113\", 0]}}, \"123\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"111\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "ambiguous", "text": null}, "positive": {"source": null, "status": "ambiguous", "text": null}}, "resolution": {"node": null, "reason": "Multiple sampler branches", "reason_code": null, "sampler_id": null, "state": "ambiguous"}}, "name": "select-fuzz-0-7", "records": {"comfyassets_prompt_pairs": "{\"version\": 1, \"nodes\": {\"110\": {\"records\": [{\"text\": \"\\u0085snow\\ufeff\", \"negative_text\": \"\\u001cdetailed fur\\u3000\", \"prepend_text\": \"caf\\u00e9\\ta,\", \"append_text\": \"fox\\ufeff\", \"positive_text\": \" caf\\u00e9\\u0085\", \"negative_effective_text\": \"\\u001cdetailed fur\\u3000\"}]}}}", "prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"104\": {\"class_type\": \"PrimitiveString\", \"inputs\": {\"value\": \"\\ncafé\"}}, \"103\": {\"class_type\": \"PromptManagerV2\", \"inputs\": {\"text\": [\"104\", 0], \"prepend_text\": \"a,\\t\", \"negative_text\": \"a,
 \"}}, \"106\": {\"class_type\": \"StringConcatenate\", \"inputs\": {\"string_a\": \" 猫\\u001c\", \"string_b\": \"  \", \"delimiter\": \"\"}}, \"105\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": [\"106\", 0], \"clip\": [\"4\", 1]}}, \"102\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"103\", 0], \"conditioning_2\": [\"105\", 0]}}, \"101\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"102\", 0]}}, \"107\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"latent_image\": [\"5\", 0]}}, \"110\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"…snow\", \"negative_text\": \"\\u001cdetailed fur \", \"prepend_text\": \"café\\ta,\", \"append_text\": \"fox\"}}, \"109\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"110\", 1], \"conditioning_2\": [\"110\", 1]}}, \"108\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"109\", 0]}}, \"111\": {\"class_type\": \"PromptManagerV2\", \"inputs\": {\"text\": \"\\ta, \", \"prepend_text\": \"…猫 \", \"negative_text\": \"snow\"}}, \"112\": {\"class_type\": \"SamplerCustom\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"108\", 0], \"negative\": [\"111\", 0], \"latent_image\": [\"5\", 0]}}, \"114\": {\"class_type\": \"FancyEncoder\", \"inputs\": {\"text\": \"\\tdetailed fur\\u001c\"}}, \"115\": {\"class_type\": \"PromptSearchList\", \"inputs\": {\"text\": \"
 猫\"}}, \"113\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": true, \"on_true\": [\"114\", 0], \"on_false\": [\"115\", 0]}}, \"119\": {\"class_type\": \"PrimitiveStringMultiline\", \"inputs\": {\"value\": \"\\u001cfox
 \"}}, \"120\": {\"class_type\": \"StringConstantMultiline\", \"inputs\": {\"string\": \"\\nsnow
 \", \"strip_newlines\": false}}, \"118\": {\"class_type\": \"StringConcatenate\", \"inputs\": {\"string_a\": [\"119\", 0], \"string_b\": [\"120\", 0], \"delimiter\": \" \"}}, \"117\": {\"class_type\": \"PromptManagerPositive\", \"inputs\": {\"text\": [\"118\", 0], \"prepend_text\": \"\\u001csnow\\t\", \"negative_text\": \" snow \"}}, \"116\": {\"class_type\": \"ConditioningZeroOut\", \"inputs\": {\"conditioning\": [\"117\", 0]}}, \"121\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"113\", 0], \"negative\": [\"116\", 0], \"latent_image\": [\"5\", 0]}}, \"122\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"107\", 0]}}, \"123\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"122\", 0]}}, \"124\": {\"class_type\": \"PreviewAny\", \"inputs\": {\"images\": [\"122\", 0]}}, \"125\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"112\", 0]}}, \"128\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": \" a,\", \"text_l\": \" a,\"}}, \"131\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"\\t猫snow\", \"clip\": [\"4\", 1]}}, \"132\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"😀…\", \"clip\": [\"4\", 1]}}, \"130\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": true, \"on_true\": [\"131\", 0], \"on_false\": [\"132\", 0]}}, \"129\": {\"class_type\": \"ConditioningZeroOut\", \"inputs\": {\"conditioning\": [\"130\", 0]}}, \"127\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"128\", 0], \"conditioning_2\": [\"129\", 0]}}, \"126\": {\"class_type\": \"UltimateSDUpscale\", \"inputs\": {\"image\": [\"125\", 0], \"model\": [\"4\", 0], \"positive\": [\"113\", 0], \"negative\": [\"127\", 0]}}, \"133\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"126\", 0]}}, \"134\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"121\", 0]}}, \"135\": {\"class_type\": \"PreviewAny\", \"inputs\": {\"images\": [\"134\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Positive prompt is empty.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Positive prompt is empty.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Positive prompt is empty", "reason_code": null, "sampler_id": null, "state": "unresolved"}}, "name": "select-fuzz-0-8", "records": {"parameters": "Steps:٣, a
\u001ca,\t\n"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt is chosen at run time (PromptSearchList).", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt is chosen at run time (PromptSearchList).", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": {"class_type": "PromptSearchList", "id": "119"}, "reason": "Text is decided at run time", "reason_code": "runtime-value", "sampler_id": "117", "state": "unresolved"}}, "name": "select-fuzz-0-9", "records": {"comfyassets_prompt_pairs": "{\"version\": 1, \"nodes\": {\"127\": {\"records\": [{\"text\": \"\\u0085\\u3000\", \"negative_text\": \"\\u2028 a,\\ufeff\", \"prepend_text\": \"\", \"append_text\": \"\\u732b\", \"positive_text\": \"snow \", \"negative_effective_text\": \"\\u2028 a,\\ufeff\"}]}}}", "prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"104\": {\"class_type\": \"StringConstantMultiline\", \"inputs\": {\"string\": \"…猫\\n\", \"strip_newlines\": false}}, \"103\": {\"class_type\": \"PromptManager\", \"inputs\": {\"text\": [\"104\", 0], \"prepend_text\": \"…detailed fur\", \"append_text\": \"detailed fur \", \"clip\": [\"4\", 1]}}, \"102\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"103\", 0]}}, \"101\": {\"class_type\": \"Unknown\", \"inputs\": {\"conditioning_1\": [\"102\", 0], \"conditioning_2\": [\"102\", 0]}}, \"108\": {\"class_type\": \"PrimitiveString\", \"inputs\": {\"value\": \"…😀café\"}}, \"107\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"108\", 0]}}, \"106\": {\"class_type\": \"JoinStringMulti\", \"inputs\": {\"string_1\": \"a,
 detailed fur\", \"string_2\": [\"107\", 0], \"string_3\": \"
 café\", \"inputcount\": 3, \"delimiter\": \" \", \"return_list\": false}}, \"105\": {\"class_type\": \"PromptManager\", \"inputs\": {\"text\": [\"106\", 0], \"prepend_text\": \" café\\n\", \"append_text\": \"…café…\", \"clip\": [\"4\", 1]}}, \"109\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"105\", 0], \"latent_image\": [\"5\", 0]}}, \"113\": {\"class_type\": \"PrimitiveString\", \"inputs\": {\"value\": \"\\u001csnow\\u001c\"}}, \"112\": {\"class_type\": \"PromptManagerText\", \"inputs\": {\"text\": [\"113\", 0], \"prepend_text\": \"\\tdetailed fur\", \"append_text\": \"\\ndetailed fur\\u001c\"}}, \"111\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": [\"112\", 0], \"clip\": [\"4\", 1]}}, \"110\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"111\", 0], \"conditioning_2\": [\"111\", 0]}}, \"114\": {\"class_type\": \"PromptSearchList\", \"inputs\": {\"text\": \"😀 \"}}, \"115\": {\"class_type\": \"SamplerCustom\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"110\", 0], \"negative\": [\"114\", 0], \"latent_image\": [\"109\", 0]}}, \"116\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"109\", 0]}}, \"119\": {\"class_type\": \"PromptSearchList\", \"inputs\": {\"text\": \" fox \"}}, \"118\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"119\", 0], \"conditioning_2\": [\"119\", 0]}}, \"122\": {\"class_type\": \"Text Concatenate\", \"inputs\": {\"text_d\": \"\\u001c😀
 猫\", \"clean_whitespace\": true}}, \"121\": {\"class_type\": \"StringConstantMultiline\", \"inputs\": {\"string\": [\"122\", 0], \"strip_newlines\": true}}, \"120\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": [\"121\", 0], \"text_l\": [\"121\", 0]}}, \"117\": {\"class_type\": \"UltimateSDUpscale\", \"inputs\": {\"image\": [\"116\", 0], \"model\": [\"4\", 0], \"positive\": [\"118\", 0], \"negative\": [\"120\", 0]}}, \"123\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"115\", 0]}}, \"125\": {\"class_type\": \"PromptSearchList\", \"inputs\": {\"text\": \"\\tsnow \"}}, \"127\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"… \", \"negative_text\": \"
 a,\", \"prepend_text\": \"\\u001c\", \"append_text\": \"猫\"}}, \"126\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"127\", 1]}}, \"124\": {\"class_type\": \"UltimateSDUpscaleNoUpscale\", \"inputs\": {\"model\": [\"4\", 0], \"positive\": [\"125\", 0], \"negative\": [\"126\", 0], \"upscaled_image\": [\"123\", 0]}}, \"128\": {\"class_type\": \"PreviewAny\", \"inputs\": {\"images\": [\"124\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Positive prompt is empty.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Positive prompt is empty.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Positive prompt is empty", "reason_code": null, "sampler_id": "110", "state": "unresolved"}}, "name": "select-fuzz-0-10", "records": {"comfyassets_prompt_pairs": "{\"version\": 1, \"nodes\": {\"113\": {\"records\": [{\"text\": \"\\u0085detailed fur\\ufeffdetailed fur\", \"negative_text\": \"\\ufeff\\u001c\", \"prepend_text\": \"a,\\u001cfox\", \"append_text\": \"a,fox\", \"positive_text\": \"\\u3000\\u732b\\u3000\", \"negative_effective_text\": \"\\ufeff\\u001c\"}]}}}", "prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"105\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"\\nsnow\\u3000\", \"clip\": [\"4\", 1]}}, \"104\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"105\", 0], \"conditioning_2\": [\"105\", 0]}}, \"103\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"104\", 0]}}, \"102\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"103\", 0]}}, \"101\": {\"class_type\": \"ConditioningZeroOut\", \"inputs\": {\"conditioning\": [\"102\", 0]}}, \"106\": {\"class_type\": \"PromptManagerFocused\", \"inputs\": {\"text\": \"\\ta,\\tcaf\\u00e9\", \"prepend_text\": \"\\ufeffa,\\u3000\", \"append_text\": \"\\ufeffdetailed fur\\n\", \"clip\": [\"4\", 1]}}, \"107\": {\"class_type\": \"SamplerCustom\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"106\", 0], \"latent_image\": [\"5\", 0]}}, \"108\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"107\", 0]}}, \"109\": {\"class_type\": \"PreviewAny\", \"inputs\": {\"images\": [\"108\", 0]}}, \"112\": {\"class_type\": \"PromptSearchList\", \"inputs\": {\"text\": \"\\n\\ud83d\\ude00 \"}}, \"113\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"\\u0085detailed fur\\ufeffdetailed fur\", \"negative_text\": \"\\ufeff\\u001c\", \"prepend_text\": \"\\na,\\u001cfox\", \"append_text\": \"\\ta,fox\"}}, \"111\": {\"class_type\": \"Unknown\", \"inputs\": {\"boolean\": true, \"on_true\": [\"112\", 0], \"on_false\": [\"113\", 1]}}, \"110\": {\"class_type\": \"DetailerForEach\", \"inputs\": {\"image\": [\"108\", 0], \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"111\", 0]}}, \"114\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"110\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "ambiguous", "text": null}, "positive": {"source": null, "status": "ambiguous", "text": null}}, "resolution": {"node": null, "reason": "Multiple sampler branches", "reason_code": null, "sampler_id": null, "state": "ambiguous"}}, "name": "select-fuzz-0-11", "records": {"comfyassets_prompt_pairs": "{\"version\": 1, \"nodes\": {\"105\": {\"records\": [{\"text\": \"\\u001c\\u0085\", \"negative_text\": \"\\nfox\\u001c\", \"prepend_text\": \"fox\\ufeff\", \"append_text\": \"\\ufefffox\", \"positive_text\": \" snow caf\\u00e9\", \"negative_effective_text\": \"\\nfox\\u001c\"}]}}}", "prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"102\": {\"class_type\": \"StringConstantMultiline\", \"inputs\": {\"string\": \"\\na,\\n\", \"strip_newlines\": false}}, \"101\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": [\"102\", 0], \"clip\": [\"4\", 1]}}, \"105\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"\\u001c…\", \"negative_text\": \"\\nfox\\u001c\", \"prepend_text\": \" fox\", \"append_text\": \"fox \"}}, \"107\": {\"class_type\": \"StringConstant\", \"inputs\": {\"string\": \"\\n\\n😀\"}}, \"106\": {\"class_type\": \"PromptManagerV2\", \"inputs\": {\"text\": [\"107\", 0], \"prepend_text\": \"…a, 😀\", \"negative_text\": \"
 猫\\u001c\"}}, \"104\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"105\", 1], \"conditioning_2\": [\"106\", 0]}}, \"108\": {\"class_type\": \"PromptManagerPositive\", \"inputs\": {\"text\": \"snow detailed fur\", \"prepend_text\": \"\\n猫\\u001c\", \"negative_text\": \"detailed fur😀\"}}, \"103\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"104\", 0], \"conditioning_2\": [\"108\", 0]}}, \"109\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"103\", 0], \"latent_image\": [\"5\", 0]}}, \"111\": {\"class_type\": \"FancyEncoder\", \"inputs\": {\"text\": \"\\n猫 \"}}, \"110\": {\"class_type\": \"ConditioningZeroOut\", \"inputs\": {\"conditioning\": [\"111\", 0]}}, \"116\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"\\tcafé\", \"clip\": [\"4\", 0.0]}}, \"115\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"116\", 0]}}, \"114\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"115\", 0], \"conditioning_2\": [\"115\", 0]}}, \"118\": {\"class_type\": \"PromptSearchList\", \"inputs\": {\"text\": \"\\tdetailed fur\\t\"}}, \"117\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"118\", 0]}}, \"113\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": [\"4\", 1], \"on_true\": [\"114\", 0], \"on_false\": [\"117\", 0]}}, \"112\": {\"class_type\": \"ConditioningZeroOut\", \"inputs\": {\"conditioning\": [\"113\", 0]}}, \"119\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"110\", 0], \"negative\": [\"112\", 0], \"latent_image\": [\"5\", 0]}}, \"120\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"103\", 0], \"latent_image\": [\"5\", 0]}}, \"121\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"109\", 0]}}, \"122\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"121\", 0]}}, \"123\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"121\", 0]}}, \"124\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"119\", 0]}}, \"125\": {\"class_type\": \"PreviewAny\", \"inputs\": {\"images\": [\"124\", 0]}}, \"126\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"120\", 0]}}, \"130\": {\"class_type\": \"JoinStringMulti\", \"inputs\": {\"string_1\": \"\\ncafé\", \"inputcount\": 1, \"delimiter\": \",\", \"return_list\": false}}, \"129\": {\"class_type\": \"PrimitiveStringMultiline\", \"inputs\": {\"value\": [\"130\", 0]}}, \"128\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": [\"129\", 0], \"text_l\": \"\"}}, \"132\": {\"class_type\": \"PromptManager\", \"inputs\": {\"text\": \"…猫…\", \"prepend_text\": \"\\n😀…\", \"append_text\": \"\\u001cfox…\", \"clip\": [\"4\", 1]}}, \"131\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"132\", 0], \"conditioning_2\": [\"132\", 0]}}, \"127\": {\"class_type\": \"UltimateSDUpscale\", \"inputs\": {\"image\": [\"126\", 0], \"model\": [\"4\", 0], \"positive\": [\"128\", 0], \"negative\": [\"131\", 0]}}, \"133\": {\"class_type\": \"PreviewImage\", \"inputs\": {\"images\": [\"127\", 0]}}, \"134\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"127\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt uses a node the inspector does not support (FancyEncoder).", "source": null, "status": "unresolved", "text": null}, "positive": {"source": "graph-inferred", "status": "resolved", "text": " café\n"}}, "resolution": {"node": {"class_type": "FancyEncoder", "id": "104"}, "reason": "Unsupported node semantics or output slot", "reason_code": "unsupported-node", "sampler_id": "105", "state": "unresolved"}}, "name": "select-fuzz-0-12", "records": {"prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"101\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \" café\\n\", \"clip\": [\"4\", 1]}}, \"104\": {\"class_type\": \"FancyEncoder\", \"inputs\": {\"text\": \" detailed fur\"}}, \"103\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"104\", 0]}}, \"102\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"103\", 0], \"conditioning_2\": [\"103\", 0]}}, \"105\": {\"class_type\": \"SamplerCustom\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"102\", 0], \"latent_image\": [\"5\", 0]}}, \"106\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"105\", 0]}}, \"107\": {\"class_type\": \"PreviewAny\", \"inputs\": {\"images\": [\"106\", 0]}}, \"108\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"106\", 0]}}, \"109\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"106\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "ambiguous", "text": null}, "positive": {"source": null, "status": "ambiguous", "text": null}}, "resolution": {"node": null, "reason": "Multiple sampler branches", "reason_code": null, "sampler_id": null, "state": "ambiguous"}}, "name": "select-fuzz-0-13", "records": {"prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"102\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": \"\\t猫\", \"text_l\": \"\"}}, \"101\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"102\", 0]}}, \"104\": {\"class_type\": \"PrimitiveStringMultiline\", \"inputs\": {\"value\": \" fox\"}}, \"103\": {\"class_type\": \"PromptManagerV2\", \"inputs\": {\"text\": [\"104\", 0], \"prepend_text\": \"…😀\\t\", \"negative_text\": \"
 a,\\t\"}}, \"105\": {\"class_type\": \"SamplerCustom\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"103\", 0], \"latent_image\": [\"5\", 0]}}, \"106\": {\"class_type\": \"SamplerCustom\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"103\", 0], \"latent_image\": [\"5\", 0]}}, \"107\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"105\", 0]}}, \"109\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"😀\", \"negative_text\": \" detailed fur…\", \"prepend_text\": \"😀😀\", \"append_text\": \"\\na, \"}}, \"108\": {\"class_type\": \"UltimateSDUpscale\", \"inputs\": {\"image\": [\"107\", 0], \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"109\", 1]}}, \"110\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"108\", 0]}}, \"111\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"106\", 0]}}, \"112\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"111\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "A1111 settings footer is not unique", "reason_code": "malformed", "sampler_id": null, "state": "unresolved"}}, "name": "select-fuzz-0-14", "records": {"parameters": "Negative prompt:\r\ncafé…\r\n"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "café "}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "detailed fur 😀, snow \u001c café
 café"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "105", "state": "resolved"}}, "name": "select-fuzz-0-15", "records": {"prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"103\": {\"class_type\": \"PromptManagerText\", \"inputs\": {\"text\": \"\\u001c\", \"prepend_text\": \"\\nsnow\\u2028 \", \"append_text\": \"\\u001ccaf\\u00e9\\u2028 caf\\u00e9\"}}, \"102\": {\"class_type\": \"Text Concatenate\", \"inputs\": {\"text_b\": [\"103\", 0], \"clean_whitespace\": true}}, \"101\": {\"class_type\": \"PromptManagerPositive\", \"inputs\": {\"text\": [\"102\", 0], \"prepend_text\": \" detailed fur\\u3000\\ud83d\\ude00\", \"negative_text\": \"detailed fur\\u001c\"}}, \"104\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"\\u0085\\u732b\\u3000\", \"negative_text\": \"caf\\u00e9\\u3000\", \"prepend_text\": \"detailed fur\\n\", \"append_text\": \"\\u0085caf\\u00e9\\n\"}}, \"105\": {\"class_type\": \"SamplerCustom\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"104\", 1], \"latent_image\": [\"5\", 0]}}, \"106\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"105\", 0]}}, \"107\": {\"class_type\": \"PreviewImage\", \"inputs\": {\"images\": [\"106\", 0]}}, \"108\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"106\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Positive prompt is empty.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Positive prompt is empty.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Positive prompt is empty", "reason_code": null, "sampler_id": "135", "state": "unresolved"}}, "name": "select-fuzz-0-16", "records": {"comfyassets_prompt_pairs": "{\"version\": 1, \"nodes\": {\"107\": {\"records\": [{\"text\": \"\\tcaf\\u00e9\\t\", \"negative_text\": \"fox\\u2028 \", \"prepend_text\": \"caf\\u00e9\", \"append_text\": \"snow\\u2028 \\ud83d\\ude00\", \"positive_text\": \"\\u001ca,\\u001c\", \"negative_effective_text\": \"fox\\u2028 \"}]}}}", "prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"104\": {\"class_type\": \"StringConstantMultiline\", \"inputs\": {\"string\": \"\\u3000detailed fur\\u3000\", \"strip_newlines\": false}}, \"105\": {\"class_type\": \"PromptManagerText\", \"inputs\": {\"text\": \"\\u0085caf\\u00e9\\u001c\", \"prepend_text\": \"detailed fur\\ufeff\", \"append_text\": \"\\tfox \"}}, \"103\": {\"class_type\": \"JoinStringMulti\", \"inputs\": {\"string_1\": \"\\u0085a, \", \"string_2\": [\"104\", 0], \"string_3\": [\"105\", 0], \"inputcount\": 3, \"delimiter\": \",\", \"return_list\": false}}, \"102\": {\"class_type\": \"Text Concatenate\", \"inputs\": {\"text_a\": [\"103\", 0], \"text_b\": \" \", \"clean_whitespace\": \"true\"}}, \"101\": {\"class_type\": \"PromptManagerV2\", \"inputs\": {\"text\": [\"102\", 0], \"prepend_text\": \"\\u0085detailed fur\\t\", \"negative_text\": \"\\u001cdetailed fur\\t\"}}, \"107\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"\\tcaf\\u00e9\\t\", \"negative_text\": \"fox\\u2028 \", \"prepend_text\": \"\\u3000caf\\u00e9\\u0085\", \"append_text\": \" snow\\u2028 \\ud83d\\ude00\"}}, \"106\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"107\", 0], \"conditioning_2\": [\"107\", 0]}}, \"108\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"106\", 0], \"latent_image\": [\"108\", 0]}}, \"111\": {\"class_type\": \"Text Concatenate\", \"inputs\": {\"text_a\": \"\\u2028 a,\", \"text_c\": \"\\u3000a,\\ufeff\", \"clean_whitespace\": \"false\"}}, \"112\": {\"class_type\": \"StringConcatenate\", \"inputs\": {\"string_a\": \"caf\\u00e9\\t\\u732b\", \"string_b\": \"\\ud83d\\ude00\\u3000\", \"delimiter\": \" \"}}, \"110\": {\"class_type\": \"Text Concatenate\", \"inputs\": {\"text_a\": \"\\ufeff\\u732b\\ufeff\", \"text_b\": [\"111\", 0], \"text_d\": [\"112\", 0], \"clean_whitespace\": true}}, \"109\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": [\"110\", 0], \"clip\": [\"4\", 1]}}, \"113\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": \"\\ufeffa,\\u001c\", \"text_l\": \"\\ufeffa,\\u001c\"}}, \"114\": {\"class_type\": \"SamplerCustom\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"109\", 0], \"negative\": [\"113\", 0], \"latent_image\": [\"5\", 0]}}, \"115\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"108\", 0]}}, \"119\": {\"class_type\": \"PromptManager\", \"inputs\": {\"text\": \"\\u0085snow fox\", \"prepend_text\": \"\\tdetailed fur\", \"append_text\": \"\\u2028 \\ufefffox\", \"clip\": [\"4\", 1]}}, \"118\": {\"class_type\": \"ConditioningZeroOut\", \"inputs\": {\"conditioning\": [\"119\", 0]}}, \"120\": {\"class_type\": \"PromptManagerFocused\", \"inputs\": {\"text\": \"\\u2028 snow \", \"prepend_text\": \" \\ud83d\\ude00\", \"append_text\": \"fox\\u3000\", \"clip\": [\"4\", 1]}}, \"117\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"118\", 0], \"conditioning_2\": [\"120\", 0]}}, \"124\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"\\na,\", \"clip\": [\"4\", 1]}}, \"123\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"124\", 0]}}, \"122\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"123\", 0]}}, \"121\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"122\", 0]}}, \"116\": {\"class_type\": \"DetailerForEach\", \"inputs\": {\"image\": [\"115\", 0], \"model\": [\"4\", 0], \"positive\": [\"117\", 0], \"negative\": [\"121\", 0]}}, \"125\": {\"class_type\": \"PreviewAny\", \"inputs\": {\"images\": [\"116\", 0]}}, \"126\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"114\", 0]}}, \"131\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"\\u3000\\u0085\", \"clip\": [\"4\", 1]}}, \"130\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"131\", 0]}}, \"133\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"\\u3000fox\\t\", \"clip\": [\"4\", 1]}}, \"132\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"133\", 0]}}, \"129\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": false, \"on_true\": [\"130\", 0], \"on_false\": [\"132\", 0]}}, \"128\": {\"class_type\": \"ConditioningZeroOut\", \"inputs\": {\"conditioning\": [\"129\", 0]}}, \"134\": {\"class_type\": \"FancyEncoder\", \"inputs\": {\"text\": \"\\tcaf\\u00e9a,\"}}, \"127\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"126\", 0], \"model\": [\"4\", 0], \"positive\": [\"128\", 0], \"negative\": [\"134\", 0]}}, \"139\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"\\tdetailed fur\", \"clip\": [\"4\", 1]}}, \"138\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"139\", 0]}}, \"137\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"138\", 0]}}, \"136\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"137\", 0]}}, \"135\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"127\", 0], \"model\": [\"4\", 0], \"positive\": [\"109\", 0], \"negative\": [\"136\", 0]}}, \"140\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"135\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "A1111 settings footer is not unique", "reason_code": "malformed", "sampler_id": null, "state": "unresolved"}}, "name": "select-fuzz-0-17", "records": {"parameters": " detailed fur \r\nNegative Template: n\r\nNegative Template: n
Steps:٣, a\r\nNegative prompt: blur
"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "ambiguous", "text": null}, "positive": {"source": null, "status": "ambiguous", "text": null}}, "resolution": {"node": null, "reason": "Selector branches have different prompts", "reason_code": null, "sampler_id": "110", "state": "ambiguous"}}, "name": "select-fuzz-0-18", "records": {"prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"101\": {\"class_type\": \"PromptManagerV2\", \"inputs\": {\"text\": \"\\u0085detailed fur \", \"prepend_text\": \"caf\\u00e9\\u3000detailed fur\", \"negative_text\": \"\\u3000detailed fur \"}}, \"105\": {\"class_type\": \"PromptManager\", \"inputs\": {\"text\": \"\\u001csnow\\u0085\", \"prepend_text\": \"\\u0085\\u2028 \", \"append_text\": \"\\n\\ud83d\\ude00\\t\", \"clip\": [\"4\", 1]}}, \"107\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"\\u3000fox\\u001c\", \"clip\": [\"4\", 1]}}, \"108\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"detailed fur\\u001c\", \"clip\": [\"4\", 1]}}, \"106\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": [\"4\", 0], \"on_true\": [\"107\", 0], \"on_false\": [\"108\", 0]}}, \"104\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": [\"4\", 1], \"on_true\": [\"105\", 0], \"on_false\": [\"106\", 0]}}, \"103\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"104\", 0]}}, \"102\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"103\", 0]}}, \"109\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"102\", 0], \"latent_image\": [\"5\", 0]}}, \"110\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"102\", 0], \"latent_image\": [\"5\", 0]}}, \"111\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"109\", 0]}}, \"114\": {\"class_type\": \"PromptManagerFocused\", \"inputs\": {\"text\": \" detailed fur\\u2028 \", \"prepend_text\": \"\\tcaf\\u00e9\\t\\u732b\", \"append_text\": \"caf\\u00e9\\ufeff\", \"clip\": [\"4\", 1]}}, \"113\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"114\", 0]}}, \"118\": {\"class_type\": \"PrimitiveString\", \"inputs\": {\"value\": \"\\u2028 a,\\u2028 \"}}, \"117\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"118\", 0]}}, \"116\": {\"class_type\": \"PromptManagerFocused\", \"inputs\": {\"text\": [\"117\", 0], \"prepend_text\": \"\\u3000\\ud83d\\ude00\\n\", \"append_text\": \"\\u3000a,\\u3000\", \"clip\": [\"4\", 1]}}, \"119\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"\\u3000detailed fur \", \"negative_text\": \"\\tcaf\\u00e9\", \"prepend_text\": \"\\u2028 snow\\n\", \"append_text\": \"\\u2028 snow\\u001c\"}}, \"115\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": true, \"on_true\": [\"116\", 0], \"on_false\": [\"119\", 1]}}, \"112\": {\"class_type\": \"UltimateSDUpscale\", \"inputs\": {\"image\": [\"111\", 0], \"model\": [\"4\", 0], \"positive\": [\"113\", 0], \"negative\": [\"115\", 0]}}, \"122\": {\"class_type\": \"PromptManagerText\", \"inputs\": {\"text\": \"\\ufeffa,\\u001c\", \"prepend_text\": \"\\ncaf\\u00e9\\u2028 \", \"append_text\": \" caf\\u00e9\"}}, \"121\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": [\"122\", 0], \"text_l\": \"\\u2028 detailed fur\\n\"}}, \"120\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"112\", 0], \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"121\", 0], \"wildcard\": \"\"}}, \"123\": {\"class_type\": \"PreviewAny\", \"inputs\": {\"images\": [\"120\", 0]}}, \"124\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"110\", 0]}}, \"125\": {\"class_type\": \"PreviewAny\", \"inputs\": {\"images\": [\"124\", 0]}}, \"126\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"124\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "ambiguous", "text": null}, "positive": {"source": null, "status": "ambiguous", "text": null}}, "resolution": {"node": null, "reason": "Multiple sampler branches", "reason_code": null, "sampler_id": null, "state": "ambiguous"}}, "name": "select-fuzz-0-19", "records": {"prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"103\": {\"class_type\": \"Text Concatenate\", \"inputs\": {\"text_c\": \"\\t \", \"clean_whitespace\": \"false\"}}, \"102\": {\"class_type\": \"StringConcatenate\", \"inputs\": {\"string_a\": \"\\u2028 \\u2028 \", \"string_b\": [\"103\", 0], \"delimiter\": \", \"}}, \"101\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": [\"102\", 0], \"clip\": [\"4\", 1]}}, \"104\": {\"class_type\": \"PromptManager\", \"inputs\": {\"text\": \"\\nsnow\\u001csnow\", \"prepend_text\": \"\\tfox\\t\", \"append_text\": \"\\nfox\", \"clip\": [\"4\", 1]}}, \"105\": {\"class_type\": \"SamplerCustom\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"104\", 0], \"latent_image\": [\"5\", 0]}}, \"108\": {\"class_type\": \"PrimitiveString\", \"inputs\": {\"value\": \"\\u001c\\ud83d\\ude00\\na,\"}}, \"107\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"108\", 0]}}, \"106\": {\"class_type\": \"PromptManagerPositive\", \"inputs\": {\"text\": [\"107\", 0], \"prepend_text\": \"\\u2028 a, detailed fur\", \"negative_text\": \"\\na,\\u3000\"}}, \"111\": {\"class_type\": \"PromptManagerPositive\", \"inputs\": {\"text\": \"\\tsnow\\u3000\", \"prepend_text\": \"\\ufeff\\u732b\\u0085\", \"negative_text\": \"\\u0085detailed fur\\u001c\"}}, \"110\": {\"class_type\": \"ConditioningZeroOut\", \"inputs\": {\"conditioning\": [\"111\", 0]}}, \"109\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"110\", 0], \"conditioning_2\": [\"110\", 0]}}, \"112\": {\"class_type\": \"KSamplerAdvanced\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"106\", 0], \"negative\": [\"109\", 0], \"latent_image\": [\"5\", 0]}}, \"113\": {\"class_type\": \"PromptManagerPositive\", \"inputs\": {\"text\": \"\\ta,\\n\", \"prepend_text\": \"\\u0085\\ndetailed fur\", \"negative_text\": \"\\u3000\\u2028 \"}}, \"114\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"113\", 0], \"latent_image\": [\"5\", 0]}}, \"115\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"105\", 0]}}, \"119\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": \"\\ufeffa,\\u3000\", \"text_l\": \"\\ufeffa,\\u3000\"}}, \"120\": {\"class_type\": \"PromptManager\", \"inputs\": {\"text\": \"detailed fur a,\", \"prepend_text\": \"\\tsnow\\t\", \"append_text\": \"\\u0085fox\\n\", \"clip\": [\"4\", 1]}}, \"118\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": true, \"on_true\": [\"119\", 0], \"on_false\": [\"120\", 0]}}, \"117\": {\"class_type\": \"ConditioningZeroOut\", \"inputs\": {\"conditioning\": [\"118\", 0]}}, \"121\": {\"class_type\": \"PromptManager\", \"inputs\": {\"text\": \"\\nsnow\\ufefffox\", \"prepend_text\": \"\\u2028 detailed fur \", \"append_text\": \"\\n\\u732b\\u3000\", \"clip\": [\"4\", 1]}}, \"116\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"115\", 0], \"model\": [\"4\", 0], \"positive\": [\"117\", 0], \"negative\": [\"121\", 0]}}, \"126\": {\"class_type\": \"PrimitiveString\", \"inputs\": {\"value\": \"\\u0085\\t\"}}, \"125\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"126\", 0]}}, \"124\": {\"class_type\": \"JoinStringMulti\", \"inputs\": {\"string_1\": [\"125\", 0], \"inputcount\": 1, \"delimiter\": \" \", \"return_list\": false}}, \"123\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": [\"124\", 0], \"clip\": [\"4\", 1]}}, \"127\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": \"\\tcaf\\u00e9\\u3000a,\", \"text_l\": \"\\tcaf\\u00e9\\u3000a,\"}}, \"122\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"116\", 0], \"model\": [\"4\", 0], \"positive\": [\"123\", 0], \"negative\": [\"127\", 0]}}, \"128\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"122\", 0]}}, \"129\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"112\", 0]}}, \"130\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"129\", 0]}}, \"131\": {\"class_type\": \"PreviewAny\", \"inputs\": {\"images\": [\"129\", 0]}}, \"132\": {\"class_type\": \"PreviewAny\", \"inputs\": {\"images\": [\"129\", 0]}}, \"133\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"114\", 0]}}, \"137\": {\"class_type\": \"StringConstantMultiline\", \"inputs\": {\"string\": \"\\ufeff\\u732b\\u0085\", \"strip_newlines\": false}}, \"136\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"137\", 0]}}, \"135\": {\"class_type\": \"PromptManager\", \"inputs\": {\"text\": [\"136\", 0], \"prepend_text\": \"\", \"append_text\": \"\\ufeff\\u732b\\u3000\", \"clip\": [\"4\", 1]}}, \"140\": {\"class_type\": \"PromptManagerPositive\", \"inputs\": {\"text\": \"\\u0085\\t\", \"prepend_text\": \"\\u2028 snow\\nsnow\", \"negative_text\": \"caf\\u00e9\\u0085caf\\u00e9\"}}, \"139\": {\"class_type\": \"ConditioningZeroOut\", \"inputs\": {\"conditioning\": [\"140\", 0]}}, \"138\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"139\", 0]}}, \"134\": {\"class_type\": \"UltimateSDUpscaleNoUpscale\", \"inputs\": {\"model\": [\"4\", 0], \"positive\": [\"135\", 0], \"negative\": [\"138\", 0], \"upscaled_image\": [\"133\", 0]}}, \"141\": {\"class_type\": \"PreviewAny\", \"inputs\": {\"images\": [\"134\", 0]}}, \"142\": {\"class_type\": \"PreviewAny\", \"inputs\": {\"images\": [\"134\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "ambiguous", "text": null}, "positive": {"source": null, "status": "ambiguous", "text": null}}, "resolution": {"node": null, "reason": "Sampler stages have different prompts", "reason_code": "multistage-differs", "sampler_id": "111", "state": "ambiguous"}}, "name": "select-fuzz-0-20", "records": {"comfyassets_prompt_pairs": "{\"version\": 1, \"nodes\": {\"103\": {\"records\": [{\"text\": \"\\t\\n\", \"negative_text\": \"\\u732b\\ufeff\", \"prepend_text\": \"\\u732bcaf\\u00e9\", \"append_text\": \"\", \"positive_text\": \"\\u2028 detailed fur\\u001c\", \"negative_effective_text\": \"\\u732b\\ufeff\"}]}}}", "prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"103\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"\\t\\n\", \"negative_text\": \"猫\", \"prepend_text\": \"…猫café\", \"append_text\": \"\\n\\n\"}}, \"104\": {\"class_type\": \"PromptSearchList\", \"inputs\": {\"text\": \"\\ta,
 \"}}, \"102\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": false, \"on_true\": [\"103\", 1], \"on_false\": [\"104\", 0]}}, \"105\": {\"class_type\": \"PromptManagerPositive\", \"inputs\": {\"text\": \"… snow\", \"prepend_text\": \"\\tdetailed fur\", \"negative_text\": \"\\na,猫\"}}, \"101\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": false, \"on_true\": [\"102\", 0], \"on_false\": [\"105\", 0]}}, \"108\": {\"class_type\": \"PromptManagerText\", \"inputs\": {\"text\": \"
 snow \", \"prepend_text\": \"fox\\n\", \"append_text\": \"…😀 \"}}, \"107\": {\"class_type\": \"PrimitiveString\", \"inputs\": {\"value\": [\"108\", 0]}}, \"106\": {\"class_type\": \"PromptManagerFocused\", \"inputs\": {\"text\": [\"107\", 0], \"prepend_text\": \"\\nfox
 \", \"append_text\": \"😀 \", \"clip\": [\"4\", 1]}}, \"109\": {\"class_type\": \"KSamplerAdvanced\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"106\", 0], \"latent_image\": [\"5\", 0]}}, \"110\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"109\", 0]}}, \"115\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \" fox\\t\", \"clip\": [\"4\", 1]}}, \"116\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"snow
 \", \"clip\": [\"4\", 1]}}, \"114\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_2\": [\"116\", 0]}}, \"113\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"114\", 0]}}, \"112\": {\"class_type\": \"ConditioningZeroOut\", \"inputs\": {\"conditioning\": [\"113\", 0]}}, \"111\": {\"class_type\": \"UltimateSDUpscale\", \"inputs\": {\"image\": [\"110\", 0], \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"112\", 0]}}, \"117\": {\"class_type\": \"PreviewAny\", \"inputs\": {\"images\": [\"111\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Cyclic latent ancestry", "reason_code": "malformed", "sampler_id": "108", "state": "unresolved"}}, "name": "select-fuzz-0-21", "records": {"prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"102\": {\"class_type\": \"StringConstantMultiline\", \"inputs\": {\"string\": \"\\u001c\\ud83d\\ude00\\u3000\\ud83d\\ude00\", \"strip_newlines\": false}}, \"101\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": [\"102\", 0], \"text_l\": \"\"}}, \"106\": {\"class_type\": \"StringConstantMultiline\", \"inputs\": {\"string\": \"\\u0085snow\\u2028 \", \"strip_newlines\": true}}, \"107\": {\"class_type\": \"StringConstantMultiline\", \"inputs\": {\"string\": \"\\u3000\\u732b\\ufeff\", \"strip_newlines\": false}}, \"105\": {\"class_type\": \"Text Concatenate\", \"inputs\": {\"text_a\": [\"106\", 0], \"text_c\": [\"107\", 0], \"clean_whitespace\": true}}, \"104\": {\"class_type\": \"JoinStringMulti\", \"inputs\": {\"string_1\": \" detailed fur snow\", \"string_2\": [\"105\", 0], \"inputcount\": 3, \"delimiter\": \",\", \"return_list\": false}}, \"103\": {\"class_type\": \"PromptManagerV2\", \"inputs\": {\"text\": [\"104\", 0], \"prepend_text\": \"\\u2028 \\u732b\\n\", \"negative_text\": \"\\u0085caf\\u00e9\\u001c\"}}, \"108\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"103\", 0], \"latent_image\": [\"108\", 0]}}, \"109\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"108\", 0]}}, \"110\": {\"class_type\": \"PreviewImage\", \"inputs\": {\"images\": [\"109\", 0]}}, \"111\": {\"class_type\": \"PreviewImage\", \"inputs\": {\"images\": [\"109\", 0]}}, \"112\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"109\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "ambiguous", "text": null}, "positive": {"source": null, "status": "ambiguous", "text": null}}, "resolution": {"node": null, "reason": "Multiple sampler branches", "reason_code": null, "sampler_id": null, "state": "ambiguous"}}, "name": "select-fuzz-0-22", "records": {"comfyassets_prompt_pairs": "{\"version\": 1, \"nodes\": {\"110\": {\"records\": [{\"text\": \"\\n\\u3000snow\", \"negative_text\": \"\\u001csnow\\t\", \"prepend_text\": \"\\ufefffox\", \"append_text\": \"\\u732b\\ufeffdetailed fur\", \"positive_text\": \"caf\\u00e9\\u2028 \", \"negative_effective_text\": \"\\u001csnow\\t\"}]}}}", "prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"102\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"\\ufeffsnow\\u3000\", \"negative_text\": \"\\u3000snow \", \"prepend_text\": \"\\ufeffa,\\tdetailed fur\", \"append_text\": \"\\u001cfox\"}}, \"101\": {\"class_type\": \"ConditioningZeroOut\", \"inputs\": {\"conditioning\": [\"102\", 1]}}, \"103\": {\"class_type\": \"FancyEncoder\", \"inputs\": {\"text\": \"\\u0085\\ud83d\\ude00\"}}, \"104\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"103\", 0], \"latent_image\": [\"5\", 0]}}, \"108\": {\"class_type\": \"TextInput\", \"inputs\": {\"string\": \"\\u001ca,\\u2028 \"}}, \"107\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": [\"108\", 0], \"clip\": [\"4\", 1]}}, \"106\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"107\", 0]}}, \"105\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"106\", 0], \"conditioning_2\": [\"106\", 0]}}, \"110\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"\\n\\u3000snow\", \"negative_text\": \"\\u001csnow\\t\", \"prepend_text\": \"\\ufefffox\\u0085\", \"append_text\": \" \\u732b\\ufeffdetailed fur\"}}, \"114\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"\\t\\ud83d\\ude00\\u2028 \\u732b\", \"clip\": [\"4\", 1]}}, \"113\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"114\", 0]}}, \"115\": {\"class_type\": \"PromptManager\", \"inputs\": {\"text\": \"\\ncaf\\u00e9\\t\", \"prepend_text\": \"\\u2028 \\ud83d\\ude00\", \"append_text\": \"\\nfox\\u3000detailed fur\", \"clip\": [\"4\", 1]}}, \"112\": {\"class_type\": \"ConditioningCombine\"}, \"111\": {\"class_type\": \"ConditioningZeroOut\", \"inputs\": {\"conditioning\": [\"112\", 0]}}, \"109\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": false, \"on_true\": [\"110\", 0], \"on_false\": [\"111\", 0]}}, \"116\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"105\", 0], \"negative\": [\"109\", 0], \"latent_image\": [\"5\", 0]}}, \"117\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"105\", 0], \"negative\": [\"109\", 0], \"latent_image\": [\"5\", 0]}}, \"118\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"104\", 0]}}, \"119\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"118\", 0]}}, \"120\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"116\", 0]}}, \"124\": {\"class_type\": \"PromptManagerText\", \"inputs\": {\"text\": \"\\u001cdetailed fur\\u0085\", \"prepend_text\": \"\\u001csnow\\t\", \"append_text\": \"\\nfox\"}}, \"123\": {\"class_type\": \"StringConstantMultiline\", \"inputs\": {\"string\": [\"124\", 0], \"strip_newlines\": true}}, \"122\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": [\"123\", 0], \"clip\": [\"4\", 1]}}, \"121\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"120\", 0], \"model\": [\"4\", 0], \"positive\": [\"105\", 0], \"negative\": [\"122\", 0]}}, \"127\": {\"class_type\": \"FancyEncoder\", \"inputs\": {\"text\": \"caf\\u00e9\\u0085\"}}, \"126\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"127\", 0]}}, \"125\": {\"class_type\": \"DetailerForEach\", \"inputs\": {\"image\": [\"121\", 0], \"model\": [\"4\", 0], \"positive\": [\"105\", 0], \"negative\": [\"126\", 0]}}, \"128\": {\"class_type\": \"PreviewImage\", \"inputs\": {\"images\": [\"125\", 0]}}, \"129\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"117\", 0]}}, \"133\": {\"class_type\": \"PromptManagerFocused\", \"inputs\": {\"text\": \"\\ufeff\\ud83d\\ude00\\ufeff\\ud83d\\ude00\", \"prepend_text\": \"\\u0085a,\\u3000\", \"append_text\": \"\\u3000\\ud83d\\ude00\\n\\u732b\", \"clip\": [\"4\", 1]}}, \"132\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"133\", 0]}}, \"131\": {\"class_type\": \"ConditioningZeroOut\", \"inputs\": {\"conditioning\": [\"132\", 0]}}, \"134\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"\\u2028 caf\\u00e9\\u2028 \", \"negative_text\": \"\\u2028 \\u0085\\ud83d\\ude00\", \"prepend_text\": \"\\ufefffox\\n\", \"append_text\": \"\\ufeffsnowsnow\"}}, \"130\": {\"class_type\": \"UltimateSDUpscale\", \"inputs\": {\"image\": [\"129\", 0], \"model\": [\"4\", 0], \"positive\": [\"131\", 0], \"negative\": [\"134\", 0]}}, \"135\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"130\", 0]}}, \"136\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"130\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": "execution", "status": "resolved", "text": "😀\t"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "fox snowfox \u001csnow snow snow \tsnow  fox 😀"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "118", "state": "resolved"}}, "name": "select-fuzz-0-23", "records": {"comfyassets_prompt_pairs": "{\"version\": 1, \"nodes\": {\"117\": {\"records\": [{\"text\": \"\\ufeffcaf\\u00e9\\u2028 \", \"negative_text\": \"\\t\\u732b\\t\", \"prepend_text\": \"\\ufeff\\ud83d\\ude00\\u001cdetailed fur\", \"append_text\": \"fox\", \"positive_text\": \"\\ud83d\\ude00\\t\", \"negative_effective_text\": \"\\t\\u732b\\t\"}]}}}", "prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"105\": {\"class_type\": \"PrimitiveString\", \"inputs\": {\"value\": \"\\u0085snow\\u3000\"}}, \"104\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"105\", 0]}}, \"106\": {\"class_type\": \"Text Concatenate\", \"inputs\": {\"text_c\": \"\\u0085\\u732b\\u3000caf\\u00e9\", \"clean_whitespace\": \"true\"}}, \"103\": {\"class_type\": \"Text Concatenate\", \"inputs\": {\"text_b\": [\"104\", 0], \"text_d\": [\"106\", 0], \"clean_whitespace\": \"false\"}}, \"102\": {\"class_type\": \"PrimitiveString\", \"inputs\": {\"value\": [\"103\", 0]}}, \"101\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": [\"102\", 0], \"clip\": [\"4\", 1]}}, \"109\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"\\nfox\", \"negative_text\": \"caf\\u00e9\\u2028 \\ud83d\\ude00\", \"prepend_text\": \"\\u0085\\u732b\", \"append_text\": \"\\ta,\\u001c\"}}, \"108\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"109\", 1]}}, \"107\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"108\", 0]}}, \"110\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"107\", 0], \"latent_image\": [\"111\", 0]}}, \"111\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"107\", 0], \"latent_image\": [\"5\", 0]}}, \"115\": {\"class_type\": \"PromptManagerText\", \"inputs\": {\"text\": \"\\u001csnow\\u3000snow\", \"prepend_text\": \"\\u0085snowfox\", \"append_text\": \"\\tsnow\\u3000\"}}, \"116\": {\"class_type\": \"PrimitiveStringMultiline\", \"inputs\": {\"value\": \"\\tsnow\\u3000\"}}, \"114\": {\"class_type\": \"StringConcatenate\", \"inputs\": {\"string_a\": [\"115\", 0], \"string_b\": [\"116\", 0], \"delimiter\": \" \"}}, \"113\": {\"class_type\": \"StringConstant\", \"inputs\": {\"string\": [\"114\", 0]}}, \"112\": {\"class_type\": \"PromptManager\", \"inputs\": {\"text\": [\"113\", 0], \"prepend_text\": \"\\nfox\\n\", \"append_text\": \"fox\\u3000\\ud83d\\ude00\", \"clip\": [\"4\", 1]}}, \"117\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"\\ufeffcaf\\u00e9\\u2028 \", \"negative_text\": \"\\t\\u732b\\t\", \"prepend_text\": \"\\ufeff\\ud83d\\ude00\\u001cdetailed fur\", \"append_text\": \"fox\\u2028 \"}}, \"118\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"112\", 0], \"negative\": [\"117\", 0], \"latent_image\": [\"5\", 0]}}, \"119\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"110\", 0]}}, \"120\": {\"class_type\": \"PreviewImage\", \"inputs\": {\"images\": [\"119\", 0]}}, \"121\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"111\", 0]}}, \"122\": {\"class_type\": \"PreviewImage\", \"inputs\": {\"images\": [\"121\", 0]}}, \"123\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"118\", 0]}}, \"124\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"123\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt is chosen at run time (PromptSearchList).", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt is chosen at run time (PromptSearchList).", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": {"class_type": "PromptSearchList", "id": "114"}, "reason": "Text is decided at run time", "reason_code": "runtime-value", "sampler_id": "121", "state": "unresolved"}}, "name": "select-fuzz-0-24", "records": {"comfyassets_prompt_pairs": "{\"version\": 1, \"nodes\": {\"101\": {\"records\": [{\"text\": \" a,\\ufeff\", \"negative_text\": \"\\u3000\\u2028 \", \"prepend_text\": \"\\u732b\", \"append_text\": \"a,\", \"positive_text\": \"detailed fur \", \"negative_effective_text\": \"\\u3000\\u2028 \"}]}}}", "prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"101\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \" a,\\ufeff\", \"negative_text\": \"\\u3000\\u2028 \", \"prepend_text\": \" \\u732b\\t\", \"append_text\": \"\\u2028 a,\\t\"}}, \"105\": {\"class_type\": \"StringConstantMultiline\", \"inputs\": {\"string\": \"\\u2028 fox\\u3000\", \"strip_newlines\": false}}, \"104\": {\"class_type\": \"PromptManagerText\", \"inputs\": {\"text\": [\"105\", 0], \"prepend_text\": \"\\u0085fox\\n\", \"append_text\": \"\\ufeffsnow\\u0085\"}}, \"103\": {\"class_type\": \"PromptManagerFocused\", \"inputs\": {\"text\": [\"104\", 0], \"prepend_text\": \"\\nfox\\u2028 \", \"append_text\": \"a,\", \"clip\": [\"4\", 1]}}, \"108\": {\"class_type\": \"PromptManagerPositive\", \"inputs\": {\"text\": \"\\u3000fox\\n\", \"prepend_text\": \"\\ufeffa,\\t\", \"negative_text\": \"\\ufeffcaf\\u00e9\\u3000\"}}, \"109\": {\"class_type\": \"FancyEncoder\", \"inputs\": {\"text\": \"\\nfox\\ufeff\"}}, \"107\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": [\"4\", 0], \"on_true\": [\"108\", 0], \"on_false\": [\"109\", 0]}}, \"106\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"107\", 0]}}, \"102\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": [\"4\", 1], \"on_true\": [\"103\", 0], \"on_false\": [\"106\", 0]}}, \"110\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"102\", 0], \"latent_image\": [\"5\", 0]}}, \"114\": {\"class_type\": \"PromptSearchList\", \"inputs\": {\"text\": \"\\u2028 detailed fur\\u3000detailed fur\"}}, \"113\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"114\", 0]}}, \"117\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"\\n\\ud83d\\ude00\\n\", \"clip\": [\"4\", 1]}}, \"116\": {\"class_type\": \"ConditioningZeroOut\", \"inputs\": {\"conditioning\": [\"117\", 0]}}, \"119\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"\\na,\\t\", \"clip\": [\"4\", 1]}}, \"120\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"\\t\\ud83d\\ude00\\u2028 snow\", \"clip\": [\"4\", 1]}}, \"118\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": [\"4\", 1], \"on_true\": [\"119\", 0], \"on_false\": [\"120\", 0]}}, \"115\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"116\", 0], \"conditioning_2\": [\"118\", 0]}}, \"112\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": [\"4\", 1], \"on_true\": [\"113\", 0], \"on_false\": [\"115\", 0]}}, \"111\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"112\", 0]}}, \"121\": {\"class_type\": \"SamplerCustom\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"111\", 0], \"latent_image\": [\"5\", 0]}}, \"125\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": \"\\u2028 \\n\\ud83d\\ude00\", \"text_l\": \"\\u2028 \\n\\ud83d\\ude00\"}}, \"127\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"\\u0085fox\\u732b\", \"clip\": [\"4\", 1]}}, \"128\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"\\t \\ud83d\\ude00\", \"clip\": [\"4\", 1]}}, \"126\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": [\"4\", 1], \"on_true\": [\"127\", 0], \"on_false\": [\"128\", 0]}}, \"124\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": false, \"on_true\": [\"125\", 0], \"on_false\": [\"126\", 0]}}, \"123\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"124\", 0]}}, \"122\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"123\", 0]}}, \"132\": {\"class_type\": \"JoinStringMulti\", \"inputs\": {\"string_1\": \"\\u001cdetailed fur\\ufeff\", \"string_3\": \" snow\\u2028 \", \"inputcount\": 4, \"delimiter\": \",\", \"return_list\": false}}, \"131\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"132\", 0]}}, \"130\": {\"class_type\": \"StringConstant\", \"inputs\": {\"string\": [\"131\", 0]}}, \"129\": {\"class_type\": \"PromptManagerFocused\", \"inputs\": {\"text\": [\"130\", 0], \"prepend_text\": \" \\u732bsnow\", \"append_text\": \"\\u001csnow\\u3000\", \"clip\": [\"4\", 1]}}, \"133\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"122\", 0], \"negative\": [\"129\", 0], \"latent_image\": [\"5\", 0]}}, \"134\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"110\", 0]}}, \"139\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"\\u001c\\u001c\\ud83d\\ude00\", \"clip\": [\"4\", 1]}}, \"138\": {\"class_type\": \"ConditioningZeroOut\", \"inputs\": {\"conditioning\": [\"139\", 0]}}, \"137\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"138\", 0]}}, \"136\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"137\", 0]}}, \"135\": {\"class_type\": \"DetailerForEach\", \"inputs\": {\"image\": [\"134\", 0], \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"136\", 0], \"wildcard\": [\"4\", 0]}}, \"140\": {\"class_type\": \"PreviewAny\", \"inputs\": {\"images\": [\"135\", 0]}}, \"141\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"121\", 0]}}, \"142\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"141\", 0]}}, \"143\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"133\", 0]}}, \"144\": {\"class_type\": \"PreviewImage\", \"inputs\": {\"images\": [\"143\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt combines several texts (CLIPTextEncodeSDXL).", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt combines several texts (CLIPTextEncodeSDXL).", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": {"class_type": "CLIPTextEncodeSDXL", "id": "102"}, "reason": "Encoder parts have different texts", "reason_code": "composite", "sampler_id": "112", "state": "unresolved"}}, "name": "select-fuzz-0-25", "records": {"comfyassets_prompt_pairs": "{\"version\": 1, \"nodes\": {\"109\": {\"records\": [{\"text\": \" caf\\u00e9\\u3000snow\", \"negative_text\": \"\\u2028 caf\\u00e9\\ufeff\\ud83d\\ude00\", \"prepend_text\": \"\\u732b\", \"append_text\": \"\", \"positive_text\": \"\\u3000detailed fur\\u2028 caf\\u00e9\", \"negative_effective_text\": \"\\u2028 caf\\u00e9\\ufeff\\ud83d\\ude00\"}]}}}", "prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"105\": {\"class_type\": \"PrimitiveString\", \"inputs\": {\"value\": \"\\ncafé\"}}, \"104\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"105\", 0]}}, \"103\": {\"class_type\": \"JoinStringMulti\", \"inputs\": {\"string_1\": \"\\t猫\", \"string_2\": [\"104\", 0], \"inputcount\": 2, \"delimiter\": \",\", \"return_list\": false}}, \"102\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": [\"103\", 0], \"text_l\": \"\\u001c猫 \"}}, \"101\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"102\", 0], \"conditioning_2\": [\"102\", 0]}}, \"109\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \" café snow\", \"negative_text\": \"
 café😀\", \"prepend_text\": \"
 猫\\t\", \"append_text\": \"\\u001c…\"}}, \"108\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"109\", 1], \"conditioning_2\": [\"109\", 1]}}, \"107\": {\"class_type\": \"ConditioningZeroOut\", \"inputs\": {\"conditioning\": [\"108\", 0]}}, \"106\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"107\", 0], \"conditioning_2\": [\"107\", 0]}}, \"110\": {\"class_type\": \"SamplerCustom\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"106\", 0], \"latent_image\": [\"5\", 0]}}, \"111\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"110\", 0]}}, \"113\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \" \\t猫\", \"negative_text\": \"\\nsnow \", \"prepend_text\": \"
 猫
 \", \"append_text\": \" 猫 \"}}, \"112\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"111\", 0], \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"113\", 1]}}, \"114\": {\"class_type\": \"PreviewImage\", \"inputs\": {\"images\": [\"112\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "ambiguous", "text": null}, "positive": {"source": null, "status": "ambiguous", "text": null}}, "resolution": {"node": null, "reason": "Multiple sampler branches", "reason_code": null, "sampler_id": null, "state": "ambiguous"}}, "name": "select-fuzz-0-26", "records": {"comfyassets_prompt_pairs": "{\"version\": 1, \"nodes\": {\"112\": {\"records\": [{\"text\": \"\\ufeffa,\\t\", \"negative_text\": \"\\u3000\\ud83d\\ude00\\ncaf\\u00e9\", \"prepend_text\": \"\", \"append_text\": \"fox\", \"positive_text\": \"\\u0085snow \", \"negative_effective_text\": \"\\u3000\\ud83d\\ude00\\ncaf\\u00e9\"}]}}}", "prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"103\": {\"class_type\": \"PromptSearchList\", \"inputs\": {\"text\": \"\\n\\n\"}}, \"102\": {\"class_type\": \"ConditioningZeroOut\", \"inputs\": {\"conditioning\": [\"103\", 0]}}, \"105\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \" \", \"clip\": [\"4\", 1]}}, \"104\": {\"class_type\": \"ConditioningZeroOut\", \"inputs\": {\"conditioning\": [\"105\", 0]}}, \"101\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": [\"4\", 0], \"on_true\": [\"102\", 0], \"on_false\": [\"104\", 0]}}, \"109\": {\"class_type\": \"PromptManagerText\", \"inputs\": {\"text\": \"…snow\\n\", \"prepend_text\": \" 猫\", \"append_text\": \"\\ta,\\ta,\"}}, \"108\": {\"class_type\": \"JoinStringMulti\", \"inputs\": {\"string_1\": [\"109\", 0], \"inputcount\": 1, \"delimiter\": \",\", \"return_list\": false}}, \"107\": {\"class_type\": \"PromptManagerFocused\", \"inputs\": {\"text\": [\"108\", 0], \"prepend_text\": \" a,\\n😀\", \"append_text\": \"
 detailed fur \", \"clip\": [\"4\", 1]}}, \"106\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"107\", 0], \"conditioning_2\": [\"107\", 0]}}, \"110\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"106\", 0], \"latent_image\": [\"5\", 0]}}, \"112\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"a,\\t\", \"negative_text\": \" 😀\\ncafé\", \"prepend_text\": \"\\t
 \", \"append_text\": \"\\tfox\\u001c\"}}, \"111\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"112\", 0]}}, \"113\": {\"class_type\": \"PromptSearchList\", \"inputs\": {\"text\": \"😀\\u001c\"}}, \"114\": {\"class_type\": \"SamplerCustom\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"111\", 0], \"negative\": [\"113\", 0], \"latent_image\": [\"5\", 0]}}, \"115\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"110\", 0]}}, \"116\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"115\", 0]}}, \"117\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"114\", 0]}}, \"119\": {\"class_type\": \"PromptSearchList\", \"inputs\": {\"text\": \"猫\\t\"}}, \"120\": {\"class_type\": \"PromptSearchList\", \"inputs\": {\"text\": \"\\tsnow 😀\"}}, \"118\": {\"class_type\": \"DetailerForEach\", \"inputs\": {\"image\": [\"117\", 0], \"model\": [\"4\", 0], \"positive\": [\"119\", 0], \"negative\": [\"120\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "ambiguous", "text": null}, "positive": {"source": null, "status": "ambiguous", "text": null}}, "resolution": {"node": null, "reason": "Multiple sampler branches", "reason_code": null, "sampler_id": null, "state": "ambiguous"}}, "name": "select-fuzz-0-27", "records": {"comfyassets_prompt_pairs": "{\"version\": 1, \"nodes\": {\"127\": {\"records\": [{\"text\": \"\\u2028 \\ud83d\\ude00 \\ud83d\\ude00\", \"negative_text\": \"\\u732b \", \"prepend_text\": \"\\ufeffcaf\\u00e9\", \"append_text\": \"\", \"positive_text\": \" snow\\u001cdetailed fur\", \"negative_effective_text\": \"\\u732b \"}]}}}", "prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"102\": {\"class_type\": \"PromptManagerV2\", \"inputs\": {\"text\": \"\\u001cdetailed fur \", \"prepend_text\": \"\\u001c猫\\n\", \"negative_text\": \"\\t猫
 a,\"}}, \"101\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"102\", 0]}}, \"103\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"latent_image\": [\"5\", 0]}}, \"104\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"…detailed fur\\n\", \"negative_text\": \"\\u001c猫\\u001c\", \"prepend_text\": \"\\u001csnow…fox\", \"append_text\": \" \"}}, \"106\": {\"class_type\": \"StringConstantMultiline\"}, \"105\": {\"class_type\": \"PromptManagerFocused\", \"inputs\": {\"text\": [\"106\", 0], \"prepend_text\": \"\\ndetailed fur\", \"append_text\": \"\\t\\u001c\", \"clip\": [\"4\", 1]}}, \"107\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"104\", 0], \"negative\": [\"105\", 0], \"latent_image\": [\"5\", 0]}}, \"108\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"103\", 0]}}, \"112\": {\"class_type\": \"StringConstantMultiline\", \"inputs\": {\"string\": \"…detailed fur\\t\", \"strip_newlines\": true}}, \"111\": {\"class_type\": \"TextInput\", \"inputs\": {\"text\": [\"112\", 0]}}, \"110\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": [\"111\", 0], \"text_l\": [\"111\", 0]}}, \"115\": {\"class_type\": \"PrimitiveString\", \"inputs\": {\"value\": \" detailed fur…\"}}, \"114\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"115\", 0]}}, \"113\": {\"class_type\": \"PromptManagerV2\", \"inputs\": {\"text\": [\"114\", 0], \"prepend_text\": \" \\tdetailed fur\", \"negative_text\": \"猫 \"}}, \"109\": {\"class_type\": \"UltimateSDUpscaleNoUpscale\", \"inputs\": {\"model\": [\"4\", 0], \"positive\": [\"110\", 0], \"negative\": [\"113\", 0], \"upscaled_image\": [\"108\", 0]}}, \"120\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"\\u001c😀
 a,\", \"clip\": [\"4\", 1]}}, \"119\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"120\", 0]}}, \"118\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"119\", 0]}}, \"117\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"118\", 0], \"conditioning_2\": [\"118\", 0]}}, \"121\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"…\\n\", \"clip\": [\"4\", 1]}}, \"116\": {\"class_type\": \"DetailerForEach\", \"inputs\": {\"image\": [\"109\", 0], \"model\": [\"4\", 0], \"positive\": [\"117\", 0], \"negative\": [\"121\", 0]}}, \"122\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"116\", 0]}}, \"123\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"107\", 0]}}, \"127\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"
 😀 😀\", \"negative_text\": \"猫 \", \"prepend_text\": \"café\\t\", \"append_text\": \"\\t\"}}, \"129\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a,\\t\", \"clip\": [\"4\", 1]}}, \"128\": {\"class_type\": \"ConditioningZeroOut\", \"inputs\": {\"conditioning\": [\"129\", 0]}}, \"126\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"127\", 0], \"conditioning_2\": [\"128\", 0]}}, \"131\": {\"class_type\": \"StringConstantMultiline\", \"inputs\": {\"string\": \"\\u001ca,
 \", \"strip_newlines\": false}}, \"130\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": [\"131\", 0], \"clip\": [\"4\", 1]}}, \"125\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": false, \"on_true\": [\"126\", 0], \"on_false\": [\"130\", 0]}}, \"135\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"\\n…\", \"clip\": [\"4\", 1]}}, \"134\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"135\", 0]}}, \"133\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"134\", 0]}}, \"138\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"\\u001c猫\", \"clip\": [\"4\", 1]}}, \"137\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"138\", 0]}}, \"139\": {\"class_type\": \"PromptManagerV2\", \"inputs\": {\"text\": \"\\ndetailed fur…\", \"prepend_text\": \" \", \"negative_text\": \"a,猫\"}}, \"136\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": [\"4\", 1], \"on_true\": [\"137\", 0], \"on_false\": [\"139\", 0]}}, \"132\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": [\"4\", 0], \"on_true\": [\"133\", 0], \"on_false\": [\"136\", 0]}}, \"124\": {\"class_type\": \"UltimateSDUpscale\", \"inputs\": {\"image\": [\"123\", 0], \"model\": [\"4\", 0], \"positive\": [\"125\", 0], \"negative\": [\"132\", 0]}}, \"140\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"124\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "ambiguous", "text": null}, "positive": {"source": null, "status": "ambiguous", "text": null}}, "resolution": {"node": null, "reason": "Multiple sampler branches", "reason_code": null, "sampler_id": null, "state": "ambiguous"}}, "name": "select-fuzz-0-28", "records": {"comfyassets_prompt_pairs": "{\"version\": 1, \"nodes\": {\"101\": {\"records\": [{\"text\": \"\\u001c\\u732b \", \"negative_text\": \"\\u2028 \\u0085\", \"prepend_text\": \"\\u732b\", \"append_text\": \"\", \"positive_text\": \"snow\\u0085\", \"negative_effective_text\": \"\\u2028 \\u0085\"}]}}}", "prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"101\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"\\u001c\\u732b \", \"negative_text\": \"\\u2028 \\u0085\", \"prepend_text\": \"\\u732b\\u001c\", \"append_text\": \"\\u3000\\u2028 \"}}, \"102\": {\"class_type\": \"PromptSearchList\", \"inputs\": {\"text\": \"\\u001c\\u3000\"}}, \"103\": {\"class_type\": \"SamplerCustom\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 1], \"negative\": [\"102\", 0], \"latent_image\": [\"5\", 0]}}, \"104\": {\"class_type\": \"SamplerCustom\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 1], \"negative\": [\"102\", 0], \"latent_image\": [\"5\", 0]}}, \"105\": {\"class_type\": \"SamplerCustom\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 1], \"negative\": [\"102\", 0], \"latent_image\": [\"5\", 0]}}, \"106\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"103\", 0]}}, \"107\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"104\", 0]}}, \"110\": {\"class_type\": \"PromptSearchList\", \"inputs\": {\"text\": \"\\u0085fox\\u3000\"}}, \"109\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"110\", 0]}}, \"114\": {\"class_type\": \"PrimitiveString\", \"inputs\": {\"value\": \"\\ndetailed fur\"}}, \"113\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"114\", 0]}}, \"112\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": [\"113\", 0], \"text_l\": [\"113\", 0]}}, \"116\": {\"class_type\": \"Text Concatenate\", \"inputs\": {\"text_a\": \"detailed fur\\u3000detailed fur\", \"text_d\": \"\\ufeff\\ud83d\\ude00\\u2028 \", \"text_c\": \"\\tsnow \", \"clean_whitespace\": true}}, \"115\": {\"class_type\": \"PromptManagerV2\", \"inputs\": {\"text\": [\"116\", 0], \"prepend_text\": \"\\u001c\\ud83d\\ude00\\u001c\", \"negative_text\": \"\\u001ca,\\ufeff\\ud83d\\ude00\"}}, \"111\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"112\", 0], \"conditioning_2\": [\"115\", 0]}}, \"108\": {\"class_type\": \"UltimateSDUpscaleNoUpscale\", \"inputs\": {\"model\": [\"4\", 0], \"positive\": [\"109\", 0], \"negative\": [\"111\", 0], \"upscaled_image\": [\"107\", 0]}}, \"118\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"\\u3000snow\\u3000snow\", \"negative_text\": \"\\u0085fox\\u001c\", \"prepend_text\": \"foxcaf\\u00e9\", \"append_text\": \"\\ufeffsnow\\tcaf\\u00e9\"}}, \"117\": {\"class_type\": \"UltimateSDUpscale\", \"inputs\": {\"image\": [\"108\", 0], \"model\": [\"4\", 0], \"positive\": [\"101\", 1], \"negative\": [\"118\", 0]}}, \"119\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"117\", 0]}}, \"120\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"105\", 0]}}, \"122\": {\"class_type\": \"PromptManager\", \"inputs\": {\"text\": \"\\ufeffcaf\\u00e9\\u2028 a,\", \"prepend_text\": \"\\u3000caf\\u00e9 \", \"append_text\": \"\\u732b \", \"clip\": [\"4\", 1]}}, \"121\": {\"class_type\": \"UltimateSDUpscaleNoUpscale\", \"inputs\": {\"model\": [\"4\", 0], \"positive\": [\"101\", 1], \"negative\": [\"122\", 0], \"upscaled_image\": [\"120\", 0]}}, \"123\": {\"class_type\": \"PreviewImage\", \"inputs\": {\"images\": [\"121\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "ambiguous", "text": null}, "positive": {"source": null, "status": "ambiguous", "text": null}}, "resolution": {"node": null, "reason": "Multiple sampler branches", "reason_code": null, "sampler_id": null, "state": "ambiguous"}}, "name": "select-fuzz-0-29", "records": {"prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"102\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": \"\\u3000a,\\u001c\", \"text_l\": \"\\u3000a,\\u001c\"}}, \"101\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"102\", 0]}}, \"103\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"\\ncaf\\u00e9\\t\", \"clip\": [\"4\", 1]}}, \"104\": {\"class_type\": \"SamplerCustom\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"103\", 0], \"latent_image\": [\"105\", 0]}}, \"105\": {\"class_type\": \"SamplerCustom\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"103\", 0], \"latent_image\": [\"5\", 0]}}, \"106\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"\\u0085a,\\n\", \"clip\": [\"4\", 1]}}, \"109\": {\"class_type\": \"PrimitiveString\", \"inputs\": {\"value\": \" \\u732b\\u0085\"}}, \"108\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"109\", 0]}}, \"107\": {\"class_type\": \"PromptManagerV2\", \"inputs\": {\"text\": [\"108\", 0], \"prepend_text\": \" \\ud83d\\ude00\\u3000\", \"negative_text\": \"a,\\n\"}}, \"110\": {\"class_type\": \"SamplerCustom\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"106\", 0], \"negative\": [\"107\", 0], \"latent_image\": [\"104\", 0]}}, \"111\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"104\", 0]}}, \"115\": {\"class_type\": \"StringConstant\", \"inputs\": {\"text\": \"\\ncaf\\u00e9\\u001c\"}}, \"114\": {\"class_type\": \"JoinStringMulti\", \"inputs\": {\"string_1\": [\"115\", 0], \"inputcount\": 1, \"delimiter\": \" \", \"return_list\": false}}, \"113\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": [\"114\", 0], \"clip\": [\"4\", 1]}}, \"112\": {\"class_type\": \"UltimateSDUpscale\", \"inputs\": {\"image\": [\"111\", 0], \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"113\", 0]}}, \"116\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"105\", 0]}}, \"117\": {\"class_type\": \"PreviewImage\", \"inputs\": {\"images\": [\"116\", 0]}}, \"121\": {\"class_type\": \"StringConstantMultiline\", \"inputs\": {\"string\": \"\\u732b\\ufeff\", \"strip_newlines\": false}}, \"120\": {\"class_type\": \"PromptManager\", \"inputs\": {\"text\": [\"121\", 0], \"prepend_text\": \"\\u0085a,\\u0085fox\", \"append_text\": \"\\u3000a,\\t\", \"clip\": [\"4\", 1]}}, \"123\": {\"class_type\": \"FancyEncoder\", \"inputs\": {\"text\": \"\\u732b\\ufeff\"}}, \"122\": {\"class_type\": \"ConditioningZeroOut\", \"inputs\": {\"conditioning\": [\"123\", 0]}}, \"119\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": false, \"on_true\": [\"120\", 0], \"on_false\": [\"122\", 0]}}, \"118\": {\"class_type\": \"DetailerForEach\", \"inputs\": {\"image\": [\"116\", 0], \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"119\", 0]}}, \"124\": {\"class_type\": \"PreviewAny\", \"inputs\": {\"images\": [\"118\", 0]}}, \"125\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"110\", 0]}}, \"126\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"125\", 0]}}, \"127\": {\"class_type\": \"PreviewImage\", \"inputs\": {\"images\": [\"125\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt is chosen at run time (JoinStringMulti).", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt is chosen at run time (JoinStringMulti).", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": {"class_type": "JoinStringMulti", "id": "104"}, "reason": "Text is decided at run time", "reason_code": "runtime-value", "sampler_id": "107", "state": "unresolved"}}, "name": "select-fuzz-0-30", "records": {"prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"104\": {\"class_type\": \"JoinStringMulti\", \"inputs\": {\"string_1\": \"\\u001c😀\\u001c\", \"string_2\": \"猫 detailed fur\", \"string_3\": \"detailed fur
 😀\", \"inputcount\": 3, \"delimiter\": \" \", \"return_list\": true}}, \"103\": {\"class_type\": \"JoinStringMulti\", \"inputs\": {\"string_1\": \"
 detailed fur…\", \"string_2\": [\"104\", 0], \"inputcount\": 2, \"delimiter\": \",\", \"return_list\": false}}, \"102\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": [\"103\", 0], \"text_l\": [\"103\", 0]}}, \"101\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"102\", 0]}}, \"106\": {\"class_type\": \"PrimitiveStringMultiline\", \"inputs\": {\"value\": \" snow\\n\"}}, \"105\": {\"class_type\": \"PromptManagerPositive\", \"inputs\": {\"text\": [\"106\", 0], \"prepend_text\": \"\\t…\", \"negative_text\": \"\\t\\u001c\"}}, \"107\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"105\", 0], \"latent_image\": [\"5\", 0]}}, \"108\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"107\", 0]}}, \"109\": {\"class_type\": \"PreviewImage\", \"inputs\": {\"images\": [\"108\", 0]}}, \"110\": {\"class_type\": \"PreviewAny\", \"inputs\": {\"images\": [\"108\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "ambiguous", "text": null}, "positive": {"source": null, "status": "ambiguous", "text": null}}, "resolution": {"node": null, "reason": "Multiple sampler branches", "reason_code": null, "sampler_id": null, "state": "ambiguous"}}, "name": "select-fuzz-0-31", "records": {"comfyassets_prompt_pairs": "{\"version\": 1, \"nodes\": {\"102\": {\"records\": [{\"text\": \"\\tsnow\\u0085fox\", \"negative_text\": \"\\ufeff \", \"prepend_text\": \"caf\\u00e9\\ufeff\", \"append_text\": \"snow\", \"positive_text\": \"\\ufeffa,\", \"negative_effective_text\": \"\\ufeff \"}]}}}", "prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"102\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"\\tsnow…fox\", \"negative_text\": \" \", \"prepend_text\": \"café\", \"append_text\": \"\\u001csnow\\u001c\"}}, \"101\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"102\", 0]}}, \"103\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": \"\\u001ca,\\t\", \"text_l\": \"\"}}, \"104\": {\"class_type\": \"SamplerCustom\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"103\", 0], \"latent_image\": [\"104\", 0]}}, \"107\": {\"class_type\": \"StringConstantMultiline\", \"inputs\": {\"string\": \"\\t…\", \"strip_newlines\": false}}, \"106\": {\"class_type\": \"Text Concatenate\", \"inputs\": {\"text_b\": [\"107\", 0], \"text_d\": \"\\t\\t\", \"clean_whitespace\": \"true\"}}, \"105\": {\"class_type\": \"PromptManagerFocused\", \"inputs\": {\"text\": [\"106\", 0], \"prepend_text\": \"café \", \"append_text\": \"a,\\ta,\", \"clip\": [\"4\", 1]}}, \"109\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": \"…😀…\", \"text_l\": \"\\n😀\\tcafé\"}}, \"112\": {\"class_type\": \"JoinStringMulti\", \"inputs\": {\"string_1\": \"detailed fur \", \"inputcount\": 1, \"delimiter\": \",\", \"return_list\": false}}, \"111\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": [\"112\", 0], \"text_l\": \"snow\\t\"}}, \"110\": {\"class_type\": \"ConditioningZeroOut\", \"inputs\": {\"conditioning\": [\"111\", 0]}}, \"108\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": true, \"on_true\": [\"109\", 0], \"on_false\": [\"110\", 0]}}, \"113\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"105\", 0], \"negative\": [\"108\", 0], \"latent_image\": [\"5\", 0]}}, \"114\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"104\", 0]}}, \"118\": {\"class_type\": \"JoinStringMulti\", \"inputs\": {\"string_1\": \"\\tdetailed fur\\u001c\", \"string_2\": \"\\tsnow\\n\", \"inputcount\": 3, \"delimiter\": \",\", \"return_list\": false}}, \"117\": {\"class_type\": \"PromptManager\", \"inputs\": {\"text\": [\"118\", 0], \"prepend_text\": \"snow…\", \"append_text\": \"\\u001ccafé\", \"clip\": [\"4\", 1]}}, \"116\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"117\", 0], \"conditioning_2\": [\"117\", 0]}}, \"115\": {\"class_type\": \"DetailerForEach\", \"inputs\": {\"image\": [\"114\", 0], \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"116\", 0], \"wildcard\": \"x\"}}, \"119\": {\"class_type\": \"PreviewAny\", \"inputs\": {\"images\": [\"115\", 0]}}, \"120\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"113\", 0]}}, \"121\": {\"class_type\": \"PreviewImage\", \"inputs\": {\"images\": [\"120\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "ambiguous", "text": null}, "positive": {"source": null, "status": "ambiguous", "text": null}}, "resolution": {"node": null, "reason": "Multiple sampler branches", "reason_code": null, "sampler_id": null, "state": "ambiguous"}}, "name": "select-fuzz-0-32", "records": {"comfyassets_prompt_pairs": "{\"version\": 1, \"nodes\": {\"102\": {\"records\": [{\"text\": \"\\u001cfox\\tsnow\", \"negative_text\": \"\\u001c\\t\", \"prepend_text\": \"detailed fur\", \"append_text\": \"\\u732b\", \"positive_text\": \"\\tfox\", \"negative_effective_text\": \"\\u001c\\t\"}]}}}", "prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"102\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"\\u001cfox\\tsnow\", \"negative_text\": \"\\u001c\\t\", \"prepend_text\": \"
 detailed fur \", \"append_text\": \"…猫…\"}}, \"103\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": \"\\u001c猫 \", \"text_l\": \"\\ta, \"}}, \"101\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": [\"4\", 1], \"on_true\": [\"102\", 1], \"on_false\": [\"103\", 0]}}, \"104\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"latent_image\": [\"5\", 0]}}, \"108\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"fox 猫\", \"clip\": [\"4\", 1]}}, \"107\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"108\", 0]}}, \"106\": {\"class_type\": \"ConditioningZeroOut\", \"inputs\": {\"conditioning\": [\"107\", 0]}}, \"105\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"106\", 0]}}, \"109\": {\"class_type\": \"PromptSearchList\", \"inputs\": {\"text\": \"…😀\\u001c\"}}, \"110\": {\"class_type\": \"SamplerCustom\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"105\", 0], \"negative\": [\"109\", 0], \"latent_image\": [\"5\", 0]}}, \"111\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"104\", 0]}}, \"112\": {\"class_type\": \"PreviewAny\", \"inputs\": {\"images\": [\"111\", 0]}}, \"113\": {\"class_type\": \"PreviewImage\", \"inputs\": {\"images\": [\"111\", 0]}}, \"114\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"110\", 0]}}, \"115\": {\"class_type\": \"PreviewAny\", \"inputs\": {\"images\": [\"114\", 0]}}, \"116\": {\"class_type\": \"PreviewImage\", \"inputs\": {\"images\": [\"114\", 0]}}, \"117\": {\"class_type\": \"PreviewAny\", \"inputs\": {\"images\": [\"114\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "ambiguous", "text": null}, "positive": {"source": null, "status": "ambiguous", "text": null}}, "resolution": {"node": null, "reason": "Multiple sampler branches", "reason_code": null, "sampler_id": null, "state": "ambiguous"}}, "name": "select-fuzz-0-33", "records": {"prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"101\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"😀 \", \"clip\": [\"4\", 1]}}, \"102\": {\"class_type\": \"PromptSearchList\", \"inputs\": {\"text\": \"\\t\\n\"}}, \"103\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"102\", 0], \"latent_image\": [\"5\", 0]}}, \"107\": {\"class_type\": \"Text Concatenate\", \"inputs\": {\"text_d\": \"\\ndetailed fur\\tfox\", \"text_a\": \"
 fox \", \"text_b\": \" café\\t\", \"clean_whitespace\": true}}, \"106\": {\"class_type\": \"JoinStringMulti\", \"inputs\": {\"string_1\": [\"107\", 0], \"string_2\": \" \\t\", \"inputcount\": 2, \"delimiter\": \" \", \"return_list\": false}}, \"105\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": [\"106\", 0], \"text_l\": \"\"}}, \"104\": {\"class_type\": \"ConditioningZeroOut\", \"inputs\": {\"conditioning\": [\"105\", 0]}}, \"108\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"104\", 0], \"latent_image\": [\"5\", 0]}}, \"109\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"104\", 0], \"latent_image\": [\"5\", 0]}}, \"110\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"103\", 0]}}, \"111\": {\"class_type\": \"PreviewImage\", \"inputs\": {\"images\": [\"110\", 0]}}, \"112\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"110\", 0]}}, \"113\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"108\", 0]}}, \"115\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": \"猫…\", \"text_l\": \"猫…\"}}, \"116\": {\"class_type\": \"PromptManager\", \"inputs\": {\"text\": \"\\u001cfox\", \"prepend_text\": \"…a,\\t\", \"append_text\": \"
 😀café\", \"clip\": [\"4\", 1]}}, \"114\": {\"class_type\": \"UltimateSDUpscale\", \"inputs\": {\"image\": [\"113\", 0], \"model\": [\"4\", 0], \"positive\": [\"115\", 0], \"negative\": [\"116\", 0]}}, \"117\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"109\", 0]}}, \"121\": {\"class_type\": \"PromptManagerText\", \"inputs\": {\"text\": \" café\\u001c\", \"prepend_text\": \"\\ta,\", \"append_text\": \"\\tdetailed furcafé\"}}, \"120\": {\"class_type\": \"PrimitiveStringMultiline\", \"inputs\": {\"value\": [\"121\", 0]}}, \"119\": {\"class_type\": \"PromptManagerFocused\", \"inputs\": {\"text\": [\"120\", 0], \"prepend_text\": \"\\tsnow \", \"append_text\": \"
 
 \", \"clip\": [\"4\", 1]}}, \"118\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"117\", 0], \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"119\", 0]}}, \"122\": {\"class_type\": \"PreviewImage\", \"inputs\": {\"images\": [\"118\", 0]}}, \"123\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"118\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "A1111 settings footer is not unique", "reason_code": "malformed", "sampler_id": null, "state": "unresolved"}}, "name": "select-fuzz-0-34", "records": {"parameters": "Negative prompt: blur"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "ambiguous", "text": null}, "positive": {"source": null, "status": "ambiguous", "text": null}}, "resolution": {"node": null, "reason": "Multiple sampler branches", "reason_code": null, "sampler_id": null, "state": "ambiguous"}}, "name": "select-fuzz-0-35", "records": {"comfyassets_prompt_pairs": "{\"version\": 1, \"nodes\": {\"102\": {\"records\": [{\"text\": \"\\u001c\\u3000\", \"negative_text\": \"\\n\", \"prepend_text\": \"a,\", \"append_text\": \"\\ud83d\\ude00\\t\\ud83d\\ude00\", \"positive_text\": \" a,\\u3000\\ud83d\\ude00\", \"negative_effective_text\": \"\\n\"}]}}}", "prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"101\": {\"class_type\": \"PromptSearchList\", \"inputs\": {\"text\": \"detailed fur\\u0085\\ud83d\\ude00\"}}, \"102\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"\\u001c\\u3000\", \"negative_text\": \"\\n\", \"prepend_text\": \"\\u3000a,\\n\", \"append_text\": \"\\u0085\\ud83d\\ude00\\t\\ud83d\\ude00\"}}, \"103\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"102\", 0], \"latent_image\": [\"5\", 0]}}, \"104\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"102\", 0], \"latent_image\": [\"5\", 0]}}, \"105\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"103\", 0]}}, \"106\": {\"class_type\": \"PreviewAny\"}, \"107\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"104\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "ambiguous", "text": null}, "positive": {"source": null, "status": "ambiguous", "text": null}}, "resolution": {"node": null, "reason": "Multiple sampler branches", "reason_code": null, "sampler_id": null, "state": "ambiguous"}}, "name": "select-fuzz-0-36", "records": {"prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"104\": {\"class_type\": \"Text Concatenate\", \"inputs\": {\"text_b\": \"\\u001csnow\\t\", \"text_c\": \"
 fox…\", \"clean_whitespace\": true}}, \"105\": {\"class_type\": \"JoinStringMulti\", \"inputs\": {\"string_1\": \" a, \", \"inputcount\": 1, \"delimiter\": \",\", \"return_list\": false}}, \"103\": {\"class_type\": \"Text Concatenate\", \"inputs\": {\"text_a\": \" fox\", \"text_b\": [\"104\", 0], \"text_c\": [\"105\", 0], \"clean_whitespace\": \"false\"}}, \"102\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": [\"103\", 0], \"text_l\": \"\\u001ccafé…fox\"}}, \"101\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"102\", 0]}}, \"107\": {\"class_type\": \"PromptManagerFocused\", \"inputs\": {\"text\": \"café\", \"prepend_text\": \"fox detailed fur\", \"append_text\": \"\\t猫
 fox\", \"clip\": [\"4\", 1]}}, \"106\": {\"class_type\": \"ConditioningZeroOut\", \"inputs\": {\"conditioning\": [\"107\", 0]}}, \"108\": {\"class_type\": \"SamplerCustom\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"106\", 0], \"latent_image\": [\"5\", 0]}}, \"112\": {\"class_type\": \"PrimitiveStringMultiline\", \"inputs\": {\"value\": \"fox\\n\"}}, \"111\": {\"class_type\": \"StringConstant\", \"inputs\": {\"text\": [\"112\", 0]}}, \"110\": {\"class_type\": \"PromptManager\", \"inputs\": {\"text\": [\"111\", 0], \"prepend_text\": \"\\t猫\\nsnow\", \"append_text\": \"\\u001ca,…\", \"clip\": [\"4\", 1]}}, \"109\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"110\", 0]}}, \"115\": {\"class_type\": \"PromptManagerFocused\", \"inputs\": {\"text\": \" snowdetailed fur\", \"prepend_text\": \"\\ndetailed fur \", \"append_text\": \"…fox
 detailed fur\", \"clip\": [\"4\", 1]}}, \"114\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"115\", 0]}}, \"113\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"114\", 0]}}, \"116\": {\"class_type\": \"SamplerCustom\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"109\", 0], \"negative\": [\"113\", 0], \"latent_image\": [\"108\", 0]}}, \"117\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"108\", 0]}}, \"118\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"116\", 0]}}, \"120\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"\\tcafé
 \", \"clip\": [\"4\", 1]}}, \"119\": {\"class_type\": \"UltimateSDUpscale\", \"inputs\": {\"image\": [\"118\", 0], \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"120\", 0]}}, \"121\": {\"class_type\": \"PreviewImage\", \"inputs\": {\"images\": [\"119\", 0]}}, \"122\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"119\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "ambiguous", "text": null}, "positive": {"source": null, "status": "ambiguous", "text": null}}, "resolution": {"node": null, "reason": "Multiple sampler branches", "reason_code": null, "sampler_id": null, "state": "ambiguous"}}, "name": "select-fuzz-0-37", "records": {"prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"103\": {\"class_type\": \"StringConcatenate\", \"inputs\": {\"string_a\": \"\\na,\\n\\u732b\", \"string_b\": \"\\u3000snow\\u2028 \", \"delimiter\": \" \"}}, \"102\": {\"class_type\": \"JoinStringMulti\", \"inputs\": {\"string_1\": \" \\ud83d\\ude00\\ufeff\", \"string_2\": [\"103\", 0], \"inputcount\": 2, \"delimiter\": \" \", \"return_list\": false}}, \"101\": {\"class_type\": \"PromptManager\", \"inputs\": {\"text\": [\"102\", 0], \"prepend_text\": \"\\u0085detailed fur\", \"append_text\": \"detailed fur \\u732b\", \"clip\": [\"4\", 1]}}, \"106\": {\"class_type\": \"TextInput\", \"inputs\": {\"string\": \" \\ud83d\\ude00\\u001c\\ud83d\\ude00\"}}, \"105\": {\"class_type\": \"StringConcatenate\", \"inputs\": {\"string_a\": [\"106\", 0], \"string_b\": \"\\u2028 \\u732b \", \"delimiter\": \"\"}}, \"104\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": [\"105\", 0], \"text_l\": [\"105\", 0]}}, \"107\": {\"class_type\": \"KSamplerAdvanced\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"104\", 0], \"latent_image\": [\"5\", 0]}}, \"110\": {\"class_type\": \"PromptManagerText\", \"inputs\": {\"text\": \"detailed fur \", \"prepend_text\": \"\\tsnow \", \"append_text\": \"\\n\\ud83d\\ude00 \"}}, \"109\": {\"class_type\": \"Text Concatenate\", \"inputs\": {\"text_c\": \"\\ufeffdetailed fur\\ufeff\", \"text_d\": [\"110\", 0], \"clean_whitespace\": true}}, \"108\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": [\"109\", 0], \"text_l\": \"\\u001cfox\"}}, \"114\": {\"class_type\": \"PromptSearchList\", \"inputs\": {\"text\": \" caf\\u00e9\\u001c\"}}, \"113\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"114\", 0]}}, \"112\": {\"class_type\": \"ConditioningZeroOut\", \"inputs\": {\"conditioning\": [\"113\", 0]}}, \"111\": {\"class_type\": \"ConditioningZeroOut\", \"inputs\": {\"conditioning\": [\"112\", 0]}}, \"115\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"108\", 0], \"negative\": [\"111\", 0], \"latent_image\": [\"5\", 0]}}, \"116\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"107\", 0]}}, \"117\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"115\", 0]}}, \"121\": {\"class_type\": \"StringConstantMultiline\", \"inputs\": {\"string\": \" snow\\ufeff\", \"strip_newlines\": false}}, \"120\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": [\"121\", 0], \"text_l\": [\"121\", 0]}}, \"123\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": \"\\nfox\\u3000a,\", \"text_l\": \"\\nfox\\u3000a,\"}}, \"122\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"123\", 0]}}, \"119\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": false, \"on_true\": [\"120\", 0], \"on_false\": [\"122\", 0]}}, \"118\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"117\", 0], \"model\": [\"4\", 0], \"positive\": [\"108\", 0], \"negative\": [\"119\", 0]}}, \"124\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"118\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": "execution", "status": "resolved", "text": " a,"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "a,a,"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "105", "state": "resolved"}}, "name": "select-fuzz-0-38", "records": {"comfyassets_prompt_pairs": "{\"version\": 1, \"nodes\": {\"104\": {\"records\": [{\"text\": \"\\u3000fox\\u2028 \", \"negative_text\": \" a,\", \"prepend_text\": \"caf\\u00e9\", \"append_text\": \"\\ud83d\\ude00\", \"positive_text\": \" \\u732b\\u3000\", \"negative_effective_text\": \" a,\"}]}}}", "prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"103\": {\"class_type\": \"PrimitiveString\", \"inputs\": {\"value\": \"a,\\u0085\"}}, \"102\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"103\", 0]}}, \"101\": {\"class_type\": \"PromptManagerPositive\", \"inputs\": {\"text\": [\"102\", 0], \"prepend_text\": \"\\u0085a,\\u0085\", \"negative_text\": \"\\u3000\\u732b\"}}, \"104\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"\\u3000fox\\u2028 \", \"negative_text\": \" a,\", \"prepend_text\": \" caf\\u00e9\", \"append_text\": \"\\u2028 \\ud83d\\ude00\\n\"}}, \"105\": {\"class_type\": \"KSamplerAdvanced\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"104\", 1], \"latent_image\": [\"5\", 0]}}, \"106\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"105\", 0]}}, \"107\": {\"class_type\": \"PreviewAny\", \"inputs\": {\"images\": [\"106\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "\n猫 fox"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "猫a,caféfox"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "103", "state": "resolved"}}, "name": "select-fuzz-0-39", "records": {"prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"101\": {\"class_type\": \"PromptManagerV2\", \"inputs\": {\"text\": \"caféfox\", \"prepend_text\": \"\\n猫a,\", \"negative_text\": \"
 a,\"}}, \"102\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"\\n猫 fox\", \"clip\": [\"4\", 1]}}, \"103\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"102\", 0], \"latent_image\": [\"5\", 0]}}, \"107\": {\"class_type\": \"StringConstantMultiline\", \"inputs\": {\"string\": \"\\t😀
 detailed fur\", \"strip_newlines\": true}}, \"106\": {\"class_type\": \"StringConstant\", \"inputs\": {\"text\": [\"107\", 0]}}, \"105\": {\"class_type\": \"PromptManagerFocused\", \"inputs\": {\"text\": [\"106\", 0], \"prepend_text\": \"猫 😀\", \"append_text\": \" a,\\u001c\", \"clip\": [\"4\", 1]}}, \"104\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"105\", 0]}}, \"108\": {\"class_type\": \"PromptManagerPositive\", \"inputs\": {\"text\": \"\\u001ca, \", \"prepend_text\": \"…fox \", \"negative_text\": \"…snow
 fox\"}}, \"109\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"104\", 0], \"negative\": [\"108\", 0], \"latent_image\": [\"5\", 0]}}, \"110\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"104\", 0], \"negative\": [\"108\", 0], \"latent_image\": [\"5\", 0]}}, \"111\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"103\", 0]}}, \"112\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"109\", 0]}}, \"113\": {\"class_type\": \"PreviewAny\", \"inputs\": {\"images\": [\"112\", 0]}}, \"114\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"110\", 0]}}, \"115\": {\"class_type\": \"PreviewImage\", \"inputs\": {\"images\": [\"114\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Cyclic latent ancestry", "reason_code": "malformed", "sampler_id": "103", "state": "unresolved"}}, "name": "select-fuzz-0-40", "records": {"prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"101\": {\"class_type\": \"PromptManagerPositive\", \"inputs\": {\"text\": \"😀 \", \"prepend_text\": \" café \", \"negative_text\": \"…😀\\n\"}}, \"102\": {\"class_type\": \"PromptManagerPositive\", \"inputs\": {\"text\": \" snow\\t\", \"prepend_text\": \"\\u001ca,…\", \"negative_text\": \" a,\\t\"}}, \"103\": {\"class_type\": \"SamplerCustom\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"102\", 0], \"latent_image\": [\"103\", 0]}}, \"104\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"103\", 0]}}, \"105\": {\"class_type\": \"PreviewImage\", \"inputs\": {\"images\": [\"104\", 0]}}, \"106\": {\"class_type\": \"PreviewImage\", \"inputs\": {\"images\": [\"104\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "ambiguous", "text": null}, "positive": {"source": null, "status": "ambiguous", "text": null}}, "resolution": {"node": null, "reason": "Multiple sampler branches", "reason_code": null, "sampler_id": null, "state": "ambiguous"}}, "name": "select-fuzz-0-41", "records": {"comfyassets_prompt_pairs": "{\"version\": 1, \"nodes\": {\"108\": {\"records\": [{\"text\": \"\\u001csnow\\t\\ud83d\\ude00!\", \"negative_text\": \"\\u3000\\ud83d\\ude00 \", \"prepend_text\": \"\\ud83d\\ude00\\u0085\\u732b\", \"append_text\": \"\\ufefffox\", \"positive_text\": \"\\nsnow\\u2028 a,\", \"negative_effective_text\": \"\\u3000\\ud83d\\ude00 \"}]}}}", "prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"104\": {\"class_type\": \"PromptManagerV2\", \"inputs\": {\"text\": \"café\\n\", \"prepend_text\": \"\\nsnow\", \"negative_text\": \" fox…fox\"}}, \"103\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"104\", 0], \"conditioning_2\": [\"104\", 0]}}, \"107\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"\\t \", \"clip\": [\"4\", 1]}}, \"106\": {\"class_type\": \"ConditioningZeroOut\", \"inputs\": {\"conditioning\": [\"107\", 0]}}, \"108\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"\\u001csnow\\t😀\", \"negative_text\": \" 😀 \", \"prepend_text\": \"…😀…猫\", \"append_text\": \"fox \"}}, \"105\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"106\", 0], \"conditioning_2\": [\"108\", 0]}}, \"102\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": [\"4\", 0], \"on_true\": [\"103\", 0], \"on_false\": [\"105\", 0]}}, \"101\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"102\", 0]}}, \"110\": {\"class_type\": \"PromptManagerPositive\", \"inputs\": {\"text\": \"😀\\t😀\", \"prepend_text\": \"…😀\\n\", \"negative_text\": \"snow\\t\"}}, \"109\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"110\", 0]}}, \"111\": {\"class_type\": \"KSamplerAdvanced\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"109\", 0], \"latent_image\": [\"5\", 0]}}, \"112\": {\"class_type\": \"KSamplerAdvanced\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"109\", 0], \"latent_image\": [\"5\", 0]}}, \"116\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": \"\\tcafé
 detailed fur\", \"text_l\": \"\\tcafé
 detailed fur\"}}, \"115\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"116\", 0]}}, \"114\": {\"class_type\": null, \"inputs\": {\"value\": [\"115\", 0]}}, \"113\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"114\", 0]}}, \"118\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a,…\", \"clip\": [\"4\", 1]}}, \"117\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"118\", 0]}}, \"119\": {\"class_type\": \"KSamplerAdvanced\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"113\", 0], \"negative\": [\"117\", 0], \"latent_image\": [\"5\", 0]}}, \"120\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"111\", 0]}}, \"125\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"\\ncafé\\t\", \"clip\": [\"4\", 1]}}, \"124\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"125\", 0]}}, \"126\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": \"…snow\\t\", \"text_l\": \"…snow\\t\"}}, \"123\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"124\", 0], \"conditioning_2\": [\"126\", 0]}}, \"122\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"123\", 0]}}, \"128\": {\"class_type\": \"PromptManagerFocused\", \"inputs\": {\"text\": \"…café\\u001c\", \"prepend_text\": \"\\ta,\", \"append_text\": \"…a,\\u001c\", \"clip\": [\"4\", 1]}}, \"127\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"128\", 0]}}, \"121\": {\"class_type\": \"UltimateSDUpscaleNoUpscale\", \"inputs\": {\"model\": [\"4\", 0], \"positive\": [\"122\", 0], \"negative\": [\"127\", 0], \"upscaled_image\": [\"120\", 0]}}, \"129\": {\"class_type\": \"PreviewImage\", \"inputs\": {\"images\": [\"121\", 0]}}, \"130\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"112\", 0]}}, \"131\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"130\", 0]}}, \"132\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"130\", 0]}}, \"133\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"119\", 0]}}, \"135\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \" \", \"clip\": [\"4\", 1]}}, \"134\": {\"class_type\": \"DetailerForEach\", \"inputs\": {\"image\": [\"133\", 0], \"model\": [\"4\", 0], \"positive\": [\"113\", 0], \"negative\": [\"135\", 0]}}, \"138\": {\"class_type\": \"StringConstantMultiline\", \"inputs\": {\"string\": \"\\ndetailed fur\", \"strip_newlines\": false}}, \"137\": {\"class_type\": \"PromptManagerFocused\", \"inputs\": {\"text\": [\"138\", 0], \"prepend_text\": \" detailed fur café\", \"append_text\": \"\\ta, \", \"clip\": [\"4\", 1]}}, \"141\": {\"class_type\": \"PromptManager\", \"inputs\": {\"text\": \"\\t😀\", \"prepend_text\": \"a,\", \"append_text\": \"
 猫\\u001c\", \"clip\": [\"4\", 1]}}, \"140\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"141\", 0]}}, \"139\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"140\", 0]}}, \"136\": {\"class_type\": \"UltimateSDUpscaleNoUpscale\", \"inputs\": {\"model\": [\"4\", 0], \"positive\": [\"137\", 0], \"negative\": [\"139\", 0], \"upscaled_image\": [\"134\", 0]}}, \"142\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"136\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt combines several texts (ConditioningCombine).", "source": null, "status": "unresolved", "text": null}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "snow\t"}}, "resolution": {"node": {"class_type": "ConditioningCombine", "id": "117"}, "reason": "Combined conditioning has different texts", "reason_code": "composite", "sampler_id": "110", "state": "unresolved"}}, "name": "select-fuzz-0-42", "records": {"prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"102\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"\\u3000caf\\u00e9\\u001c\", \"clip\": [\"4\", 1]}}, \"101\": {\"class_type\": \"ConditioningZeroOut\", \"inputs\": {\"conditioning\": [\"102\", 0]}}, \"103\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"\\tdetailed fur\\ufeff\", \"negative_text\": \"\\ufeffcaf\\u00e9\", \"prepend_text\": \"\\u3000detailed fur\\u0085\", \"append_text\": \"\\t\\ud83d\\ude00\\ufeff\\u732b\"}}, \"104\": {\"class_type\": \"SamplerCustom\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"103\", 0], \"latent_image\": [\"104\", 0]}}, \"105\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"104\", 0]}}, \"109\": {\"class_type\": \"StringConstant\", \"inputs\": {\"text\": \"\\u001ccaf\\u00e9\\t\"}}, \"108\": {\"class_type\": \"StringConstant\", \"inputs\": {\"text\": [\"109\", 0]}}, \"107\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": [\"108\", 0], \"clip\": [\"4\", 1]}}, \"106\": {\"class_type\": \"UltimateSDUpscaleNoUpscale\", \"inputs\": {\"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"107\", 0], \"upscaled_image\": [\"105\", 0]}}, \"114\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"snow\\t\", \"clip\": [\"4\", 1]}}, \"113\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"114\", 0]}}, \"112\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"113\", 0]}}, \"111\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"112\", 0]}}, \"118\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"\\tfox\\u001c\", \"clip\": [\"4\", 1]}}, \"119\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \" detailed fur\\u001c\", \"clip\": [\"4\", 1]}}, \"117\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"118\", 0], \"conditioning_2\": [\"119\", 0]}}, \"120\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \" fox\", \"negative_text\": \"\\u3000\\u0085\", \"prepend_text\": \"\\nsnow\\u3000\", \"append_text\": \" \\ud83d\\ude00\\u2028 \"}}, \"116\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"117\", 0], \"conditioning_2\": [\"120\", 1]}}, \"115\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"116\", 0]}}, \"110\": {\"class_type\": \"UltimateSDUpscale\", \"inputs\": {\"image\": [\"106\", 0], \"model\": [\"4\", 0], \"positive\": [\"111\", 0], \"negative\": [\"115\", 0]}}, \"121\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"110\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "A1111 settings footer is not unique", "reason_code": "malformed", "sampler_id": null, "state": "unresolved"}}, "name": "select-fuzz-0-43", "records": {"parameters": " snow \n\n猫…\n café "}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "absent", "text": null}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "😀 …snow 猫"}}, "resolution": {"node": null, "reason": "Negative input is absent", "reason_code": null, "sampler_id": "103", "state": "absent"}}, "name": "select-fuzz-0-44", "records": {"prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"101\": {\"class_type\": \"PromptManager\", \"inputs\": {\"text\": \"\\u0085snow \", \"prepend_text\": \"\\t\\ud83d\\ude00\\n\", \"append_text\": \"\\t\\u732b\\u3000\", \"clip\": [\"4\", 1]}}, \"102\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"latent_image\": [\"5\", 0]}}, \"103\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"latent_image\": [\"5\", 0]}}, \"107\": {\"class_type\": \"StringConstantMultiline\", \"inputs\": {\"string\": \"\\ufeff\\u732b\", \"strip_newlines\": true}}, \"106\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"107\", 0]}}, \"105\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"106\", 0]}}, \"104\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": [\"105\", 0], \"text_l\": [\"105\", 0]}}, \"108\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"\\u0085snow\\u001c\", \"clip\": [\"4\", 1]}}, \"109\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"104\", 0], \"negative\": [\"108\", 0], \"latent_image\": [\"5\", 0]}}, \"110\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"102\", 0]}}, \"111\": {\"class_type\": \"PreviewImage\", \"inputs\": {\"images\": [\"110\", 0]}}, \"112\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"103\", 0]}}, \"113\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"112\", 0]}}, \"114\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"109\", 0]}}, \"115\": {\"class_type\": \"PreviewImage\", \"inputs\": {\"images\": [\"114\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": "zeroed-conditioning", "status": "resolved", "text": ""}, "positive": {"source": "graph-inferred", "status": "resolved", "text": ", 猫"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "108", "state": "resolved"}}, "name": "select-fuzz-0-45", "records": {"prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"102\": {\"class_type\": \"StringConstantMultiline\", \"inputs\": {\"string\": \"\\n\\u732b \", \"strip_newlines\": false}}, \"101\": {\"class_type\": \"PromptManagerPositive\", \"inputs\": {\"text\": [\"102\", 0], \"prepend_text\": \" \\ufeff\", \"negative_text\": \"\\u001c\\n\"}}, \"106\": {\"class_type\": \"PromptManagerFocused\", \"inputs\": {\"text\": \"\\u3000\\ud83d\\ude00\\u3000\", \"prepend_text\": \" \\u732b\\u0085\", \"append_text\": \"\\u001cfox\\u001c\", \"clip\": [\"4\", 1]}}, \"105\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"106\", 0]}}, \"104\": {\"class_type\": \"ConditioningZeroOut\", \"inputs\": {\"conditioning\": [\"105\", 0]}}, \"103\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"104\", 0]}}, \"107\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"103\", 0], \"latent_image\": [\"5\", 0]}}, \"108\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"103\", 0], \"latent_image\": [\"5\", 0]}}, \"109\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"107\", 0]}}, \"110\": {\"class_type\": \"PreviewImage\", \"inputs\": {\"images\": [\"109\", 0]}}, \"111\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"108\", 0]}}, \"112\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"111\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "A1111 settings footer is not unique", "reason_code": "malformed", "sampler_id": null, "state": "unresolved"}}, "name": "select-fuzz-0-46", "records": {"parameters": "\u001c… 
 detailed fur Negative prompt: fox…\n"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Expected a typed graph link or text input", "reason_code": "malformed", "sampler_id": "109", "state": "unresolved"}}, "name": "select-fuzz-0-47", "records": {"comfyassets_prompt_pairs": "{\"version\": 1, \"nodes\": {\"108\": {\"records\": [{\"text\": \"\\u2028 detailed fur\\t\", \"negative_text\": \"\\tfox\\u001c\", \"prepend_text\": \"\\ufeff\", \"append_text\": \"\\u732b \\u732b\", \"positive_text\": \"\\u001c\\u732b\\u2028 \", \"negative_effective_text\": \"\\tfox\\u001c\"}]}}}", "prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"104\": {\"class_type\": \"TextInput\", \"inputs\": {\"string\": \" café \"}}, \"103\": {\"class_type\": \"PrimitiveString\", \"inputs\": {\"value\": [\"104\", 0]}}, \"106\": {\"class_type\": \"JoinStringMulti\", \"inputs\": {\"string_1\": \"\\nfox
 \", \"string_4\": \"snow\\nsnow\", \"inputcount\": 4, \"delimiter\": \" \", \"return_list\": false}}, \"105\": {\"class_type\": \"StringConstantMultiline\", \"inputs\": {\"string\": [\"106\", 0], \"strip_newlines\": false}}, \"102\": {\"class_type\": \"StringConcatenate\", \"inputs\": {\"string_a\": [\"103\", 0], \"string_b\": [\"105\", 0], \"delimiter\": \", \"}}, \"101\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": [\"102\", 0], \"clip\": [\"4\", 1]}}, \"108\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"
 detailed fur\\t\", \"negative_text\": \"\\tfox\\u001c\", \"prepend_text\": \" \", \"append_text\": \"
 猫 猫\"}}, \"107\": {\"class_type\": \"ConditioningZeroOut\", \"inputs\": {\"conditioning\": [\"108\", 0]}}, \"109\": {\"class_type\": \"SamplerCustom\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"107\", 0], \"latent_image\": [\"5\", 0]}}, \"111\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": \"\\nsnow\\n\", \"text_l\": \"\\nsnow\\n\"}}, \"110\": {\"class_type\": \"ConditioningZeroOut\", \"inputs\": {\"conditioning\": [\"111\", 0]}}, \"113\": {\"class_type\": \"StringConstantMultiline\", \"inputs\": {\"string\": \"…detailed fur \", \"strip_newlines\": false}}, \"112\": {\"class_type\": \"PromptManagerFocused\", \"inputs\": {\"text\": [\"113\", 0], \"prepend_text\": \"snow\\t\", \"append_text\": \"
 😀\\u001cdetailed fur\", \"clip\": [\"4\", 1]}}, \"114\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"110\", 0], \"negative\": [\"112\", 0], \"latent_image\": [\"5\", 0]}}, \"115\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"109\", 0]}}, \"116\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"114\", 0]}}, \"117\": {\"class_type\": \"PreviewAny\", \"inputs\": {\"images\": [\"116\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "ambiguous", "text": null}, "positive": {"source": null, "status": "ambiguous", "text": null}}, "resolution": {"node": null, "reason": "Multiple sampler branches", "reason_code": null, "sampler_id": null, "state": "ambiguous"}}, "name": "select-fuzz-0-48", "records": {"comfyassets_prompt_pairs": "{\"version\": 1, \"nodes\": {\"105\": {\"records\": [{\"text\": \"\\u3000\\tfox\", \"negative_text\": \"\\u2028 detailed fur\\u0085a,\", \"prepend_text\": \"detailed fur\", \"append_text\": \"detailed fur\\ufeff\", \"positive_text\": \"\\nsnow\\u2028 \", \"negative_effective_text\": \"\\u2028 detailed fur\\u0085a,\"}]}}}", "prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"104\": {\"class_type\": \"StringConstantMultiline\", \"inputs\": {\"string\": \"\\ta, 😀\", \"strip_newlines\": false}}, \"103\": {\"class_type\": \"PrimitiveString\", \"inputs\": {\"value\": [\"104\", 0]}}, \"102\": {\"class_type\": \"PromptManagerV2\", \"inputs\": {\"text\": [\"103\", 0], \"prepend_text\": \"\\nsnow\\u001c猫\", \"negative_text\": \"\\ncafé\"}}, \"101\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"102\", 0], \"conditioning_2\": [\"102\", 0]}}, \"105\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \" \\tfox\", \"negative_text\": \"
 detailed fur…a,\", \"prepend_text\": \"
 detailed fur\", \"append_text\": \"detailed fur\"}}, \"106\": {\"class_type\": \"SamplerCustom\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"105\", 0], \"latent_image\": [\"5\", 0]}}, \"107\": {\"class_type\": \"SamplerCustom\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"105\", 0], \"latent_image\": [\"107\", 0]}}, \"108\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"106\", 0]}}, \"109\": {\"class_type\": \"PreviewImage\", \"inputs\": {\"images\": [\"108\", 0]}}, \"110\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"108\", 0]}}, \"111\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"107\", 0]}}, \"112\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"111\", 0]}}, \"115\": {\"class_type\": \"PromptManager\", \"inputs\": {\"text\": \"
 café\\t\", \"prepend_text\": \"\\n😀
 \", \"append_text\": \"猫\", \"clip\": [\"4\", 1]}}, \"116\": {\"class_type\": \"FancyEncoder\", \"inputs\": {\"text\": \"\\u001c\\u001ca,\"}}, \"114\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": [\"4\", 0], \"on_true\": [\"115\", 0], \"on_false\": [\"116\", 0]}}, \"113\": {\"class_type\": \"UltimateSDUpscaleNoUpscale\", \"inputs\": {\"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"114\", 0], \"upscaled_image\": [\"111\", 0]}}, \"117\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"113\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "A1111 settings footer is not unique", "reason_code": "malformed", "sampler_id": null, "state": "unresolved"}}, "name": "select-fuzz-0-49", "records": {"parameters": "Negative Template: n Steps: x\r\n"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "ambiguous", "text": null}, "positive": {"source": null, "status": "ambiguous", "text": null}}, "resolution": {"node": null, "reason": "Multiple sampler branches", "reason_code": null, "sampler_id": null, "state": "ambiguous"}}, "name": "select-fuzz-0-50", "records": {"comfyassets_prompt_pairs": "{\"version\": 1, \"nodes\": {\"101\": {\"records\": [{\"text\": \"\\u2028 snow\\u3000\", \"negative_text\": \" detailed fur\\ufeff\", \"prepend_text\": \"\", \"append_text\": \"detailed fur\", \"positive_text\": \"\\u001c\\ud83d\\ude00\\t\", \"negative_effective_text\": \" detailed fur\\ufeff\"}]}}}", "prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"101\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"
 snow \", \"negative_text\": \" detailed fur\", \"prepend_text\": \" \\t\", \"append_text\": \"\\tdetailed fur
 \"}}, \"104\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"\\n
 \", \"negative_text\": \"
 fox\\n\", \"prepend_text\": \"
 猫\\u001ca,\", \"append_text\": \"\\u001cdetailed fur\\t😀\"}}, \"103\": {\"class_type\": \"ConditioningZeroOut\", \"inputs\": {\"conditioning\": [\"104\", 0]}}, \"102\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"103\", 0]}}, \"105\": {\"class_type\": \"SamplerCustom\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"102\", 0], \"latent_image\": [\"5\", 0]}}, \"106\": {\"class_type\": \"SamplerCustom\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"102\", 0], \"latent_image\": [\"5\", 0]}}, \"107\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"105\", 0]}}, \"108\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"107\", 0]}}, \"109\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"107\", 0]}}, \"110\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"106\", 0]}}, \"114\": {\"class_type\": \"StringConstantMultiline\", \"inputs\": {\"string\": \"\\ncafé\", \"strip_newlines\": true}}, \"113\": {\"class_type\": \"StringConcatenate\", \"inputs\": {\"string_a\": \"detailed fur 猫\", \"string_b\": [\"114\", 0], \"delimiter\": \", \"}}, \"112\": {\"class_type\": \"PromptManagerFocused\", \"inputs\": {\"text\": [\"113\", 0], \"prepend_text\": \"\\nfox \", \"append_text\": \" detailed fur \", \"clip\": [\"4\", 1]}}, \"111\": {\"class_type\": \"UltimateSDUpscale\", \"inputs\": {\"image\": [\"110\", 0], \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"112\", 0]}}, \"115\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"111\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Positive prompt is empty.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Positive prompt is empty.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Positive prompt is empty", "reason_code": null, "sampler_id": "107", "state": "unresolved"}}, "name": "select-fuzz-0-51", "records": {"prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"104\": {\"class_type\": \"PromptManagerPositive\", \"inputs\": {\"text\": \" a, \", \"prepend_text\": \"…猫\", \"negative_text\": \"…\\n\"}}, \"105\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \" detailed fur\\n\", \"clip\": [\"4\", 1]}}, \"103\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"104\", 0], \"conditioning_2\": [\"105\", 0]}}, \"102\": {\"class_type\": \"ConditioningZeroOut\", \"inputs\": {\"conditioning\": [\"103\", 0]}}, \"101\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"102\", 0]}}, \"106\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"
 \\tfox\", \"negative_text\": \"
 \", \"prepend_text\": \"…café \", \"append_text\": \"…\\n\"}}, \"107\": {\"class_type\": \"SamplerCustom\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"106\", 1], \"latent_image\": [\"5\", 0]}}, \"108\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"107\", 0]}}, \"109\": {\"class_type\": \"PreviewImage\", \"inputs\": {\"images\": [\"108\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "ambiguous", "text": null}, "positive": {"source": null, "status": "ambiguous", "text": null}}, "resolution": {"node": null, "reason": "Sampler stages have different prompts", "reason_code": "multistage-differs", "sampler_id": "108", "state": "ambiguous"}}, "name": "select-fuzz-0-52", "records": {"prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"103\": {\"class_type\": \"PromptManagerText\", \"inputs\": {\"text\": \"\\u001c
 \", \"prepend_text\": \"snow\\u001cdetailed fur\", \"append_text\": \"a,\"}}, \"102\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": [\"103\", 0], \"clip\": [\"4\", 1]}}, \"101\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"102\", 0]}}, \"104\": {\"class_type\": \"PromptManagerPositive\", \"inputs\": {\"text\": \"\\ncafé
 snow\", \"prepend_text\": \"\\tfox
 \", \"negative_text\": \"
 
 \"}}, \"105\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"104\", 0], \"latent_image\": [\"5\", 0]}}, \"106\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"105\", 0]}}, \"107\": {\"class_type\": \"PreviewAny\", \"inputs\": {\"images\": [\"106\", 0]}}, \"109\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \" snow\", \"clip\": [\"4\", 1]}}, \"112\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": \"\\u001cfox\\t\", \"text_l\": \"\\u001cfox\\t\"}}, \"111\": {\"class_type\": \"ConditioningZeroOut\", \"inputs\": {\"conditioning\": [\"112\", 0]}}, \"110\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"111\", 0]}}, \"108\": {\"class_type\": \"UltimateSDUpscaleNoUpscale\", \"inputs\": {\"model\": [\"4\", 0], \"positive\": [\"109\", 0], \"negative\": [\"110\", 0], \"upscaled_image\": [\"106\", 0]}}, \"113\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"missing\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "ambiguous", "text": null}, "positive": {"source": null, "status": "ambiguous", "text": null}}, "resolution": {"node": null, "reason": "Multiple sampler branches", "reason_code": null, "sampler_id": null, "state": "ambiguous"}}, "name": "select-fuzz-0-53", "records": {"comfyassets_prompt_pairs": "{\"version\": 1, \"nodes\": {\"124\": {\"records\": [{\"text\": \"a,\\n\", \"negative_text\": \"\\n\\u2028 \", \"prepend_text\": \"a,\", \"append_text\": \"detailed fur\", \"positive_text\": \"\\ncaf\\u00e9\\u0085\", \"negative_effective_text\": \"\\n\\u2028 \"}]}}}", "prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"101\": {\"class_type\": \"PromptSearchList\", \"inputs\": {\"text\": \"\\ufeff\"}}, \"105\": {\"class_type\": \"StringConcatenate\", \"inputs\": {\"string_a\": \"\\u2028 caf\\u00e9 \", \"string_b\": \"\\u0085\\u732b\\u2028 \", \"delimiter\": \" \"}}, \"104\": {\"class_type\": \"TextInput\", \"inputs\": {\"string\": [\"105\", 0]}}, \"103\": {\"class_type\": \"PrimitiveString\", \"inputs\": {\"value\": [\"104\", 0]}}, \"102\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": [\"103\", 0], \"clip\": [\"4\", 1]}}, \"106\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"102\", 0], \"latent_image\": [\"5\", 0]}}, \"108\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"\\tcaf\\u00e9\\u0085\\ud83d\\ude00\", \"negative_text\": \"\\u3000detailed fur\\u2028 caf\\u00e9\", \"prepend_text\": \" a,\\u3000caf\\u00e9\", \"append_text\": \"detailed fur\\u2028 \\ud83d\\ude00\"}}, \"110\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"fox\\n\", \"clip\": [\"4\", 1]}}, \"112\": {\"class_type\": \"PromptSearchList\", \"inputs\": {\"text\": \"\\u001c\\ud83d\\ude00\\ufeffsnow\"}}, \"113\": {\"class_type\": \"PromptManager\", \"inputs\": {\"text\": \"\\u001c\\ud83d\\ude00 \", \"prepend_text\": \"\\u2028 snow\\u0085\", \"append_text\": \"\\u001ca,\\ud83d\\ude00\", \"clip\": [\"4\", 1]}}, \"111\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": true, \"on_true\": [\"112\", 0], \"on_false\": [\"113\", 0]}}, \"109\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": false, \"on_true\": [\"110\", 0], \"on_false\": [\"111\", 0]}}, \"107\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"108\", 0], \"conditioning_2\": [\"109\", 0]}}, \"114\": {\"class_type\": \"KSamplerAdvanced\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"107\", 0], \"latent_image\": [\"5\", 0]}}, \"115\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"102\", 0], \"latent_image\": [\"5\", 0]}}, \"116\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"106\", 0]}}, \"117\": {\"class_type\": \"PreviewAny\", \"inputs\": {\"images\": [\"116\", 0]}}, \"118\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"114\", 0]}}, \"119\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"118\", 0]}}, \"120\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"115\", 0]}}, \"121\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"120\", 0]}}, \"124\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"a,\\n\", \"negative_text\": \"\\n\\u2028 \", \"prepend_text\": \"\\na,\\u0085\", \"append_text\": \"detailed fur \"}}, \"126\": {\"class_type\": \"PromptSearchList\", \"inputs\": {\"text\": \"\\nsnow\\u3000\"}}, \"125\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"126\", 0], \"conditioning_2\": [\"126\", 0]}}, \"123\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"124\", 1], \"conditioning_2\": [\"125\", 0]}}, \"122\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"120\", 0], \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"123\", 0]}}, \"127\": {\"class_type\": \"PreviewAny\", \"inputs\": {\"images\": [\"122\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "ambiguous", "text": null}, "positive": {"source": null, "status": "ambiguous", "text": null}}, "resolution": {"node": null, "reason": "Multiple sampler branches", "reason_code": null, "sampler_id": null, "state": "ambiguous"}}, "name": "select-fuzz-0-54", "records": {"comfyassets_prompt_pairs": "{\"version\": 1, \"nodes\": {\"122\": {\"records\": [{\"text\": \"\\u0085\\u732b\\t\", \"negative_text\": \"\\u2028 caf\\u00e9\\u3000\", \"prepend_text\": \"\\ud83d\\ude00\\u3000a,\", \"append_text\": \"caf\\u00e9\", \"positive_text\": \" \\ud83d\\ude00\\u3000\", \"negative_effective_text\": \"\\u2028 caf\\u00e9\\u3000\"}]}}}", "prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"102\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"\\ta,\", \"clip\": [\"4\", 1]}}, \"101\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"102\", 0]}}, \"106\": {\"class_type\": \"PromptManagerV2\", \"inputs\": {\"text\": \"café \", \"prepend_text\": \"…a, café\", \"negative_text\": \"…a,\\n😀\"}}, \"105\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"106\", 0]}}, \"108\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": \"猫\", \"text_l\": \"猫\"}}, \"109\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": \"\\n猫\\n\", \"text_l\": \"\\n猫\\n\"}}, \"107\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": true, \"on_true\": [\"108\", 0], \"on_false\": [\"109\", 0]}}, \"104\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": [\"4\", 1], \"on_true\": [\"105\", 0], \"on_false\": [\"107\", 0]}}, \"110\": {\"class_type\": \"PromptSearchList\", \"inputs\": {\"text\": \"…fox…snow\"}}, \"103\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"104\", 0], \"conditioning_2\": [\"110\", 0]}}, \"111\": {\"class_type\": \"SamplerCustom\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"103\", 0], \"latent_image\": [\"5\", 0]}}, \"113\": {\"class_type\": \"FancyEncoder\", \"inputs\": {\"text\": \"a,\"}}, \"112\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"113\", 0]}}, \"117\": {\"class_type\": \"Text Concatenate\", \"inputs\": {\"text_b\": \"猫\", \"text_d\": \"\", \"text_a\": \"a,
 \", \"clean_whitespace\": \"true\"}}, \"116\": {\"class_type\": \"StringConcatenate\", \"inputs\": {\"string_a\": \"😀 fox\", \"string_b\": [\"117\", 0], \"delimiter\": \"\"}}, \"115\": {\"class_type\": \"PrimitiveString\", \"inputs\": {\"value\": [\"116\", 0]}}, \"114\": {\"class_type\": \"PromptManagerV2\", \"inputs\": {\"text\": [\"115\", 0], \"prepend_text\": \"\", \"negative_text\": \"\\u001cdetailed fur\\n\"}}, \"118\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"112\", 0], \"negative\": [\"114\", 0], \"latent_image\": [\"5\", 0]}}, \"119\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"112\", 0], \"negative\": [\"114\", 0], \"latent_image\": [\"118\", 0]}}, \"120\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"111\", 0]}}, \"122\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"…猫\\t\", \"negative_text\": \"
 café \", \"prepend_text\": \" 😀 a,\", \"append_text\": \" café \"}}, \"121\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"120\", 0], \"model\": [\"4\", 0], \"positive\": [\"112\", 0], \"negative\": [\"122\", 0]}}, \"123\": {\"class_type\": \"PreviewAny\", \"inputs\": {\"images\": [\"121\", 0]}}, \"124\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"118\", 0]}}, \"125\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"124\", 0]}}, \"126\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"119\", 0]}}, \"128\": {\"class_type\": \"PromptManagerFocused\", \"inputs\": {\"text\": \" 猫 \", \"prepend_text\": \"猫\\u001c😀\", \"append_text\": \" 猫\\tdetailed fur\", \"clip\": [\"4\", 1]}}, \"132\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"
 \", \"clip\": [\"4\", 1]}}, \"131\": {\"class_type\": \"ConditioningZeroOut\", \"inputs\": {\"conditioning\": [\"132\", 0]}}, \"130\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"131\", 0]}}, \"129\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"130\", 0], \"conditioning_2\": [\"130\", 0]}}, \"127\": {\"class_type\": \"UltimateSDUpscale\", \"inputs\": {\"image\": [\"126\", 0], \"model\": [\"4\", 0], \"positive\": [\"128\", 0], \"negative\": [\"129\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Prompt metadata in this file is malformed.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "A1111 settings footer is not unique", "reason_code": "malformed", "sampler_id": null, "state": "unresolved"}}, "name": "select-fuzz-0-55", "records": {"parameters": "…😀\t\r\nTemplate: t\rSteps: x Steps:٣, a\r\t\n\r"}}, {"evidence": {"prompts": {"negative": {"source": "graph-inferred", "status": "resolved", "text": "fox 😀 detailed fur 猫\u001c猫"}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "detailed fur, fox 
 😀\u001c, … a,"}}, "resolution": {"node": null, "reason": "", "reason_code": null, "sampler_id": "106", "state": "resolved"}}, "name": "select-fuzz-0-56", "records": {"prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"104\": {\"class_type\": \"StringConcatenate\", \"inputs\": {\"string_a\": \"
 😀\\u001c\", \"string_b\": \"… \", \"delimiter\": \", \"}}, \"103\": {\"class_type\": \"PromptManagerText\", \"inputs\": {\"text\": [\"104\", 0], \"prepend_text\": \" fox \", \"append_text\": \"a,\"}}, \"102\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"103\", 0]}}, \"101\": {\"class_type\": \"PromptManagerPositive\", \"inputs\": {\"text\": [\"102\", 0], \"prepend_text\": \"detailed fur\\n\", \"negative_text\": \"😀
 \"}}, \"105\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \" detailed fur \", \"negative_text\": \"\\u001ca,…café\", \"prepend_text\": \"
 fox 😀\", \"append_text\": \"
 猫\\u001c猫\"}}, \"106\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"105\", 0], \"latent_image\": [\"5\", 0]}}, \"107\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"missing\", 0]}}, \"108\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"107\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Prompt combines several texts (CLIPTextEncodeSDXL).", "source": null, "status": "unresolved", "text": null}, "positive": {"source": "graph-inferred", "status": "resolved", "text": "detailed fur, snow 
 a,
  a,"}}, "resolution": {"node": {"class_type": "CLIPTextEncodeSDXL", "id": "104"}, "reason": "Encoder parts have different texts", "reason_code": "composite", "sampler_id": "105", "state": "unresolved"}}, "name": "select-fuzz-0-57", "records": {"comfyassets_prompt_pairs": "{\"version\": 1, \"nodes\": {\"107\": {\"records\": [{\"text\": \" snow\\u0085\", \"negative_text\": \"\\u001c\\u732b\\ufeff\", \"prepend_text\": \"fox\", \"append_text\": \"a,\\ufeff\\ud83d\\ude00\", \"positive_text\": \"\\ufeffcaf\\u00e9\\ufeff\", \"negative_effective_text\": \"\\u001c\\u732b\\ufeff\"}]}}}", "prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"103\": {\"class_type\": \"JoinStringMulti\", \"inputs\": {\"string_1\": \"\\u0085snow\\ufeff\", \"string_2\": \"\\u2028 a,\\u2028 \", \"inputcount\": 2, \"delimiter\": \" \", \"return_list\": false}}, \"102\": {\"class_type\": \"StringConcatenate\", \"inputs\": {\"string_a\": [\"103\", 0], \"string_b\": \"\\u3000a, \", \"delimiter\": \"\"}}, \"101\": {\"class_type\": \"PromptManagerPositive\", \"inputs\": {\"text\": [\"102\", 0], \"prepend_text\": \"\\ufeffdetailed fur\\u2028 \", \"negative_text\": \"\\u001ccaf\\u00e9\\t\"}}, \"104\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": \"\\na,\\u3000\", \"text_l\": \" \"}}, \"105\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"104\", 0], \"latent_image\": [\"5\", 0]}}, \"107\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \" snow\\u0085\", \"negative_text\": \"\\u001c\\u732b\\ufeff\", \"prepend_text\": \"\\u001c\\u0085fox\", \"append_text\": \"\\u0085a,\\ufeff\\ud83d\\ude00\"}}, \"106\": {\"class_type\": \"Reroute\", \"inputs\": {}}, \"111\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": \"\\ncaf\\u00e9\\ufeffa,\", \"text_l\": \"\\u3000a,\\n\"}}, \"110\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"111\", 0]}}, \"109\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"110\", 0]}}, \"108\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"109\", 0]}}, \"112\": {\"class_type\": \"SamplerCustom\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"106\", 0], \"negative\": [\"108\", 0], \"latent_image\": [\"5\", 0]}}, \"113\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"105\", 0]}}, \"114\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"113\", 0]}}, \"115\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"112\", 0]}}, \"119\": {\"class_type\": \"JoinStringMulti\", \"inputs\": {\"string_1\": \"\\u0085fox\\t\", \"inputcount\": 1, \"delimiter\": \" \", \"return_list\": false}}, \"118\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": [\"119\", 0], \"text_l\": [\"119\", 0]}}, \"121\": {\"class_type\": \"PrimitiveStringMultiline\", \"inputs\": {\"value\": \"\\u0085\\n\"}}, \"120\": {\"class_type\": \"PromptManagerFocused\", \"inputs\": {\"text\": [\"121\", 0], \"prepend_text\": \"\\u0085detailed fur\\u3000\", \"append_text\": \"\\t\", \"clip\": [\"4\", 1]}}, \"117\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": [\"4\", 1], \"on_true\": [\"118\", 0], \"on_false\": [\"120\", 0]}}, \"122\": {\"class_type\": \"PromptManagerV2\", \"inputs\": {\"text\": \"\\t\\u732b \", \"prepend_text\": \"\\u3000\\u732b\\u2028 \", \"negative_text\": \"\\tdetailed fur\\ta,\"}}, \"116\": {\"class_type\": \"FaceDetailer\", \"inputs\": {\"image\": [\"115\", 0], \"model\": [\"4\", 0], \"positive\": [\"117\", 0], \"negative\": [\"122\", 0]}}, \"124\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": \"\\u2028 snow \", \"text_l\": \"\"}}, \"125\": {\"class_type\": \"PromptManagerV2\", \"inputs\": {\"text\": \"\\u001c\\u732b\\u0085caf\\u00e9\", \"prepend_text\": \"\\ufeffcaf\\u00e9\\u0085\", \"negative_text\": \"\\n\\u732b\\t\"}}, \"123\": {\"class_type\": \"UltimateSDUpscale\", \"inputs\": {\"image\": [\"116\", 0], \"model\": [\"4\", 0], \"positive\": [\"124\", 0], \"negative\": [\"125\", 0]}}, \"126\": {\"class_type\": \"PreviewAny\", \"inputs\": {\"images\": [\"123\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"message": "Positive prompt is empty.", "source": null, "status": "unresolved", "text": null}, "positive": {"message": "Positive prompt is empty.", "source": null, "status": "unresolved", "text": null}}, "resolution": {"node": null, "reason": "Positive prompt is empty", "reason_code": null, "sampler_id": "108", "state": "unresolved"}}, "name": "select-fuzz-0-58", "records": {"prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"101\": {\"class_type\": \"PromptManager\", \"inputs\": {\"text\": \"\\u001csnow\\n\", \"prepend_text\": \"\\u3000detailed fur\\ufeffsnow\", \"append_text\": \"\\u0085\\u3000\", \"clip\": [\"4\", 1]}}, \"102\": {\"class_type\": \"PromptSearchList\", \"inputs\": {\"text\": \" \\ufeff\"}}, \"103\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"102\", 0], \"latent_image\": [\"5\", 0]}}, \"104\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"102\", 0], \"latent_image\": [\"5\", 0]}}, \"105\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"103\", 0]}}, \"106\": {\"class_type\": \"PreviewImage\", \"inputs\": {\"images\": [\"105\", 0]}}, \"107\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"104\", 0]}}, \"112\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \" fox a,\", \"clip\": [\"4\", 1]}}, \"111\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"112\", 0]}}, \"114\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \" detailed fur\\u001c\", \"clip\": [\"4\", 1]}}, \"113\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"114\", 0]}}, \"110\": {\"class_type\": \"easy ifElse\", \"inputs\": {\"boolean\": true, \"on_true\": [\"111\", 0], \"on_false\": [\"113\", 0]}}, \"109\": {\"class_type\": \"ConditioningZeroOut\", \"inputs\": {\"conditioning\": [\"110\", 0]}}, \"117\": {\"class_type\": \"Text Concatenate\", \"inputs\": {\"text_a\": \" \\ud83d\\ude00\\u3000\", \"text_b\": \"\\ncaf\\u00e9 \", \"text_d\": \"\\n\\ud83d\\ude00\\u001c\", \"clean_whitespace\": \"true\"}}, \"118\": {\"class_type\": \"PromptManagerText\", \"inputs\": {\"text\": \"\\u001ccaf\\u00e9 \\u732b\", \"prepend_text\": \"\\u2028 \\u732b\\n\\u732b\", \"append_text\": \"\\ufeff \"}}, \"116\": {\"class_type\": \"StringConcatenate\", \"inputs\": {\"string_a\": [\"117\", 0], \"string_b\": [\"118\", 0], \"delimiter\": \", \"}}, \"115\": {\"class_type\": \"PromptManagerPositive\", \"inputs\": {\"text\": [\"116\", 0], \"prepend_text\": \"\\u2028 caf\\u00e9\", \"negative_text\": \"\\tcaf\\u00e9\\u3000\"}}, \"108\": {\"class_type\": \"DetailerForEach\", \"inputs\": {\"image\": [\"107\", 0], \"model\": [\"4\", 0], \"positive\": [\"109\", 0], \"negative\": [\"115\", 0]}}}"}}, {"evidence": {"prompts": {"negative": {"source": null, "status": "ambiguous", "text": null}, "positive": {"source": null, "status": "ambiguous", "text": null}}, "resolution": {"node": null, "reason": "Sampler stages have different prompts", "reason_code": "multistage-differs", "sampler_id": "113", "state": "ambiguous"}}, "name": "select-fuzz-0-59", "records": {"prompt": "{\"4\": {\"class_type\": \"CheckpointLoaderSimple\", \"inputs\": {\"ckpt_name\": \"m\"}}, \"5\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {\"width\": 64}}, \"103\": {\"class_type\": \"PromptManagerText\", \"inputs\": {\"text\": \"…猫…\", \"prepend_text\": \"a,\", \"append_text\": \"
 detailed fur\\u001c\"}}, \"102\": {\"class_type\": \"PromptManagerV2\", \"inputs\": {\"text\": [\"103\", 0], \"prepend_text\": \"\\t猫\", \"negative_text\": \"\\n\\u001c\"}}, \"101\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"102\", 0]}}, \"104\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"latent_image\": [\"5\", 0]}}, \"106\": {\"class_type\": \"PromptSearchList\", \"inputs\": {\"text\": \"fox\"}}, \"108\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": \"\\u001c😀 \", \"text_l\": \"\\nfox…\"}}, \"107\": {\"class_type\": \"FluxGuidance\", \"inputs\": {\"conditioning\": [\"108\", 0]}}, \"105\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"106\", 0], \"conditioning_2\": [\"107\", 0]}}, \"109\": {\"class_type\": \"FancyEncoder\", \"inputs\": {\"text\": \" 😀a,\"}}, \"110\": {\"class_type\": \"KSamplerAdvanced\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"105\", 0], \"negative\": [\"109\", 0], \"latent_image\": [\"5\", 0]}}, \"111\": {\"class_type\": \"KSampler\", \"inputs\": {\"seed\": 1, \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"latent_image\": [\"5\", 0]}}, \"112\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"104\", 0]}}, \"115\": {\"class_type\": \"PromptManagerPair\", \"inputs\": {\"text\": \"\\u001ccafé\", \"negative_text\": \"\\u001c猫café\", \"prepend_text\": \"café\\n\", \"append_text\": \"…😀 \"}}, \"114\": {\"class_type\": \"Reroute\", \"inputs\": {\"value\": [\"115\", 0]}}, \"119\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"a,\\u001c\", \"clip\": [\"4\", 1]}}, \"120\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"\\u001c猫\\u001c\", \"clip\": [\"4\", 1]}}, \"118\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"119\", 0], \"conditioning_2\": [\"120\", 0]}}, \"122\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"\\u001cdetailed fursnow\", \"clip\": [\"4\", 1]}}, \"121\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"122\", 0], \"conditioning_2\": [\"122\", 0]}}, \"117\": {\"class_type\": \"ConditioningCombine\", \"inputs\": {\"conditioning_1\": [\"118\", 0], \"conditioning_2\": [\"121\", 0]}}, \"116\": {\"class_type\": \"ConditioningZeroOut\", \"inputs\": {\"conditioning\": [\"117\", 0]}}, \"113\": {\"class_type\": \"UltimateSDUpscaleNoUpscale\", \"inputs\": {\"model\": [\"4\", 0], \"positive\": [\"114\", 0], \"negative\": [\"116\", 0], \"upscaled_image\": [\"112\", 0]}}, \"123\": {\"class_type\": \"SaveImage\", \"inputs\": {\"images\": [\"113\", 0]}}, \"124\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"110\", 0]}}, \"125\": {\"class_type\": \"PreviewImage\", \"inputs\": {\"images\": [\"124\", 0]}}, \"126\": {\"class_type\": \"PreviewAny\", \"inputs\": {\"images\": [\"124\", 0]}}, \"127\": {\"class_type\": \"VAEDecode\", \"inputs\": {\"samples\": [\"111\", 0]}}, \"128\": {\"class_type\": \"PreviewAny\", \"inputs\": {\"images\": [\"127\", 0]}}, \"131\": {\"class_type\": \"PromptManagerText\", \"inputs\": {\"text\": \" fox\", \"prepend_text\": \"\\ndetailed fur…\", \"append_text\": \" \\u001c\"}}, \"130\": {\"class_type\": \"CLIPTextEncodeSDXL\", \"inputs\": {\"text_g\": [\"131\", 0], \"text_l\": [\"131\", 0]}}, \"129\": {\"class_type\": \"UltimateSDUpscale\", \"inputs\": {\"image\": [\"127\", 0], \"model\": [\"4\", 0], \"positive\": [\"101\", 0], \"negative\": [\"130\", 0]}}, \"132\": {\"class_type\": \"PreviewImage\", \"inputs\": {\"images\": [\"129\", 0]}}}"}}], "version": 2} diff --git a/tests/js/api-client.test.js b/tests/js/api-client.test.js index 01b45b4..df2fa21 100644 --- a/tests/js/api-client.test.js +++ b/tests/js/api-client.test.js @@ -236,6 +236,35 @@ test("latestOnly: errors from the current call propagate, errors from superseded assert.equal(await first, STALE); }); +test("latestOnly with a lifetime: once it ends, no request starts and the call is STALE", async () => { + const fetch = fakeFetch(); + const api = createApiClient(fetch); + const page = new AbortController(); + const latest = latestOnly({ lifetime: page.signal }); + page.abort(); + assert.equal(await latest((signal) => api.get("/recent", { signal })), STALE); + assert.equal(fetch.calls.length, 0, "a leaving page starts no read"); +}); + +test("latestOnly with a lifetime: its end aborts the request in flight, which is STALE, not an error", async () => { + const fetch = fakeFetch(); + const api = createApiClient(fetch); + const page = new AbortController(); + const latest = latestOnly({ lifetime: page.signal }); + const call = latest((signal) => api.get("/recent", { signal })); + page.abort(); + assert.equal(fetch.calls[0].aborted, true, "the read in flight is aborted"); + fetch.calls[0].reject(new DOMException("The operation was aborted.", "AbortError")); + assert.equal(await call, STALE); +}); + +test("latestOnly with a lifetime: real errors still propagate while the page lives", async () => { + const page = new AbortController(); + const latest = latestOnly({ lifetime: page.signal }); + await assert.rejects(latest(async () => { throw new Error("server down"); }), /server down/); + assert.equal(await latest(async () => "fine"), "fine"); +}); + test("ApiError exposes only validated history recovery actions on all error paths", async () => { const permitted = [null, { action: "refresh-history" }, { action: "reload-outputs" }, { action: "retry-read" }, { action: "smaller-limit", limit: 1 }, diff --git a/tests/js/gallery-thumbnails.test.js b/tests/js/gallery-thumbnails.test.js index 431beea..5b085f2 100644 --- a/tests/js/gallery-thumbnails.test.js +++ b/tests/js/gallery-thumbnails.test.js @@ -73,6 +73,28 @@ test('Not Now persists the collection state and suppresses the unchanged prompt' assert.equal(f.prompts.length, 1); }); +// WebKit fails a fetch a leaving page starts ("Fetch API cannot load … due to access control checks.") and logs it +// as a page error: the delayed startup check starts no read once the page has begun to leave (as admin.js, #290). +test('the startup check starts no read once the page is leaving', async () => { + const f = fixture({ success: true, total_images: 100, pending: 30, collection_id: 'a' }); + f.manager.pageLifetime = new AbortController(); + f.manager.pageLifetime.abort(); + await f.check(); + assert.equal(f.requests.length, 0); + assert.equal(f.prompts.length, 0); +}); + +test('the startup check passes the page lifetime to its read', async () => { + const f = fixture({ success: true, total_images: 100, pending: 30, collection_id: 'a' }); + const signals = []; + f.manager.pageLifetime = new AbortController(); + const get = f.manager.api.get; + f.manager.api.get = async (url, options) => { signals.push(options?.signal); return get(url); }; + await f.check(); + assert.deepEqual(signals, [f.manager.pageLifetime.signal]); + assert.equal(f.prompts.length, 1); +}); + test('disabled checks do not request collection status', async () => { const f = fixture({}, { checkThumbnailsAtStartup: false }); await f.check(); diff --git a/tests/js/legacy-refusals.test.js b/tests/js/legacy-refusals.test.js new file mode 100644 index 0000000..6daec1c --- /dev/null +++ b/tests/js/legacy-refusals.test.js @@ -0,0 +1,150 @@ +// The legacy admin and gallery pages show a server refusal's reason (#220, PR #289 review I1 and M8): a job that +// runs in the workspace refuses their record-changing requests, and a corrupt config.json refuses a settings save. +// Each page must name the reason, not report a lost connection, a bare HTTP status or a success. +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +function elements() { + const nodes = {}; + return { + nodes, + getElementById: id => nodes[id] ||= { id, textContent: '', innerHTML: '', disabled: false, style: {}, + value: '', checked: false, addEventListener() {}, querySelectorAll: () => [] }, + }; +} + +function jsonResponse(status, body) { + return { ok: status >= 200 && status < 300, status, statusText: status === 409 ? 'Conflict' : 'OK', + json: async () => body }; +} + +function sseResponse(frames) { + const bytes = new TextEncoder().encode(frames.map(frame => `data: ${JSON.stringify(frame)}\n\n`).join('')); + let sent = false; + return { ok: true, status: 200, body: { getReader: () => ({ + read: async () => (sent ? { done: true } : (sent = true, { done: false, value: bytes })), + }) } }; +} + +function load(file, className, fetchUrl, extra = {}, dom = elements()) { + const timers = []; + const notifications = []; + const storage = new Map([['pm_cached', '1'], ['userData', 'keep']]); + const localStorage = { + getItem: key => storage.get(key) ?? null, + setItem: (key, value) => storage.set(key, String(value)), + removeItem: key => storage.delete(key), + }; + const context = vm.createContext({ + document: { readyState: 'loading', addEventListener() {}, getElementById: dom.getElementById, + querySelectorAll: () => [] }, + window: {}, console: { error() {}, warn() {}, debug() {} }, + setTimeout: callback => timers.push(callback), + confirm: () => true, + localStorage, + Object: { ...Object, keys: target => (target === localStorage ? [...storage.keys()] : Object.keys(target)) }, + TextDecoder, TextEncoder, JSON, + ApiClient: { fetchUrl }, + ...extra, + }); + const source = fs.readFileSync(path.join(__dirname, `../../web/js/${file}`), 'utf8'); + const Page = vm.runInContext(`${source}\n${className};`, context, { filename: path.join(__dirname, `../../web/js/${file}`) }); + const page = Object.create(Page.prototype); + page.showNotification = (message, type) => notifications.push([type, message]); + page.showModal = id => notifications.push(['show', id]); + page.hideModal = id => notifications.push(['hide', id]); + return { page, dom, timers, notifications, storage }; +} + +const BUSY = 'Scan for metadata already running'; + +test('gallery cache clear reports a refused server clear and never claims success', async () => { + const calls = []; + const { page, dom, notifications, storage } = load('gallery.js', 'GalleryManager', async url => { + calls.push(url); + return jsonResponse(409, { success: false, error: BUSY }); + }); + page.getSettings = () => ({ thumbnailsGenerated: true }); + dom.getElementById('thumbnailStatus').textContent = 'Generated'; + + await page.clearCache(); + + assert.deepEqual(calls, ['/prompt_manager/images/clear-thumbnails']); + assert.deepEqual(notifications, [['error', `Thumbnails were not cleared: ${BUSY}`]]); + assert.equal(dom.nodes.thumbnailStatus.textContent, 'Generated'); + assert.equal(storage.get('gallerySettings'), undefined); // thumbnailsGenerated is not reset +}); + +test('gallery cache clear still says so when the server clears', async () => { + const { page, dom, notifications, storage } = load('gallery.js', 'GalleryManager', + async () => jsonResponse(200, { success: true, cleared_count: 3 })); + page.getSettings = () => ({ thumbnailsGenerated: true }); + + await page.clearCache(); + + assert.deepEqual(notifications, [['success', 'PromptManager cache cleared successfully! Original images untouched.']]); + assert.equal(dom.nodes.thumbnailStatus.textContent, 'Not generated'); + assert.equal(JSON.parse(storage.get('gallerySettings')).thumbnailsGenerated, false); + assert.equal(storage.get('userData'), 'keep'); + assert.equal(storage.has('pm_cached'), false); +}); + +test('admin LoRA import shows a refusal frame and closes its modal', async () => { + const { page, dom, timers, notifications } = load('admin.js', 'PromptAdmin', + async () => sseResponse([{ type: 'error', status: BUSY, progress: 0, message: BUSY }])); + page.saveLoraSettings = async () => true; + page.loadStatistics = () => assert.fail('a refused import must not reload statistics'); + + await page.runLoraImport(); + + assert.equal(dom.nodes.loraImportStatus.textContent, `Error: ${BUSY}`); + assert.deepEqual(notifications.filter(([type]) => type === 'error'), [['error', `LoRA import failed: ${BUSY}`]]); + assert.equal(timers.length, 1); + timers[0](); + assert.deepEqual(notifications.at(-1), ['hide', 'loraImportModal']); +}); + +test('admin settings save shows why config.json refused it', async () => { + const message = 'config.json cannot be read, so nothing was saved. Fix or remove the file, then save again.'; + const { page, notifications } = load('admin.js', 'PromptAdmin', + async () => jsonResponse(409, { success: false, persisted: false, code: 'config-unreadable', error: message })); + page.settings = {}; + page._collectScanPaths = () => []; + page.saveLoraSettings = async () => assert.fail('nothing else is saved after a refusal'); + + await page.saveSettings(); + + assert.deepEqual(notifications, [['error', `Failed to save settings: ${message}`]]); +}); + +test('admin settings save keeps its plain message when the reply has no reason', async () => { + const { page, notifications } = load('admin.js', 'PromptAdmin', async () => ({ ok: false, status: 500, + json: async () => { throw new SyntaxError('not json'); } })); + page.settings = {}; + page._collectScanPaths = () => []; + + await page.saveSettings(); + + assert.deepEqual(notifications, [['error', 'Failed to save settings']]); +}); + +test('admin maintenance names the job that refused it', async () => { + const { page, dom, notifications } = load('admin.js', 'PromptAdmin', + async () => jsonResponse(409, { success: false, error: BUSY }), { + escapeHtml: text => String(text), + document: { readyState: 'loading', addEventListener() {}, getElementById: id => dom.getElementById(id), + querySelectorAll: selector => (selector === '.maintenance-option:checked' ? [{ id: 'vacuum' }] : []) }, + }); + + await page.runMaintenance(); + + const results = dom.nodes.maintenanceResults.innerHTML; + assert.match(results, /Maintenance Failed/); + assert.match(results, new RegExp(BUSY)); + assert.doesNotMatch(results, /HTTP 409/); + assert.deepEqual(notifications, [['error', '❌ Maintenance failed']]); + assert.equal(dom.nodes.runMaintenanceBtn.disabled, false); +}); diff --git a/tests/js/support/local-inspect-oracle.mjs b/tests/js/support/local-inspect-oracle.mjs index 61711ce..c85d1d1 100644 --- a/tests/js/support/local-inspect-oracle.mjs +++ b/tests/js/support/local-inspect-oracle.mjs @@ -14,6 +14,11 @@ // the carrier rule alone, as the server selects from ffprobe's tag map. // A {records} entry (records null = an unreadable file) answers // {classified: null, result: null, metadata} for the projection alone. +// A {mode: 'resolve'} entry (PR L3) answers {evidence}: resolveLocalEvidence of its {records} texts +// with the repository semantics table. With a {path} it reads the file locally and also answers +// {facts} (and {unreadable: true} with the server's unreadable evidence when nothing could be read): +// extractGenerationFacts on the local payload through projectInspectionPayload, exactly as +// tests/js/support/inspection-parity-facts.mjs reads the server's response for vectors.json. import fs from 'node:fs'; import path from 'node:path'; import { createRequire } from 'node:module'; @@ -28,6 +33,22 @@ const web = path.join(here, '..', '..', '..', 'web', 'js', 'workspace'); const { selectVideoCarrier } = await import(moduleUrl(path.join(web, 'local-video.mjs'))); const { projectMetadata } = await import(moduleUrl(path.join(web, 'local-projection.mjs'))); const { readLocalFile, toInspectionRead } = await import(moduleUrl(path.join(web, 'local-inspection.mjs'))); +const { resolveLocalEvidence, compileSemantics, unreadableEvidence } = await import(moduleUrl(path.join(web, 'local-resolver.mjs'))); +const { buildLocalPayload } = await import(moduleUrl(path.join(web, 'local-inspection.mjs'))); +const { projectInspectionPayload } = await import(moduleUrl(path.join(web, 'inspection-state.mjs'))); +const semantics = compileSemantics(JSON.parse(fs.readFileSync(path.join(here, '..', '..', '..', 'web', 'js', 'inspection-semantics.json'), 'utf8'))); +const { extractGenerationFacts } = require(path.join(here, '..', '..', '..', 'web', 'js', 'comfy-metadata.js')); + +/** The local evidence for one set of record texts. */ +const resolved = records => ({ evidence: resolveLocalEvidence(records, semantics) }); + +/** The local wire's evidence and six facts for one read (no legacy fill, as in the vectors' own harness). */ +function localPayload(read, file, classification) { + const payload = buildLocalPayload({ read, file, classification, semantics }); + const record = projectInspectionPayload(payload, { kind: 'local-file', key: 'local:1', path: file.name }, null, extractGenerationFacts); + const facts = Object.fromEntries(Object.entries(record.generation).map(([field, fact]) => [field, { status: fact.status, value: fact.value }])); + return { evidence: { prompts: payload.prompts, resolution: payload.resolution }, facts }; +} const selectionRead = (selection) => selection.status === 'complete' ? { records: selection.records, record_formats: selection.record_formats, carrierText: selection.carrierText, limited: selection.limited } : null; @@ -37,6 +58,19 @@ for await (const chunk of process.stdin) chunks.push(chunk); const output = {}; for (const entry of JSON.parse(Buffer.concat(chunks).toString('utf8'))) { const { name } = entry; + if (entry.mode === 'resolve') { + if (entry.records !== undefined) { output[name] = resolved(entry.records); continue; } + const blob = await fs.openAsBlob(entry.path); + const { classification, result } = await readLocalFile(blob, { pngMetadata }); + const read = classification === null ? null : toInspectionRead(classification, result).read; + if (classification === null) { output[name] = { unreadable: true }; continue; } + // The evidence is the local wire's own (buildLocalPayload), and it equals resolveLocalEvidence of the records. + const local = localPayload(result, { name: path.basename(entry.path), size: blob.size }, classification); + const direct = read === null ? unreadableEvidence() : resolved(read.records).evidence; + if (JSON.stringify(direct) !== JSON.stringify(local.evidence)) throw new Error(`${name}: the wire's roles are not the resolver's`); + output[name] = read === null ? { unreadable: true, ...local } : local; + continue; + } if (entry.records !== undefined) { const read = entry.records === null ? null : { records: entry.records, diff --git a/tests/js/workspace-activity-store.test.js b/tests/js/workspace-activity-store.test.js index 89eb72d..5fce2cd 100644 --- a/tests/js/workspace-activity-store.test.js +++ b/tests/js/workspace-activity-store.test.js @@ -261,8 +261,13 @@ test('summaries are validated and the copy follows the spec', async () => { const loose = parseJob({ id: ID_A, state: 'running', progress: 140.4, processed: -1, total: 1.5, counters: { added: 'x', errors: 2 }, title: ' ', started_at: 'x', scope: [] }); assert.deepEqual(loose, { id: ID_A, kind: '', title: 'Background job', state: 'running', started_at: null, finished_at: null, - progress: 100, status_line: '', processed: 0, total: 0, counters: { added: 0, errors: 2 }, scope: {}, listed: {}, log_length: 0 }); + progress: 100, status_line: '', processed: 0, total: 0, counters: { added: 0, errors: 2 }, scope: {}, listed: {}, log_length: 0, + cancellable: true }); assert.equal(parseJob(summary({ progress: -3 })).progress, 0); + // A held legacy job cannot be cancelled; an older server sends no key, which keeps Cancel. + assert.equal(parseJob(summary({ cancellable: false })).cancellable, false); + assert.equal(parseJob(summary({ cancellable: true })).cancellable, true); + assert.equal(parseJob(summary()).cancellable, true); const scan = parseJob(summary({ progress: 42, processed: 8240, total: 19622, counters: { added: 1, errors: 1 } })); assert.deepEqual(pillParts(scan), { label: 'Scanning · 42%', files: ' · 8,240 / 19,622', text: 'Scanning · 42% · 8,240 / 19,622' }); @@ -283,6 +288,48 @@ test('summaries are validated and the copy follows the spec', async () => { assert.deepEqual([-1, 0, 1, 4, 5, 6, 1e9, 0.5].map(backoffDelay), [1000, 1000, 2000, 16000, 30000, 30000, 30000, 1000]); }); +// D89 (plan D66): an image tagging job's view has its own tiles, outcomes, openable rows, log and cancel labels. +test('image-tags jobs get their own tiles outcomes and pill verb', async () => { + const { parseJob, jobView, pillParts, outcomeLine, startToast } = await source; + assert.equal(typeof jobView, 'function'); + const job = parseJob(summary({ kind: 'image-tags', title: 'Auto-tag images', progress: 40, processed: 2, total: 5, + counters: { tagged: 1420, unchanged: 3, skipped: 2, errors: 1 }, scope: { mode: 'auto', images: 5, model_type: 'wd14-vit' } })); + const view = jobView(job.kind); + assert.deepEqual(view.tiles, [['tagged', 'Tagged', 'added'], ['unchanged', 'Unchanged', ''], ['skipped', 'Skipped', 'warn'], + ['errors', 'Errors', 'bad']]); + assert.deepEqual(view.outcomes, [['tagged', 'Tagged'], ['errors', 'Errors'], ['skipped', 'Skipped'], ['unchanged', 'Unchanged']]); + assert.deepEqual([...view.openable].sort(), ['tagged', 'unchanged']); + assert.equal(view.log, 'Tagging log'); + assert.equal(view.cancel, 'Cancel tagging'); + assert.deepEqual(pillParts(job), { label: 'Tagging · 40%', files: ' · 2 / 5', text: 'Tagging · 40% · 2 / 5' }); + assert.equal(outcomeLine(job), '1,420 tagged · 1 error'); + assert.equal(outcomeLine({ ...job, counters: {} }), '0 tagged · 0 errors'); + assert.equal(startToast(job), 'Auto-tagging started.'); + // The design's toast pattern, as the scan's: "<Job> finished · N tagged · M errors". + const { finishToast } = await source; + assert.equal(finishToast({ ...job, state: 'finished' }), 'Auto-tag images finished · 1,420 tagged · 1 error'); + assert.equal(finishToast({ ...job, state: 'cancelled' }), 'Auto-tag images cancelled · 1,420 tagged'); + assert.equal(finishToast({ ...job, state: 'failed' }), 'Auto-tag images failed · 1,420 tagged · 1 error'); + assert.equal(finishToast({ ...job, title: 'Add tags to images', counters: {}, state: 'finished' }), + 'Add tags to images finished · 0 tagged · 0 errors'); +}); + +test('unknown kinds keep the generic view', async () => { + const { jobView } = await source; + const scan = jobView('scan'); + assert.deepEqual(scan.tiles, [['added', 'Added to Library', 'added'], ['known', 'Already known', ''], + ['review', 'Needs review', 'warn'], ['no_metadata', 'No usable metadata', ''], ['errors', 'Errors', 'bad']]); + assert.deepEqual(scan.outcomes, [['added', 'Added to Library'], ['review', 'Needs review'], ['errors', 'Errors'], + ['no_metadata', 'No usable metadata'], ['known', 'Already known']]); + assert.deepEqual([...scan.openable].sort(), ['added', 'known']); + assert.deepEqual([scan.log, scan.cancel], ['Scan log', 'Cancel scan']); + for (const kind of ['maintenance', '', 'constructor', '__proto__']) { + const view = jobView(kind); + assert.deepEqual([view.tiles, view.outcomes, [...view.openable]], [scan.tiles, scan.outcomes, [...scan.openable]]); + assert.deepEqual([view.log, view.cancel], ['Job log', 'Cancel job']); + } +}); + test('pagehide stops polling, closes streams and aborts the in-flight read; pageshow resumes', async () => { const { createActivityStore } = await source; const { world, api, ...deps } = harness(); diff --git a/tests/js/workspace-file-inspector.test.js b/tests/js/workspace-file-inspector.test.js index cb722ff..6371f59 100644 --- a/tests/js/workspace-file-inspector.test.js +++ b/tests/js/workspace-file-inspector.test.js @@ -215,3 +215,151 @@ test('none and unavailable render no image chips', async () => { assert.deepEqual(inspectorTagChips(tagged({state, tags:[]}, {status:'none', tags:[], tags_scope:null}), {filterable:true}), []); } }); + +// LI-4 (D117): "Copy settings summary" after the six facts of the full inspector consolidates the legacy metadata +// page's COPY ALL (web/metadata.html copyAllData). The legacy template is read from that page itself and run on the +// legacy parse, so the summary's text is the legacy page's, line for line, wherever the two agree on the values. +const METADATA_PAGE = fs.readFileSync(new URL('../../web/metadata.html', import.meta.url), 'utf8'); +function legacyCopyAll(params) { + const template = METADATA_PAGE.match(/const allData = `([\s\S]*?)`;/)[1]; + return new Function('currentWorkflowData', `return \`${template}\`;`)(params); +} +const legacyParams = record => ComfyMetadata.extractGenerationParams({prompt:record.metadata.prompt_graph.status === 'parsed' ? record.metadata.prompt_graph.value : null, + workflow:record.metadata.workflow_graph.status === 'parsed' ? record.metadata.workflow_graph.value : null}); + +/** + * Each summary line against the legacy page's, allowing only the recorded differences (D117): a number keeps the + * inspector's (the server's) spelling ("8.0" where the legacy page wrote 8); a fact the legacy parse left "Unknown" may + * be known; and a negative the #249 ZeroOut guard zeroed reads "No negative prompt found" where the legacy parse read + * the positive's text through the zero-out. + */ +function assertLegacySummary(summary, legacy, record) { + const lines = summary.split('\n'), expected = legacy.split('\n'); + assert.deepEqual(lines.map(line => line.split(':')[0]), expected.map(line => line.split(':')[0]), 'the legacy labels, in order'); + lines.forEach((line, index) => { + if (line === expected[index]) return; + const [label, value] = [line.slice(0, line.indexOf(': ')), line.slice(line.indexOf(': ') + 2)]; + const old = expected[index].slice(label.length + 2); + if (label === 'Negative Prompt' && record.prompts.negative?.source === 'zeroed-conditioning' && value === 'No negative prompt found') return; + if (old === 'Unknown' && !/Prompt$/.test(label)) return; + assert.ok(value.trim() !== '' && Number(value) === Number(old), `${label}: "${value}" differs from the legacy "${old}"`); + }); +} + +test('settings summary is the legacy COPY ALL text for a local file', async () => { + const {settingsSummary} = await load(); + assert.equal(typeof settingsSummary, 'function', 'settingsSummary is exported'); + const record = await localRecord(localPng('fox.png')); + assertLegacySummary(settingsSummary(record), legacyCopyAll(legacyParams(record)), record); + assert.equal(settingsSummary(record), ['Checkpoint: synthetic-v1.safetensors', 'Positive Prompt: a red fox in snow, detailed fur', + 'Negative Prompt: blurry, lowres', 'Steps: 20', 'CFG Scale: 8.0', 'Sampler: euler', 'Seed: 7'].join('\n')); + // Every parity PNG read in this browser: the legacy page's text but for the recorded differences, and exactly the + // legacy text for most of them (f09 among them: the server is ambiguous, so the record shows the legacy fill). + const exact = []; + for (const name of fs.readdirSync(PARITY).filter(item => item.endsWith('.png')).sort()) { + const parity = await localRecord(new File([fs.readFileSync(new URL(name, PARITY))], name)); + const summary = settingsSummary(parity), legacy = legacyCopyAll(legacyParams(parity)); + assertLegacySummary(summary, legacy, parity); + if (summary === legacy) exact.push(name); + } + assert.ok(exact.includes('f09.png')); + assert.deepEqual(fs.readdirSync(PARITY).filter(item => item.endsWith('.png') && !exact.includes(item)).sort(), ['f01b.png', 'f17.png', 'f23.png']); +}); + +test('settings summary uses the legacy placeholders for what the record does not know', async () => { + const {settingsSummary} = await load(); + const unknown = {...record(), prompts:{positive:{status:'unresolved'}, negative:{status:'ambiguous'}}, generation:{}}; + assert.equal(settingsSummary(unknown), legacyCopyAll({}).replace('Positive Prompt: undefined', 'Positive Prompt: No prompt found') + .replace('Negative Prompt: undefined', 'Negative Prompt: No negative prompt found')); + assert.equal(settingsSummary(unknown), ['Checkpoint: Unknown', 'Positive Prompt: No prompt found', 'Negative Prompt: No negative prompt found', + 'Steps: Unknown', 'CFG Scale: Unknown', 'Sampler: Unknown', 'Seed: Unknown'].join('\n')); + // A server answer is shown as the inspector shows it: known facts verbatim, every other state the legacy "Unknown". + const server = {...record(), prompts:{positive:{status:'resolved', text:'a\n猫'}, negative:{status:'resolved', text:''}}, generation:{ + model:{status:'known', value:'m.safetensors'}, steps:{status:'not-recorded', value:null}, cfg:{status:'known', value:'6.50'}, + sampler:{status:'ambiguous', value:null}, seed:{status:'known', value:'9007199254740993'}, scheduler:{status:'known', value:'karras'}}}; + assert.equal(settingsSummary(server), ['Checkpoint: m.safetensors', 'Positive Prompt: a\n猫', 'Negative Prompt: No negative prompt found', + 'Steps: Unknown', 'CFG Scale: 6.50', 'Sampler: Unknown', 'Seed: 9007199254740993'].join('\n')); +}); + +/** The full inspector with a toast node and `clipboard` as `navigator.clipboard`; returns the summary button. */ +function renderSummary(record, {variant = 'full', clipboard} = {}) { + const f = mountedDom(), scope = createScope(), fallbacks = [], toast = new f.Node('div'); + f.doc.getElementById = id => id === 'workspace-toast' ? toast : null; + const previous = Object.getOwnPropertyDescriptor(globalThis, 'navigator'); + Object.defineProperty(globalThis, 'navigator', {configurable:true, value:clipboard === undefined ? {} : {clipboard}}); + const view = mountFileInspector(f.container, {sender:{open() {}}, parentScope:scope, variant, copyFallback:(text, options) => fallbacks.push({text, ...options})}); + view.setState({loading:false, error:false, record}); + const button = f.walk(f.container, n => n.tagName === 'button' && n.textContent === 'Copy settings summary')[0] ?? null; + return {f, button, toast, fallbacks, scope, done() { + view.dispose(); scope.dispose(); f.restore(); + if (previous) Object.defineProperty(globalThis, 'navigator', previous); else delete globalThis.navigator; + }}; +} + +test('Copy settings summary follows the facts in the full inspector only', async () => { + const full = renderSummary(record()); + try { + assert.ok(full.button, 'the full inspector offers Copy settings summary'); + const inner = full.button.parent, index = inner.children.indexOf(full.button); + assert.equal(inner.children[index - 1].tagName, 'dl', 'it follows the six facts'); + assert.equal(inner.children[index + 1].tagName, 'details', 'File & provenance follows it'); + // M7: the D117 wrap rule targets this button by its class, never any button of a details block. + assert.equal(full.button.className, 'settings-summary'); + } finally { full.done(); } + const panel = renderSummary(record(), {variant:'panel'}); + try { assert.equal(panel.button, null, 'the Images side panel keeps the concept footer'); } finally { panel.done(); } +}); + +test('Copy settings summary copies the summary and shows the shared toast', async () => { + const {settingsSummary} = await load(), copied = []; + const view = renderSummary(record(), {clipboard:{writeText:async text => {copied.push(text);}}}); + try { + await view.button.listeners.get('click')[0].callback({target:view.button}); + assert.deepEqual(copied, [settingsSummary(record())]); + assert.equal(view.toast.textContent, 'Settings summary copied.'); + assert.equal(view.fallbacks.length, 0); + } finally { view.done(); } +}); + +test('Copy settings summary without a clipboard opens the select-to-copy fallback', async () => { + const {settingsSummary} = await load(); + for (const clipboard of [undefined, {writeText:async () => {throw new Error('Denied');}}]) { + const view = renderSummary(record(), {clipboard}); + try { + await view.button.listeners.get('click')[0].callback({target:view.button}); + assert.equal(view.fallbacks.length, 1); + assert.equal(view.fallbacks[0].text, settingsSummary(record())); + assert.equal(view.fallbacks[0].title, 'Copy settings summary'); + assert.equal(view.fallbacks[0].trigger, view.button); + assert.notEqual(view.toast.textContent, 'Settings summary copied.'); + } finally { view.done(); } + } +}); + +test('the inspector is busy while a read is loading, then not', async () => { + const f = mountedDom(), scope = createScope(); + const view = mountFileInspector(f.container, {sender:{open() {}}, parentScope:scope, variant:'full'}); + try { + view.setState({loading:true, error:false, record:null}); + assert.equal(f.container.getAttribute('aria-busy'), 'true'); + assert.ok(f.walk(f.container, n => n.textContent === 'Loading file details…').length); + view.setState({loading:false, error:false, record:record()}); + assert.equal(f.container.getAttribute('aria-busy'), null); + view.setState({loading:true, error:false, record:null}); + view.setState({loading:false, error:true, record:null}); + assert.equal(f.container.getAttribute('aria-busy'), null); + view.setState({loading:true, error:false, record:null}); + } finally { + view.dispose(); + assert.equal(f.container.getAttribute('aria-busy'), null, 'dispose clears the busy state'); + scope.dispose(); f.restore(); + } +}); + +test('the D117 wrap rule targets only the settings summary button', async () => { + const fs = await import('node:fs'); + const css = fs.readFileSync(new URL('../../web/js/workspace/workspace.css', import.meta.url), 'utf8'); + const rules = css.split('\n').filter(line => line.includes('D117')); + assert.deepEqual(rules.map(line => line.trim()), ['.ca-tools #viewer-details .settings-summary { white-space: normal; /* D117 */ }']); + assert.doesNotMatch(css, /#viewer-details > div > button/); +}); diff --git a/tests/js/workspace-images-tagging-bulk.test.js b/tests/js/workspace-images-tagging-bulk.test.js new file mode 100644 index 0000000..e5647db --- /dev/null +++ b/tests/js/workspace-images-tagging-bulk.test.js @@ -0,0 +1,198 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +// Guarded: before the module exists every test fails on an assertion, not on the import. +const loaded = await import('../../web/js/workspace/images-tagging-bulk.mjs').catch(() => null); +const module = () => { assert.ok(loaded, 'images-tagging-bulk.mjs is not implemented'); return loaded; }; +const state = await import('../../web/js/workspace/image-tagging-state.mjs'); +const { startToast, parseJob } = await import('../../web/js/workspace/activity-store.mjs'); +const SCOPE = 'b'.repeat(64); +const JOB_ID = 'c'.repeat(32); +const image = (path, extra = {}) => ({ root_index: 0, root_scope: SCOPE, path, filename: path.split('/').pop(), ...extra }); +const pageScope = () => { + const controller = new AbortController(); + const cleanups = []; + return { signal: controller.signal, cleanup: fn => cleanups.push(fn), abort: () => controller.abort(), cleanups }; +}; + +test('targets keep listing order and identity', () => { + const { bulkTargets } = module(); + const listing = [image('b/2.png', { width: 9 }), image('a/1.png'), { ...image('x.png'), root_scope: 'nope' }, image('c/3.webm')]; + assert.deepEqual(bulkTargets(listing), [ + { root_index: 0, root_scope: SCOPE, path: 'b/2.png' }, + { root_index: 0, root_scope: SCOPE, path: 'a/1.png' }, + { root_index: 0, root_scope: SCOPE, path: 'c/3.webm' }, + ]); + assert.deepEqual(bulkTargets([]), []); +}); + +test('over 500 selected refuses with the toast', () => { + const { bulkTargets, openAddTags, openAutoTag, MAX_BULK_IMAGES, TOO_MANY } = module(); + assert.equal(MAX_BULK_IMAGES, 500); + assert.equal(TOO_MANY, 'Select at most 500 images.'); + const many = Array.from({ length: 501 }, (_, index) => image(`bulk/${index}.png`)); + assert.equal(bulkTargets(many), null); + assert.equal(bulkTargets(many.slice(0, 500)).length, 500); + const toasts = []; + const store = { start: () => assert.fail('no job starts') }; + // Refused before any dialog opens (no DOM here). + openAddTags({ scope: pageScope(), store, images: many, notify: text => toasts.push(text) }); + openAutoTag({ api: {}, scope: pageScope(), store, images: many, notify: text => toasts.push(text) }); + assert.deepEqual(toasts, [TOO_MANY, TOO_MANY]); +}); + +test('add parses comma tags with the same dedupe as the dialog', () => { + const { parseBulkTags, ADD_DIALOG } = module(); + assert.deepEqual(ADD_DIALOG, { title: 'Tag selected images', label: 'Tags, separated by commas', action: 'Apply' }); + assert.deepEqual(parseBulkTags(' Sunset, sunset ,, blue_hour,SUNSET, Blue_Hour '), ['Sunset', 'blue_hour']); + assert.deepEqual(parseBulkTags(' , ,'), []); + assert.equal(parseBulkTags, state.parseManualTags); +}); + +test('Add tags keeps its dialog and the typed text when the field parses to no names', async () => { + const { openAddTags, NO_TAGS } = module(); + const toasts = []; + const posted = []; + const store = { start: async (kind, body) => { posted.push(body); return { job: { id: JOB_ID, state: 'running' } }; }, + find: () => null, subscribe: () => () => {} }; + let dialog = null; + openAddTags({ scope: pageScope(), store, images: [image('a.png')], trigger: null, notify: text => toasts.push(text), + openInput: args => { dialog = args; } }); + assert.equal(typeof dialog?.validate, 'function'); + // The dialog reports the message on its field and stays open: nothing toasts, nothing is sent. + assert.equal(dialog.validate(' , ,'), NO_TAGS); + assert.equal(dialog.validate('sunset'), null); + assert.deepEqual(toasts, []); + await dialog.onApply('sunset, Sunset'); + assert.deepEqual(posted.map(body => body.tags), [['sunset']]); +}); + +test('busy start keeps the selection and names the running job', async () => { + const { startTagJob } = module(); + const toasts = []; + const posted = []; + const running = { id: JOB_ID, title: 'Scan for metadata' }; + const store = { start: async (kind, body) => { posted.push([kind, body]); return { busy: running, message: 'busy' }; } }; + const body = { mode: 'add', targets: [{ root_index: 0, root_scope: SCOPE, path: 'a.png' }], tags: ['x'] }; + assert.equal(await startTagJob({ store, scope: pageScope(), body, notify: text => toasts.push(text) }), null); + assert.deepEqual(posted, [['image-tags', body]]); + assert.deepEqual(toasts, [['Another job is running: ', { text: 'Scan for metadata', href: `#/activity/${JOB_ID}` }, '.']]); +}); + +test('a started job is returned without a toast of its own; refusals toast their sentence', async () => { + const { startTagJob } = module(); + const toasts = []; + const notify = text => toasts.push(text); + const job = { id: JOB_ID, kind: 'image-tags' }; + assert.equal(await startTagJob({ store: { start: async () => ({ job }) }, scope: pageScope(), body: { mode: 'add' }, notify }), job); + const refused = Object.assign(new Error('The tagging model is busy. Try again when it finishes.'), { name: 'ApiError', status: 409, code: 'model-busy' }); + assert.equal(await startTagJob({ store: { start: async () => { throw refused; } }, scope: pageScope(), body: { mode: 'auto' }, notify }), null); + assert.equal(await startTagJob({ store: { start: async () => { throw new TypeError('down'); } }, scope: pageScope(), body: { mode: 'auto' }, notify }), null); + assert.equal(await startTagJob({ store: { start: async () => { throw new TypeError('down'); } }, scope: pageScope(), body: { mode: 'add' }, notify }), null); + assert.deepEqual(toasts, ['The tagging model is busy. Try again when it finishes.', 'Auto-tagging could not start. Try again.', + 'Tagging could not start. Try again.']); + // A page left meanwhile says nothing. + const left = pageScope(); + left.abort(); + assert.equal(await startTagJob({ store: { start: async () => { throw refused; } }, scope: left, body: { mode: 'auto' }, notify }), null); + assert.equal(toasts.length, 3); +}); + +test('the start toast names the tagging mode', () => { + const job = mode => parseJob({ id: JOB_ID, state: 'running', kind: 'image-tags', title: 'Add tags to images', scope: { mode, images: 2 } }); + assert.equal(startToast(job('add')), 'Tagging started.'); + assert.equal(startToast(job('auto')), 'Auto-tagging started.'); + assert.equal(startToast(job('other')), 'Add tags to images started.'); + assert.equal(startToast(parseJob({ id: JOB_ID, state: 'running', kind: 'scan', title: 'Scan for metadata' })), 'Scan started.'); +}); + +test('a followed job reports its end once', () => { + const { followJobEnd } = module(); + let jobs = [{ id: JOB_ID, state: 'running', counters: {} }]; + const listeners = new Set(); + const store = { find: id => jobs.find(job => job.id === id), subscribe: fn => { listeners.add(fn); return () => listeners.delete(fn); } }; + const ended = []; + const scope = pageScope(); + followJobEnd({ store, scope, id: JOB_ID, onEnd: job => ended.push(job.state) }); + assert.equal(listeners.size, 1); + for (const fn of [...listeners]) fn(store); + assert.deepEqual(ended, []); + jobs = [{ id: JOB_ID, state: 'finished', counters: { tagged: 1 } }]; + for (const fn of [...listeners]) fn(store); + for (const fn of [...listeners]) fn(store); + assert.deepEqual(ended, ['finished']); + assert.equal(listeners.size, 0); + assert.equal(scope.cleanups.length, 1); +}); + +test('a model that is not downloaded is named with the server sentence', () => { + const { modelUnavailable } = state; + assert.equal(modelUnavailable(null, 'wd14-vit'), ''); + assert.equal(modelUnavailable({ 'wd14-vit': { downloaded: true } }, 'wd14-vit'), ''); + assert.equal(modelUnavailable({ 'wd14-vit': { downloaded: false } }, 'wd14-vit'), 'WD14 ViT is not downloaded. Download it from the legacy Auto-tag page.'); + assert.equal(modelUnavailable({ gguf: { downloaded: false, name: 'GGUF (Recommended)' } }, 'gguf'), + 'GGUF (Recommended) is not downloaded. Download it from the legacy Auto-tag page.'); + assert.throws(() => modelUnavailable({}, 'nope'), TypeError); +}); + +test('the bulk options start from the server defaults and wait for them before Start', async () => { + const { createTaggingOptionsController } = state; + let answer; + const api = { get: () => new Promise(resolve => { answer = resolve; }) }; + const options = createTaggingOptionsController({ api }); + const seen = []; + options.subscribe(value => seen.push(value.optionsRevision)); + assert.equal(options.state.canStart, false); + assert.equal(options.state.canGenerate, false); + const opening = options.open(); + answer({ wd14_general_threshold: 0.5, wd14_character_threshold: 0.9, default_prompt: 'Say it', suggestion_limit: 7, + models: { 'wd14-swinv2': { downloaded: true }, 'wd14-vit': { downloaded: false }, gguf: { downloaded: true } } }); + await opening; + assert.equal(options.state.canStart, true); + assert.deepEqual(options.request(), { model_type: 'wd14-swinv2', options: { general_threshold: 0.5, character_threshold: 0.9, suggestion_limit: 7 } }); + options.setOption('suggestion_limit', '3'); + options.setModel('wd14-vit'); + assert.equal(options.state.canStart, false); + assert.match(options.state.unavailable, /^WD14 ViT is not downloaded\./); + options.setModel('gguf'); + assert.deepEqual(options.request(), { model_type: 'gguf', options: { instructions: 'Say it', suggestion_limit: 3 } }); + options.setOption('instructions', ' '); + assert.equal(options.request(), null); + assert.deepEqual(seen, [1, 1, 2, 3, 3]); + assert.throws(() => options.setModel('nope'), TypeError); + assert.throws(() => options.setOption('nope', 1), TypeError); + options.dispose(); + options.setOption('suggestion_limit', '4'); + assert.equal(options.state.options.suggestion_limit, '3'); +}); + +test('the bulk options keep the documented defaults when the read fails', async () => { + const { createTaggingOptionsController } = state; + const options = createTaggingOptionsController({ api: { get: async () => { throw new Error('offline'); } } }); + await options.open(); + await options.open(); + assert.equal(options.state.canStart, true); + assert.equal(options.state.unavailable, ''); + assert.deepEqual(options.request(), { model_type: 'wd14-swinv2', options: { general_threshold: 0.35, character_threshold: 0.85, suggestion_limit: 10 } }); + assert.throws(() => createTaggingOptionsController({}), TypeError); +}); + +test('a preset starts the Tag image dialog on the chosen model and options', async () => { + const { createImageTaggingController } = state; + const target = { root_index: 0, root_scope: SCOPE, path: 'a.png' }; + const api = { + get: async () => ({ wd14_general_threshold: 0.35, wd14_character_threshold: 0.85, default_prompt: 'Describe', suggestion_limit: 10 }), + post: async () => ({ success: true, library_epoch: '12345678-1234-1234-1234-123456789abc', filename: 'a.png', title: null, tags: [], tag_revision: 0, + capture: { token: 'tok', sha256: 'd'.repeat(64), preview_url: '/prompt_manager/image-annotations/capture/tok', width: 4, height: 4, + media_type: 'image', generate: { supported: true, reason: null } } }), + put: async () => ({}), del: async () => ({}), + }; + const controller = createImageTaggingController({ api, target, source: 'bulk', preset: { model: 'gguf', options: { instructions: 'Name it', suggestion_limit: 3 } } }); + assert.equal(controller.state.model, 'gguf'); + await controller.open(); + assert.deepEqual([controller.state.model, controller.state.options.instructions, controller.state.options.suggestion_limit], ['gguf', 'Name it', 3]); + controller.dispose(); + assert.throws(() => createImageTaggingController({ api, target, preset: { model: 'nope', options: {} } }), TypeError); + assert.throws(() => createImageTaggingController({ api, target, preset: { model: 'gguf', options: { instructions: '' } } }), TypeError); + assert.throws(() => createImageTaggingController({ api, target, preset: { model: 'gguf', options: { nope: 1 } } }), TypeError); +}); diff --git a/tests/js/workspace-images-tagging-review.test.js b/tests/js/workspace-images-tagging-review.test.js new file mode 100644 index 0000000..123ba63 --- /dev/null +++ b/tests/js/workspace-images-tagging-review.test.js @@ -0,0 +1,110 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +// Guarded: before the module exists every test fails on an assertion, not on the import. +const loaded = await import('../../web/js/workspace/images-tagging-review.mjs').catch(() => null); +const module = () => { assert.ok(loaded, 'images-tagging-review.mjs is not implemented'); return loaded; }; +const SCOPE = 'e'.repeat(64); +const images = ['a.png', 'b.png', 'c.png'].map(path => ({ root_index: 0, root_scope: SCOPE, path, filename: path })); +const OPTIONS = { general_threshold: 0.4, character_threshold: 0.8, suggestion_limit: 3 }; + +/** A session over fake dialogs: `dialogs` holds every open call's arguments and whether it was closed. */ +function session(extra = {}) { + const dialogs = []; + const toasts = []; + let closes = 0; + const saved = []; + const handle = module().startReviewSession({ + api: {}, scope: {}, images, model_type: 'wd14-vit', options: OPTIONS, trigger: 'auto-tag', + onSaved: event => saved.push(event.filename), + open: args => { + const dialog = { args, closed: 0 }; + dialogs.push(dialog); + return { close: () => { dialog.closed += 1; args.onStop(); } }; + }, + close: () => { closes += 1; }, + notify: text => toasts.push(text), + ...extra, + }); + return { handle, dialogs, toasts, saved, closes: () => closes }; +} +const approve = (dialog, tags = ['x']) => dialog.args.onSaved({ filename: dialog.args.image.filename, tags, target: {} }); + +test('the first image opens at once with the session model, options and its position', () => { + const { dialogs } = session(); + assert.equal(dialogs.length, 1); + const { args } = dialogs[0]; + assert.equal(args.image, images[0]); + assert.deepEqual(args.position, { index: 1, total: 3 }); + assert.deepEqual(args.preset, { model: 'wd14-vit', options: OPTIONS }); + assert.equal(args.autoGenerate, true); + assert.equal(args.actions, 'review'); + assert.equal(args.source, 'bulk'); + assert.equal(args.trigger, 'auto-tag'); + assert.throws(() => module().startReviewSession({ images: [] }), TypeError); +}); + +test('advances only on approve or skip and writes nothing on skip or stop', () => { + const { dialogs, toasts, saved } = session(); + // Nothing from the dialog (a Generate in progress, a model-busy answer): the session waits on that image. + assert.equal(dialogs.length, 1); + dialogs[0].args.onSkip(); + assert.equal(dialogs.length, 2); + assert.deepEqual(dialogs[1].args.position, { index: 2, total: 3 }); + // A second report from a dialog already left changes nothing. + dialogs[0].args.onSkip(); + dialogs[0].args.onStop(); + assert.equal(dialogs.length, 2); + approve(dialogs[1]); + assert.equal(dialogs.length, 3); + dialogs[2].args.onStop(); + assert.deepEqual(saved, ['b.png']); + assert.deepEqual(toasts, ['1 of 3 images tagged.']); + assert.equal(dialogs.length, 3); +}); + +test('approve saves exactly the dialog\'s manual-plus-chips set', () => { + // The session never writes itself: each approve is the dialog's own Save (its event carries the saved set). + const forwarded = []; + const { dialogs } = session({ onSaved: event => forwarded.push(event.tags) }); + approve(dialogs[0], ['manual', 'chip']); + assert.deepEqual(forwarded, [['manual', 'chip']]); + assert.deepEqual(dialogs[1].args.preset, { model: 'wd14-vit', options: OPTIONS }); +}); + +test('last image offers Approve and finish', () => { + const { dialogs, toasts, closes } = session(); + approve(dialogs[0]); + dialogs[1].args.onSkip(); + assert.deepEqual(dialogs[2].args.position, { index: 3, total: 3 }); + approve(dialogs[2]); + // The last approve ends the session: the dialog closes and the count is toasted once. + assert.equal(closes(), 1); + assert.deepEqual(toasts, ['2 of 3 images tagged.']); + dialogs[2].args.onStop(); + assert.deepEqual(toasts, ['2 of 3 images tagged.']); +}); + +test('model-busy on one image waits for retry or skip', () => { + const { dialogs, toasts } = session(); + // The dialog shows the refusal with Retry; a Retry that succeeds then approves, all on the same image. + assert.equal(dialogs.length, 1); + approve(dialogs[0]); + assert.equal(dialogs.length, 2); + assert.deepEqual(toasts, []); +}); + +test('stop releases listeners and in-flight requests', () => { + const { handle, dialogs, toasts, saved } = session(); + handle.stop(); + assert.equal(dialogs[0].closed, 1); + assert.deepEqual(toasts, ['0 of 3 images tagged.']); + // A save that settles after the stop still counts as written, but nothing opens or toasts again. + approve(dialogs[0]); + dialogs[0].args.onSkip(); + handle.stop(); + assert.equal(dialogs.length, 1); + assert.deepEqual(saved, ['a.png']); + assert.deepEqual(toasts, ['0 of 3 images tagged.']); + assert.equal(module().reviewOutcome(1, 1), '1 of 1 images tagged.'); +}); diff --git a/tests/js/workspace-local-inspection.test.js b/tests/js/workspace-local-inspection.test.js index 0769055..2994f9e 100644 --- a/tests/js/workspace-local-inspection.test.js +++ b/tests/js/workspace-local-inspection.test.js @@ -20,6 +20,16 @@ const { extractGenerationFacts } = ComfyMetadata; // The page loads both as scripts; the legacy fill and the PNG reader find them on the global. globalThis.PngMetadata = PngMetadata; globalThis.ComfyMetadata = ComfyMetadata; +// The page's API client and the node table it serves (local-semantics.mjs fetches it once). +const SEMANTICS = fs.readFileSync(path.join(here, "..", "..", "web", "js", "inspection-semantics.json"), "utf8"); +globalThis.window = { ApiClient: { resolveUrl: (value) => value } }; +// A test can hold the table request (`tableGate`) to stand for a slow or stalled proxy. +let tableGate = null; +globalThis.fetch = async (url) => { + if (url !== "/prompt_manager/js/inspection-semantics.json") throw new Error(`unexpected request ${url}`); + if (tableGate) await tableGate; + return new Response(SEMANTICS, { status: 200 }); +}; const m = await import("../../web/js/workspace/local-inspection.mjs").catch((error) => { if (error.code !== "ERR_MODULE_NOT_FOUND") throw error; return {}; @@ -59,6 +69,37 @@ test("contract", () => { assert.ok(Object.isFrozen(m.localFileAdapter)); }); +// M3: the first read waits for the page's one table request; an aborted, replaced or cleared selection must not. +test("a read waiting for the node table ends with its selection", async () => { + let release; + tableGate = new Promise((resolve) => { release = resolve; }); + try { + const file = pngFile("slow.png", [tEXt("prompt", PROMPT)]); + const aborted = new AbortController(); + const reading = m.localFileAdapter.read(await handleFor(file), { signal: aborted.signal }); + const settled = (promise) => Promise.race([promise.then(() => "resolved", (error) => error.name), new Promise((resolve) => setTimeout(() => resolve("pending"), 200))]); + await new Promise((resolve) => setTimeout(resolve, 20)); + aborted.abort(); + assert.equal(await settled(reading), "AbortError"); + // Through the controller: clearing the selection ends its read; nothing is published for it. + const controller = createInspectionController({ api: null, adapter: m.localFileAdapter, extractFacts: extractGenerationFacts }); + const states = []; + controller.subscribe((state) => states.push(state)); + const selected = controller.select(await handleFor(file)); + await new Promise((resolve) => setTimeout(resolve, 20)); + controller.clear(); + assert.equal(await settled(selected), "resolved"); + assert.ok(states.every((state) => state.record === null && state.error === false), "no record or error for a cleared selection"); + controller.dispose(); + } finally { + tableGate = null; + release(); + } + // The shared request still lands for the next selection. + const { wire } = await recordFor(pngFile("next.png", [tEXt("prompt", PROMPT)])); + assert.equal(wire.resolution.state, "resolved"); +}); + test("a PNG File becomes the server-shaped wire with local provenance", async () => { const file = pngFile("Fox Render.PNG", [tEXt("prompt", PROMPT), tEXt("workflow", WORKFLOW)]); const { wire, record, handle } = await recordFor(file); @@ -66,8 +107,12 @@ test("a PNG File becomes the server-shaped wire with local provenance", async () size: file.size, width: 640, height: 480, modified_time: 1_700_000_000.123 }); assert.equal(wire.success, true); assert.equal(wire.inspection_version, 1); - assert.deepEqual(wire.prompts, { positive: { status: "unresolved" }, negative: { status: "unresolved" } }); - assert.deepEqual(wire.resolution, { state: "unresolved", sampler_id: null, reason: "Resolved in this browser from the workflow.", reason_code: null, node: null }); + // The roles the server's inspector resolves for the same records (local-resolver.mjs). + assert.deepEqual(wire.prompts, { + positive: { status: "resolved", text: "a red fox in snow, detailed fur", source: "graph-inferred" }, + negative: { status: "resolved", text: "blurry, lowres", source: "graph-inferred" }, + }); + assert.deepEqual(wire.resolution, { state: "resolved", sampler_id: "3", reason: "", reason_code: null, node: null }); assert.deepEqual(wire.library, { status: "unavailable", scope: "saved-library", tags: [], category_scope: null, tags_scope: null }); assert.equal(wire.metadata.status, "available"); assert.deepEqual(wire.metadata.raw, { prompt: PROMPT, workflow: WORKFLOW }); @@ -96,6 +141,33 @@ test("a PNG File becomes the server-shaped wire with local provenance", async () assert.equal(exported.file.source_label, "Selected local file"); }); +test("roles answer as the server's inspector: unreadable, unavailable table, ambiguous", async () => { + const png = { media: "image", kind: "png" }; + const unreadable = m.buildLocalPayload({ read: null, file: { name: "a.png" }, classification: png }); + assert.deepEqual(unreadable.resolution, { state: "unresolved", sampler_id: null, reason: "Media metadata could not be read", reason_code: null, node: null }); + assert.equal(unreadable.prompts.positive.message, "Metadata in this file could not be read."); + const records = { prompt: PROMPT }; + const read = { status: "complete", records, limited: [], width: 2, height: 3 }; + // Without a table (it could not be fetched) the roles stay open, with the server's own line. + const missing = m.buildLocalPayload({ read, file: { name: "a.png" }, classification: png }); + assert.equal(missing.resolution.reason, "Inspection node semantics are unavailable"); + assert.equal(missing.prompts.positive.message, "The inspector's node table is unavailable."); + const semantics = await (await import("../../web/js/workspace/local-semantics.mjs")).loadInspectionSemantics(); + const graph = JSON.parse(PROMPT); + graph["9"] = { ...graph["3"], inputs: { ...graph["3"].inputs, positive: ["8", 0] } }; + graph["8"] = { class_type: "CLIPTextEncode", inputs: { text: "a cat", clip: ["4", 1] } }; + const several = m.buildLocalPayload({ read: { ...read, records: { prompt: JSON.stringify(graph) } }, file: { name: "a.png" }, classification: png, semantics }); + assert.equal(several.resolution.reason, "Multiple sampler branches"); + assert.deepEqual(several.prompts.positive, { status: "ambiguous", text: null, source: null }); + // The legacy fill then applies exactly as it does to the server's answer for the same bytes in Images. + const handle = await handleFor(pngFile("grid.png", [tEXt("prompt", JSON.stringify(graph))])); + const local = m.localFileAdapter.toRecord(several, handle, extractGenerationFacts); + const asServed = { ...several, file: { ...several.file, observation: "current-file", source_label: "output" } }; + const served = projectInspectionPayload(asServed, { kind: "rooted-file", key: "0:grid.png", root_index: 0, path: "grid.png", root_scope: null }, null, extractGenerationFacts); + assert.deepEqual(local.prompts, served.prompts); + assert.deepEqual(local.generation, served.generation); +}); + test("file facts follow the server: extension like Path.suffix, null when unknown", async () => { const cases = [["a.b.PnG", ".png"], [".hidden", ""], ["noext", ""], ["dot.", ""], ["x.tar.gz", ".gz"]]; for (const [name, extension] of cases) { @@ -307,3 +379,89 @@ test("the parity corpus PNGs read into renderable records", async () => { assert.ok(["parsed", "limited", "malformed"].includes(wire.metadata.prompt_graph.status)); } }); + +// M4: resolution runs on the page's main thread, so a read yields between its bounded steps (the busy state paints +// and input is handled) and no step may exceed a budget on the largest record the readers keep (2 MiB of roles). +const ROLE_BYTES = 2 * 1024 * 1024; +// Generous for a shared CI runner: a desktop resolves these in 130-455 ms. +const STEP_BUDGET_MS = 2500; +const ENCODE = (text) => ({ class_type: "CLIPTextEncode", inputs: { text, clip: ["4", 1] } }); +const KSAMPLER = (positive, negative, latent) => ({ class_type: "KSampler", inputs: { seed: 1, steps: 20, cfg: 8.0, sampler_name: "euler", scheduler: "normal", + denoise: 1.0, model: ["4", 0], positive, negative, latent_image: latent } }); +const LOADERS = { "4": { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "m.safetensors" } }, "5": { class_type: "EmptyLatentImage", inputs: { width: 64, height: 64, batch_size: 1 } } }; +/** The largest graph of this shape whose JSON fits the role budget. */ +function maximal(build, high) { + let low = 1; + while (low < high) { + const middle = (low + high + 1) >> 1; + if (Buffer.byteLength(JSON.stringify(build(middle)), "utf8") <= ROLE_BYTES) low = middle; + else high = middle - 1; + } + return JSON.stringify(build(low)); +} +const MAXIMAL_GRAPHS = { + // Many candidates: every sampler is a final stage with its own pair. + independent: maximal((count) => { + const graph = { ...LOADERS }; + for (let index = 0; index < count; index++) { + graph[`s${index}`] = KSAMPLER([`p${index}`, 0], [`n${index}`, 0], ["5", 0]); + graph[`p${index}`] = ENCODE(`fox ${index}`); + graph[`n${index}`] = ENCODE(`blur ${index}`); + } + return graph; + }, 6666), + // One long stage chain through the latent: the deepest stage walk. + chain: maximal((count) => { + const graph = { ...LOADERS, "6": ENCODE("fox"), "7": ENCODE("blur") }; + for (let index = 0; index < count; index++) graph[`s${index}`] = KSAMPLER(["6", 0], ["7", 0], index ? [`s${index - 1}`, 0] : ["5", 0]); + return graph; + }, 19996), +}; + +test("resolution of a maximal 2 MiB graph stays within the step budget", async () => { + const { resolveLocalEvidence } = await import("../../web/js/workspace/local-resolver.mjs"); + const semantics = await (await import("../../web/js/workspace/local-semantics.mjs")).loadInspectionSemantics(); + for (const [name, prompt] of Object.entries(MAXIMAL_GRAPHS)) { + assert.ok(Buffer.byteLength(prompt, "utf8") > ROLE_BYTES - 1024, `${name} fills the role budget`); + const started = performance.now(); + const evidence = resolveLocalEvidence({ prompt }, semantics); + const elapsed = performance.now() - started; + assert.ok(elapsed < STEP_BUDGET_MS, `${name}: ${Math.round(elapsed)} ms`); + assert.notEqual(evidence.resolution.reason_code, "malformed", name); + } +}); + +test("a read yields between bounded steps and answers byte for byte as one synchronous build", async () => { + const semantics = await (await import("../../web/js/workspace/local-semantics.mjs")).loadInspectionSemantics(); + const file = pngFile("maximal.png", [tEXt("prompt", MAXIMAL_GRAPHS.independent)]); + const handle = await handleFor(file); + const marks = []; + const yieldControl = async (signal) => { + signal?.throwIfAborted(); + marks.push(performance.now()); + await new Promise((resolve) => setTimeout(resolve, 0)); + }; + const started = performance.now(); + const wire = await m.localFileAdapter.read(handle, { signal: new AbortController().signal, yieldControl }); + marks.push(performance.now()); + // Resolution and the projection each run in a turn of their own after the table is in. + assert.ok(marks.length >= 3, `yields: ${marks.length - 1}`); + const gaps = marks.map((mark, index) => mark - (index ? marks[index - 1] : started)); + assert.ok(Math.max(...gaps) < STEP_BUDGET_MS, `longest step ${Math.round(Math.max(...gaps))} ms`); + const { result } = await m.readLocalFile(file, { classification: handle.classification }); + const direct = await m.fitLocalPayload(m.buildLocalPayload({ read: result, file: { name: handle.filename, size: file.size, lastModified: file.lastModified }, + classification: handle.classification, semantics })); + assert.equal(JSON.stringify(wire), JSON.stringify(direct)); + assert.equal(wire.resolution.state, "ambiguous"); + // An abort at any yield ends the read there. + for (let stop = 0; stop < marks.length - 1; stop++) { + const controller = new AbortController(); + let seen = 0; + const stopping = async (signal) => { + if (seen++ === stop) controller.abort(); + signal.throwIfAborted(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }; + await assert.rejects(m.localFileAdapter.read(handle, { signal: controller.signal, yieldControl: stopping }), { name: "AbortError" }, `yield ${stop}`); + } +}); diff --git a/tests/js/workspace-local-resolver.test.js b/tests/js/workspace-local-resolver.test.js new file mode 100644 index 0000000..cc9e160 --- /dev/null +++ b/tests/js/workspace-local-resolver.test.js @@ -0,0 +1,161 @@ +// PR L3 Tasks 8-9: the server's inspection resolver in this browser. The values below are what CPython answers +// (json.loads, len, str.strip, repr, utils/prompt_inspector.inspect_prompt_evidence); the full comparison with the +// server runs in tests/test_local_inspect_parity.py through tests/js/support/local-inspect-oracle.mjs. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; + +const py = await import('../../web/js/workspace/py-json.mjs'); +const resolver = await import('../../web/js/workspace/local-resolver.mjs'); +const semantics = resolver.compileSemantics(JSON.parse(fs.readFileSync(new URL('../../web/js/inspection-semantics.json', import.meta.url), 'utf8'))); + +test('json.loads keeps Python value kinds and dict order', () => { + const value = py.pyJsonLoads('{"9": 1, "10": 1.0, "a": NaN, "9": [true, -0, 9007199254740993, -Infinity]}'); + assert.ok(value instanceof Map); + // A repeated key keeps its first position and takes its last value; integer-like keys keep text order. + assert.deepEqual([...value.keys()], ['9', '10', 'a']); + const [flag, zero, big, negative] = value.get('9'); + assert.equal(flag, true); assert.equal(zero, 0); assert.equal(big, 9007199254740993n); + assert.ok(negative instanceof py.PyFloat); assert.equal(negative.value, -Infinity); + assert.ok(value.get('10') instanceof py.PyFloat, '1.0 is a float, not an int'); + assert.ok(Number.isNaN(value.get('a').value)); + assert.equal(py.isInt(value.get('10')), false); + assert.equal(py.pyJsonLoads('"\\ud83d\\ude00\\ud800"'), '\u{1f600}\ud800'); + for (const bad of ['{}', '{"a":1,}', '[01]', '{"a" 1}', '"\u0001"', '"\\x"', '1 2', '-', `${'9'.repeat(4301)}`, '"\\ud800\\uzzzz"']) { + assert.throws(() => py.pyJsonLoads(bad), py.PyValueError, bad); + } + assert.equal(py.pyJsonLoads(` ${'9'.repeat(4300)}\n`), BigInt('9'.repeat(4300))); +}); + +// One depth cap for both browser CPython-JSON parsers (py-json.mjs here, local-video.mjs for video envelopes): every +// array or object counts, an empty one too, as CPython's scanner enters recursion for each; past the cap it refuses. +test('json.loads refuses nesting past the shared depth cap, counting every container', () => { + const video = import('../../web/js/workspace/local-video.mjs'); + assert.equal(py.JSON_MAX_DEPTH, 1000); + const nest = (depth, inner = '') => '['.repeat(depth) + inner + ']'.repeat(depth); + const objects = (depth, inner) => '{"a":'.repeat(depth) + inner + '}'.repeat(depth); + for (const text of [nest(1000), nest(1000, '1'), nest(999, '{}'), objects(1000, '1'), objects(999, '[]')]) assert.doesNotThrow(() => py.pyJsonLoads(text)); + for (const text of [nest(1001), nest(1001, '1'), nest(1000, '{}'), objects(1001, '1'), objects(1000, '[]')]) { + assert.throws(() => py.pyJsonLoads(text), py.PyValueError); + } + return video.then(({ pythonJsonLoads, PythonJsonError }) => { + for (const text of [nest(1000), nest(1000, '1'), objects(999, '[]')]) assert.doesNotThrow(() => pythonJsonLoads(text)); + for (const text of [nest(1001), nest(1001, '1'), objects(1000, '[]')]) { + assert.throws(() => pythonJsonLoads(text), error => error instanceof PythonJsonError && error.recursion === true); + } + }); +}); + +test('str helpers measure, slice and strip as CPython', () => { + assert.equal(py.pyLen('a\u{1f600}\ud800'), 3); + assert.equal(py.pySlice('\u{1f600}\u{1f600}x', 2), '\u{1f600}\u{1f600}'); + // str.isspace() includes \x1c-\x1f and \x85 and excludes , unlike String.prototype.trim. + assert.equal(py.pyStrip('\x1c\x85 a b \x1f'), 'a b'); + assert.equal(py.pyStrip('a'), 'a'); + assert.deepEqual([1.0, 0.0001, 1e-5, 1e16, 123456789012345.6, -2.5, 1e22].map(py.pyFloatRepr), + ['1.0', '0.0001', '1e-05', '1e+16', '123456789012345.6', '-2.5', '1e+22']); + assert.deepEqual([null, true, 0, 1n, new py.PyFloat(2), '', [], new Map()].map(py.pyTruthy), [false, true, false, true, true, false, false, false]); + assert.deepEqual([1, 1n, true, new py.PyFloat(1), '1', 2].map(py.pyEqualsOne), [true, true, true, true, false, false]); +}); + +const encode = text => ({class_type: 'CLIPTextEncode', inputs: {text, clip: ['4', 1]}}); +const sampler = (extra = {}) => ({class_type: 'KSampler', inputs: {seed: 1, positive: ['6', 0], negative: ['7', 0], latent_image: ['5', 0], model: ['4', 0], ...extra}}); +const base = (nodes = {}) => ({'3': sampler(), '4': {class_type: 'CheckpointLoaderSimple', inputs: {ckpt_name: 'm.safetensors'}}, + '5': {class_type: 'EmptyLatentImage', inputs: {width: 64, height: 64, batch_size: 1}}, '6': encode('a fox'), '7': encode('blurry'), ...nodes}); + +test('a single chain resolves both roles with their sources', () => { + const evidence = resolver.resolveLocalEvidence({prompt: JSON.stringify(base())}, semantics); + assert.deepEqual(evidence, { + prompts: {positive: {status: 'resolved', text: 'a fox', source: 'graph-inferred'}, negative: {status: 'resolved', text: 'blurry', source: 'graph-inferred'}}, + resolution: {state: 'resolved', sampler_id: '3', reason: '', reason_code: null, node: null}, + }); +}); + +test('records without roles, malformed text and a missing sampler answer as the server', () => { + assert.deepEqual(resolver.resolveLocalEvidence({workflow: '{}'}, semantics).resolution, + {state: 'absent', sampler_id: null, reason: 'No parsed prompt metadata', reason_code: null, node: null}); + const malformed = resolver.resolveLocalEvidence({prompt: '{broken'}, semantics); + assert.equal(malformed.resolution.reason, 'Prompt evidence is malformed'); + assert.equal(malformed.prompts.positive.message, 'Prompt metadata in this file is malformed.'); + const none = resolver.resolveLocalEvidence({prompt: JSON.stringify({'1': encode('x'), '2': {class_type: 'MySampler', inputs: {positive: ['1', 0]}}})}, semantics); + assert.deepEqual(none.resolution, {state: 'unresolved', sampler_id: null, reason: 'No supported sampler pair found (saw MySampler)', reason_code: 'no-sampler', node: null}); + assert.equal(none.prompts.negative.message, 'No supported sampler was found.'); + assert.equal(resolver.resolveLocalEvidence({prompt: JSON.stringify(base()), parameters: 'x'}, null).resolution.reason, 'Inspection node semantics are unavailable'); +}); + +test('a float output slot is not a link, and a runtime node is named', () => { + const floatSlot = resolver.resolveLocalEvidence({prompt: JSON.stringify(base()).replace('"positive":["6",0]', '"positive":["6",0.0]')}, semantics); + assert.deepEqual(floatSlot.resolution, {state: 'unresolved', sampler_id: '3', reason: 'Expected a typed graph link or text input', reason_code: 'malformed', node: null}); + const runtime = resolver.resolveLocalEvidence({prompt: JSON.stringify(base({'6': {class_type: 'PromptSearchList', inputs: {}}}))}, semantics); + assert.deepEqual(runtime.resolution.node, {id: '6', class_type: 'PromptSearchList'}); + assert.equal(runtime.prompts.positive.message, 'Prompt is chosen at run time (PromptSearchList).'); +}); + +test('execution evidence replaces a PromptManagerPair text when it matches the node inputs', () => { + const pair = {class_type: 'PromptManagerPair', inputs: {text: 'fox', negative_text: 'blur', prepend_text: '', append_text: ''}}; + const record = {text: 'fox', negative_text: 'blur', positive_text: 'fox, executed', negative_effective_text: 'blur', prepend_text: '', append_text: ''}; + const graph = base({'3': sampler({positive: ['8', 0], negative: ['8', 1]}), '8': pair}); + const evidence = resolver.resolveLocalEvidence({prompt: JSON.stringify(graph), comfyassets_prompt_pairs: JSON.stringify({version: 1, nodes: {'8': {records: [record]}}})}, semantics); + assert.deepEqual(evidence.prompts.positive, {status: 'resolved', text: 'fox, executed', source: 'execution'}); + const stale = resolver.resolveLocalEvidence({prompt: JSON.stringify(graph), comfyassets_prompt_pairs: JSON.stringify({version: 1, nodes: {'8': {records: [{...record, text: 'other'}]}}})}, semantics); + assert.equal(stale.resolution.reason, 'Execution evidence does not match recorded node inputs'); +}); + +test('identical sampler branches resolve to their shared pair, differing ones stay ambiguous (#270)', () => { + const shared = resolver.resolveLocalEvidence({prompt: JSON.stringify(base({'9': sampler()}))}, semantics); + assert.deepEqual(shared.resolution, {state: 'resolved', sampler_id: null, reason: 'Every sampler branch uses the same prompt pair', reason_code: null, node: null}); + assert.equal(shared.prompts.positive.text, 'a fox'); + const differ = resolver.resolveLocalEvidence({prompt: JSON.stringify(base({'9': sampler({positive: ['8', 0]}), '8': encode('a cat')}))}, semantics); + assert.deepEqual(differ.resolution, {state: 'ambiguous', sampler_id: null, reason: 'Multiple sampler branches', reason_code: null, node: null}); + assert.equal(differ.prompts.positive.status, 'ambiguous'); +}); + +test('a detailer stage is compared with the sampler in its image ancestry', () => { + const graph = base({'10': {class_type: 'VAEDecode', inputs: {samples: ['3', 0]}}, '8': encode('a cat'), '12': {class_type: 'SaveImage', inputs: {images: ['11', 0]}}, + '11': {class_type: 'FaceDetailer', inputs: {image: ['10', 0], model: ['4', 0], positive: ['8', 0], negative: ['7', 0]}}}); + const evidence = resolver.resolveLocalEvidence({prompt: JSON.stringify(graph)}, semantics); + assert.deepEqual(evidence.resolution, {state: 'ambiguous', sampler_id: '11', reason: 'Sampler stages have different prompts', reason_code: 'multistage-differs', node: null}); + assert.equal(evidence.prompts.positive.message, undefined); +}); + +test('A1111 parameters split on the unique settings footer as Python does', () => { + const read = text => resolver.resolveLocalEvidence({parameters: text}, semantics); + assert.deepEqual(read('a fox\nNegative prompt: blur\nSteps: 20, Sampler: euler').prompts, { + positive: {status: 'resolved', text: 'a fox', source: 'embedded-parameters'}, negative: {status: 'resolved', text: 'blur', source: 'embedded-parameters'}, + }); + // Python's multiline ^ and $ break only at \n: a U+2028 or a lone \r never starts the footer line. + for (const text of ['a fox\u2028Steps: 20, x', 'a fox\rSteps: 20, x']) assert.equal(read(text).resolution.reason, 'A1111 settings footer is not unique'); + // \d is any Unicode decimal digit and \s spans line breaks, as in Python's re. + assert.equal(read('a fox\nSteps: \u0662\u0660, x').resolution.reason, 'Negative prompt delimiter is absent'); + assert.equal(read('a fox\nSteps:\n 20 \n, x').resolution.state, 'absent'); + assert.equal(read('a fox\nSteps: 1, x\nNegative Template: n\nTemplate: t').resolution.reason_code, 'malformed'); + assert.equal(read('\u{1f600}'.repeat(60000) + '\nSteps: 1, x').resolution.state, 'absent', 'the length limit counts code points'); +}); + +test('an unusable semantics table is refused', () => { + for (const doc of [null, {schema: 2}, {schema: 1, samplers: []}]) assert.throws(() => resolver.compileSemantics(doc), resolver.SemanticsError); +}); + +// The committed server answers (tests/fixtures/local-inspect/resolver-vectors.json, written by +// `python3 -m tests.local_resolver_cases --write` and checked current by the Python suite). +test('committed server answers equal the local evidence', () => { + const {vectors} = JSON.parse(fs.readFileSync(new URL('../fixtures/local-inspect/resolver-vectors.json', import.meta.url), 'utf8')); + const canonical = value => Array.isArray(value) ? value.map(canonical) : + value !== null && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value; + let compared = 0; + for (const {name, records, evidence} of vectors) { + assert.deepEqual(canonical(resolver.resolveLocalEvidence(records, semantics)), canonical(evidence), name); + compared++; + } + assert.ok(compared > 280, `compared ${compared}`); +}); + +test('a list or object workflow node type is skipped, as on the server (#286)', () => { + const graph = base({'6': {class_type: 'PromptManager', inputs: {text: 'a fox', clip: ['4', 1]}}}); + for (const type of [['PromptManager'], {name: 'PromptManager'}]) { + const evidence = resolver.resolveLocalEvidence({prompt: JSON.stringify(graph), workflow: JSON.stringify({nodes: [{id: 6, type, widgets_values: ['x']}]})}, semantics); + assert.equal(evidence.resolution.state, 'resolved'); + assert.equal(evidence.prompts.positive.status, 'resolved'); + assert.equal(evidence.prompts.positive.text, 'a fox'); + } +}); diff --git a/tests/js/workspace-local-semantics.test.js b/tests/js/workspace-local-semantics.test.js new file mode 100644 index 0000000..f0e657d --- /dev/null +++ b/tests/js/workspace-local-semantics.test.js @@ -0,0 +1,43 @@ +// web/js/workspace/local-semantics.mjs (#249 local Inspect, PR L3 Task 10): the node table local Inspect resolves +// with is the server's own web/js/inspection-semantics.json, fetched once through the API client before a file is +// chosen. A table that cannot be fetched or used answers as the server does when its table is unusable. +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; + +const TABLE = fs.readFileSync(new URL("../../web/js/inspection-semantics.json", import.meta.url), "utf8"); +let instance = 0; +/** A fresh module instance (its cache is per page) with `fetch` and the API client stubbed. */ +async function load(respond) { + const calls = []; + globalThis.window = { ApiClient: { resolveUrl: (path) => `/proxy${path}` } }; + globalThis.fetch = async (url, init) => { calls.push([url, init]); return respond(); }; + const module = await import(`../../web/js/workspace/local-semantics.mjs?instance=${++instance}`); + return { module, calls }; +} +test.after(() => { delete globalThis.window; delete globalThis.fetch; }); + +test("the table is fetched once through the API client and compiled", async () => { + const { module, calls } = await load(() => new Response(TABLE, { status: 200 })); + const [first, second] = await Promise.all([module.loadInspectionSemantics(), module.loadInspectionSemantics()]); + assert.equal(first, second); + assert.ok(first.samplers.has("KSampler")); + assert.equal(await module.loadInspectionSemantics(), first); + assert.deepEqual(calls, [["/proxy/prompt_manager/js/inspection-semantics.json", { credentials: "same-origin" }]]); +}); + +test("a failed or unusable table is null, and a later selection asks again", async () => { + for (const respond of [() => new Response("nope", { status: 404 }), () => new Response("{\"schema\": 2}", { status: 200 }), () => { throw new TypeError("offline"); }]) { + const { module, calls } = await load(respond); + assert.equal(await module.loadInspectionSemantics(), null); + assert.equal(await module.loadInspectionSemantics(), null); + assert.equal(calls.length, 2); + } +}); + +test("without the API client nothing is fetched", async () => { + const { module, calls } = await load(() => new Response(TABLE)); + delete globalThis.window; + assert.equal(await module.loadInspectionSemantics(), null); + assert.deepEqual(calls, []); +}); diff --git a/tests/js/workspace-local-session.test.js b/tests/js/workspace-local-session.test.js index 7749876..36bd64d 100644 --- a/tests/js/workspace-local-session.test.js +++ b/tests/js/workspace-local-session.test.js @@ -193,8 +193,8 @@ test("unclassified input creates no object URL", async () => { // declares a supported type it opens the inspector's unreadable state, with no preview and no URL (ledger ruling). const gone = pngFile("gone.png", "fox", { gate: () => Promise.reject(new DOMException("The file was removed", "NotReadableError")) }); assert.equal(value.select([gone]), "accepted"); - await settle(); - const unreadable = value.controller.state; + // The read yields between its steps (local-inspection.mjs): wait for the answer, not a fixed number of turns. + const unreadable = await recorded(value.controller); assert.ok(unreadable.record, "the unreadable record is published"); assert.deepEqual([value.state.phase, value.state.message, value.state.preview], ["ready", m.LOCAL_SESSION_MESSAGES.unreadable, null]); assert.equal(m.LOCAL_SESSION_MESSAGES.unreadable, "Embedded metadata could not be read."); diff --git a/tests/js/workspace-toolbar.test.js b/tests/js/workspace-toolbar.test.js index d825d6a..91e1c3b 100644 --- a/tests/js/workspace-toolbar.test.js +++ b/tests/js/workspace-toolbar.test.js @@ -521,6 +521,10 @@ test('copy fallback selects the text', async () => { assert.equal(note.className, 'small'); assert.equal(note.textContent, 'Automatic clipboard access requires a secure browser context.'); assert.throws(() => openCopyFallback(42, { scope }), TypeError); + // LI-4 (D117): the settings summary names its own dialog; the select-to-copy body is the same. + openCopyFallback('Seed: 7', { scope, title: 'Copy settings summary' }); + assert.equal(byId(dom, 'modal-title').textContent, 'Copy settings summary'); + assert.equal(byId(dom, 'copy-text').value, 'Seed: 7'); } finally { closeWorkspaceDialog(); scope.dispose(); diff --git a/tests/local_resolver_cases.py b/tests/local_resolver_cases.py new file mode 100644 index 0000000..3880ef4 --- /dev/null +++ b/tests/local_resolver_cases.py @@ -0,0 +1,1405 @@ +"""Record sets for the local resolver parity test (#249 local Inspect, PR L3 Task 8). + +Every case is a dict of record texts as ``read_inspection_file`` returns them +(``prompt``, ``workflow``, ``comfyassets_prompt_pairs``), so the server's +``inspect_prompt_evidence`` and the browser's ``resolveLocalEvidence`` read the +same bytes. ``golden_cases`` names one case per traced strategy, bound and +malformed shape; ``fuzz_cases`` builds coherent single-sampler graphs from the +same node vocabulary, then corrupts some of them. All content is synthetic. +``single_chain`` says, from the server's own sampler table, whether a record +set resolves without candidate selection (the Task 8 subset); +``local_resolver_selection_cases`` holds the candidate-selection cases. +""" + +import argparse +import json +import random +from pathlib import Path + +from utils.inspection_semantics import get_semantics +from utils.prompt_inspector import inspect_prompt_evidence +from utils.prompt_pair_resolver import INSPECTION_PROFILE + +VECTORS_PATH = ( + Path(__file__).resolve().parent + / "fixtures" + / "local-inspect" + / "resolver-vectors.json" +) +# Committed record sets stay small; the large bound cases are built at test time. +MAX_VECTOR_CHARS = 20_000 +COMMITTED_FUZZ = (0, 60) + +SPACES = ["", " ", "\t", "\n", "\x1c", "\x85", " ", "", "
 "] +WORDS = ["fox", "snow", "café", "猫", "\U0001f600", "a,", "detailed fur", ""] + + +def _dump(graph, ensure_ascii=False): + return json.dumps(graph, ensure_ascii=ensure_ascii) + + +def _encode(text, name="CLIPTextEncode"): + return {"class_type": name, "inputs": {"text": text, "clip": ["4", 1]}} + + +def _graph(positive, negative, extra=None, sampler=None): + graph = { + "3": sampler + or { + "class_type": "KSampler", + "inputs": { + "seed": 1, + "model": ["4", 0], + "positive": positive, + "negative": negative, + "latent_image": ["5", 0], + }, + }, + "4": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "m"}}, + "5": {"class_type": "EmptyLatentImage", "inputs": {"width": 64}}, + } + graph.update(extra or {}) + return graph + + +def _pair(text="fox", negative="blur", prepend="", append=""): + return { + "class_type": "PromptManagerPair", + "inputs": { + "text": text, + "negative_text": negative, + "prepend_text": prepend, + "append_text": append, + }, + } + + +def _execution(node_id, **changes): + record = { + "text": "fox", + "negative_text": "blur", + "positive_text": "fox executed", + "negative_effective_text": "blur", + "prepend_text": "", + "append_text": "", + } + record.update(changes) + return json.dumps({"version": 1, "nodes": {node_id: {"records": [record]}}}) + + +def _chain(length, start="6"): + nodes = {start: _encode("deep")} + previous = start + for index in range(length): + node_id = f"r{index}" + nodes[node_id] = {"class_type": "Reroute", "inputs": {"value": [previous, 0]}} + previous = node_id + return nodes, previous + + +def _combine_tree(levels): + """A full ConditioningCombine tree over equal encoders: many distinct visits.""" + nodes, layer = {}, [] + for index in range(2**levels): + nodes[f"e{index}"] = _encode("same") + layer.append(f"e{index}") + level = 0 + while len(layer) > 1: + nxt = [] + for index in range(0, len(layer), 2): + node_id = f"c{level}_{index}" + nodes[node_id] = { + "class_type": "ConditioningCombine", + "inputs": { + "conditioning_1": [layer[index], 0], + "conditioning_2": [layer[index + 1], 0], + }, + } + nxt.append(node_id) + layer, level = nxt, level + 1 + return nodes, layer[0] + + +def golden_cases(): + """Named record sets, one per strategy, bound and malformed shape.""" + cases = {} + + def add(name, graph=None, prompt=None, **records): + cases[name] = {"prompt": prompt if prompt is not None else _dump(graph)} + cases[name].update(records) + + neg = ["7", 0] + base = {"6": _encode("a fox"), "7": _encode("blurry")} + add("encode", _graph(["6", 0], neg, base)) + add("empty-positive", _graph(["6", 0], neg, {**base, "6": _encode(" \x85")})) + add("feff-positive", _graph(["6", 0], neg, {**base, "6": _encode("")})) + add("no-negative", _graph(["6", 0], None, base)) + add( + "negative-missing-input", + None, + prompt=_dump(_graph(["6", 0], neg, base)).replace('"negative": ["7", 0], ', ""), + ) + add( + "float-slot", + None, + prompt=_dump(_graph(["6", 0], neg, base)).replace('["6", 0]', '["6", 0.0]'), + ) + add( + "bool-slot", + None, + prompt=_dump(_graph(["6", 0], neg, base)).replace('["6", 0]', '["6", true]'), + ) + add("int-id", None, prompt=_dump(_graph([6, 0], neg, base))) + add("dangling", _graph(["60", 0], neg, base)) + add("wrong-slot", _graph(["6", 3], neg, base)) + add( + "cycle", + _graph( + ["8", 0], + neg, + {**base, "8": {"class_type": "Reroute", "inputs": {"value": ["8", 0]}}}, + ), + ) + add( + "reroute-ambiguous", + _graph( + ["8", 0], + neg, + { + **base, + "8": { + "class_type": "Reroute", + "inputs": {"value": ["6", 0], "in": ["6", 0]}, + }, + }, + ), + ) + add( + "duplicate-key", + None, + prompt=_dump(_graph(["6", 0], neg, base))[:-1] + + ', "6": ' + + _dump(_encode("last wins")) + + "}", + ) + add( + "nan-input", + None, + prompt=_dump(_graph(["6", 0], neg, base)).replace('"seed": 1', '"seed": NaN'), + ) + add( + "class-type-number", + _graph( + ["6", 0], neg, {**base, "6": {"class_type": 1.5, "inputs": {"text": "x"}}} + ), + ) + add( + "class-type-missing", + _graph(["6", 0], neg, {**base, "6": {"inputs": {"text": "x"}}}), + ) + add( + "inputs-list", + _graph( + ["6", 0], + neg, + {**base, "6": {"class_type": "CLIPTextEncode", "inputs": ["x"]}}, + ), + ) + add("prompt-list", None, prompt="[1, 2]") + add("prompt-empty", None, prompt="{}") + add("prompt-bom", None, prompt="" + _dump(_graph(["6", 0], neg, base))) + add( + "huge-int", + None, + prompt=_dump(_graph(["6", 0], neg, base)).replace( + '"seed": 1', '"seed": ' + "9" * 4301 + ), + ) + add("only-execution", None, prompt=None) + cases["only-execution"] = {"comfyassets_prompt_pairs": _execution("8")} + add( + "no-sampler", + { + "1": _encode("x"), + "2": {"class_type": "XSampler", "inputs": {"positive": ["1", 0]}}, + "9": {"class_type": "YGuider", "inputs": {"guider": ["1", 0]}}, + }, + ) + add("no-sampler-plain", {"1": _encode("x")}) + sdxl = { + "class_type": "CLIPTextEncodeSDXL", + "inputs": {"text_g": "a", "text_l": "a"}, + } + add("sdxl-equal", _graph(["8", 0], neg, {**base, "8": sdxl})) + add( + "sdxl-one", + _graph( + ["8", 0], + neg, + { + **base, + "8": { + "class_type": "CLIPTextEncodeSDXL", + "inputs": {"text_g": "a", "text_l": ""}, + }, + }, + ), + ) + add( + "sdxl-differ", + _graph( + ["8", 0], + neg, + { + **base, + "8": { + "class_type": "CLIPTextEncodeSDXL", + "inputs": {"text_g": "a", "text_l": "b"}, + }, + }, + ), + ) + for flag in (False, True, 1, ["9", 0]): + smz = { + "class_type": "smZ CLIPTextEncode", + "inputs": {"text": "t", "text_g": "g", "text_l": "g", "with_SDXL": flag}, + } + add(f"smz-{json.dumps(flag)}", _graph(["8", 0], neg, {**base, "8": smz})) + add( + "zeroed", + _graph( + ["6", 0], + ["8", 0], + { + **base, + "8": { + "class_type": "ConditioningZeroOut", + "inputs": {"conditioning": ["6", 0]}, + }, + }, + ), + ) + add( + "passthrough", + _graph( + ["8", 0], + neg, + { + **base, + "8": { + "class_type": "FluxGuidance", + "inputs": {"conditioning": ["6", 0], "guidance": 3.5}, + }, + }, + ), + ) + add( + "controlnet-slot1", + _graph( + ["8", 0], + ["8", 1], + { + **base, + "8": { + "class_type": "ControlNetApplyAdvanced", + "inputs": {"positive": ["6", 0], "negative": ["7", 0]}, + }, + }, + ), + ) + add( + "combine-equal", + _graph( + ["8", 0], + neg, + { + **base, + "9": _encode("a fox"), + "8": { + "class_type": "ConditioningCombine", + "inputs": {"conditioning_1": ["6", 0], "conditioning_2": ["9", 0]}, + }, + }, + ), + ) + add( + "combine-differ", + _graph( + ["8", 0], + neg, + { + **base, + "8": { + "class_type": "ConditioningCombine", + "inputs": {"conditioning_1": ["6", 0], "conditioning_2": ["7", 0]}, + }, + }, + ), + ) + for condition in (True, False, ["9", 0], 1, "true"): + select = { + "class_type": "easy ifElse", + "inputs": {"boolean": condition, "on_true": ["6", 0], "on_false": ["7", 0]}, + } + add( + f"select-{json.dumps(condition)}", + _graph( + ["8", 0], + ["10", 0], + { + **base, + "8": select, + "10": _encode("neg"), + "9": {"class_type": "PrimitiveBoolean", "inputs": {"value": True}}, + }, + ), + ) + same = { + "class_type": "easy ifElse", + "inputs": {"boolean": ["9", 0], "on_true": ["6", 0], "on_false": ["11", 0]}, + } + add( + "select-converge", + _graph(["8", 0], neg, {**base, "8": same, "11": _encode("a fox")}), + ) + # Two selectors on one condition: forced together, so two candidates, not four. + add( + "select-shared", + _graph( + ["8", 0], + ["12", 0], + { + **base, + "8": { + "class_type": "easy ifElse", + "inputs": { + "boolean": ["9", 0], + "on_true": ["6", 0], + "on_false": ["7", 0], + }, + }, + "12": { + "class_type": "Switch conditioning [Crystools]", + "inputs": { + "boolean": ["9", 0], + "on_true": ["7", 0], + "on_false": ["6", 0], + }, + }, + }, + ), + ) + add( + "runtime", + _graph( + ["8", 0], + neg, + {**base, "8": {"class_type": "PromptSearchList", "inputs": {}}}, + ), + ) + add( + "unknown-encoder", + _graph( + ["8", 0], + neg, + { + **base, + "8": {"class_type": "FancyEncoder" + "x" * 80, "inputs": {"text": "x"}}, + }, + ), + ) + add( + "primitive", + _graph( + ["6", 0], + neg, + { + **base, + "6": _encode(["8", 0]), + "8": { + "class_type": "PrimitiveStringMultiline", + "inputs": {"value": " multi\nline "}, + }, + }, + ), + ) + add( + "string-kind-mismatch", + _graph( + ["8", 0], + neg, + {**base, "8": {"class_type": "PrimitiveString", "inputs": {"value": "x"}}}, + ), + ) + concat = { + "class_type": "StringConcatenate", + "inputs": {"string_a": " a ", "string_b": "", "delimiter": ", "}, + } + add( + "string-concatenate", + _graph(["6", 0], neg, {**base, "6": _encode(["8", 0]), "8": concat}), + ) + for clean in ("true", "False", True, "TRUE", 0): + text_concat = { + "class_type": "Text Concatenate", + "inputs": { + "text_b": " b ", + "text_a": " a", + "text_aa": "x", + "text_c": "", + "clean_whitespace": clean, + }, + } + add( + f"text-concatenate-{json.dumps(clean)}", + _graph(["6", 0], neg, {**base, "6": _encode(["8", 0]), "8": text_concat}), + ) + for strip in (True, False, 1): + multiline = { + "class_type": "StringConstantMultiline", + "inputs": {"string": "\n a\nb \n", "strip_newlines": strip}, + } + add( + f"strip-newlines-{json.dumps(strip)}", + _graph(["6", 0], neg, {**base, "6": _encode(["8", 0]), "8": multiline}), + ) + for count, as_list in ( + (3, False), + (1, False), + (0, False), + (1001, False), + (2, True), + (2.0, False), + (True, False), + ): + join = { + "class_type": "JoinStringMulti", + "inputs": { + "string_1": "", + "string_2": "b", + "string_3": "", + "string_4": "d", + "inputcount": count, + "delimiter": " | ", + "return_list": as_list, + }, + } + add( + f"join-multi-{json.dumps(count)}-{json.dumps(as_list)}", + _graph(["6", 0], neg, {**base, "6": _encode(["8", 0]), "8": join}), + ) + manager = { + "class_type": "PromptManager", + "inputs": { + "text": "main", + "prepend_text": " before ", + "append_text": "", + "clip": ["4", 1], + }, + } + add("prompt-manager", _graph(["8", 0], neg, {**base, "8": manager})) + add( + "prompt-manager-null-prepend", + _graph( + ["8", 0], + neg, + { + **base, + "8": {**manager, "inputs": {**manager["inputs"], "prepend_text": None}}, + }, + ), + ) + for name, slot in ( + ("PromptManagerV2", 0), + ("PromptManagerV2", 1), + ("PromptManagerPositive", 0), + ("PromptManagerNegative", 0), + ): + historical = { + "class_type": name, + "inputs": { + "text": " main ", + "negative_text": " neg ", + "prepend_text": "lead,", + "append_text": None, + }, + } + add( + f"historical-{name}-{slot}", + _graph(["8", slot], neg, {**base, "8": historical}), + ) + pair_graph = _graph(["8", 0], ["8", 1], {**base, "8": _pair()}) + add("pair-graph", pair_graph) + add("pair-execution", pair_graph, comfyassets_prompt_pairs=_execution("8")) + add( + "pair-execution-other-node", + pair_graph, + comfyassets_prompt_pairs=_execution("99"), + ) + add( + "pair-execution-mismatch", + pair_graph, + comfyassets_prompt_pairs=_execution("8", text="other"), + ) + add( + "pair-execution-negative-changed", + pair_graph, + comfyassets_prompt_pairs=_execution("8", negative_effective_text="x"), + ) + add( + "pair-execution-version-true", + pair_graph, + comfyassets_prompt_pairs=_execution("8").replace( + '"version": 1', '"version": true' + ), + ) + add( + "pair-execution-version-float", + pair_graph, + comfyassets_prompt_pairs=_execution("8").replace( + '"version": 1', '"version": 1.0' + ), + ) + add( + "pair-execution-truncated", + pair_graph, + comfyassets_prompt_pairs=_execution("8").replace( + '"version": 1', '"version": 1, "truncated": []' + ), + ) + add("pair-execution-bad-json", pair_graph, comfyassets_prompt_pairs="{nope") + two = json.loads(_execution("8")) + two["nodes"]["8"]["records"].append( + dict(two["nodes"]["8"]["records"][0], positive_text="other") + ) + add( + "pair-execution-two-records", + pair_graph, + comfyassets_prompt_pairs=json.dumps(two), + ) + linked_pair = _graph( + ["8", 0], + ["8", 1], + { + **base, + "8": {**_pair(), "inputs": {**_pair()["inputs"], "text": ["9", 0]}}, + "9": {"class_type": "PromptSearchList", "inputs": {}}, + }, + ) + add( + "pair-execution-runtime-input", + linked_pair, + comfyassets_prompt_pairs=_execution("8"), + ) + add( + "pair-execution-runtime-wrong-slot", + _graph( + ["8", 0], + ["8", 1], + { + **base, + "8": {**_pair(), "inputs": {**_pair()["inputs"], "text": ["9", 2]}}, + "9": {"class_type": "PromptManagerText", "inputs": {"text": "x"}}, + }, + ), + comfyassets_prompt_pairs=_execution("8"), + ) + guided = { + "class_type": "SamplerCustomAdvanced", + "inputs": { + "noise": ["20", 0], + "guider": ["21", 0], + "sampler": ["22", 0], + "sigmas": ["23", 0], + "latent_image": ["5", 0], + }, + } + add( + "custom-cfg", + _graph( + None, + None, + { + **base, + "21": { + "class_type": "CFGGuider", + "inputs": { + "positive": ["6", 0], + "negative": ["7", 0], + "model": ["4", 0], + "cfg": 5, + }, + }, + }, + sampler=guided, + ), + ) + add( + "custom-basic", + _graph( + None, + None, + { + **base, + "21": { + "class_type": "BasicGuider", + "inputs": {"conditioning": ["6", 0], "model": ["4", 0]}, + }, + }, + sampler=guided, + ), + ) + add( + "custom-reroute-guider", + _graph( + None, + None, + { + **base, + "21": {"class_type": "Reroute", "inputs": {"value": ["24", 0]}}, + "24": { + "class_type": "BasicGuider", + "inputs": {"conditioning": ["6", 0]}, + }, + }, + sampler=guided, + ), + ) + add( + "custom-unknown-guider", + _graph( + None, + None, + { + **base, + "21": {"class_type": "DualCFGGuider", "inputs": {"cond1": ["6", 0]}}, + }, + sampler=guided, + ), + ) + add( + "latent-stage", + _graph( + ["6", 0], + neg, + { + **base, + "5": {"class_type": "LatentUpscale", "inputs": {"samples": ["30", 0]}}, + "30": { + "class_type": "KSamplerPreview", + "inputs": {"model": ["4", 0], "positive": ["6", 0]}, + }, + }, + ), + ) + add( + "latent-encode", + _graph( + ["6", 0], + neg, + { + **base, + "5": { + "class_type": "VAEEncode", + "inputs": {"pixels": ["31", 0], "vae": ["4", 2]}, + }, + "31": {"class_type": "LoadImage", "inputs": {"image": "x.png"}}, + }, + ), + ) + add( + "latent-cycle", + _graph( + ["6", 0], + neg, + { + **base, + "5": {"class_type": "LatentUpscale", "inputs": {"samples": ["3", 0]}}, + }, + ), + ) + for override in ("", "wild", ["6", 0]): + detail = { + "class_type": "KSampler", + "inputs": { + "positive": ["6", 0], + "negative": neg, + "latent_image": ["5", 0], + "wildcard": override, + }, + } + add( + f"override-ignored-{json.dumps(override)}", + _graph(None, None, base, sampler=detail), + ) + chain, end = _chain(70) + add("depth-limit", _graph([end, 0], neg, {**base, **chain})) + chain, end = _chain(61) + add("depth-near", _graph([end, 0], neg, {**base, **chain})) + tree, top = _combine_tree(11) + add("work-limit-visits", _graph([top, 0], neg, {**base, **tree})) + tree, top = _combine_tree(9) + add("diamond-memo", _graph([top, 0], [top, 0], {**base, **tree})) + add("text-limit", _graph(["6", 0], neg, {**base, "6": _encode("x" * 100_001)})) + add( + "text-at-limit", + _graph(["6", 0], neg, {**base, "6": _encode("\U0001f600" * 100_000)}), + ) + big = { + f"s{index}": { + "class_type": "PrimitiveString", + "inputs": {"value": "y" * 99_000}, + } + for index in range(20) + } + joined = { + "class_type": "JoinStringMulti", + "inputs": { + "string_1": ["s0", 0], + "inputcount": 20, + "delimiter": "", + "return_list": False, + }, + } + joined["inputs"].update( + {f"string_{index + 1}": [f"s{index}", 0] for index in range(1, 20)} + ) + add( + "join-over-length", + _graph(["6", 0], neg, {**base, **big, "6": _encode(["8", 0]), "8": joined}), + ) + concats = { + f"k{index}": { + "class_type": "StringConcatenate", + "inputs": {"string_a": "z" * 90_000, "string_b": "", "delimiter": ""}, + } + for index in range(20) + } + combine = {f"q{index}": _encode([f"k{index}", 0]) for index in range(20)} + add("built-chars", _graph(["q0", 0], ["q1", 0], {**base, **concats, **combine})) + ceiling = { + str(index): {"class_type": "Note", "inputs": {}} for index in range(100, 20_102) + } + add("graph-ceiling", _graph(["6", 0], neg, {**base, **ceiling})) + workflow = { + "nodes": [ + {"id": 6, "type": "PromptManager", "widgets_values": ["own text"]}, + {"id": 7, "type": "PromptManager", "widgets_values": ["other text"]}, + ] + } + overwritten = {**base, "6": _encode("shared"), "7": _encode("shared")} + overwritten["6"]["inputs"]["search_text"] = "" + add( + "overwritten", + _graph(["6", 0], ["7", 0], overwritten), + workflow=json.dumps(workflow), + ) + nested = { + "nodes": [{"id": 1, "type": "sub-a", "widgets_values": []}], + "definitions": { + "subgraphs": [ + { + "id": "sub-a", + "nodes": [ + { + "id": 6, + "type": "PromptManagerFocused", + "widgets_values": {"text": "a"}, + }, + { + "id": 7, + "type": "PromptManagerFocused", + "widgets_values": ["b"], + }, + ], + } + ] + }, + } + nested_graph = { + **base, + "1:6": { + "class_type": "PromptManager", + "inputs": {"text": "same", "clip": ["4", 1]}, + }, + "1:7": {"class_type": "PromptManager", "inputs": {"text": "same"}}, + } + add( + "overwritten-subgraph", + _graph(["1:6", 0], ["1:7", 0], nested_graph), + workflow=json.dumps(nested), + ) + add( + "overwritten-bad-workflow", + _graph(["6", 0], ["7", 0], overwritten), + workflow="{bad", + ) + # A list or object node ``type`` is skipped like a non-dict node (#286), also + # inside a subgraph; a skipped node never hides the rest of the walk. + for label, kind in (("list", ["PromptManager"]), ("dict", {"a": 1})): + add( + f"workflow-type-{label}", + _graph(["6", 0], ["7", 0], overwritten), + workflow=json.dumps( + {"nodes": [{"id": 6, "type": "PromptManager"}, {"id": 8, "type": kind}]} + ), + ) + add( + "workflow-type-in-subgraph", + _graph(["1:6", 0], ["1:7", 0], nested_graph), + workflow=json.dumps( + { + **nested, + "definitions": { + "subgraphs": [{"id": "sub-a", "nodes": [{"id": 2, "type": [1]}]}] + }, + } + ), + ) + add( + "workflow-type-skipped-keeps-overwritten", + _graph(["6", 0], ["7", 0], overwritten), + workflow=json.dumps( + { + "nodes": [ + workflow["nodes"][0], + {"id": 8, "type": ["PromptManager"]}, + workflow["nodes"][1], + ] + } + ), + ) + add( + "workflow-type-without-id", + _graph(["6", 0], ["7", 0], overwritten), + workflow=json.dumps({"nodes": [{"id": True, "type": ["x"]}, "skipped"]}), + ) + add( + "several-samplers", + _graph( + ["6", 0], + neg, + { + **base, + "9": { + "class_type": "KSampler", + "inputs": {"positive": ["6", 0], "negative": neg}, + }, + }, + ), + ) + add( + "detailer", + _graph( + None, + None, + base, + sampler={ + "class_type": "FaceDetailer", + "inputs": { + "image": ["5", 0], + "positive": ["6", 0], + "negative": neg, + "wildcard": "", + }, + }, + ), + ) + cases["parameters-only"] = { + "parameters": "a fox\nNegative prompt: blur\nSteps: 20, Sampler: euler" + } + cases["prompt-and-parameters"] = { + "prompt": _dump(_graph(["6", 0], neg, base)), + "parameters": "ignored", + } + return cases + + +def _visits_graph(count): + """``count`` PrimitiveString links joined under one encoder (``count`` visits).""" + first, second = count // 2, count - count // 2 + nodes = { + f"p{index}": {"class_type": "PrimitiveString", "inputs": {"value": "w"}} + for index in range(count) + } + for name, start, size in (("j1", 0, first), ("j2", first, second)): + inputs = {"inputcount": max(size, 1), "delimiter": "", "return_list": False} + inputs.update( + {f"string_{index + 1}": [f"p{start + index}", 0] for index in range(size)} + ) + inputs.setdefault("string_1", "") + nodes[name] = {"class_type": "JoinStringMulti", "inputs": inputs} + nodes["k"] = { + "class_type": "StringConcatenate", + "inputs": {"string_a": ["j1", 0], "string_b": ["j2", 0], "delimiter": ""}, + } + nodes["6"] = _encode(["k", 0]) + nodes["7"] = _encode("blurry") + return {"prompt": _dump(_graph(["6", 0], ["7", 0], nodes))} + + +def _depth_graph(length): + chain, end = _chain(length) + return { + "prompt": _dump(_graph([end, 0], ["7", 0], {**chain, "7": _encode("blurry")})) + } + + +def _scan_graph(tail): + """Whitespace-only Text Concatenate parts: scanned before stripping, never built.""" + nodes, parts = {}, [] + for index in range(4): + if index < 3: + inputs = { + f"text_{letter}": " " * 99_000 for letter in "abcdefghijklmnopqrstu" + } + else: + inputs, left = {}, tail + for letter in "abcdefghijklmnopqrstu": + inputs[f"text_{letter}"] = " " * min(99_000, left) + left -= min(99_000, left) + inputs["text_z"] = "kept" if index == 0 else "" + nodes[f"t{index}"] = {"class_type": "Text Concatenate", "inputs": inputs} + parts.append([f"t{index}", 0]) + nodes["k"] = { + "class_type": "JoinStringMulti", + "inputs": { + "string_1": parts[0], + "string_2": parts[1], + "string_3": parts[2], + "string_4": parts[3], + "inputcount": 4, + "delimiter": "", + "return_list": False, + }, + } + nodes["6"] = _encode(["k", 0]) + nodes["7"] = _encode("blurry") + return {"prompt": _dump(_graph(["6", 0], ["7", 0], nodes))} + + +def _threshold(build, low, high): + """The largest parameter in [low, high) the server still resolves.""" + + def resolved(value): + evidence = inspect_prompt_evidence( + build(value), None, profile=INSPECTION_PROFILE + ) + return evidence["resolution"]["state"] == "resolved" + + assert resolved(low) and not resolved(high) + while high - low > 1: + middle = (low + high) // 2 + if resolved(middle): + low = middle + else: + high = middle + return low + + +# The nesting the browser's CPython-JSON parsers accept (py-json.mjs JSON_MAX_DEPTH). +JSON_MAX_DEPTH = 1000 +JSON_DEPTHS = (JSON_MAX_DEPTH - 1, JSON_MAX_DEPTH, JSON_MAX_DEPTH + 1) + + +def _nested(depth): + """``depth`` nested arrays.""" + return "[" * depth + "]" * depth + + +def depth_cases(): + """``(name, kind, depth, records)``: a deep record at the JSON depth cap. + + A ``prompt`` holds a junk input nested to ``depth`` containers in all (the + graph, its node and the inputs are three); parsed, both roles resolve. + A ``workflow`` nested to ``depth`` makes two PromptManager nodes + overwritten (R1) when it is read, and the prompt resolves when it is not. + """ + cases = [] + base = {"6": _encode("a fox"), "7": _encode("blurry")} + for depth in JSON_DEPTHS: + graph = _dump(_graph(["6", 0], ["7", 0], {**base, "9": {"class_type": "Note"}})) + junk = '"inputs": {"junk": ' + _nested(depth - 3) + "}" + prompt = graph.replace('"class_type": "Note"', '"class_type": "Note", ' + junk) + cases.append((f"prompt-depth-{depth}", "prompt", depth, {"prompt": prompt})) + overwritten = {**base, "6": _encode("shared"), "7": _encode("shared")} + overwritten["6"]["inputs"]["search_text"] = "" + nodes = [ + {"id": 6, "type": "PromptManager", "widgets_values": ["own text"]}, + {"id": 7, "type": "PromptManager", "widgets_values": ["other text"]}, + ] + workflow = json.dumps({"nodes": nodes, "extra": 0}).replace( + '"extra": 0', '"extra": ' + _nested(depth - 1) + ) + records = { + "prompt": _dump(_graph(["6", 0], ["7", 0], overwritten)), + "workflow": workflow, + } + cases.append((f"workflow-depth-{depth}", "workflow", depth, records)) + return cases + + +def boundary_cases(): + """Each work bound at its edge: the last value that resolves, then the next.""" + cases = {} + for name, build, low, high in ( + ("visits", _visits_graph, 10, 2000), + ("depth", _depth_graph, 1, 80), + ("scanned", _scan_graph, 0, 2_000_000), + ): + edge = _threshold(build, low, high) + cases[f"{name}-edge"] = build(edge) + cases[f"{name}-over"] = build(edge + 1) + long_name = "\U0001f600" * 70 + cases["named-astral"] = { + "prompt": _dump( + _graph( + ["\U0001f600" * 70, 0], + ["7", 0], + { + "\U0001f600" + * 70: {"class_type": long_name, "inputs": {"text": "x"}}, + "7": _encode("b"), + }, + ) + ) + } + return cases + + +class _Builder: + """Coherent random graphs over the traced vocabulary, sometimes corrupted.""" + + def __init__(self, rng): + self.rng = rng + self.nodes = {} + self.records = {} + self.counter = 100 + + def new_id(self): + self.counter += 1 + return str(self.counter) + + def word(self): + rng = self.rng + parts = [rng.choice(SPACES), rng.choice(WORDS), rng.choice(SPACES)] + if rng.random() < 0.3: + parts.append(rng.choice(WORDS)) + return "".join(parts) + + def string(self, depth): + """A string input: a literal, or a link to a string node.""" + rng = self.rng + if depth > 3 or rng.random() < 0.45: + return self.word() + node_id, choice = self.new_id(), rng.randrange(8) + if choice == 0: + node = { + "class_type": rng.choice( + ["PrimitiveString", "PrimitiveStringMultiline"] + ), + "inputs": {"value": self.string(depth + 1)}, + } + elif choice == 1: + node = { + "class_type": "StringConcatenate", + "inputs": { + "string_a": self.string(depth + 1), + "string_b": self.string(depth + 1), + "delimiter": rng.choice(["", ", ", " "]), + }, + } + elif choice == 2: + inputs = { + f"text_{letter}": self.string(depth + 1) + for letter in rng.sample("abcd", rng.randint(1, 3)) + } + inputs["clean_whitespace"] = rng.choice(["true", "false", True]) + node = {"class_type": "Text Concatenate", "inputs": inputs} + elif choice == 3: + node = { + "class_type": "StringConstantMultiline", + "inputs": { + "string": self.string(depth + 1), + "strip_newlines": rng.choice([True, False]), + }, + } + elif choice == 4: + count = rng.randint(1, 4) + inputs = { + f"string_{index}": self.string(depth + 1) + for index in range(1, count + 1) + if index == 1 or rng.random() < 0.8 + } + inputs.update( + { + "inputcount": count, + "delimiter": rng.choice([" ", ","]), + "return_list": rng.random() < 0.1, + } + ) + node = {"class_type": "JoinStringMulti", "inputs": inputs} + elif choice == 5: + node = { + "class_type": "Reroute", + "inputs": {"value": self.string_link(depth + 1)}, + } + elif choice == 6: + node = { + "class_type": rng.choice(["StringConstant", "TextInput"]), + "inputs": {rng.choice(["string", "text"]): self.string(depth + 1)}, + } + else: + node = { + "class_type": "PromptManagerText", + "inputs": { + "text": self.string(depth + 1), + "prepend_text": self.word(), + "append_text": self.word(), + }, + } + self.nodes[node_id] = node + return [node_id, 0] + + def string_link(self, depth): + value = self.string(depth) + if isinstance(value, list): + return value + node_id = self.new_id() + self.nodes[node_id] = { + "class_type": "PrimitiveString", + "inputs": {"value": value}, + } + return [node_id, 0] + + def conditioning(self, depth): + rng = self.rng + node_id = self.new_id() + choice = rng.randrange(11) if depth < 4 else 0 + slot = 0 + if choice == 0: + node = { + "class_type": "CLIPTextEncode", + "inputs": {"text": self.string(depth + 1), "clip": ["4", 1]}, + } + elif choice == 1: + text = self.string(depth + 1) + node = { + "class_type": "CLIPTextEncodeSDXL", + "inputs": { + "text_g": text, + "text_l": ( + text if rng.random() < 0.6 else rng.choice(["", self.word()]) + ), + }, + } + elif choice == 2: + node = { + "class_type": "FluxGuidance", + "inputs": {"conditioning": self.conditioning(depth + 1)}, + } + elif choice == 3: + node = { + "class_type": "ConditioningZeroOut", + "inputs": {"conditioning": self.conditioning(depth + 1)}, + } + elif choice == 4: + first = self.conditioning(depth + 1) + node = { + "class_type": "ConditioningCombine", + "inputs": { + "conditioning_1": first, + "conditioning_2": ( + first if rng.random() < 0.5 else self.conditioning(depth + 1) + ), + }, + } + elif choice == 5: + condition = rng.choice([True, False, ["4", 0], ["4", 1]]) + node = { + "class_type": "easy ifElse", + "inputs": { + "boolean": condition, + "on_true": self.conditioning(depth + 1), + "on_false": self.conditioning(depth + 1), + }, + } + elif choice == 6: + node = { + "class_type": rng.choice(["PromptManager", "PromptManagerFocused"]), + "inputs": { + "text": self.string(depth + 1), + "prepend_text": self.word(), + "append_text": self.word(), + "clip": ["4", 1], + }, + } + elif choice == 7: + node = { + "class_type": rng.choice(["PromptManagerV2", "PromptManagerPositive"]), + "inputs": { + "text": self.string(depth + 1), + "prepend_text": self.word(), + "negative_text": self.word(), + }, + } + elif choice == 8: + slot = rng.choice([0, 1]) + texts = { + "text": self.word(), + "negative_text": self.word(), + "prepend_text": self.word(), + "append_text": self.word(), + } + node = {"class_type": "PromptManagerPair", "inputs": dict(texts)} + if rng.random() < 0.6: + record = { + **texts, + "positive_text": self.word(), + "negative_effective_text": texts["negative_text"], + } + record["prepend_text"], record["append_text"] = ( + record["prepend_text"].strip(), + record["append_text"].strip(), + ) + if rng.random() < 0.2: + record["text"] = record["text"] + "!" + self.records[EXECUTION_KEY] = json.dumps( + {"version": 1, "nodes": {node_id: {"records": [record]}}} + ) + elif choice == 9: + node = { + "class_type": "Reroute", + "inputs": {"value": self.conditioning(depth + 1)}, + } + else: + node = { + "class_type": rng.choice(["PromptSearchList", "FancyEncoder"]), + "inputs": {"text": self.word()}, + } + self.nodes[node_id] = node + return [node_id, slot] + + def corrupt(self): + rng = self.rng + node_id = rng.choice(sorted(self.nodes)) + node = self.nodes[node_id] + links = [ + key + for key, value in node.get("inputs", {}).items() + if isinstance(value, list) + ] + choice = rng.randrange(6) + if choice == 0 and links: + node["inputs"][rng.choice(links)][1] = rng.choice([0.0, True, 7, -1]) + elif choice == 1 and links: + node["inputs"][rng.choice(links)][0] = rng.choice(["missing", node_id]) + elif choice == 2: + node["class_type"] = rng.choice([None, 3, "Unknown"]) + elif choice == 3: + node["inputs"] = rng.choice([[], {}]) + elif choice == 4 and links: + del node["inputs"][rng.choice(links)] + else: + node.pop("inputs", None) + + +EXECUTION_KEY = "comfyassets_prompt_pairs" + + +def fuzz_cases(seed, count): + rng = random.Random(seed) + cases = {} + for index in range(count): + builder = _Builder(rng) + positive = builder.conditioning(0) + negative = builder.conditioning(0) if rng.random() < 0.9 else None + graph = _graph(positive, negative, builder.nodes) + if negative is None: + del graph["3"]["inputs"]["negative"] + if rng.random() < 0.25: + builder.nodes = graph + builder.corrupt() + records = {"prompt": json.dumps(graph, ensure_ascii=rng.random() < 0.5)} + records.update(builder.records) + cases[f"fuzz-{seed}-{index}"] = records + return cases + + +def single_chain(records): + """Whether the server answers ``records`` without choosing among samplers.""" + roles = {key for key in records if key in ("prompt", "parameters", EXECUTION_KEY)} + if "prompt" not in roles: + return "parameters" not in roles + try: + graph = json.loads(records["prompt"]) + except (ValueError, RecursionError): + return True + if ( + not isinstance(graph, dict) + or not graph + or len(graph) > INSPECTION_PROFILE.node_ceiling + ): + return True + samplers = get_semantics().samplers + found = [ + node["class_type"] + for node in graph.values() + if isinstance(node, dict) + and isinstance(node.get("class_type"), str) + and node["class_type"] in samplers + and samplers[node["class_type"]]["strategy"] + in ("ksampler", "custom-advanced", "detailer-stage") + ] + return len(found) <= 1 and not any( + samplers[name]["strategy"] == "detailer-stage" for name in found + ) + + +def _utf8(records): + """Whether the record texts can be written as UTF-8 (no lone surrogate).""" + try: + json.dumps(records, ensure_ascii=False).encode("utf-8") + except UnicodeEncodeError: + return False + return True + + +def committed_vectors(): + """The committed record sets with the server's evidence, in a stable order.""" + try: + from tests import local_resolver_selection_cases as selection + except ImportError: # pragma: no cover - discover -s tests puts tests/ first + import local_resolver_selection_cases as selection + + named = {**golden_cases(), **selection.selection_cases()} + cases = { + name: records + for name, records in named.items() + if len(json.dumps(records)) <= MAX_VECTOR_CHARS and _utf8(records) + } + cases.update(fuzz_cases(*COMMITTED_FUZZ)) + cases.update(selection.selection_fuzz_cases(*COMMITTED_FUZZ)) + vectors = [ + { + "name": name, + "records": records, + "evidence": inspect_prompt_evidence( + records, None, profile=INSPECTION_PROFILE + ), + } + for name, records in cases.items() + ] + return {"version": 2, "vectors": vectors} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--write", action="store_true", help="rewrite the vectors") + if parser.parse_args().write: + text = json.dumps(committed_vectors(), ensure_ascii=False, sort_keys=True) + VECTORS_PATH.write_text(text + "\n", encoding="utf-8", newline="\n") + + +if __name__ == "__main__": + main() diff --git a/tests/local_resolver_selection_cases.py b/tests/local_resolver_selection_cases.py new file mode 100644 index 0000000..9591e97 --- /dev/null +++ b/tests/local_resolver_selection_cases.py @@ -0,0 +1,574 @@ +"""Candidate-selection record sets for the local resolver parity test (PR L3 Task 9). + +``local_resolver_cases`` covers one sampler's tracing. These cases reach the +rest of ``_resolve``: several sampler candidates and the identical-branch rule +(#270), stages that only reach previews, detailer stages and their upstream +samplers (multistage), and A1111 ``parameters`` with every footer shape. The +server's ``inspect_prompt_evidence`` is the oracle; nothing here states an +expected answer. All content is synthetic. +""" + +import json +import random + +try: + from tests.local_resolver_cases import EXECUTION_KEY, SPACES, WORDS, _Builder +except ImportError: # pragma: no cover - discover -s tests puts tests/ first + from local_resolver_cases import EXECUTION_KEY, SPACES, WORDS, _Builder + + +def _encode(text): + return {"class_type": "CLIPTextEncode", "inputs": {"text": text, "clip": ["4", 1]}} + + +def _sampler(positive, negative, latent=None, name="KSampler"): + inputs = {"seed": 1, "model": ["4", 0], "positive": positive} + if negative is not None: + inputs["negative"] = negative + inputs["latent_image"] = latent or ["5", 0] + return {"class_type": name, "inputs": inputs} + + +def _decode(sampler_id): + return {"class_type": "VAEDecode", "inputs": {"samples": [sampler_id, 0]}} + + +def _save(image, name="SaveImage"): + return {"class_type": name, "inputs": {"images": image}} + + +def _detailer(image, positive, negative, name="FaceDetailer", **extra): + inputs = {"image": image, "model": ["4", 0], "positive": positive} + inputs["negative"] = negative + inputs.update(extra) + return {"class_type": name, "inputs": inputs} + + +def _base(**nodes): + graph = { + "4": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "m"}}, + "5": {"class_type": "EmptyLatentImage", "inputs": {"width": 64}}, + "6": _encode("a fox"), + "7": _encode("blurry"), + "8": _encode("a cat"), + } + graph.update(nodes) + return graph + + +def _samplers(): + """Several unanchored samplers: identical, differing and mixed branches.""" + same = _sampler(["6", 0], ["7", 0]) + cases = { + "two-identical": _base(**{"3": same, "9": same}), + "two-differ": _base(**{"3": same, "9": _sampler(["8", 0], ["7", 0])}), + "two-negative-differs": _base(**{"3": same, "9": _sampler(["6", 0], ["8", 0])}), + "identical-without-negative": _base( + **{"3": _sampler(["6", 0], None), "9": _sampler(["6", 0], None)} + ), + "one-unresolved": _base(**{"3": same, "9": _sampler(["missing", 0], ["7", 0])}), + "both-unresolved": _base( + **{"3": _sampler(["x", 0], ["7", 0]), "9": _sampler(["y", 0], ["7", 0])} + ), + "three-identical-one-advanced": _base( + **{ + "3": same, + "9": same, + "10": _sampler(["6", 0], ["7", 0], name="KSamplerAdvanced"), + } + ), + # Equal texts, different recipes: a PromptManager pair against an encoder. + "same-text-other-recipe": _base( + **{ + "3": same, + "9": _sampler(["11", 0], ["7", 0]), + "11": { + "class_type": "PromptManager", + "inputs": { + "text": "fox", + "prepend_text": "a", + "append_text": "", + "clip": ["4", 1], + }, + }, + } + ), + "multistage-latent": _base( + **{"3": same, "9": _sampler(["6", 0], ["7", 0], latent=["3", 0])} + ), + } + selector = { + "class_type": "easy ifElse", + "inputs": {"boolean": ["4", 0], "on_true": ["6", 0], "on_false": ["8", 0]}, + } + cases["selector-and-plain"] = _base( + **{"3": same, "9": _sampler(["12", 0], ["7", 0]), "12": selector} + ) + return cases + + +def _previews(): + """Stages whose outputs reach only previews are not the saved file's.""" + same, other = _sampler(["6", 0], ["7", 0]), _sampler(["8", 0], ["7", 0]) + return { + "preview-dropped": _base( + **{ + "3": same, + "9": other, + "13": _decode("3"), + "14": _decode("9"), + "15": _save(["13", 0]), + "16": _save(["14", 0], "PreviewImage"), + } + ), + "all-previews-kept": _base( + **{ + "3": same, + "9": other, + "13": _decode("3"), + "14": _decode("9"), + "15": _save(["13", 0], "PreviewImage"), + "16": _save(["14", 0], "Image Comparer (rgthree)"), + } + ), + "preview-and-file": _base( + **{ + "3": same, + "9": other, + "13": _decode("3"), + "14": _decode("9"), + "15": _save(["13", 0]), + "16": _save(["14", 0], "PreviewImage"), + "17": _save(["14", 0]), + } + ), + "unconsumed-stage-kept": _base( + **{ + "3": same, + "9": other, + "14": _decode("9"), + "16": _save(["14", 0], "PreviewImage"), + } + ), + # A list or object class_type is never a preview class (#286). + "preview-unhashable-class": _base( + **{ + "3": same, + "9": other, + "14": _decode("9"), + "16": {"class_type": ["PreviewImage"], "inputs": {"images": ["14", 0]}}, + "17": {"class_type": {"a": 1}, "inputs": {"images": ["3", 0]}}, + } + ), + "preview-through-bad-node": _base( + **{ + "3": same, + "9": other, + "13": _decode("3"), + "15": _save(["13", 0]), + "16": _save(["9", 0], "PreviewImage"), + "18": "not a node", + "19": {"class_type": "Note", "inputs": {"x": ["18", 0]}}, + } + ), + } + + +def _detailers(): + """Detailer stages judged with the samplers in their image ancestry.""" + same = _sampler(["6", 0], ["7", 0]) + cases = { + "detailer-same-prompt": _base( + **{ + "3": same, + "10": _decode("3"), + "11": _detailer(["10", 0], ["6", 0], ["7", 0]), + "12": _save(["11", 0]), + } + ), + "detailer-differs": _base( + **{ + "3": same, + "10": _decode("3"), + "11": _detailer(["10", 0], ["8", 0], ["7", 0]), + "12": _save(["11", 0]), + } + ), + "detailer-and-own-output": _base( + **{ + "3": same, + "10": _decode("3"), + "11": _detailer(["10", 0], ["8", 0], ["7", 0]), + "12": _save(["11", 0]), + "13": _save(["10", 0]), + } + ), + "detailer-image-literal": _base( + **{ + "11": _detailer("not a link", ["6", 0], ["7", 0]), + "12": _save(["11", 0]), + } + ), + "detailer-cycle": _base( + **{ + "11": _detailer(["10", 0], ["6", 0], ["7", 0]), + "10": {"class_type": "ImageScale", "inputs": {"image": ["11", 0]}}, + "12": _save(["11", 0]), + } + ), + "detailer-chain": _base( + **{ + "3": same, + "10": _decode("3"), + "11": _detailer(["10", 0], ["6", 0], ["7", 0]), + "21": _detailer( + ["11", 0], ["8", 0], ["7", 0], name="UltimateSDUpscale" + ), + "12": _save(["21", 0]), + } + ), + "detailer-chain-same": _base( + **{ + "3": same, + "10": _decode("3"), + "11": _detailer(["10", 0], ["6", 0], ["7", 0]), + "21": _detailer(["11", 0], ["6", 0], ["7", 0], name="DetailerForEach"), + "12": _save(["21", 0]), + } + ), + "detailer-two-upstream": _base( + **{ + "3": same, + "9": _sampler(["8", 0], ["7", 0]), + "10": { + "class_type": "ImageBlend", + "inputs": {"image1": ["13", 0], "image2": ["14", 0]}, + }, + "13": _decode("3"), + "14": _decode("9"), + "11": _detailer(["10", 0], ["6", 0], ["7", 0]), + "12": _save(["11", 0]), + } + ), + "detailer-unsupported-stage": _base( + **{ + "10": { + "class_type": "MysterySampler", + "inputs": {"positive": ["6", 0], "model": ["4", 0]}, + }, + "11": _detailer(["10", 0], ["6", 0], ["7", 0]), + "12": _save(["11", 0]), + } + ), + "detailer-upstream-unresolved": _base( + **{ + "3": _sampler(["gone", 0], ["7", 0]), + "10": _decode("3"), + "11": _detailer(["10", 0], ["6", 0], ["7", 0]), + "12": _save(["11", 0]), + } + ), + "detailer-upstream-selector": _base( + **{ + "3": _sampler(["15", 0], ["7", 0]), + "15": { + "class_type": "easy ifElse", + "inputs": { + "boolean": ["4", 0], + "on_true": ["6", 0], + "on_false": ["8", 0], + }, + }, + "10": _decode("3"), + "11": _detailer(["10", 0], ["6", 0], ["7", 0]), + "12": _save(["11", 0]), + } + ), + "detailer-wildcard": _base( + **{ + "3": same, + "10": _decode("3"), + "11": _detailer(["10", 0], ["6", 0], ["7", 0], wildcard="[x|y]"), + "12": _save(["11", 0]), + } + ), + "detailer-no-upscaled-image": _base( + **{ + "3": same, + "10": _decode("3"), + "11": _detailer( + ["10", 0], ["6", 0], ["7", 0], name="UltimateSDUpscaleNoUpscale" + ), + "12": _save(["11", 0]), + } + ), + "detailer-no-sampler-upstream": _base( + **{ + "10": {"class_type": "LoadImage", "inputs": {"image": "a.png"}}, + "11": _detailer(["10", 0], ["6", 0], ["7", 0]), + "12": _save(["11", 0]), + } + ), + } + deep = {"3": _sampler(["6", 0], ["7", 0]), "d0": _decode("3")} + for index in range(1, 70): + deep[f"d{index}"] = { + "class_type": "ImageScale", + "inputs": {"image": [f"d{index - 1}", 0]}, + } + deep["11"] = _detailer(["d69", 0], ["6", 0], ["7", 0]) + deep["12"] = _save(["11", 0]) + cases["detailer-deep-ancestry"] = _base(**deep) + # More nested detailer stages than the depth bound. + stacked = {"3": _sampler(["6", 0], ["7", 0]), "s0": _decode("3")} + previous = "s0" + for index in range(1, 67): + stacked[f"s{index}"] = _detailer([previous, 0], ["6", 0], ["7", 0]) + previous = f"s{index}" + stacked["12"] = _save([previous, 0]) + cases["detailer-stacked-past-depth"] = _base(**stacked) + # Each detailer walks a wide shared image ancestry: the total visit budget + # runs out across stages before any candidate is read. + wide = { + f"l{index}": {"class_type": "LoadImage", "inputs": {}} for index in range(1500) + } + wide["hub"] = { + "class_type": "ImageBatchMany", + "inputs": {f"i{index}": [f"l{index}", 0] for index in range(1500)}, + } + for index in range(15): + wide[f"w{index}"] = _detailer(["hub", 0], ["6", 0], ["7", 0]) + wide[f"o{index}"] = _save([f"w{index}", 0]) + cases["detailers-exhaust-total-visits"] = _base(**wide) + cases["detailer-mutual-cycle"] = _base( + **{ + "11": _detailer(["21", 0], ["6", 0], ["7", 0]), + "21": _detailer(["11", 0], ["6", 0], ["7", 0]), + "12": _save(["21", 0]), + } + ) + # The sampler's output reaches a preview through more nodes than one + # detailer's allowance: the walk runs out, so the sampler is not absorbed. + long = {"3": _sampler(["6", 0], ["7", 0]), "10": _decode("3")} + previous = "10" + for index in range(2100): + long[f"c{index}"] = { + "class_type": "ImageScale", + "inputs": {"image": [previous, 0]}, + } + previous = f"c{index}" + long["p"] = _save([previous, 0], "PreviewImage") + long["11"] = _detailer(["10", 0], ["6", 0], ["7", 0]) + long["12"] = _save(["11", 0]) + cases["detailer-output-walk-exhausted"] = _base(**long) + # Every detailer's ancestry walk has its own allowance: the first one is + # wide, and the second still absorbs the first. + chained = {"3": _sampler(["6", 0], ["7", 0]), "10": _decode("3")} + chained["h1"] = { + "class_type": "ImageBatchMany", + "inputs": { + "i": ["10", 0], + **{f"i{index}": [f"a{index}", 0] for index in range(1500)}, + }, + } + chained["h2"] = { + "class_type": "ImageBatchMany", + "inputs": { + "i": ["11", 0], + **{f"i{index}": [f"b{index}", 0] for index in range(600)}, + }, + } + for index in range(1500): + chained[f"a{index}"] = {"class_type": "LoadImage", "inputs": {}} + for index in range(600): + chained[f"b{index}"] = {"class_type": "LoadImage", "inputs": {}} + chained["11"] = _detailer(["h1", 0], ["6", 0], ["7", 0]) + chained["21"] = _detailer(["h2", 0], ["6", 0], ["7", 0]) + chained["12"] = _save(["21", 0]) + cases["detailers-each-reset"] = _base(**chained) + return cases + + +_FOOTER = "Steps: 20, Sampler: euler, CFG scale: 7" + + +def _parameters(): + """A1111 ``parameters`` texts: every footer shape and delimiter rule.""" + texts = { + "basic": f"a fox\nNegative prompt: blur\n{_FOOTER}", + "no-negative": f"a fox\n{_FOOTER}", + "two-delimiters": f"a\nNegative prompt: b\nNegative prompt: c\n{_FOOTER}", + "leading-delimiter": f"Negative prompt: blur\n{_FOOTER}", + "blank-positive": f" \x85\nNegative prompt: blur\n{_FOOTER}", + "footer-not-last": f"a fox\n{_FOOTER}\nmore text", + "two-footers": f"a\n{_FOOTER}\n{_FOOTER}", + "bare-footer": "a fox\nNegative prompt: blur\nSteps: 20", + "bare-and-full": f"a fox\nSteps: 5\n{_FOOTER}", + "two-bare": "a\nSteps: 1\nSteps: 2", + "template": f"a fox\n{_FOOTER}\nTemplate: {{a|b}}", + "both-templates": f"a\n{_FOOTER}\nTemplate: x\nNegative Template: y", + "templates-reversed": f"a\n{_FOOTER}\nNegative Template: y\nTemplate: x", + "two-templates": f"a\n{_FOOTER}\nTemplate: x\nTemplate: y", + "template-multiline": f"a\n{_FOOTER}\nTemplate: x\nmore", + "crlf": f"a fox\r\nNegative prompt: blur\r\n{_FOOTER}\r\n", + "cr-only": f"a fox\rNegative prompt: blur\r{_FOOTER}", + "cr-template": f"a\n{_FOOTER}\rTemplate: x", + "line-separator": f"a fox
{_FOOTER}", + "next-line": f"a fox\x85{_FOOTER}", + "spaced-steps": "a fox\nSteps:\n 20 \n, x", + "steps-newline-bare": "a fox\nSteps:\n20\n", + "arabic-digits": "a fox\nSteps: ٢٠, Sampler: euler", + "fullwidth-digits": "a fox\nSteps: 2, x", + "no-digits": "a fox\nSteps: , x", + "trailing-spaces": f"a fox \nNegative prompt: blur \n{_FOOTER} \n \n", + "empty-negative": f"a fox\nNegative prompt:\n{_FOOTER}", + "astral-within-limit": "\U0001f600" * 60_000 + f"\n{_FOOTER}", + "over-limit": "x" * 100_001, + "at-limit": "x" * (100_000 - len(_FOOTER) - 1) + "\n" + _FOOTER, + "empty": "", + "only-footer": _FOOTER, + "lone-surrogate": f"\ud800 fox\nNegative prompt: \udfff\n{_FOOTER}", + } + cases = {f"parameters-{name}": {"parameters": text} for name, text in texts.items()} + cases["parameters-with-execution"] = { + "parameters": texts["basic"], + EXECUTION_KEY: json.dumps({"version": 1, "nodes": {}}), + } + return cases + + +def selection_cases(): + """Named record sets for every Task 9 path, each a ``read_inspection_file`` dict.""" + cases = {} + for group in (_samplers(), _previews(), _detailers()): + for name, graph in group.items(): + cases[f"select-{name}"] = {"prompt": json.dumps(graph, ensure_ascii=False)} + cases.update(_parameters()) + return cases + + +class _Selection(_Builder): + """Random graphs with several samplers, outputs, previews and detailers.""" + + def sampler(self): + rng = self.rng + positive = self.conditioning(0) + negative = self.conditioning(0) if rng.random() < 0.85 else None + name = rng.choice(["KSampler", "KSampler", "KSamplerAdvanced", "SamplerCustom"]) + node_id = self.new_id() + self.nodes[node_id] = _sampler(positive, negative, name=name) + return node_id + + def shared(self, ids): + """A copy of an earlier sampler: an identical branch.""" + node_id = self.new_id() + self.nodes[node_id] = json.loads(json.dumps(self.nodes[self.rng.choice(ids)])) + return node_id + + def output(self, image): + rng = self.rng + choice = rng.randrange(5) + if choice == 4: + return + name = ["SaveImage", "SaveImage", "PreviewImage", "PreviewAny"][choice] + self.nodes[self.new_id()] = _save(image, name) + + def detailer(self, image): + rng = self.rng + node_id = self.new_id() + positive = self.conditioning(1) if rng.random() < 0.5 else None + name = rng.choice(list(_DETAILER_NAMES)) + node = _detailer(image, positive, self.conditioning(1), name=name) + if positive is None: + node["inputs"]["positive"] = self.reuse_positive() + if name == "UltimateSDUpscaleNoUpscale": + node["inputs"]["upscaled_image"] = node["inputs"].pop("image") + if name in ("FaceDetailer", "DetailerForEach") and rng.random() < 0.15: + node["inputs"]["wildcard"] = rng.choice(["", "x", ["4", 0]]) + self.nodes[node_id] = node + return node_id + + def reuse_positive(self): + samplers = [ + node + for node in self.nodes.values() + if node.get("class_type") + in ("KSampler", "KSamplerAdvanced", "SamplerCustom") + ] + if samplers: + return self.rng.choice(samplers)["inputs"]["positive"] + return self.conditioning(1) + + def build(self): + rng = self.rng + ids = [self.sampler()] + for _ in range(rng.randrange(3)): + ids.append(self.shared(ids) if rng.random() < 0.4 else self.sampler()) + for sampler_id in list(ids): + decoded = self.new_id() + self.nodes[decoded] = _decode(sampler_id) + image = [decoded, 0] + for _ in range(rng.randrange(3)): + if rng.random() < 0.5: + image = [self.detailer(image), 0] + else: + self.output(image) + self.output(image) + if rng.random() < 0.1: + self.nodes[sampler_id]["inputs"]["latent_image"] = [rng.choice(ids), 0] + graph = { + "4": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "m"}}, + "5": {"class_type": "EmptyLatentImage", "inputs": {"width": 64}}, + } + graph.update(self.nodes) + return graph + + +_DETAILER_NAMES = ( + "FaceDetailer", + "DetailerForEach", + "UltimateSDUpscale", + "UltimateSDUpscaleNoUpscale", +) +_PARAMETER_LINES = [ + "Negative prompt:", + "Negative prompt: blur", + "Steps: 20, Sampler: euler", + "Steps: 7", + "Steps: x", + "Template: t", + "Negative Template: n", + "Steps:٣, a", +] + + +def _parameter_text(rng): + parts = [] + for _ in range(rng.randint(1, 5)): + if rng.random() < 0.5: + parts.append(rng.choice(_PARAMETER_LINES)) + else: + parts.append(rng.choice(SPACES) + rng.choice(WORDS) + rng.choice(SPACES)) + joints = ["\n", "\n", "\r\n", "\r", "
", " "] + return "".join(part + rng.choice(joints) for part in parts).rstrip( + rng.choice(["", "\n", " "]) + ) + + +def selection_fuzz_cases(seed, count): + """Seeded several-sampler graphs (sometimes corrupted) and parameters texts.""" + rng = random.Random(seed) + cases = {} + for index in range(count): + if rng.random() < 0.2: + cases[f"select-fuzz-{seed}-{index}"] = {"parameters": _parameter_text(rng)} + continue + builder = _Selection(rng) + graph = builder.build() + if rng.random() < 0.2: + builder.nodes = graph + builder.corrupt() + records = {"prompt": json.dumps(graph, ensure_ascii=rng.random() < 0.5)} + records.update(builder.records) + cases[f"select-fuzz-{seed}-{index}"] = records + return cases diff --git a/tests/lock_probe.py b/tests/lock_probe.py new file mode 100644 index 0000000..88146f2 --- /dev/null +++ b/tests/lock_probe.py @@ -0,0 +1,35 @@ +"""A lock stand-in that reports when a second caller has to wait for it. + +Concurrency tests patch it over a module's lock so they can wait until the +second caller is really blocked on the lock, instead of hoping a thread +start or one event-loop tick got it there. +""" + +import threading + + +class ProbeLock: + """A re-entrant lock whose ``contended`` event is set when a caller blocks.""" + + def __init__(self): + self._lock = threading.RLock() + self.contended = threading.Event() + + def acquire(self, blocking=True, timeout=-1): + if self._lock.acquire(blocking=False): + return True + self.contended.set() + if not blocking: + return False + return self._lock.acquire(timeout=timeout) + + def release(self): + self._lock.release() + + def __enter__(self): + self.acquire() + return self + + def __exit__(self, *exc): + self.release() + return False diff --git a/tests/test_api_admin.py b/tests/test_api_admin.py index ec75051..75d203f 100644 --- a/tests/test_api_admin.py +++ b/tests/test_api_admin.py @@ -5,6 +5,7 @@ database and a stub ``folder_paths`` module, so the tests run without ComfyUI. """ import asyncio +import hashlib import json import os import shutil @@ -736,9 +737,9 @@ class TestSettingsReadMonitorThroughModule(AdminAPITestCase): def test_no_sys_modules_scan(self): import inspect - from py.api.admin import AdminRoutesMixin + from py.api.settings_routes import SettingsRoutesMixin - source = inspect.getsource(AdminRoutesMixin.get_settings) + source = inspect.getsource(SettingsRoutesMixin.get_settings) self.assertNotIn("sys.modules", source) async def test_reports_directories_of_running_monitor(self): @@ -1061,14 +1062,29 @@ class TestSettingsMisc(AdminAPITestCase): saved["gallery"]["monitoring"]["directories"], [self._root(self.output_dir)] ) - async def test_save_replaces_a_corrupt_config_file(self): - with open(self.config_path, "w") as f: - f.write("{not json") + async def test_save_refuses_a_corrupt_config_file(self): + # #220: a config.json that cannot be parsed is the user's file, never + # silently replaced; the save is refused and nothing is published. + Path(self.config_path).write_bytes(b"{not json") + before_timeout = PromptManagerConfig.RESULT_TIMEOUT resp = await self.client.request( "POST", "/prompt_manager/settings", json={"result_timeout": 4} ) - self.assertEqual(resp.status, 200) - self.assertEqual(self._read_config()["web_ui"]["result_timeout"], 4) + body = await resp.json() + self.assertEqual((resp.status, body["code"]), (409, "config-unreadable")) + self.assertIs(body["success"], False) + self.assertIs(body["persisted"], False) + self.assertEqual(PromptManagerConfig.RESULT_TIMEOUT, before_timeout) + self.assertEqual(Path(self.config_path).read_bytes(), b"{not json") + + async def test_get_settings_reports_an_unreadable_config(self): + Path(self.config_path).write_bytes(b"{not json") + resp = await self.client.request("GET", "/prompt_manager/settings") + body = await resp.json() + self.assertEqual(resp.status, 200) # the legacy page still loads + self.assertIsNone(body["revision"]) + self.assertEqual(body["config_error"], "config-unreadable") + self.assertIn("result_timeout", body["settings"]) async def test_saved_config_file_is_private_to_the_user(self): import stat @@ -1109,12 +1125,156 @@ class TestSettingsMisc(AdminAPITestCase): self.assertEqual(resp.status, 200) self.assertFalse(data["restart_required"]) - async def test_unwritable_config_path_still_succeeds(self): - os.environ[CONFIG_PATH_ENV] = os.path.join(self.tmpdir, "missing", "c.json") + async def test_unwritable_config_path_reports_failure_without_runtime_change(self): + # #220: the parent is a file, so config.json cannot be written. The + # save reports the failure and publishes nothing. + blocker = Path(self.tmpdir) / "not-a-dir" + blocker.write_text("x") + os.environ[CONFIG_PATH_ENV] = str(blocker / "c.json") + GalleryConfig.MONITORING_DIRECTORIES = [] + before_timeout = PromptManagerConfig.RESULT_TIMEOUT + with self.assertLogs("prompt_manager.api", level="ERROR"): + resp = await self.client.request( + "POST", + "/prompt_manager/settings", + json={ + "result_timeout": before_timeout + 1, + "gallery_root_paths": [self._root(self.output_dir)], + }, + ) + body = await resp.json() + self.assertEqual( + (resp.status, body["code"], body["persisted"]), + (500, "config-write-failed", False), + ) + self.assertIs(body["success"], False) + self.assertIs(body["runtime_applied"], False) + self.assertEqual(PromptManagerConfig.RESULT_TIMEOUT, before_timeout) + self.assertEqual(GalleryConfig.MONITORING_DIRECTORIES, []) + self.assertEqual(blocker.read_text(), "x") + + async def test_save_reports_the_commit(self): resp = await self.client.request( "POST", "/prompt_manager/settings", json={"result_timeout": 3} ) + body = await resp.json() self.assertEqual(resp.status, 200) + self.assertEqual(body["message"], "Settings saved successfully") + self.assertIs(body["persisted"], True) + self.assertIs(body["runtime_applied"], True) + self.assertIs(body["restart_required"], False) + self.assertEqual(body["restart_required_fields"], []) + digest = hashlib.sha256(Path(self.config_path).read_bytes()).hexdigest() + self.assertEqual(body["revision"], "sha256:" + digest) + + async def test_changed_roots_name_the_restart_field(self): + resp = await self.client.request( + "POST", + "/prompt_manager/settings", + json={"gallery_root_paths": [self._root(self.output_dir)]}, + ) + body = await resp.json() + self.assertEqual(resp.status, 200) + self.assertIs(body["restart_required"], True) + self.assertEqual(body["restart_required_fields"], ["gallery_root_paths"]) + + async def test_runtime_publication_failure_after_commit_is_reported(self): + # The values are durable; the response must not claim they are live. + self.api.reset_gallery_roots_cache = _raise + with self.assertLogs("prompt_manager.api", level="ERROR"): + resp = await self.client.request( + "POST", + "/prompt_manager/settings", + json={"gallery_root_paths": [self._root(self.output_dir)]}, + ) + body = await resp.json() + self.assertEqual(resp.status, 200) + self.assertIs(body["persisted"], True) + self.assertIs(body["runtime_applied"], False) + self.assertIs(body["restart_required"], True) + self.assertEqual(body["restart_required_fields"], ["gallery_root_paths"]) + self.assertEqual( + self._read_config()["gallery"]["monitoring"]["directories"], + [self._root(self.output_dir)], + ) + + async def test_get_settings_reports_revision(self): + resp = await self.client.request("GET", "/prompt_manager/settings") + body = await resp.json() + self.assertEqual(body["revision"], "absent") + self.assertNotIn("config_error", body) + + self._write_config({"web_ui": {"result_timeout": 2}}) + body = await ( + await self.client.request("GET", "/prompt_manager/settings") + ).json() + digest = hashlib.sha256(Path(self.config_path).read_bytes()).hexdigest() + self.assertEqual(body["revision"], "sha256:" + digest) + + async def test_native_save_with_stale_revision_is_409(self): + self._write_config({"web_ui": {"result_timeout": 2}}) + body = await ( + await self.client.request("GET", "/prompt_manager/settings") + ).json() + seen = body["revision"] + self._write_config({"web_ui": {"result_timeout": 6}}) # another writer + before = Path(self.config_path).read_bytes() + before_timeout = PromptManagerConfig.RESULT_TIMEOUT + + resp = await self.client.request( + "POST", + "/prompt_manager/settings", + json={"result_timeout": 9, "expected_revision": seen}, + ) + body = await resp.json() + self.assertEqual((resp.status, body["code"]), (409, "settings-changed")) + self.assertIs(body["persisted"], False) + digest = hashlib.sha256(before).hexdigest() + self.assertEqual(body["revision"], "sha256:" + digest) + self.assertEqual(Path(self.config_path).read_bytes(), before) + self.assertEqual(PromptManagerConfig.RESULT_TIMEOUT, before_timeout) + + async def test_native_save_with_current_revision_commits(self): + body = await ( + await self.client.request("GET", "/prompt_manager/settings") + ).json() + resp = await self.client.request( + "POST", + "/prompt_manager/settings", + json={"result_timeout": 9, "expected_revision": body["revision"]}, + ) + self.assertEqual(resp.status, 200) + self.assertEqual(self._read_config()["web_ui"]["result_timeout"], 9) + + async def test_expected_revision_must_be_a_string(self): + resp = await self.client.request( + "POST", + "/prompt_manager/settings", + json={"result_timeout": 9, "expected_revision": 5}, + ) + self.assertEqual(resp.status, 400) + self.assertFalse(os.path.exists(self.config_path)) + + async def test_legacy_partial_update_without_revision_still_merges(self): + self._write_config( + { + "integrations": {"lora_manager": {"civitai_api_key": "secret"}}, + "web_ui": {"result_timeout": 1, "webui_display_mode": "popup"}, + "custom_section": {"keep": True}, + } + ) + resp = await self.client.request( + "POST", "/prompt_manager/settings", json={"result_timeout": 8} + ) + self.assertEqual(resp.status, 200) + self.assertNotIn("secret", await resp.text()) + saved = self._read_config() + self.assertEqual( + saved["integrations"]["lora_manager"]["civitai_api_key"], "secret" + ) + self.assertEqual(saved["custom_section"], {"keep": True}) + self.assertEqual(saved["web_ui"]["result_timeout"], 8) + self.assertEqual(saved["web_ui"]["webui_display_mode"], "popup") # not sent async def test_save_unexpected_error_is_500(self): self.api._parse_gallery_roots = _raise diff --git a/tests/test_api_backup_restore.py b/tests/test_api_backup_restore.py index d91b302..aa211ff 100644 --- a/tests/test_api_backup_restore.py +++ b/tests/test_api_backup_restore.py @@ -5,12 +5,16 @@ stream the upload to disk, reject anything that is not a healthy prompts database and leave the live database untouched when it does. """ +import asyncio import glob +import hashlib import os import sqlite3 import sys import tempfile +import threading import unittest +from pathlib import Path from unittest.mock import MagicMock, patch _mock_server = MagicMock() @@ -142,6 +146,85 @@ class TestBackupRoute(BackupRestoreTestCase): self.assertIn("backup", data["error"].lower()) +class TestRestoreHoldsTheRegistry(BackupRestoreTestCase): + """A legacy restore and a workspace job refuse each other (#269 gap).""" + + def _digest(self): + return { + suffix: hashlib.sha256(Path(self.db_path + suffix).read_bytes()).hexdigest() + for suffix in ("", "-wal") + if os.path.exists(self.db_path + suffix) + } + + async def test_legacy_restore_refused_while_scan_job_runs(self): + self._save("keep me") + upload = self._make_db_file(["restored"]) + gate = asyncio.Event() + + async def runner(job): + await gate.wait() + + scan = self.api._jobs.start( + "scan", "Scan for metadata", record_changing=True, scope={}, runner=runner + ) + before = self._digest() + try: + resp = await self._restore(upload) + self.assertEqual(resp.status, 409) + self.assertEqual( + await resp.json(), + {"success": False, "error": "Scan for metadata already running"}, + ) + self.assertEqual(self._digest(), before) + self.assertEqual( + [p["text"] for p in self.api.db.search_prompts()], ["keep me"] + ) + self.assertEqual(glob.glob(self.db_path + ".backup_*"), []) + finally: + gate.set() + await asyncio.wait_for(scan.task, 5) + + async def test_scan_refused_while_legacy_restore_holds(self): + upload = self._make_db_file(["restored"]) + entered = threading.Event() + release = threading.Event() + model = self.api.db.model + real_verify = model.verify_database_file + + def gated(path, **kwargs): + entered.set() + release.wait(10) + return real_verify(path, **kwargs) + + loop = asyncio.get_running_loop() + with patch.object(model, "verify_database_file", gated): + restore = asyncio.ensure_future(self._restore(upload)) + self.assertTrue(await loop.run_in_executor(None, entered.wait, 10)) + resp = await self.client.post("/prompt_manager/jobs/scan") + body = await resp.json() + listed = (await (await self.client.get("/prompt_manager/jobs")).json())[ + "jobs" + ] + release.set() + restored = await restore + + self.assertEqual((resp.status, body["code"]), (409, "busy")) + self.assertEqual(body["running"]["title"], "Database restore (admin page)") + self.assertEqual( + [(j["kind"], j["state"], j["cancellable"]) for j in listed], + [("restore", "running", False)], + ) + self.assertEqual(restored.status, 200) + self.assertEqual(self.api._jobs.list()[0].state, "finished") + self.assertIsNone(self.api._jobs.active("restore")) + + async def test_rejected_upload_ends_its_hold_failed(self): + resp = await self._restore(self._write("notes.txt", b"not a database")) + self.assertEqual(resp.status, 400) + job = self.api._jobs.list()[0] + self.assertEqual((job.kind, job.state), ("restore", "failed")) + + class TestRestoreRoute(BackupRestoreTestCase): async def test_restore_replaces_database_and_reports_backup(self): self._save("old prompt") diff --git a/tests/test_api_core.py b/tests/test_api_core.py index 7e64377..29f2e82 100644 --- a/tests/test_api_core.py +++ b/tests/test_api_core.py @@ -462,6 +462,15 @@ class TestUiRoutes(CoreRoutesTestCase): ): self.assertIn(entry, table) + async def test_image_tag_job_route_is_registered_beside_the_scan_job(self): + table = { + (route.method, route.resource.canonical) + for route in self.app.router.routes() + if route.resource is not None + } + self.assertIn(("POST", "/prompt_manager/jobs/scan"), table) + self.assertIn(("POST", "/prompt_manager/jobs/image-tags"), table) + async def test_review_routes_are_registered(self): table = { (route.method, route.resource.canonical) @@ -490,6 +499,30 @@ class TestStaticRoutes(CoreRoutesTestCase): self.assertEqual(resp.status, 200) self.assertIn("application/javascript", resp.headers["Content-Type"]) + async def test_inspection_semantics_table_is_the_one_json_served(self): + """Local Inspect reads the node table the server resolves with (#249 PR L3). + + Only that exact file is served; every other JSON stays forbidden. + """ + resp = await self.client.request( + "GET", "/prompt_manager/js/inspection-semantics.json" + ) + self.assertEqual(resp.status, 200) + self.assertIn("application/json", resp.headers["Content-Type"]) + table = Path(__file__).resolve().parents[1] / "web" / "js" + self.assertEqual( + await resp.read(), (table / "inspection-semantics.json").read_bytes() + ) + for path in ( + "js/shared/toolkit/provenance.json", + "lib/viewerjs/provenance.json", + "lib/inspection-semantics.json", + "js/workspace/..%2Finspection-semantics.json", + "js/INSPECTION-SEMANTICS.json", + ): + resp = await self.client.request("GET", f"/prompt_manager/{path}") + self.assertEqual(resp.status, 403, path) + async def test_lib_served(self): web_dir = self._fake_root() (web_dir / "lib" / "x.css").write_text("body{}") @@ -582,6 +615,28 @@ class TestStaticRoutes(CoreRoutesTestCase): resp = await self.client.request("GET", "/prompt_manager/lib/link.js") self.assertEqual(resp.status, 403) + async def test_inspection_semantics_symlink_escaping_web_dir_is_403(self): + """The exact-path JSON gets the same realpath containment as any file.""" + web_dir = self._fake_root() + secret = Path(self.tmpdir) / "secret.json" + secret.write_text('{"secret": true}') + try: + os.symlink(secret, web_dir / "js" / "inspection-semantics.json") + except (OSError, NotImplementedError): + self.skipTest("symlinks not available") + resp = await self.client.request( + "GET", "/prompt_manager/js/inspection-semantics.json" + ) + self.assertEqual(resp.status, 403) + self.assertNotIn("secret", await resp.text()) + + async def test_inspection_semantics_missing_is_404(self): + self._fake_root() + resp = await self.client.request( + "GET", "/prompt_manager/js/inspection-semantics.json" + ) + self.assertEqual(resp.status, 404) + async def test_empty_path_is_not_served(self): for prefix in ("lib", "js"): resp = await self.client.request("GET", f"/prompt_manager/{prefix}/") diff --git a/tests/test_api_images.py b/tests/test_api_images.py index 18062bb..8c0361c 100644 --- a/tests/test_api_images.py +++ b/tests/test_api_images.py @@ -5,11 +5,13 @@ Uses aiohttp's test client against a real PromptManagerAPI wired to a temporary SQLite database and a temporary output directory. """ +import asyncio import json import os import pathlib import sys import tempfile +import threading import unittest from contextlib import ExitStack from pathlib import Path @@ -638,6 +640,93 @@ class TestGenerateThumbnailsPost(ThumbnailTestCase): self.assertEqual(data["total_images"], 0) +class TestThumbnailRoutesHoldTheRegistry(ThumbnailTestCase): + """Thumbnail work is ``guarded``: never beside a record-changing job.""" + + def _run_scan(self): + gate = asyncio.Event() + + async def runner(job): + await gate.wait() + + job = self.api._jobs.start( + "scan", "Scan for metadata", admission="records", scope={}, runner=runner + ) + + async def release(): + gate.set() + await asyncio.wait_for(job.task, 5) + + self.addAsyncCleanup(release) + return job + + async def test_thumbnail_generate_refused_while_scan_runs(self): + make_png(self.output_dir / "a.png") + self._run_scan() + + resp = await self.client.request( + "POST", "/prompt_manager/images/generate-thumbnails", json={} + ) + self.assertEqual(resp.status, 409) + self.assertEqual( + await resp.json(), + {"success": False, "error": "Scan for metadata already running"}, + ) + + resp = await self.client.request("POST", self.PROGRESS_URL) + text = await resp.text() + self.assertEqual(resp.status, 200) # the gallery reads an SSE stream + self.assertEqual( + text, + "event: error\ndata: " + + json.dumps( + { + "error": "Scan for metadata already running", + "message": "Scan for metadata already running", + } + ) + + "\n\n", + ) + + thumbs = self.output_dir / "thumbnails" + make_png(thumbs / "b_thumb.png") + resp = await self.client.request( + "POST", "/prompt_manager/images/clear-thumbnails" + ) + self.assertEqual(resp.status, 409) + self.assertFalse(self._thumb_path("a").exists()) + self.assertTrue((thumbs / "b_thumb.png").exists()) + + async def test_scan_refused_while_thumbnails_generate(self): + make_png(self.output_dir / "a.png") + entered = threading.Event() + release = threading.Event() + real = self.api._generate_gallery_thumbnails_sync + + def gated(*args): + entered.set() + release.wait(10) + return real(*args) + + self.api._generate_gallery_thumbnails_sync = gated + loop = asyncio.get_running_loop() + generate = asyncio.ensure_future( + self.client.request( + "POST", "/prompt_manager/images/generate-thumbnails", json={} + ) + ) + self.assertTrue(await loop.run_in_executor(None, entered.wait, 10)) + resp = await self.client.request("POST", "/prompt_manager/jobs/scan") + body = await resp.json() + self.assertEqual((resp.status, body["code"]), (409, "busy")) + self.assertEqual( + body["running"]["title"], "Thumbnail generation (gallery page)" + ) + release.set() + self.assertEqual((await generate).status, 200) + self.assertTrue(self._thumb_path("a").exists()) + + class TestRecentImagesCaps(ImageAPITestCase): """Every path through /images/recent hands the DB a bounded window.""" diff --git a/tests/test_api_lora.py b/tests/test_api_lora.py index 36b9955..810671b 100644 --- a/tests/test_api_lora.py +++ b/tests/test_api_lora.py @@ -4,11 +4,13 @@ Filesystem detection and network downloads are patched out so the tests run anywhere without a LoraManager install or a CivitAI connection. """ +import asyncio import json import os import shutil import sys import tempfile +import threading import types import unittest from pathlib import Path @@ -34,11 +36,20 @@ from aiohttp.test_utils import AioHTTPTestCase from database.operations import PromptDatabase from py.api import PromptManagerAPI -from py import lora_utils +from py import config_store, lora_utils from py.config import IntegrationConfig, PromptManagerConfig +from py.api import settings_routes as settings_module +from py.api import lora_integration as lora_module from py.lora_utils import get_trigger_cache from utils.hashing import generate_prompt_hash +try: + from tests.lock_probe import ProbeLock + from tests.open_handles import assert_closed +except ImportError: # discovered with tests/ as the top-level directory + from lock_probe import ProbeLock + from open_handles import assert_closed + SECRET_KEY = "sk-civitai-super-secret-0123456789" @@ -56,7 +67,13 @@ class LoraAPITestCase(AioHTTPTestCase): return_value=[self.custom_nodes], ) self._custom_nodes_patch.start() - self._real_save = PromptManagerConfig.save_to_file + # Never the repository's config.json (live user data): every save in + # these tests goes to a file in this test's temp directory. + self.config_file = self.tmp_root / "config.json" + self._env_patch = patch.dict( + os.environ, {"PROMPT_MANAGER_CONFIG_PATH": str(self.config_file)} + ) + self._env_patch.start() self._config_patch = patch.multiple( IntegrationConfig, @@ -70,8 +87,6 @@ class LoraAPITestCase(AioHTTPTestCase): "py.lora_utils.detect_lora_manager", return_value=None ) self.detect_mock = self._detect_patch.start() - self._save_patch = patch.object(PromptManagerConfig, "save_to_file") - self.save_mock = self._save_patch.start() get_trigger_cache().clear() app = web.Application() @@ -83,7 +98,7 @@ class LoraAPITestCase(AioHTTPTestCase): async def tearDownAsync(self): await super().tearDownAsync() # closes the aiohttp test client - self._save_patch.stop() + self._env_patch.stop() self._detect_patch.stop() self._config_patch.stop() self._custom_nodes_patch.stop() @@ -96,8 +111,19 @@ class LoraAPITestCase(AioHTTPTestCase): ): if os.path.exists(path): os.unlink(path) + assert_closed(self, self.tmp_root) shutil.rmtree(self.tmp_root, ignore_errors=True) + def _saved(self): + return json.loads(self.config_file.read_text()) + + def _make_unwritable(self): + """Point the config path under a regular file, so no write can succeed.""" + blocker = self.tmp_root / "not-a-dir" + blocker.write_text("x") + self.config_file = blocker / "config.json" + os.environ["PROMPT_MANAGER_CONFIG_PATH"] = str(self.config_file) + async def _post_json(self, url, body): return await self.client.request( "POST", @@ -250,7 +276,7 @@ class TestLoraEnableRoute(LoraAPITestCase): ) self.assertEqual(resp.status, 400) self.assertFalse(IntegrationConfig.LORA_MANAGER_ENABLED) - self.save_mock.assert_not_called() + self.assertFalse(self.config_file.exists()) async def test_enable_persists_and_loads_trigger_cache(self): lm = _make_lora_manager(self.custom_nodes) @@ -285,7 +311,15 @@ class TestLoraEnableRoute(LoraAPITestCase): os.path.normcase(os.path.realpath(str(lm))), ) self.assertEqual(IntegrationConfig.CIVITAI_API_KEY, SECRET_KEY) - self.save_mock.assert_called_once_with() + saved = self._saved()["integrations"]["lora_manager"] + self.assertEqual(saved["civitai_api_key"], SECRET_KEY) + self.assertEqual(saved["path"], IntegrationConfig.LORA_MANAGER_PATH) + self.assertIs(saved["enabled"], True) + self.assertIs(saved["trigger_words_enabled"], True) + self.assertEqual( + (data["persisted"], data["runtime_applied"], data["cache_loaded"]), + (True, True, True), + ) self.assertTrue(get_trigger_cache().is_loaded) self.assertEqual(get_trigger_cache().get_trigger_words("neon"), ["glow"]) @@ -319,7 +353,7 @@ class TestLoraEnableRoute(LoraAPITestCase): self.assertFalse(json.loads(body)["success"]) self.assertNotIn(str(self.tmp_root), body) self.assertFalse(IntegrationConfig.LORA_MANAGER_ENABLED) - self.save_mock.assert_not_called() + self.assertFalse(self.config_file.exists()) async def test_enable_with_custom_node_lacking_lora_in_its_name_is_400(self): lm = _make_lora_manager(self.custom_nodes).parent / "ComfyUI-Other" @@ -344,7 +378,6 @@ class TestLoraEnableRoute(LoraAPITestCase): async def test_enable_writes_the_configured_config_file(self): config_file = self.tmp_root / "cfg" / "config.json" - self.save_mock.side_effect = self._real_save with patch.dict(os.environ, {"PROMPT_MANAGER_CONFIG_PATH": str(config_file)}): resp = await self._post_json( @@ -353,7 +386,6 @@ class TestLoraEnableRoute(LoraAPITestCase): ) self.assertEqual(resp.status, 200) - self.save_mock.assert_called_once_with() self.assertTrue(config_file.is_file()) saved = json.loads(config_file.read_text())["integrations"]["lora_manager"] self.assertFalse(saved["enabled"]) @@ -414,9 +446,299 @@ class TestLoraEnableRoute(LoraAPITestCase): self.assertEqual(resp.status, 400) async def test_persist_failure_is_500(self): - self.save_mock.side_effect = OSError("read-only") - resp = await self._post_json("/prompt_manager/lora/enable", {"enabled": False}) + self._make_unwritable() + with self.assertLogs("prompt_manager.api", level="ERROR"): + resp = await self._post_json( + "/prompt_manager/lora/enable", {"enabled": False} + ) self.assertEqual(resp.status, 500) + self.assertEqual((await resp.json())["code"], "config-write-failed") + + async def test_enable_write_failure_reports_and_keeps_runtime(self): + # #220: a save that cannot be written publishes nothing and says so. + lm = _make_lora_manager(self.custom_nodes) + self.detect_mock.return_value = str(lm) + IntegrationConfig.CIVITAI_API_KEY = SECRET_KEY + self._make_unwritable() + + with self.assertLogs("prompt_manager.api", level="ERROR"): + resp = await self._post_json( + "/prompt_manager/lora/enable", + { + "enabled": True, + "path": str(lm), + "trigger_words_enabled": True, + "clear_civitai_api_key": True, + }, + ) + + text = await resp.text() + body = json.loads(text) + self.assertEqual(resp.status, 500) + self.assertEqual(body["code"], "config-write-failed") + self.assertEqual((body["persisted"], body["runtime_applied"]), (False, False)) + self.assertFalse(IntegrationConfig.LORA_MANAGER_ENABLED) + self.assertEqual(IntegrationConfig.LORA_MANAGER_PATH, "") + self.assertFalse(IntegrationConfig.LORA_TRIGGER_WORDS_ENABLED) + self.assertEqual(IntegrationConfig.CIVITAI_API_KEY, SECRET_KEY) + self.assertFalse(get_trigger_cache().is_loaded) + self.assertNotIn(SECRET_KEY, text) + self.assertNotIn(str(self.tmp_root), text) + + async def test_enable_preserves_unknown_sections(self): + stored = "stored-value" # a plain marker: what is on disk must stay + self.config_file.write_text( + json.dumps( + { + "custom_section": {"keep": True}, + "web_ui": {"result_timeout": 11}, + "integrations": { + "lora_manager": {"civitai_api_key": stored, "extra": 1}, + "other_tool": {"on": True}, + }, + } + ) + ) + IntegrationConfig.CIVITAI_API_KEY = stored + + resp = await self._post_json( + "/prompt_manager/lora/enable", + {"enabled": False, "trigger_words_enabled": True}, + ) + + self.assertEqual(resp.status, 200) + saved = self._saved() + self.assertEqual(saved["custom_section"], {"keep": True}) + self.assertEqual(saved["web_ui"], {"result_timeout": 11}) + self.assertEqual(saved["integrations"]["other_tool"], {"on": True}) + lora = saved["integrations"]["lora_manager"] + self.assertEqual(lora["extra"], 1) + self.assertEqual(lora["civitai_api_key"], stored) + self.assertIs(lora["trigger_words_enabled"], True) + self.assertNotIn("database", saved) # no runtime defaults written + + async def test_enable_refuses_a_corrupt_config_file(self): + self.config_file.write_bytes(b"{not json") + resp = await self._post_json( + "/prompt_manager/lora/enable", + {"enabled": False, "trigger_words_enabled": True}, + ) + body = await resp.json() + self.assertEqual((resp.status, body["code"]), (409, "config-unreadable")) + self.assertEqual(self.config_file.read_bytes(), b"{not json") + self.assertFalse(IntegrationConfig.LORA_TRIGGER_WORDS_ENABLED) + + async def test_enable_requires_boolean_flags_and_body_cap(self): + for body in ( + {"enabled": "yes"}, + {"enabled": False, "trigger_words_enabled": 1}, + {"enabled": False, "clear_civitai_api_key": "true"}, + ): + with self.subTest(body=body): + resp = await self._post_json("/prompt_manager/lora/enable", body) + self.assertEqual(resp.status, 400) + resp = await self._post_json("/prompt_manager/lora/enable", [True]) + self.assertEqual(resp.status, 400) + resp = await self._post_json( + "/prompt_manager/lora/enable", + {"enabled": False, "civitai_api_key": "k" * (2 * 1024 * 1024)}, + ) + self.assertEqual(resp.status, 413) + self.assertFalse(self.config_file.exists()) + self.assertFalse(IntegrationConfig.LORA_MANAGER_ENABLED) + + async def test_blank_key_unchanged_and_explicit_clear(self): + self.config_file.write_text( + json.dumps({"integrations": {"lora_manager": {"civitai_api_key": "old"}}}) + ) + IntegrationConfig.CIVITAI_API_KEY = "old" + + resp = await self._post_json( + "/prompt_manager/lora/enable", {"enabled": False, "civitai_api_key": " "} + ) + self.assertEqual(resp.status, 200) + self.assertEqual( + self._saved()["integrations"]["lora_manager"]["civitai_api_key"], "old" + ) + self.assertEqual(IntegrationConfig.CIVITAI_API_KEY, "old") + + resp = await self._post_json( + "/prompt_manager/lora/enable", + {"enabled": False, "clear_civitai_api_key": True}, + ) + self.assertEqual(resp.status, 200) + self.assertEqual( + self._saved()["integrations"]["lora_manager"]["civitai_api_key"], "" + ) + self.assertEqual(IntegrationConfig.CIVITAI_API_KEY, "") + + async def test_cache_load_failure_reports_persisted_but_unloaded(self): + lm = _make_lora_manager(self.custom_nodes) + self.detect_mock.return_value = str(lm) + + with ( + patch.object( + type(get_trigger_cache()), "load", side_effect=OSError("unreadable") + ), + self.assertLogs("prompt_manager.api", level="WARNING"), + ): + resp = await self._post_json( + "/prompt_manager/lora/enable", + {"enabled": True, "path": str(lm), "trigger_words_enabled": True}, + ) + + text = await resp.text() + body = json.loads(text) + self.assertEqual(resp.status, 200) + self.assertIs(body["success"], True) + self.assertEqual((body["persisted"], body["cache_loaded"]), (True, False)) + self.assertTrue(IntegrationConfig.LORA_MANAGER_ENABLED) + self.assertIs(self._saved()["integrations"]["lora_manager"]["enabled"], True) + self.assertNotIn("unreadable", text) + + +class TestConfigWritersShareOneStore(LoraAPITestCase): + + async def asyncSetUp(self): + await super().asyncSetUp() + self._orig_timeout = PromptManagerConfig.RESULT_TIMEOUT + + async def asyncTearDown(self): + PromptManagerConfig.RESULT_TIMEOUT = self._orig_timeout + await super().asyncTearDown() + + async def test_settings_and_lora_saves_do_not_overwrite_each_other(self): + # #220: the LoRA save is sent while the settings save is inside its + # commit (lock held, file already read). The LoRA commit re-reads the + # file under the same lock, so both edits survive and nothing else + # is lost (before, the LoRA save rewrote the whole runtime config). + self.config_file.write_text(json.dumps({"custom_section": {"keep": True}})) + inside = threading.Event() + release = threading.Event() + real_mutation = settings_module._settings_mutation + + def gated_mutation(updates, new_roots): + mutate = real_mutation(updates, new_roots) + + def gated(config): + inside.set() + release.wait(10) + return mutate(config) + + return gated + + lora_calls = [] + real_lora_mutation = lora_module._lora_settings_mutation + + def recorded_lora_mutation(values): + mutate = real_lora_mutation(values) + + def recorded(config): + lora_calls.append(dict(config)) + return mutate(config) + + return recorded + + probe = ProbeLock() + loop = asyncio.get_running_loop() + with ( + patch.object(settings_module, "_settings_mutation", gated_mutation), + patch.object( + lora_module, "_lora_settings_mutation", recorded_lora_mutation + ), + patch.object(config_store, "_COMMIT_LOCK", probe), + ): + settings = asyncio.ensure_future( + self._post_json("/prompt_manager/settings", {"result_timeout": 13}) + ) + self.assertTrue(await loop.run_in_executor(None, inside.wait, 10)) + lora = asyncio.ensure_future( + self._post_json( + "/prompt_manager/lora/enable", + {"enabled": False, "trigger_words_enabled": True}, + ) + ) + # Wait until the LoRA commit is blocked on the lock the settings + # commit holds; its mutation must not have run yet. + self.assertTrue(await loop.run_in_executor(None, probe.contended.wait, 10)) + self.assertEqual(lora_calls, []) + release.set() + settings_resp, lora_resp = await asyncio.gather(settings, lora) + self.assertEqual(len(lora_calls), 1) + self.assertEqual(lora_calls[0]["web_ui"]["result_timeout"], 13) + + self.assertEqual((settings_resp.status, lora_resp.status), (200, 200)) + saved = self._saved() + self.assertEqual(saved["custom_section"], {"keep": True}) + self.assertEqual(saved["web_ui"]["result_timeout"], 13) + self.assertIs( + saved["integrations"]["lora_manager"]["trigger_words_enabled"], True + ) + settings_body = await settings_resp.json() + lora_body = await lora_resp.json() + self.assertNotEqual(settings_body["revision"], lora_body["revision"]) + + def _delay_first_commit_result(self): + """Hold back the first config commit's result from its handler. + + The commit itself has run (the file is written); only the handler's + continuation waits until the returned event is set. Before #220's fix + round the handler published after this point, so a later save could + publish first and the earlier one overwrite it at runtime. + """ + original = self.api._run_in_executor + written = asyncio.Event() + resume = asyncio.Event() + delayed = [] + + async def run(func, *args, **kwargs): + result = await original(func, *args, **kwargs) + if getattr(func, "__func__", None) is config_store.ConfigStore.commit: + if not delayed: + delayed.append(func) + written.set() + await resume.wait() + return result + + patcher = patch.object(self.api, "_run_in_executor", run) + patcher.start() + self.addCleanup(patcher.stop) + return written, resume + + async def test_concurrent_settings_saves_end_on_the_last_written(self): + written, resume = self._delay_first_commit_result() + first = asyncio.ensure_future( + self._post_json("/prompt_manager/settings", {"result_timeout": 11}) + ) + await asyncio.wait_for(written.wait(), 10) + second = await self._post_json( + "/prompt_manager/settings", {"result_timeout": 22} + ) + resume.set() + first = await first + self.assertEqual((first.status, second.status), (200, 200)) + self.assertEqual(self._saved()["web_ui"]["result_timeout"], 22) + self.assertEqual(PromptManagerConfig.RESULT_TIMEOUT, 22) + + async def test_concurrent_lora_saves_end_on_the_last_written(self): + written, resume = self._delay_first_commit_result() + first = asyncio.ensure_future( + self._post_json( + "/prompt_manager/lora/enable", + {"enabled": False, "trigger_words_enabled": True}, + ) + ) + await asyncio.wait_for(written.wait(), 10) + second = await self._post_json( + "/prompt_manager/lora/enable", + {"enabled": False, "trigger_words_enabled": False}, + ) + resume.set() + first = await first + self.assertEqual((first.status, second.status), (200, 200)) + lora = self._saved()["integrations"]["lora_manager"] + self.assertIs(lora["trigger_words_enabled"], False) + self.assertIs(IntegrationConfig.LORA_TRIGGER_WORDS_ENABLED, False) class TestLoraScanRoute(LoraAPITestCase): @@ -445,6 +767,12 @@ class TestLoraScanRoute(LoraAPITestCase): body = await resp.text() self.assertNotIn(str(self.tmp_root), body) self.assertIn("x.db", body) + # The admin page's modal shows the failure as its status and closes. + error = self._sse_events(body)[-1] + self.assertEqual( + (error["type"], error["status"], error["progress"]), + ("error", error["message"], 0), + ) @staticmethod def _sse_events(text): diff --git a/tests/test_api_prompt_inspector.py b/tests/test_api_prompt_inspector.py index 4163b43..56c0523 100644 --- a/tests/test_api_prompt_inspector.py +++ b/tests/test_api_prompt_inspector.py @@ -1643,6 +1643,62 @@ class TestPromptInspector(ImageAPITestCase): str(path), False, report_ambiguity=True ) + async def test_unhashable_workflow_node_type_is_skipped(self): + """A UI workflow node whose ``type`` is a list or dict is skipped (#286). + + The R1 check looks each node's ``type`` up among subgraph IDs; an + unhashable ``type`` used to raise there and the route answered 500. + Such a node can be neither a subgraph instance nor a PromptManager + node, so the walk skips it and a prompt that resolves still resolves. + """ + source = graph() + source["1"]["class_type"] = "PromptManager" + for kind in (["PromptManager"], {"name": "PromptManager"}): + with self.subTest(kind=type(kind).__name__): + workflow = json.dumps( + {"nodes": [{"id": 1, "type": kind, "widgets_values": ["x"]}]} + ) + png( + self.output_dir / "image.png", + prompt=json.dumps(source), + workflow=workflow, + ) + response, result = await self.inspect() + self.assertEqual(response.status, 200) + self.assertEqual(result["resolution"]["state"], "resolved") + self.assertEqual(result["resolution"]["sampler_id"], "3") + self.assertEqual(result["prompts"]["positive"]["status"], "resolved") + self.assertEqual(result["prompts"]["positive"]["text"], "portrait") + self.assertEqual(result["prompts"]["negative"]["text"], "blurry") + self.assertNotIn("unhashable", json.dumps(result)) + + async def test_unhashable_graph_class_type_is_not_a_preview_output(self): + """A list or object ``class_type`` never names a preview output (#286). + + With several samplers the inspector first drops stages that reach only + previews; looking such a ``class_type`` up among the preview classes + used to raise, and the route answered 500. + """ + for kind in (["PreviewImage"], {"name": "PreviewImage"}): + with self.subTest(kind=type(kind).__name__): + source = graph() + source["9"] = { + "class_type": "KSampler", + "inputs": {"positive": ["8", 0], "negative": ["2", 0]}, + } + source["8"] = { + "class_type": "CLIPTextEncode", + "inputs": {"text": "cat"}, + } + source["10"] = {"class_type": kind, "inputs": {"images": ["9", 0]}} + png(self.output_dir / "image.png", prompt=json.dumps(source)) + response, result = await self.inspect() + self.assertEqual(response.status, 200) + self.assertEqual( + result["resolution"]["reason"], "Multiple sampler branches" + ) + self.assertEqual(result["resolution"]["state"], "ambiguous") + class TestInspectorImageTags(ImageAPITestCase): """B3: the inspector envelope's ``image_tags`` facet (spec amendment S1).""" diff --git a/tests/test_browser_slow_reset_watch.py b/tests/test_browser_slow_reset_watch.py new file mode 100644 index 0000000..631e7ca --- /dev/null +++ b/tests/test_browser_slow_reset_watch.py @@ -0,0 +1,234 @@ +"""A slow page-workflow reset reports where the server waited. + +CI run 39904288262 had one ``/__test__/reset`` wait 16 s with nothing in the +server's stderr. tests/browser/slow_reset_watch.py reports any reset past +SLOW_RESET_SECONDS with every thread's stack, taken while it still waits. +These tests hold a real SQLite write lock behind a real test server's reset. +""" + +import glob +import io +import os +import sqlite3 +import subprocess +import sys +import tempfile +import threading +import time +import unittest +import urllib.request +from pathlib import Path + +from tests.browser import slow_reset_watch +from tests.browser.slow_reset_watch import REPORT_PREFIX, ResetWatch, is_reset_path + +REPO_ROOT = Path(__file__).resolve().parent.parent +SERVER_TIMEOUT_S = 60 +HOLD_SECONDS = slow_reset_watch.SLOW_RESET_SECONDS + 4 +# The reset may take a little longer than the hold, never the 30 s busy timeout. +RESET_MARGIN_SECONDS = 10 +# While the reset waits, the server still answers other requests this fast. +# Reading the waiting connection from the report's thread froze it (the GIL is +# held while SQLite's connection mutex is awaited) until the lock was released. +RESPONSIVE_SECONDS = 2.0 +PROBE_INTERVAL_SECONDS = 0.1 + + +def blocked_here(gate: threading.Event) -> None: + gate.wait() + + +class TestResetWatch(unittest.TestCase): + def test_reset_paths_are_the_test_reset_routes(self): + self.assertTrue(is_reset_path("/__test__/reset")) + self.assertTrue(is_reset_path("/__test__/tagging/reset")) + self.assertFalse(is_reset_path("/prompt_manager/reset")) + self.assertFalse(is_reset_path("/__test__/reset/extra")) + + def test_a_stalled_reset_reports_state_and_stacks_while_it_waits(self): + stream = io.StringIO() + reported = threading.Event() + + class Stream(io.StringIO): + def flush(self): + reported.set() + + stream = Stream() + gate = threading.Event() + worker = threading.Thread(target=blocked_here, args=(gate,), name="held-up") + worker.start() + self.addCleanup(worker.join) + self.addCleanup(gate.set) + watch = ResetWatch( + "POST /__test__/reset", + lambda: ["database: 1 connections"], + threshold=0.05, + stream=stream, + ).start() + self.assertTrue(reported.wait(SERVER_TIMEOUT_S)) + report = stream.getvalue().splitlines() + self.assertTrue(all(line.startswith(REPORT_PREFIX) for line in report)) + text = "\n".join(report) + self.assertIn("POST /__test__/reset still running after", text) + self.assertIn("database: 1 connections", text) + self.assertIn(f"thread 0x{worker.ident:016x} is held-up", text) + self.assertIn("in blocked_here", text) + + watch.record_call("seed", 0.25, 1.5) + watch.finish("answered 200") + self.assertIn("POST /__test__/reset answered 200 after", stream.getvalue()) + self.assertIn( + "executor call seed: queued 0.250s, ran 1.500s", stream.getvalue() + ) + + def test_a_fast_reset_writes_nothing_and_stops_its_timer(self): + stream = io.StringIO() + watch = ResetWatch("reset", lambda: [], threshold=60, stream=stream).start() + watch.finish("answered 200") + self.assertTrue(watch._timer.finished.is_set()) + self.assertEqual(stream.getvalue(), "") + + def test_a_failing_state_probe_still_reports_the_stacks(self): + def broken(): + raise RuntimeError("no state") + + reported = threading.Event() + + class Stream(io.StringIO): + def flush(self): + reported.set() + + stream = Stream() + ResetWatch("reset", broken, threshold=0.01, stream=stream).start() + self.assertTrue(reported.wait(SERVER_TIMEOUT_S)) + self.assertIn("state unavailable: RuntimeError('no state')", stream.getvalue()) + self.assertIn("most recent call first", stream.getvalue()) + + +class TestSlowResetOnTheServer(unittest.TestCase): + """A reset behind another process's write lock, on tests/browser/server.py.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + env = {**os.environ, "TMPDIR": self.tmp.name} + env.update(TEMP=self.tmp.name, TMP=self.tmp.name) + self.stderr = tempfile.TemporaryFile() + self.addCleanup(self.stderr.close) + process = subprocess.Popen( + [sys.executable, "-m", "tests.browser.server", "--port", "0"], + cwd=REPO_ROOT, + env=env, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=self.stderr, + ) + + def stop(): + if process.poll() is None: + process.kill() + process.wait(timeout=SERVER_TIMEOUT_S) + for stream in (process.stdin, process.stdout): + stream.close() + + self.addCleanup(stop) + line = process.stdout.readline().decode() + self.assertTrue(line.startswith("PM_TEST_SERVER_PORT="), line) + self.base = f"http://127.0.0.1:{int(line.split('=', 1)[1])}" + deadline = time.monotonic() + SERVER_TIMEOUT_S + while True: + try: + with urllib.request.urlopen( # nosec B310 + self.base + "/prompt_manager/test", timeout=1 + ): + break + except OSError: + self.assertIsNone(process.poll(), "the server exited early") + self.assertLess(time.monotonic(), deadline, "never ready") + time.sleep(0.1) + [self.db_path] = glob.glob( + os.path.join(self.tmp.name, "pm-browser-*", "browser.db") + ) + + def reset(self) -> tuple[int, float]: + request = urllib.request.Request( + self.base + "/__test__/reset", + data=b"{}", + headers={"Content-Type": "application/json"}, + method="POST", + ) + start = time.monotonic() + with urllib.request.urlopen( # nosec B310 + request, timeout=SERVER_TIMEOUT_S + ) as response: + return response.status, time.monotonic() - start + + def server_stderr(self) -> str: + self.stderr.seek(0) + return self.stderr.read().decode("utf-8", "replace") + + def test_a_reset_held_behind_a_write_lock_names_the_wait_and_ends_with_it(self): + self.assertEqual(self.reset()[0], 200, self.server_stderr()) + self.assertNotIn(REPORT_PREFIX, self.server_stderr()) + + held = threading.Event() + + def hold_write_lock(): + holder = sqlite3.connect(self.db_path, timeout=SERVER_TIMEOUT_S) + try: + holder.execute("BEGIN IMMEDIATE") + held.set() + time.sleep(HOLD_SECONDS) + holder.rollback() + finally: + holder.close() + + lock = threading.Thread(target=hold_write_lock) + lock.start() + self.addCleanup(lock.join) + self.assertTrue(held.wait(SERVER_TIMEOUT_S)) + outcome = [] + resetting = threading.Thread(target=lambda: outcome.append(self.reset())) + resetting.start() + self.addCleanup(resetting.join) + slowest = 0.0 + while resetting.is_alive(): + start = time.monotonic() + with urllib.request.urlopen( # nosec B310 + self.base + "/prompt_manager/test", timeout=SERVER_TIMEOUT_S + ): + slowest = max(slowest, time.monotonic() - start) + time.sleep(PROBE_INTERVAL_SECONDS) + resetting.join() + lock.join() + self.assertEqual(len(outcome), 1, self.server_stderr()) + [(status, elapsed)] = outcome + + self.assertLess(slowest, RESPONSIVE_SECONDS) + self.assertEqual(status, 200) + self.assertGreaterEqual(elapsed, HOLD_SECONDS - 0.5) + self.assertLess(elapsed, HOLD_SECONDS + RESET_MARGIN_SECONDS) + report = [ + line[len(REPORT_PREFIX) :] + for line in self.server_stderr().splitlines() + if line.startswith(REPORT_PREFIX) + ] + text = "\n".join(report) + self.assertIn("POST /__test__/reset still running after", text) + self.assertIn("default executor:", text) + if sys.platform.startswith("linux"): + # SQLite's busy handler sleeps between its retries. + self.assertRegex( + text, r"kernel: asyncio_\d+ \(0x[0-9a-f]{16}\) state \w, waiting in " + ) + # The plain reset's seed() waits on its first DELETE. + self.assertRegex(text, r"line \d+ in seed\n") + self.assertIn("POST /__test__/reset answered 200 after", text) + self.assertRegex( + text, + r"executor call create_app.<locals>.seed: queued \d+\.\d+s, ran \d+\.\d+s", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_config_store.py b/tests/test_config_store.py new file mode 100644 index 0000000..5099dbb --- /dev/null +++ b/tests/test_config_store.py @@ -0,0 +1,419 @@ +"""Tests for the serialized config store (py/config_store.py, #220). + +Every test works on a config.json inside its own temporary directory; the +repository's config.json (live user data) is never read or written. +""" + +import hashlib +import json +import os +import shutil +import sys +import tempfile +import threading +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + +_mock_server = MagicMock() +_mock_server.PromptServer.instance.routes = MagicMock() +sys.modules.setdefault("server", _mock_server) + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from py import config_store # noqa: E402 +from py.config_store import ( # noqa: E402 + ABSENT_REVISION, + ConfigStore, + ConfigUnreadable, + RevisionConflict, + get_config_store, + revision_of, +) + +try: + from tests.lock_probe import ProbeLock # noqa: E402 + from tests.open_handles import assert_closed # noqa: E402 +except ImportError: # discovered with tests/ as the top-level directory + from lock_probe import ProbeLock # noqa: E402 + from open_handles import assert_closed # noqa: E402 + + +class ConfigStoreTests(unittest.TestCase): + def setUp(self): + self.tmpdir = os.path.realpath(tempfile.mkdtemp()) + self.path = os.path.join(self.tmpdir, "config.json") + self.store = ConfigStore(lambda: self.path) + + def tearDown(self): + try: + assert_closed(self, self.tmpdir) + finally: + shutil.rmtree(self.tmpdir, ignore_errors=True) + + def _write(self, data): + Path(self.path).write_text(json.dumps(data), encoding="utf-8") + + def _bytes(self): + return Path(self.path).read_bytes() + + def _leftovers(self): + return sorted(name for name in os.listdir(self.tmpdir) if name != "config.json") + + # ── read ────────────────────────────────────────────────────────── + + def test_read_of_a_missing_file_is_empty_and_absent(self): + self.assertEqual(self.store.read(), ({}, ABSENT_REVISION)) + + def test_read_reports_the_sha256_of_the_file_bytes(self): + self._write({"web_ui": {"result_timeout": 3}}) + data, revision = self.store.read() + self.assertEqual(data, {"web_ui": {"result_timeout": 3}}) + expected = "sha256:" + hashlib.sha256(self._bytes()).hexdigest() + self.assertEqual(revision, expected) + self.assertEqual(revision_of(self._bytes()), expected) + + def test_non_object_config_is_unreadable(self): + self._write([1, 2]) + with self.assertRaises(ConfigUnreadable): + self.store.read() + + def test_unreadable_path_is_unreadable(self): + os.mkdir(self.path) # a directory where the file should be + with self.assertRaises(ConfigUnreadable): + self.store.read() + + # ── commit ──────────────────────────────────────────────────────── + + def test_commit_preserves_unknown_sections_and_secret(self): + self._write( + { + "custom_section": {"keep": True}, + "integrations": {"lora_manager": {"civitai_api_key": "secret"}}, + "web_ui": {"result_timeout": 1, "show_test_button": True}, + } + ) + + def mutate(config): + config.setdefault("web_ui", {})["result_timeout"] = 7 + return config + + saved, revision = self.store.commit(mutate, expected_revision=None) + + on_disk = json.loads(self._bytes()) + self.assertEqual(saved, on_disk) + self.assertEqual(on_disk["custom_section"], {"keep": True}) + self.assertEqual( + on_disk["integrations"]["lora_manager"]["civitai_api_key"], "secret" + ) + self.assertEqual( + on_disk["web_ui"], {"result_timeout": 7, "show_test_button": True} + ) + self.assertEqual(revision, revision_of(self._bytes())) + self.assertEqual(self.store.read(), (on_disk, revision)) + + def test_commit_creates_a_missing_file_and_its_parent(self): + self.path = os.path.join(self.tmpdir, "nested", "config.json") + saved, revision = self.store.commit( + lambda config: {**config, "a": 1}, expected_revision=ABSENT_REVISION + ) + self.assertEqual(saved, {"a": 1}) + self.assertEqual(json.loads(Path(self.path).read_text()), {"a": 1}) + self.assertNotEqual(revision, ABSENT_REVISION) + + def test_mutate_works_on_a_copy(self): + self._write({"web_ui": {"result_timeout": 1}}) + seen = {} + + def mutate(config): + seen["config"] = config + config["web_ui"]["result_timeout"] = 2 + raise RuntimeError("validation failed late") + + before = self._bytes() + with self.assertRaises(RuntimeError): + self.store.commit(mutate, expected_revision=None) + self.assertEqual(self._bytes(), before) + self.assertEqual(self.store.read()[0], {"web_ui": {"result_timeout": 1}}) + + def test_mutate_must_return_an_object(self): + self._write({"a": 1}) + before = self._bytes() + with self.assertRaises(TypeError): + self.store.commit(lambda config: None, expected_revision=None) + self.assertEqual(self._bytes(), before) + + def test_malformed_existing_config_is_refused_and_untouched(self): + Path(self.path).write_bytes(b"{not json") + calls = [] + with self.assertRaises(ConfigUnreadable): + self.store.commit( + lambda config: calls.append(config) or config, expected_revision=None + ) + self.assertEqual(calls, []) + self.assertEqual(self._bytes(), b"{not json") + self.assertEqual(self._leftovers(), []) + + def test_write_failure_leaves_bytes_and_raises(self): + self._write({"a": 1}) + before = self._bytes() + with patch.object(config_store.os, "fsync", side_effect=OSError("disk full")): + with self.assertRaises(OSError): + self.store.commit(lambda c: {**c, "b": 2}, expected_revision=None) + self.assertEqual(self._bytes(), before) + self.assertEqual(self._leftovers(), []) + + def test_unwritable_directory_raises_oserror(self): + blocker = os.path.join(self.tmpdir, "afile") + Path(blocker).write_text("x") + self.path = os.path.join(blocker, "config.json") # parent is a file + with self.assertRaises(OSError): + self.store.commit(lambda c: {"a": 1}, expected_revision=None) + + def test_stale_revision_conflicts_without_write(self): + self._write({"a": 1}) + _data, revision = self.store.read() + self._write({"a": 2}) # another writer got there first + before = self._bytes() + calls = [] + with self.assertRaises(RevisionConflict) as caught: + self.store.commit( + lambda c: calls.append(c) or c, expected_revision=revision + ) + self.assertEqual(caught.exception.current, revision_of(before)) + self.assertEqual(calls, []) + self.assertEqual(self._bytes(), before) + + def test_matching_revision_commits(self): + self._write({"a": 1}) + _data, revision = self.store.read() + saved, _ = self.store.commit( + lambda c: {**c, "b": 2}, expected_revision=revision + ) + self.assertEqual(saved, {"a": 1, "b": 2}) + + def test_temp_file_removed_after_replace_failure(self): + self._write({"a": 1}) + before = self._bytes() + with patch.object( + config_store.os, "replace", side_effect=PermissionError("locked") + ): + with self.assertRaises(OSError): + self.store.commit(lambda c: {**c, "b": 2}, expected_revision=None) + self.assertEqual(self._bytes(), before) + self.assertEqual(self._leftovers(), []) + + @unittest.skipUnless(os.name == "posix", "POSIX file modes") + def test_committed_file_is_private_to_the_user(self): + import stat + + self._write({"a": 1}) + os.chmod(self.path, 0o644) + self.store.commit(lambda c: c, expected_revision=None) + self.assertEqual(stat.S_IMODE(os.stat(self.path).st_mode), 0o600) + + def _probe_lock(self): + probe = ProbeLock() + patcher = patch.object(config_store, "_COMMIT_LOCK", probe) + patcher.start() + self.addCleanup(patcher.stop) + return probe + + def _hold_lock_in_a_commit(self, value="first"): + """Start a commit that holds the lock until the returned event is set.""" + inside = threading.Event() + release = threading.Event() + errors = [] + + def slow(config): + inside.set() + release.wait(10) + config[value] = 1 + return config + + def run(): + try: + self.store.commit(slow, expected_revision=None) + except Exception as exc: # pragma: no cover - reported by the test + errors.append(exc) + + thread = threading.Thread(target=run) + thread.start() + self.assertTrue(inside.wait(10)) + return thread, release, errors + + def test_concurrent_commits_serialize(self): + # The second commit is observed blocked on the lock before the first + # one may finish, so this fails whenever commit stops taking the lock. + self._write({"keep": True}) + probe = self._probe_lock() + first, release, errors = self._hold_lock_in_a_commit() + calls = [] + + def fast(config): + calls.append(dict(config)) + config["second"] = 2 + return config + + second = threading.Thread( + target=lambda: self.store.commit(fast, expected_revision=None) + ) + second.start() + self.assertTrue(probe.contended.wait(10), "second commit never waited") + self.assertEqual(calls, []) # not called while the first holds the lock + release.set() + first.join(10) + second.join(10) + + self.assertEqual(errors, []) + self.assertEqual(calls, [{"keep": True, "first": 1}]) # re-read inside + self.assertEqual( + json.loads(self._bytes()), {"keep": True, "first": 1, "second": 2} + ) + + def test_read_waits_for_a_running_commit(self): + # Windows cannot replace a file another handle has open, so a read + # runs under the commit lock and never overlaps a replace. + self._write({"keep": True}) + probe = self._probe_lock() + first, release, errors = self._hold_lock_in_a_commit() + seen = [] + reader = threading.Thread(target=lambda: seen.append(self.store.read())) + reader.start() + self.assertTrue(probe.contended.wait(10), "read did not take the lock") + self.assertEqual(seen, []) + release.set() + first.join(10) + reader.join(10) + self.assertEqual(errors, []) + self.assertEqual(seen[0][0], {"keep": True, "first": 1}) + self.assertEqual(seen[0][1], revision_of(self._bytes())) + + def test_replace_is_retried_after_a_transient_permission_error(self): + # Windows: an antivirus or indexer handle makes os.replace fail with + # PermissionError for a moment; the commit retries before failing. + self._write({"a": 1}) + real_replace = os.replace + attempts = [] + + def flaky(src, dst): + attempts.append(dst) + if len(attempts) == 1: + raise PermissionError("in use by another process") + return real_replace(src, dst) + + with ( + patch.object(config_store, "_REPLACE_ATTEMPTS", 5), + patch.object(config_store.os, "replace", side_effect=flaky), + patch.object(config_store.time, "sleep") as sleep, + ): + saved, _ = self.store.commit( + lambda c: {**c, "b": 2}, expected_revision=None + ) + self.assertEqual(saved, {"a": 1, "b": 2}) + self.assertEqual(json.loads(self._bytes()), {"a": 1, "b": 2}) + self.assertEqual(len(attempts), 2) + sleep.assert_called_once_with(config_store._REPLACE_BACKOFF_SECONDS) + self.assertEqual(self._leftovers(), []) + + def test_replace_gives_up_after_the_last_attempt(self): + self._write({"a": 1}) + before = self._bytes() + with ( + patch.object(config_store, "_REPLACE_ATTEMPTS", 3), + patch.object( + config_store.os, "replace", side_effect=PermissionError("locked") + ) as replace, + patch.object(config_store.time, "sleep") as sleep, + ): + with self.assertRaises(PermissionError): + self.store.commit(lambda c: {**c, "b": 2}, expected_revision=None) + self.assertEqual((replace.call_count, sleep.call_count), (3, 2)) + self.assertEqual(self._bytes(), before) + self.assertEqual(self._leftovers(), []) + + def test_replace_is_tried_once_outside_windows(self): + expected = 5 if os.name == "nt" else 1 + self.assertEqual(config_store._REPLACE_ATTEMPTS, expected) + + @unittest.skipUnless(hasattr(os, "symlink"), "needs symlinks") + def test_symlinked_config_is_written_through_the_link(self): + # A config.json that is a symlink (or a single-file bind mount) is + # written beside its real target, so the link stays a link. + real_dir = os.path.join(self.tmpdir, "real") + os.mkdir(real_dir) + target = os.path.join(real_dir, "settings.json") + Path(target).write_text(json.dumps({"a": 1}), encoding="utf-8") + try: + os.symlink(target, self.path) + except (OSError, NotImplementedError): # pragma: no cover - Windows + self.skipTest("symlinks are not permitted here") + + saved, revision = self.store.commit( + lambda c: {**c, "b": 2}, expected_revision=None + ) + + self.assertTrue(os.path.islink(self.path)) + self.assertEqual(json.loads(Path(target).read_text()), {"a": 1, "b": 2}) + self.assertEqual(self.store.read(), (saved, revision)) + self.assertEqual(os.listdir(real_dir), ["settings.json"]) + + def test_publish_runs_under_the_lock_after_the_write(self): + # Runtimes publish inside the commit, so two saves publish in the + # order they were written and the runtime ends on the last one. + self._write({"a": 1}) + seen = [] + + # The lock is re-entrant, so another thread probes whether it is held. + def probe_publish(config): + result = [] + thread = threading.Thread( + target=lambda: result.append( + config_store._COMMIT_LOCK.acquire(blocking=False) + ) + ) + thread.start() + thread.join(10) + seen.append((config, json.loads(self._bytes()), result[0])) + + saved, _ = self.store.commit( + lambda c: {**c, "b": 2}, expected_revision=None, publish=probe_publish + ) + self.assertEqual(seen, [(saved, {"a": 1, "b": 2}, False)]) + + def test_publish_is_skipped_when_the_write_fails(self): + self._write({"a": 1}) + published = [] + with patch.object(config_store.os, "fsync", side_effect=OSError("disk full")): + with self.assertRaises(OSError): + self.store.commit( + lambda c: {**c, "b": 2}, + expected_revision=None, + publish=published.append, + ) + self.assertEqual(published, []) + + def test_handles_closed_after_commit(self): + self._write({"a": 1}) + self.store.read() + self.store.commit(lambda c: {**c, "b": 2}, expected_revision=None) + assert_closed(self, self.tmpdir) + os.unlink(self.path) # Windows refuses this while a handle is open + self.assertEqual(self._leftovers(), []) + + +class SingletonTests(unittest.TestCase): + def test_get_config_store_is_one_process_store_on_the_configured_path(self): + store = get_config_store() + self.assertIs(store, get_config_store()) + tmpdir = tempfile.mkdtemp() + try: + custom = os.path.join(tmpdir, "c.json") + with patch.dict(os.environ, {"PROMPT_MANAGER_CONFIG_PATH": custom}): + self.assertEqual(store.path, custom) + finally: + shutil.rmtree(tmpdir, ignore_errors=True) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_execution_output_writer.py b/tests/test_execution_output_writer.py index 5e5c340..c91a49e 100644 --- a/tests/test_execution_output_writer.py +++ b/tests/test_execution_output_writer.py @@ -299,45 +299,126 @@ class OutputWriterTests(fixtures.unittest.TestCase): ) self.assertEqual(writer.context_budget.charged_bytes, 0) + def isolated_writer(self) -> HistoryWriter: + """A ready writer whose budget no other writer or test can charge.""" + writer = HistoryWriter( + lambda: self.db, clock=self.clock, context_budget=ContextBudget() + ) + self.writers.append(writer) + writer.start() + self.wait_for(lambda: writer.status().accepting) + return writer + + def rotate_between_transactions(self, writer, start, after, restore, hold=None): + """Wrap ``after`` so a restore or invalidation lands right after it. + + Returns the patch and an event set once that rotation has returned on + the worker. A restore is a full verify/migrate/backup/replace cycle, so + the handoff waits on this event with the generous ``BLOCK_SECONDS`` + ceiling instead of the short worker-behavior deadline (TESTING.md, + Windows portability preflight). + """ + backup = os.path.join(self.directory.name, f"restore-{start.run_id}.db") + self.assertTrue(self.db.model.backup_database(backup)) + original = getattr(self.db, after) + rotated = threading.Event() + + def write(*args, **kwargs): + result = original(*args, **kwargs) + try: + if hold is not None: + entered, release = hold + entered.set() + release.wait(BLOCK_SECONDS) + if restore: + self.db.model.restore_from_file(backup) + else: + writer.invalidate() + finally: + rotated.set() + return result + + return patch.object(self.db, after, write), rotated + def test_restore_or_generation_rotation_between_transactions_discards_evidence( self, ): for after in ("write_execution_finish", "write_execution_contexts_for_finish"): for restore in (False, True): with self.subTest(after=after, restore=restore): - writer = self.writer() - start = self.start_command(len(self.writers)) - self.db.write_execution_start(start) - backup = os.path.join(self.directory.name, "restore.db") - self.assertTrue(self.db.model.backup_database(backup)) - original = getattr(self.db, after) + # Each subtest owns its writer and budget and retires them + # before the next starts, so one slow restore cannot leave + # in-flight charges or an epoch rotation for the next. + writer = self.isolated_writer() + try: + start = self.start_command(len(self.writers)) + self.db.write_execution_start(start) + rotation, rotated = self.rotate_between_transactions( + writer, start, after, restore + ) + with rotation: + self.finalization(writer, start) + self.assertTrue(rotated.wait(BLOCK_SECONDS)) + self.wait_for(lambda: writer.status().pending == 0) + self.assertEqual( + self.db.get_execution_outputs(start.run_id)["state"], + "not_captured", + ) + self.assertEqual( + self.db.get_execution_contexts(start.run_id)["state"], + ( + "complete" + if after == "write_execution_contexts_for_finish" + and not restore + else "not_captured" + ), + ) + self.assertEqual(writer.context_budget.charged_bytes, 0) + finally: + self.assertTrue(writer.close(BLOCK_SECONDS)) - def write(*args, **kwargs): - result = original(*args, **kwargs) - if restore: - self.db.model.restore_from_file(backup) - else: - writer.invalidate() - return result - - with patch.object(self.db, after, write): - self.finalization(writer, start) - self.wait_for(lambda: writer.status().pending == 0) - self.assertEqual( - self.db.get_execution_outputs(start.run_id)["state"], - "not_captured", - ) - self.assertEqual( - self.db.get_execution_contexts(start.run_id)["state"], - ( - "complete" - if after == "write_execution_contexts_for_finish" - and not restore - else "not_captured" - ), - ) - self.assertEqual(writer.context_budget.charged_bytes, 0) - self.assertTrue(writer.close()) + def test_slow_restore_between_transactions_holds_charge_only_while_in_flight( + self, + ): + writer = self.isolated_writer() + start = self.start_command() + self.db.write_execution_start(start) + entered, release = self.block() + rotation, rotated = self.rotate_between_transactions( + writer, start, "write_execution_finish", True, (entered, release) + ) + with rotation: + context = self.sealed(writer) + outputs = self.outputs(writer, context.lease.byte_count) + charge = writer.context_budget.charged_bytes + self.assertGreater(charge, 0) + self.assertTrue( + writer.submit_finalization(self.finish_command(start), context, outputs) + ) + self.assertTrue(entered.wait(BLOCK_SECONDS)) + # The worker is between transactions: the payload is owned, not leaked. + self.assertEqual(writer.status().pending, 1) + self.assertEqual(writer.context_budget.charged_bytes, charge) + release.set() + self.assertTrue(rotated.wait(BLOCK_SECONDS)) + self.wait_for(lambda: writer.status().pending == 0) + self.assertEqual(writer.context_budget.charged_bytes, 0) + self.assertFalse(writer.status().accepting) + self.assertEqual( + self.db.get_execution_outputs(start.run_id)["state"], "not_captured" + ) + # The restored epoch is probed again and new evidence is accepted. + self.clock.advance(5) + self.wait_for(lambda: writer.status().accepting) + restored = self.start_command(2) + self.db.write_execution_start(restored) + self.finalization(writer, restored) + self.wait_for(lambda: writer.status().pending == 0) + self.assertEqual( + self.db.get_execution_outputs(restored.run_id)["state"], "complete" + ) + self.assertEqual(writer.context_budget.charged_bytes, 0) + self.assertTrue(writer.close(BLOCK_SECONDS)) def test_invalidation_during_output_preparation_rechecks_actual_commit(self): import database.execution_outputs as outputs_db diff --git a/tests/test_image_tag_jobs.py b/tests/test_image_tag_jobs.py new file mode 100644 index 0000000..8964b07 --- /dev/null +++ b/tests/test_image_tag_jobs.py @@ -0,0 +1,804 @@ +"""Bulk image tagging as an Activity job: Add tags and Auto-tag (#249 D1). + +``POST /prompt_manager/jobs/image-tags`` starts one record-changing +``image-tags`` job over 1..500 listed images. Add tags unions a fixed set +into each file's byte record; Auto-tag runs one legacy engine on a private +capture of each still image (under the shared model gate, never +downloading) and unions its top N suggestions. No real model is loaded: the +engine is B2's fake service. +""" + +import asyncio +import hashlib +import json +import sqlite3 +import threading +import unittest +from unittest.mock import patch + +try: + from test_api_annotation_suggest import SCORED, WD14, FakeTagger, SuggestTestCase + from test_api_images import make_png +except ImportError: + from tests.test_api_annotation_suggest import ( + SCORED, + WD14, + FakeTagger, + SuggestTestCase, + ) + from tests.test_api_images import make_png + +from database.execution_history_schema import rotate_history_epoch +from py import model_gate +from py.api import jobs as jobs_module +from py.config import AutotagConfig +from utils.media_capture import CaptureCapacity + +START = "/prompt_manager/jobs/image-tags" +WAIT = 30.0 +KIND = getattr(jobs_module, "IMAGE_TAG_JOB_KIND", "image-tags") +GROUPS = ("tagged", "unchanged", "skipped", "errors") + + +class HookedTagger(FakeTagger): + """The B2 fake plus a hook that runs inside each inference.""" + + def __init__(self): + super().__init__() + self.on_generate = None + + def generate_tag_suggestions(self, image_path, **kwargs): + result = super().generate_tag_suggestions(image_path, **kwargs) + if self.on_generate is not None: + self.on_generate(image_path) + return result + + +class ImageTagJobTestCase(SuggestTestCase): + async def get_application(self): + app = await super().get_application() + self.tagger = HookedTagger() + patcher = patch("py.autotag.get_autotag_service", return_value=self.tagger) + patcher.start() + self.addCleanup(patcher.stop) + return app + + async def tearDownAsync(self): + # Every job of the test has ended before the app goes away. + for job in self.api._jobs.list(): + if job.task is not None and not job.task.done(): + job.task.cancel() + await asyncio.gather(job.task, return_exceptions=True) + await super().tearDownAsync() + + # -- helpers --------------------------------------------------------- + + def png(self, path, size=(8, 6), color=(200, 120, 40)): + make_png(self.output_dir / path, size=size, color=color) + return self.output_dir / path + + def digest(self, path): + return hashlib.sha256((self.output_dir / path).read_bytes()).hexdigest() + + def stored(self, path): + return self.api.db.read_image_tags(self.locator(path), self.digest(path)) + + def add_body(self, paths, tags, **changes): + return { + "mode": "add", + "targets": [self.target(p) for p in paths], + "tags": list(tags), + **changes, + } + + def auto_body(self, paths, model_type="wd14-vit", options=None, **changes): + return { + "mode": "auto", + "targets": [self.target(p) for p in paths], + "model_type": model_type, + "options": dict(WD14) if options is None else options, + **changes, + } + + async def start(self, body): + response = await self.client.post(START, json=body) + return response.status, await self.json_of(response) + + async def start_ok(self, body): + status, data = await self.start(body) + self.assertEqual(status, 202, data) + return self.api._jobs.get(data["job"]["id"]) + + async def run_job(self, body): + job = await self.start_ok(body) + await asyncio.wait_for(asyncio.shield(job.task), WAIT) + return job + + def rows(self, job, group): + return [(row["path"], row["detail"]) for row in job.outcomes[group]] + + async def wait_for(self, event): + ok = await asyncio.get_running_loop().run_in_executor(None, event.wait, WAIT) + self.assertTrue(ok, "the fake engine was never entered") + + +class TestAddTags(ImageTagJobTestCase): + async def test_add_job_unions_tags_on_each_target_and_records_outcomes(self): + for name in ("a.png", "b.png", "c.png"): + self.png(name, color=(len(name), ord(name[0]), 9)) + self.saved_set("a.png", (self.output_dir / "a.png").read_bytes(), ["keep_me"]) + self.saved_set( + "c.png", (self.output_dir / "c.png").read_bytes(), ["Sandstone", "desert"] + ) + + job = await self.run_job( + self.add_body( + ["a.png", "b.png", "c.png", "gone.png"], ["sandstone", "desert"] + ) + ) + + self.assertEqual(job.kind, KIND) + self.assertEqual(job.title, "Add tags to images") + self.assertEqual(job.state, "finished") + self.assertTrue(job.record_changing) + self.assertEqual(job.scope, {"mode": "add", "images": 4}) + self.assertEqual( + job.counters, {"tagged": 2, "unchanged": 1, "skipped": 1, "errors": 0} + ) + self.assertEqual( + self.stored("a.png")["tags"], ["keep_me", "sandstone", "desert"] + ) + self.assertEqual(self.stored("b.png")["tags"], ["sandstone", "desert"]) + # The stored spelling wins; nothing new means no write. + self.assertEqual(self.stored("c.png")["tags"], ["Sandstone", "desert"]) + self.assertEqual(self.stored("c.png")["tag_revision"], 1) + self.assertEqual( + self.rows(job, "tagged"), + [("a.png", "Added 2 tags"), ("b.png", "Added 2 tags")], + ) + self.assertEqual(self.rows(job, "unchanged"), [("c.png", "No new tags")]) + self.assertEqual(self.rows(job, "skipped"), [("gone.png", "File not found")]) + self.assertEqual(job.outcomes["tagged"][0]["root_index"], 0) + self.assertEqual((job.processed, job.total, job.progress), (4, 4, 100)) + self.assertTrue(any("+ " in line and "a.png" in line for line in job.log)) + self.assertTrue(job.log[-1].endswith("Add tags finished · 4 files processed")) + # Add tags never needs the model. + self.assertEqual(self.tagger.load_calls, []) + self.assertIsNone(model_gate.holder()) + + async def test_add_tags_vocabulary_names_become_global_tags(self): + self.png("a.png") + + await self.run_job(self.add_body(["a.png"], ["brand_new_tag"])) + + names = set(self.api.db.get_all_tags()) + self.assertIn("brand_new_tag", names) + + async def test_add_over_the_tag_cap_is_an_error_row_with_zero_writes(self): + self.png("a.png") + full = [f"t{i:03d}" for i in range(100)] + self.saved_set("a.png", (self.output_dir / "a.png").read_bytes(), full) + + job = await self.run_job(self.add_body(["a.png"], ["one_more"])) + + self.assertEqual(job.counters["errors"], 1) + self.assertEqual( + self.rows(job, "errors"), + [("a.png", "An image can carry at most 100 tags.")], + ) + self.assertEqual(self.stored("a.png")["tags"], full) + + async def test_video_skipped_in_auto_and_tagged_in_add(self): + (self.output_dir / "clip.mp4").write_bytes(b"\x00\x00\x00\x18ftypmp42" * 8) + + added = await self.run_job(self.add_body(["clip.mp4"], ["motion"])) + self.assertEqual(self.rows(added, "tagged"), [("clip.mp4", "Added 1 tag")]) + self.assertEqual(self.stored("clip.mp4")["tags"], ["motion"]) + + auto = await self.run_job(self.auto_body(["clip.mp4"])) + self.assertEqual( + self.rows(auto, "skipped"), [("clip.mp4", "Video: manual tags only")] + ) + self.assertEqual(self.tagger.calls, []) + self.assertEqual(self.stored("clip.mp4")["tags"], ["motion"]) + + async def test_not_a_still_image_and_too_large_are_skipped(self): + (self.output_dir / "broken.png").write_bytes(b"not an image") + self.png("big.png") + self.api._capture_store().max_file = 16 + + auto = await self.run_job(self.auto_body(["big.png"])) + self.assertEqual( + self.rows(auto, "skipped"), [("big.png", "Too large to verify")] + ) + + self.api._capture_store().max_file = 2**20 + auto = await self.run_job(self.auto_body(["broken.png"])) + self.assertEqual( + self.rows(auto, "skipped"), [("broken.png", "Not a supported still image")] + ) + + self.api._capture_store().max_file = 16 + added = await self.run_job(self.add_body(["big.png"], ["x"])) + self.assertEqual( + self.rows(added, "skipped"), [("big.png", "Too large to verify")] + ) + self.assertEqual(self.tagger.calls, []) + + +class TestAutoTag(ImageTagJobTestCase): + async def test_auto_job_applies_the_requested_count_per_file_without_removing_existing( # noqa: E501 + self, + ): + self.png("a.png") + self.png("b.png", color=(1, 2, 3)) + self.saved_set("a.png", (self.output_dir / "a.png").read_bytes(), ["keep_me"]) + options = {**WD14, "suggestion_limit": 2} + + job = await self.run_job(self.auto_body(["a.png", "b.png"], options=options)) + + self.assertEqual(job.title, "Auto-tag images") + self.assertEqual( + job.scope, {"mode": "auto", "images": 2, "model_type": "wd14-vit"} + ) + self.assertEqual(job.state, "finished") + self.assertEqual( + self.api.db.read_image_tags(self.locator("a.png"), self.digest("a.png"))[ + "tags" + ], + ["keep_me", "sandstone", "desert"], + ) + self.assertEqual(self.stored("b.png")["tags"], ["sandstone", "desert"]) + self.assertEqual( + job.counters, {"tagged": 2, "unchanged": 0, "skipped": 0, "errors": 0} + ) + # Inference read the private captures only, never the current files. + for path in self.tagger.read_paths: + self.assertNotIn(str(self.output_dir), path) + self.assertEqual([call["limit"] for call in self.tagger.calls], [2, 2]) + + # limit 3: at most three names are added to a file. + self.png("c.png", color=(9, 9, 9)) + options = {**WD14, "suggestion_limit": 3} + job = await self.run_job(self.auto_body(["c.png"], options=options)) + self.assertEqual(self.stored("c.png")["tags"], [name for name, _ in SCORED[:3]]) + self.assertEqual(self.rows(job, "tagged"), [("c.png", "Added 3 tags")]) + + # Omitted: the configured default. + AutotagConfig.SUGGESTION_LIMIT = 4 + self.png("d.png", color=(7, 7, 7)) + await self.run_job(self.auto_body(["d.png"])) + self.assertEqual(self.tagger.calls[-1]["limit"], 4) + self.assertEqual(len(self.stored("d.png")["tags"]), 4) + + # Suggestions the file already carries add nothing. + job = await self.run_job(self.auto_body(["d.png"])) + self.assertEqual(self.rows(job, "unchanged"), [("d.png", "No new tags")]) + + async def test_auto_job_holds_gate_and_never_downloads(self): + self.png("a.png") + self.png("b.png", color=(1, 2, 3)) + entered, proceed = threading.Event(), threading.Event() + self.tagger.block = (entered, proceed) + + job = await self.start_ok(self.auto_body(["a.png", "b.png"])) + await self.wait_for(entered) + holder = model_gate.holder() + self.assertIsNotNone(holder) + self.assertTrue(holder.startswith("image-tag-job:"), holder) + # A legacy route cannot swap the model under the running job. + response = await self.client.post("/prompt_manager/autotag/unload") + self.assertEqual(response.status, 409) + self.assertEqual((await response.json())["code"], "model-busy") + proceed.set() + await asyncio.wait_for(asyncio.shield(job.task), WAIT) + + self.assertEqual(job.counters["tagged"], 2) + self.assertEqual(self.tagger.load_calls, [("wd14-vit", True)]) + self.tagger.download_model.assert_not_called() + self.assertIsNone(model_gate.holder()) + + async def test_model_unavailable_fails_the_job_before_the_first_file(self): + self.png("a.png") + self.tagger.downloaded["wd14-vit"] = False + + job = await self.run_job(self.auto_body(["a.png"])) + + self.assertEqual(job.state, "failed") + self.assertEqual(job.processed, 0) + self.assertEqual(job.counters, dict.fromkeys(GROUPS, 0)) + self.assertTrue( + any( + line.endswith( + "is not downloaded. Download it from the legacy Auto-tag page." + ) + for line in job.log + ), + job.log, + ) + self.assertEqual(self.tagger.load_calls, []) + self.tagger.download_model.assert_not_called() + self.assertIsNone(model_gate.holder()) + self.assertEqual(self.api._capture_store().stats()["captures"], 0) + + async def test_model_load_failure_fails_the_job_and_frees_the_gate(self): + self.png("a.png") + self.tagger.load_error = RuntimeError("/secret/models/wd14 exploded") + + job = await self.run_job(self.auto_body(["a.png"])) + + self.assertEqual(job.state, "failed") + self.assertEqual(job.processed, 0) + self.assertNotIn("/secret", json.dumps(job.detail())) + self.assertIsNone(model_gate.holder()) + + async def test_inference_failure_is_an_error_row_and_the_job_goes_on(self): + self.png("a.png") + self.png("b.png", color=(1, 2, 3)) + failing = {"first": True} + + def explode(_path): + if failing.pop("first", False): + raise RuntimeError("/secret/path boom") + + self.tagger.on_generate = explode + + job = await self.run_job(self.auto_body(["a.png", "b.png"])) + + self.assertEqual(job.counters["errors"], 1) + self.assertEqual(job.counters["tagged"], 1) + self.assertEqual( + self.rows(job, "errors"), + [("a.png", "Auto-tagging failed. Check server logs for details.")], + ) + self.assertNotIn("/secret", json.dumps(job.report())) + self.assertIsNone(model_gate.holder()) + + async def test_changed_file_is_skipped_not_tagged(self): + self.png("a.png") + before = self.digest("a.png") + + def rewrite(_capture_path): + make_png(self.output_dir / "a.png", size=(8, 6), color=(1, 1, 1)) + + self.tagger.on_generate = rewrite + + job = await self.run_job(self.auto_body(["a.png"])) + + self.assertEqual(self.rows(job, "skipped"), [("a.png", "File changed")]) + self.assertEqual(job.counters["tagged"], 0) + locator = self.locator("a.png") + self.assertEqual(self.api.db.read_image_tags(locator, before)["tags"], []) + self.assertEqual(self.stored("a.png")["tags"], []) + + async def test_captures_released_and_handles_closed(self): + try: + from open_handles import assert_closed + except ImportError: + from tests.open_handles import assert_closed + for index in range(3): + self.png(f"f{index}.png", color=(index, 0, 0)) + + await self.run_job(self.auto_body([f"f{index}.png" for index in range(3)])) + + store = self.api._capture_store() + self.assertEqual(store.stats()["captures"], 0) + assert_closed(self, store.directory) + assert_closed(self, str(self.output_dir)) + + +class TestStartAndExclusion(ImageTagJobTestCase): + async def test_start_returns_model_busy_or_job_busy(self): + self.png("a.png") + other = model_gate.new_owner("legacy-batch") + self.assertTrue(model_gate.try_acquire(other)) + try: + status, body = await self.start(self.auto_body(["a.png"])) + self.assertEqual((status, body["code"]), (409, "model-busy")) + self.assertEqual(self.api._jobs.list(), []) + # Add tags needs no model: it starts while the gate is held. + job = await self.run_job(self.add_body(["a.png"], ["x"])) + self.assertEqual(job.state, "finished") + finally: + model_gate.release(other) + + entered, proceed = threading.Event(), threading.Event() + self.tagger.block = (entered, proceed) + job = await self.start_ok(self.auto_body(["a.png"])) + await self.wait_for(entered) + try: + for body in (self.add_body(["a.png"], ["y"]), self.auto_body(["a.png"])): + resp = await self.client.post(START, json=body) + body = await resp.json() + self.assertEqual((resp.status, body["code"]), (409, "busy")) + self.assertEqual( + body["running"], {"id": job.id, "title": "Auto-tag images"} + ) + finally: + proceed.set() + await asyncio.wait_for(asyncio.shield(job.task), WAIT) + # A refused start never leaves the gate taken. + self.assertIsNone(model_gate.holder()) + + async def test_image_tag_job_refused_while_legacy_restore_holds(self): + self.png("a.png") + async with self.api._jobs.hold( + "restore", "Database restore (admin page)", admission="records" + ): + status, body = await self.start(self.add_body(["a.png"], ["x"])) + self.assertEqual((status, body["code"]), (409, "busy")) + self.assertEqual(body["running"]["title"], "Database restore (admin page)") + status, body = await self.start(self.auto_body(["a.png"])) + self.assertEqual((status, body["code"]), (409, "busy")) + self.assertIsNone(model_gate.holder()) + async with self.api._jobs.hold( + "thumbnails", "Thumbnail generation (gallery page)", admission="guarded" + ): + status, body = await self.start(self.add_body(["a.png"], ["x"])) + self.assertEqual((status, body["code"]), (409, "busy")) + + async def test_record_changing_excludes_running_scan(self): + self.png("a.png") + gate = asyncio.Event() + + async def scan_runner(job): + await gate.wait() + + scan = self.api._jobs.start( + "scan", + "Scan for metadata", + record_changing=True, + scope={"files": "new"}, + runner=scan_runner, + ) + try: + status, body = await self.start(self.add_body(["a.png"], ["x"])) + self.assertEqual((status, body["code"]), (409, "busy")) + self.assertEqual(body["running"]["title"], "Scan for metadata") + status, body = await self.start(self.auto_body(["a.png"])) + self.assertEqual((status, body["code"]), (409, "busy")) + self.assertIsNone(model_gate.holder()) + finally: + gate.set() + await scan.task + + # The other way round: a running tagging job keeps the scan out. + entered, proceed = threading.Event(), threading.Event() + self.tagger.block = (entered, proceed) + job = await self.start_ok(self.auto_body(["a.png"])) + await self.wait_for(entered) + try: + resp = await self.client.post("/prompt_manager/jobs/scan", json={}) + self.assertEqual(resp.status, 409) + self.assertEqual((await resp.json())["running"]["id"], job.id) + finally: + proceed.set() + await asyncio.wait_for(asyncio.shield(job.task), WAIT) + + async def test_a_start_that_loses_the_slot_race_frees_the_gate(self): + self.png("a.png") + gate = asyncio.Event() + + async def scan_runner(job): + await gate.wait() + + scan = self.api._jobs.start( + "scan", + "Scan for metadata", + record_changing=True, + scope={}, + runner=scan_runner, + ) + try: + # The pre-check saw a free slot; registry.start still refuses. + with patch.object(self.api._jobs, "blocker", side_effect=[None, scan]): + status, body = await self.start(self.auto_body(["a.png"])) + self.assertEqual((status, body["code"]), (409, "busy")) + self.assertIsNone(model_gate.holder()) + finally: + gate.set() + await scan.task + + async def test_cancel_stops_at_file_boundary_and_keeps_written_files(self): + for index in range(3): + self.png(f"f{index}.png", color=(index, 5, 5)) + entered, proceed = threading.Event(), threading.Event() + self.tagger.block = (entered, proceed) + + job = await self.start_ok(self.auto_body([f"f{i}.png" for i in range(3)])) + await self.wait_for(entered) + response = await self.client.post(f"/prompt_manager/jobs/{job.id}/cancel") + self.assertTrue((await response.json())["cancellation_requested"]) + proceed.set() + await asyncio.wait_for(asyncio.shield(job.task), WAIT) + + self.assertEqual(job.state, "cancelled") + self.assertEqual(job.counters["tagged"], 1) + self.assertEqual(job.processed, 1) + self.assertEqual(len(self.stored("f0.png")["tags"]), 10) + self.assertEqual(self.stored("f1.png")["tags"], []) + self.assertEqual(len(self.tagger.calls), 1) + self.assertTrue(job.log[-1].endswith("Auto-tag cancelled · 1 file processed")) + self.assertIsNone(model_gate.holder()) + self.assertEqual(self.api._capture_store().stats()["captures"], 0) + + async def test_stale_root_scope_refuses_the_start(self): + self.png("a.png") + body = self.add_body(["a.png"], ["x"]) + body["targets"][0]["root_scope"] = "0" * 64 + + status, data = await self.start(body) + + self.assertEqual((status, data["code"]), (409, "source-changed")) + self.assertEqual(self.api._jobs.list(), []) + + async def test_restore_during_the_job_stops_it_without_writes(self): + self.png("a.png") + self.png("b.png", color=(1, 2, 3)) + + def rotate(_path): + conn = sqlite3.connect(self._temp_db.name) + try: + rotate_history_epoch(conn) + finally: + conn.close() + + self.tagger.on_generate = rotate + + job = await self.run_job(self.auto_body(["a.png", "b.png"])) + + self.assertEqual(job.state, "failed") + self.assertEqual(job.counters["tagged"], 0) + self.assertEqual( + self.rows(job, "errors"), + [("a.png", "Prompt storage was restored; start the job again.")], + ) + self.assertEqual(self.stored("a.png")["tags"], []) + self.assertEqual(len(self.tagger.calls), 1) + self.assertIsNone(model_gate.holder()) + + async def test_invalid_bodies_rejected(self): + self.png("a.png") + target = self.target("a.png") + bad_bodies = [ + {"mode": "add", "targets": [], "tags": ["x"]}, + {"mode": "add", "targets": [target] * 501, "tags": ["x"]}, + {"mode": "add", "targets": "a.png", "tags": ["x"]}, + {"mode": "add", "targets": [{**target, "path": "../a.png"}], "tags": ["x"]}, + {"mode": "add", "targets": [{**target, "root_index": True}], "tags": ["x"]}, + {"mode": "add", "targets": [{**target, "extra": 1}], "tags": ["x"]}, + {"mode": "add", "targets": [{**target, "path": "a.txt"}], "tags": ["x"]}, + {"mode": "add", "targets": [target], "tags": []}, + {"mode": "add", "targets": [target], "tags": "x"}, + {"mode": "add", "targets": [target], "tags": ["bad\nname"]}, + {"mode": "add", "targets": [target]}, + {"mode": "add", "targets": [target], "tags": ["x"], "extra": 1}, + {"mode": "add", "targets": [target], "tags": ["x"], "model_type": "hf"}, + {"mode": "remove", "targets": [target], "tags": ["x"]}, + {"targets": [target], "tags": ["x"]}, + {"mode": "auto", "targets": [target], "model_type": "wd14-vit"}, + {"mode": "auto", "targets": [target], "model_type": "x", "options": {}}, + { + "mode": "auto", + "targets": [target], + "model_type": "wd14-vit", + "options": {}, + }, + { + "mode": "auto", + "targets": [target], + "model_type": "wd14-vit", + "options": {**WD14, "general_threshold": 2}, + }, + { + "mode": "auto", + "targets": [target], + "model_type": "wd14-vit", + "options": {**WD14, "suggestion_limit": 0}, + }, + { + "mode": "auto", + "targets": [target], + "model_type": "gguf", + "options": dict(WD14), + }, + { + "mode": "auto", + "targets": [target], + "model_type": "wd14-vit", + "options": dict(WD14), + "tags": ["x"], + }, + ] + for body in bad_bodies: + with self.subTest(body=json.dumps(body)[:120]): + status, data = await self.start(body) + self.assertIn(status, (400, 403), data) + self.assertFalse(data["success"]) + too_many = { + "mode": "add", + "targets": [target], + "tags": [f"t{i}" for i in range(101)], + } + status, data = await self.start(too_many) + self.assertEqual((status, data["code"]), (400, "tag-limit")) + response = await self.client.post(START, data="not json") + self.assertEqual(response.status, 400) + self.assertEqual(self.api._jobs.list(), []) + self.assertIsNone(model_gate.holder()) + + async def test_duplicate_targets_are_tagged_once(self): + self.png("a.png") + + job = await self.run_job(self.add_body(["a.png", "a.png"], ["x"])) + + self.assertEqual(job.scope["images"], 1) + self.assertEqual(job.counters["tagged"], 1) + + async def test_rows_and_report_carry_no_absolute_paths(self): + self.png("sub/a.png") + self.png("sub/b.png", color=(3, 3, 3)) + job = await self.run_job( + self.auto_body(["sub/a.png", "sub/b.png", "sub/missing.png"]) + ) + + for url in ( + START.rsplit("/", 1)[0], + f"/prompt_manager/jobs/{job.id}", + f"/prompt_manager/jobs/{job.id}/report", + ): + response = await self.client.get(url) + self.assertEqual(response.status, 200) + body = json.loads(await response.text()) + self.assert_no_paths(body) + detail = job.detail() + self.assertEqual( + [row["path"] for row in detail["outcomes"]["tagged"]], + ["sub/a.png", "sub/b.png"], + ) + self.assertTrue( + all(row["root"] == "output" for row in detail["outcomes"]["tagged"]) + ) + self.assertTrue(any("output/sub/a.png" in line for line in detail["log"])) + + +class TestFixRound(ImageTagJobTestCase): + """The final review's fixes: estimate, body bound, busy captures, roots.""" + + async def test_status_line_estimates_minutes_left_after_the_minimum(self): + for index in range(3): + self.png(f"f{index}.png", color=(index, 7, 7)) + clock = iter([0.0, 0.0, 120.0, 240.0]) + lines = [] + self.tagger.on_generate = lambda _path: lines.append( + self.api._jobs.list()[0].status_line + ) + + with patch( + "py.api.image_tag_jobs._monotonic", side_effect=lambda: next(clock, 240.0) + ): + await self.run_job(self.auto_body([f"f{i}.png" for i in range(3)])) + + # Two files make the minimum (the scan's MIN_BATCHES_FOR_ESTIMATE); then + # 120 s per file leaves about 2 min for the third. + self.assertEqual( + lines, + [ + "Processing file 1 of 3", + "Processing file 2 of 3", + "Processing file 3 of 3 · about 2 min left", + ], + ) + + def maximal_target(self, number): + """A distinct target whose path is 1024 non-ASCII code points long.""" + head = f"{number:03d}/" + ("界" * 200 + "/") * 5 + path = head + "界" * (1024 - len(head) - 4) + ".png" + self.assertEqual(len(path), 1024) + return self.target(path) + + async def test_start_body_bounds(self): + from py.api import image_tag_jobs + + body = { + "mode": "add", + "targets": [self.maximal_target(n) for n in range(500)], + "tags": ["x"], + } + # Escaped as \uXXXX (6 bytes a code point), far over the old 1 MiB. + payload = json.dumps(body).encode("utf-8") + self.assertGreater(len(payload), 3 * 1000 * 1000) + response = await self.client.post( + START, data=payload, headers={"Content-Type": "application/json"} + ) + data = await self.json_of(response) + self.assertEqual(response.status, 202, data) + self.assertEqual(data["job"]["scope"]["images"], 500) + job = self.api._jobs.get(data["job"]["id"]) + await self.client.post(f"/prompt_manager/jobs/{job.id}/cancel") + await asyncio.wait_for(asyncio.shield(job.task), WAIT) + + cap = image_tag_jobs.MAX_JOB_BODY_BYTES + self.assertGreaterEqual(cap, len(payload)) + over = b'{"mode":"add","targets":[],"tags":["x"]' + over += b" " * (cap + 1 - len(over) - 1) + b"}" + self.assertEqual(len(over), cap + 1) + response = await self.client.post( + START, data=over, headers={"Content-Type": "application/json"} + ) + self.assertEqual(response.status, 413) + + def saturate(self, store): + with store._lock: + store._workers += store.max_workers + self.addCleanup(self.unsaturate, store) + + def unsaturate(self, store): + with store._lock: + if store._workers >= store.max_workers: + store._workers -= store.max_workers + + async def test_busy_capture_workers_are_retried_then_left_for_a_rerun(self): + self.png("a.png") + self.png("b.png", color=(1, 2, 3)) + store = self.api._capture_store() + self.saturate(store) + + with patch("py.api.image_tag_jobs.CAPACITY_BACKOFF", (0.001, 0.001)): + added = await self.run_job(self.add_body(["a.png"], ["x"])) + auto = await self.run_job(self.auto_body(["b.png"])) + + retry = "The file could not be read now. Run the job again for it." + for job, path in ((added, "a.png"), (auto, "b.png")): + self.assertEqual(job.state, "finished") + self.assertEqual(self.rows(job, "skipped"), [(path, retry)]) + self.assertEqual(job.counters["errors"], 0) + self.assertNotIn("Tag dialog", json.dumps(job.detail())) + self.assertEqual(self.tagger.calls, []) + self.assertEqual(self.stored("a.png")["tags"], []) + + async def test_a_busy_capture_worker_that_frees_up_is_tagged(self): + self.png("a.png") + store = self.api._capture_store() + real = store.verify + tries = [] + + def verify(root, relative): + tries.append(relative) + if len(tries) < 3: + raise CaptureCapacity("Too many captures are in progress") + return real(root, relative) + + with ( + patch.object(store, "verify", side_effect=verify), + patch("py.api.image_tag_jobs.CAPACITY_BACKOFF", (0.001, 0.001, 0.001)), + ): + job = await self.run_job(self.add_body(["a.png"], ["x"])) + + self.assertEqual(len(tries), 3) + self.assertEqual(self.rows(job, "tagged"), [("a.png", "Added 1 tag")]) + self.assertEqual(self.stored("a.png")["tags"], ["x"]) + + async def test_output_roots_are_read_once_per_job_off_the_event_loop(self): + for index in range(3): + self.png(f"f{index}.png", color=(index, 1, 1)) + body = self.add_body([f"f{i}.png" for i in range(3)], ["x"]) + original = self.api._get_all_output_dirs + threads = [] + + def lookup(): + threads.append(threading.current_thread()) + return original() + + with patch.object(self.api, "_get_all_output_dirs", side_effect=lookup): + job = await self.run_job(body) + + self.assertEqual(job.counters["tagged"], 3) + # One read when the start checks the scopes, one for the whole run. + self.assertEqual(len(threads), 2) + self.assertNotIn(threading.main_thread(), threads) + self.assertEqual(job.outcomes["tagged"][0]["root"], "output") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_init_package.py b/tests/test_init_package.py index df95d4e..a80a493 100644 --- a/tests/test_init_package.py +++ b/tests/test_init_package.py @@ -12,6 +12,7 @@ import json import os import sys import tempfile +import threading import types import unittest import unittest.mock as mock @@ -368,10 +369,29 @@ class TestConsoleBanner(unittest.TestCase): self.assertIn("Loaded:", banner) +class PriorFileHandler(logging.FileHandler): + """A caller-owned sink that stays closed once its owner closes it. + + ``FileHandler.emit`` reopens the file when ``stream`` is None, so a record + that selected this handler before ``close()`` would reopen prior.log after + it, and Windows then refuses the temp directory cleanup (WinError 32). + ``Handler.handle`` and ``FileHandler.close`` hold the same handler lock, so + checking ``_closed`` in ``emit`` is race free (as in the product's + ``_OwnedRotatingFileHandler``). + """ + + def emit(self, record): + if not self._closed: + super().emit(record) + + class TestPackageLoggerIsolation(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.addCleanup(self.tmp.cleanup) + # Runs after restore_logging, before the cleanup Windows refuses while + # prior.log is open; /proc makes Linux fail the same way. + self.addCleanup(self.assert_tmp_has_no_open_handles) self.logger = logging.getLogger("prompt_manager") self.previous = ( self.logger.handlers[:], @@ -381,7 +401,7 @@ class TestPackageLoggerIsolation(unittest.TestCase): ) self.addCleanup(self.restore_logging) self.path = os.path.join(self.tmp.name, "prior.log") - self.handler = logging.FileHandler(self.path) + self.handler = PriorFileHandler(self.path) self.logger.handlers = [self.handler] self.logger.setLevel(logging.DEBUG) self.logger.propagate = True @@ -398,16 +418,20 @@ class TestPackageLoggerIsolation(unittest.TestCase): self.server.PromptServer.instance.prompt_queue.task_done = lambda *a, **k: None def restore_logging(self): - for handler in self.logger.handlers: - if handler not in self.previous[0]: - handler.close() - self.handler.close() + # Detach before closing, so new records stop selecting these handlers. + retired = [h for h in self.logger.handlers if h not in self.previous[0]] ( self.logger.handlers, self.logger.level, self.logger.propagate, self.logger.disabled, ) = self.previous + for handler in retired: + handler.close() + self.handler.close() + + def assert_tmp_has_no_open_handles(self): + self.assertEqual(open_handles_under(self.tmp.name), []) def assert_logging_restored(self, handlers, registration): for key, module in self.saved_modules.items(): @@ -456,6 +480,49 @@ class TestPackageLoggerIsolation(unittest.TestCase): self.assertEqual(open_at_disposal, [True]) self.assert_logging_restored(handlers, registration) + def test_record_in_flight_at_restore_cannot_reopen_prior_log(self): + """A record that selected prior.log before restore never reopens it. + + Leaked daemon threads of other tests (image workers, watchdog) log + under ``prompt_manager.*``. One that reaches the prior handler while + restore_logging closes it would, with a plain FileHandler, reopen the + file on emit and keep it open through TemporaryDirectory.cleanup(). + """ + message = "record selected before restore_logging" + selected = threading.Event() + resume = threading.Event() + errors = [] + + def pause_before_handler_lock(record): + if record.getMessage() == message: + selected.set() + if not resume.wait(5): + raise RuntimeError("logging thread was not released") + return True + + def log_from_background_thread(): + try: + logging.getLogger("prompt_manager.late_emitter").warning(message) + except BaseException as error: + errors.append(error) + + self.handler.addFilter(pause_before_handler_lock) + worker = threading.Thread(target=log_from_background_thread, daemon=True) + worker.start() + try: + self.assertTrue(selected.wait(5), "record did not select prior.log") + self.restore_logging() + self.assertIsNone(self.handler.stream) + finally: + resume.set() + worker.join(5) + self.assertFalse(worker.is_alive(), "logging thread did not finish") + self.assertEqual(errors, []) + self.assertIsNone(self.handler.stream) + self.assert_tmp_has_no_open_handles() + with open(self.path, encoding="utf-8") as log: + self.assertNotIn(message, log.read()) + def test_failed_package_import_closes_handlers_and_restores_logging(self): name = "prompt_manager_pkg_logger_failure" captured = {} diff --git a/tests/test_job_admission.py b/tests/test_job_admission.py new file mode 100644 index 0000000..59e4821 --- /dev/null +++ b/tests/test_job_admission.py @@ -0,0 +1,552 @@ +"""Registry admission classes and the legacy routes that hold the registry. + +Record-changing work never runs together (Activity design): a ``records`` +job is refused while any ``records`` or ``guarded`` job runs, a ``guarded`` +job (thumbnails) while a ``records`` job runs, and ``read`` jobs only share +the one-per-kind rule. The legacy admin and gallery routes that change +records or files take a registry hold for their duration, so a workspace job +and a legacy-page operation refuse each other, naming the one that runs. + +Fake runners and gated executor calls only; ordering is driven by events. +""" + +import asyncio +import hashlib +import json +import os +import sys +import threading +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + +_mock_server = MagicMock() +_mock_server.PromptServer.instance.routes = MagicMock() +sys.modules.setdefault("server", _mock_server) + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from py.api.jobs import ( # noqa: E402 + ADMISSIONS, + Job, + JobBusy, + JobRegistry, +) + +try: + from tests.test_api_admin import AdminAPITestCase # noqa: E402 +except ImportError: # discovered with tests/ as the top-level directory + from test_api_admin import AdminAPITestCase # noqa: E402 + + +def _gated(gate): + async def runner(job): + await gate.wait() + + return runner + + +async def _noop(job): + return None + + +class TestAdmissionClasses(unittest.IsolatedAsyncioTestCase): + + def _start(self, registry, kind, admission, gate=None, **kwargs): + return registry.start( + kind, + kind.title(), + admission=admission, + scope={}, + runner=_gated(gate) if gate is not None else _noop, + **kwargs, + ) + + async def test_admission_names_and_explicit_read(self): + self.assertEqual(ADMISSIONS, ("read", "guarded", "records")) + registry = JobRegistry() + job = registry.start( + "report", "Report", admission="read", scope={}, runner=_noop + ) + await asyncio.wait_for(job.task, 2) + self.assertEqual(job.admission, "read") + self.assertFalse(job.record_changing) + with self.assertRaises(ValueError): + registry.start("x", "X", admission="exclusive", scope={}, runner=_noop) + with self.assertRaises(ValueError): + registry.start( + "x", + "X", + admission="read", + record_changing=True, + scope={}, + runner=_noop, + ) + + async def test_start_requires_an_admission_class(self): + # A job that forgets its class must not silently run as "read" beside + # a scan or restore: every start call decides. + registry = JobRegistry() + with self.assertRaises(TypeError) as raised: + registry.start("review", "Review", scope={}, runner=_noop) + self.assertIn("admission", str(raised.exception)) + self.assertEqual(registry.list(), []) + job = registry.start( + "review", "Review", record_changing=False, scope={}, runner=_noop + ) + await asyncio.wait_for(job.task, 2) + self.assertEqual(job.admission, "read") + + async def test_record_changing_is_an_alias_for_records(self): + registry = JobRegistry() + gate = asyncio.Event() + job = registry.start( + "scan", "Scan", record_changing=True, scope={}, runner=_gated(gate) + ) + self.assertEqual(job.admission, "records") + self.assertTrue(job.record_changing) + self.assertEqual(job.summary()["admission"], "records") + self.assertIs( + registry.blocker("image-tags", record_changing=True), job + ) # the existing call shape keeps working + gate.set() + await asyncio.wait_for(job.task, 2) + + async def test_records_job_refused_while_guarded_runs(self): + registry = JobRegistry() + gate = asyncio.Event() + thumbs = self._start(registry, "thumbnails", "guarded", gate) + with self.assertRaises(JobBusy) as raised: + self._start(registry, "scan", "records") + self.assertIs(raised.exception.running, thumbs) + self.assertIs(registry.blocker("restore", admission="records"), thumbs) + gate.set() + await asyncio.wait_for(thumbs.task, 2) + scan = self._start(registry, "scan", "records") + await asyncio.wait_for(scan.task, 2) + self.assertEqual(scan.state, "finished") + + async def test_guarded_refused_while_records_runs(self): + registry = JobRegistry() + gate = asyncio.Event() + scan = self._start(registry, "scan", "records", gate) + with self.assertRaises(JobBusy) as raised: + self._start(registry, "thumbnails", "guarded") + self.assertIs(raised.exception.running, scan) + # A second guarded kind may run beside a guarded one, not beside records. + gate.set() + await asyncio.wait_for(scan.task, 2) + hold = asyncio.Event() + thumbs = self._start(registry, "thumbnails", "guarded", hold) + backup = self._start(registry, "backup", "guarded") + await asyncio.wait_for(backup.task, 2) + self.assertEqual(backup.state, "finished") + hold.set() + await asyncio.wait_for(thumbs.task, 2) + + async def test_read_jobs_run_in_parallel(self): + registry = JobRegistry() + gate = asyncio.Event() + scan = self._start(registry, "scan", "records", gate) + health = self._start(registry, "health", "read", gate) + duplicates = self._start(registry, "duplicates", "read", gate) + self.assertTrue(all(j.running for j in (scan, health, duplicates))) + with self.assertRaises(JobBusy) as raised: # one job of a kind + self._start(registry, "health", "read") + self.assertIs(raised.exception.running, health) + gate.set() + await asyncio.wait_for( + asyncio.gather(scan.task, health.task, duplicates.task), 2 + ) + + async def test_non_cancellable_job_ignores_cancel(self): + registry = JobRegistry() + gate = asyncio.Event() + job = self._start(registry, "restore", "records", gate, cancellable=False) + self.assertIs(job.summary()["cancellable"], False) + self.assertFalse(registry.cancel(job.id)) + self.assertFalse(job.cancelled) + gate.set() + await asyncio.wait_for(job.task, 2) + self.assertEqual(job.state, "finished") + + async def test_hold_registers_a_running_non_cancellable_job(self): + registry = JobRegistry() + held = registry.hold( + "restore", "Database restore (admin page)", admission="records" + ) + async with held as job: + self.assertIs(registry.active("restore"), job) + self.assertEqual(job.state, "running") + self.assertEqual( + (job.summary()["cancellable"], job.summary()["admission"]), + (False, "records"), + ) + self.assertFalse(registry.cancel(job.id)) + with self.assertRaises(JobBusy) as raised: + self._start(registry, "scan", "records") + self.assertIn("Database restore (admin page)", str(raised.exception)) + self.assertEqual(job.state, "finished") + self.assertIsNone(registry.active("restore")) + self.assertIs(registry.list()[0], job) + + async def test_hold_refused_without_waiting(self): + registry = JobRegistry() + gate = asyncio.Event() + scan = self._start(registry, "scan", "records", gate) + with self.assertRaises(JobBusy) as raised: + async with registry.hold("maintenance", "Maintenance", admission="records"): + self.fail("a refused hold must not run its body") + self.assertIs(raised.exception.running, scan) + self.assertEqual([j.kind for j in registry.list()], ["scan"]) + gate.set() + await asyncio.wait_for(scan.task, 2) + + async def test_hold_released_on_exception(self): + registry = JobRegistry() + with self.assertRaises(RuntimeError): + async with registry.hold( + "maintenance", "Maintenance", admission="records" + ) as job: + raise RuntimeError("boom") + self.assertEqual(job.state, "failed") + again = registry.hold("maintenance", "Maintenance", admission="records") + async with again as second: + second.finish("failed") # the body may end it itself + self.assertEqual(second.state, "failed") + + async def test_hold_admits_on_entry_not_at_the_call(self): + # A hold created but never entered (an await or an exception between + # the call and ``async with``) must not leave a running records job + # behind that blocks all record work until a restart. + registry = JobRegistry() + held = registry.hold("restore", "Restore", admission="records") + self.assertEqual(registry.list(), []) + self.assertIsNone(registry.blocker("scan", admission="records")) + del held + async with registry.hold("restore", "Restore", admission="records") as job: + self.assertIs(registry.active("restore"), job) + self.assertEqual(job.state, "finished") + + def test_job_defaults_stay_read_and_cancellable(self): + job = Job("duplicates", "Duplicate scan") + self.assertEqual((job.admission, job.cancellable), ("read", True)) + + +class _LegacyHoldCase(AdminAPITestCase): + """The admin test app plus a running workspace job on demand.""" + + def _run_job(self, kind="scan", title="Scan for metadata", admission="records"): + gate = asyncio.Event() + job = self.api._jobs.start( + kind, title, admission=admission, scope={}, runner=_gated(gate) + ) + self.addAsyncCleanup(self._release, gate, job) + return gate, job + + @staticmethod + async def _release(gate, job): + gate.set() + await asyncio.wait_for(job.task, 5) + + def _db_digest(self): + path = Path(self.api.db.model.db_path) + return { + suffix: hashlib.sha256(Path(f"{path}{suffix}").read_bytes()).hexdigest() + for suffix in ("", "-wal") + if Path(f"{path}{suffix}").exists() + } + + async def _busy(self, resp, title="Scan for metadata"): + body = await resp.json() + self.assertEqual(resp.status, 409) + self.assertEqual(body, {"success": False, "error": f"{title} already running"}) + + +class TestLegacyRoutesHoldTheRegistry(_LegacyHoldCase): + + async def test_hold_is_listed_as_running_job(self): + held = self.api._jobs.hold( + "restore", "Database restore (admin page)", admission="records" + ) + async with held: + resp = await self.client.request("GET", "/prompt_manager/jobs") + jobs = (await resp.json())["jobs"] + self.assertEqual(len(jobs), 1) + job = jobs[0] + self.assertEqual( + (job["kind"], job["title"], job["state"]), + ("restore", "Database restore (admin page)", "running"), + ) + self.assertIs(job["cancellable"], False) + self.assertEqual(job["admission"], "records") + + async def test_legacy_maintenance_and_cleanup_refused_while_scan_runs(self): + self.api.db.save_prompt(text="keep me", prompt_hash="h1") + before = self._db_digest() + self._run_job() + resp = await self.client.request( + "POST", + "/prompt_manager/maintenance", + json={"operations": ["cleanup_duplicates", "vacuum"]}, + ) + await self._busy(resp) + resp = await self.client.request("POST", "/prompt_manager/cleanup") + await self._busy(resp) + self.assertEqual(self._db_digest(), before) + + async def test_read_only_maintenance_runs_during_scan(self): + # The legacy statistics and consistency checks only read: they hold + # the registry as "read", so a long scan does not block them. + self.api.db.save_prompt(text="keep me", prompt_hash="h1") + _gate, scan = self._run_job() + listed = [] + real = self.api._run_maintenance_operations + + def observed(operations): + listed.extend( + (job.kind, job.admission, job.state) for job in self.api._jobs.list() + ) + return real(operations) + + self.api._run_maintenance_operations = observed + resp = await self.client.request( + "POST", + "/prompt_manager/maintenance", + json={ + "operations": [ + "statistics", + "check_consistency", + "check_hash_duplicates", + ] + }, + ) + body = await resp.json() + self.assertEqual(resp.status, 200) + self.assertIs(body["success"], True) + self.assertEqual( + set(body["results"]), + {"statistics", "check_consistency", "check_hash_duplicates"}, + ) + self.assertIn(("maintenance", "read", "running"), listed) + self.assertEqual(scan.state, "running") + maintenance = self.api._jobs.list()[-1] + self.assertEqual( + (maintenance.kind, maintenance.admission, maintenance.state), + ("maintenance", "read", "finished"), + ) + + async def test_mixed_maintenance_selection_is_refused_during_scan(self): + # One write operation among read-only ones makes the run "records". + self.api.db.save_prompt(text="keep me", prompt_hash="h1") + before = self._db_digest() + self._run_job() + resp = await self.client.request( + "POST", + "/prompt_manager/maintenance", + json={"operations": ["statistics", "vacuum"]}, + ) + await self._busy(resp) + self.assertEqual(self._db_digest(), before) + + async def test_default_maintenance_selection_is_refused_during_scan(self): + # No "operations" key means the default list, which vacuums. + self.api.db.save_prompt(text="keep me", prompt_hash="h1") + before = self._db_digest() + self._run_job() + resp = await self.client.request("POST", "/prompt_manager/maintenance", json={}) + await self._busy(resp) + self.assertEqual(self._db_digest(), before) + self.assertEqual([j.kind for j in self.api._jobs.list()], ["scan"]) + + async def test_legacy_maintenance_refused_while_image_tags_runs(self): + self._run_job("image-tags", "Add tags to 3 images") + resp = await self.client.request("POST", "/prompt_manager/maintenance") + await self._busy(resp, "Add tags to 3 images") + + async def test_scan_refused_while_legacy_maintenance_holds(self): + entered = threading.Event() + release = threading.Event() + real = self.api._run_maintenance_operations + + def gated(operations): + entered.set() + release.wait(10) + return real(operations) + + self.api._run_maintenance_operations = gated + loop = asyncio.get_running_loop() + maintenance = asyncio.ensure_future( + self.client.request( + "POST", "/prompt_manager/maintenance", json={"operations": ["vacuum"]} + ) + ) + self.assertTrue(await loop.run_in_executor(None, entered.wait, 10)) + resp = await self.client.request("POST", "/prompt_manager/jobs/scan") + body = await resp.json() + self.assertEqual((resp.status, body["code"]), (409, "busy")) + self.assertEqual(body["running"]["title"], "Maintenance (admin page)") + release.set() + self.assertEqual((await maintenance).status, 200) + listed = ( + await (await self.client.request("GET", "/prompt_manager/jobs")).json() + )["jobs"] + self.assertEqual( + [(j["kind"], j["state"]) for j in listed], [("maintenance", "finished")] + ) + + async def test_legacy_duplicate_delete_refused_while_scan_runs(self): + victim = self.output_dir / "dup.png" + victim.write_bytes(b"x") + self._run_job() + resp = await self.client.request( + "POST", + "/prompt_manager/delete_duplicate_images", + json={"image_paths": [str(victim)]}, + ) + await self._busy(resp) + self.assertTrue(victim.exists()) + # The duplicate family lock was not taken by the refused request. + self.assertIsNotNone(self.api._acquire_long_job(self.api.LONG_JOB_DUPLICATES)) + + async def test_lora_scan_refused_while_scan_runs(self): + from py.config import IntegrationConfig + + lm = Path(self.tmpdir) / "custom_nodes" / "ComfyUI-Lora-Manager" + (lm / "py").mkdir(parents=True) + self._run_job() + imported = [] + with ( + patch.multiple( + IntegrationConfig, LORA_MANAGER_ENABLED=True, LORA_MANAGER_PATH=str(lm) + ), + patch("py.lora_utils.resolve_lora_manager_path", return_value=str(lm)), + patch.object( + self.api, "_lora_scan_stream", side_effect=lambda *a: imported.append(a) + ), + ): + resp = await self.client.request("POST", "/prompt_manager/lora/scan") + text = await resp.text() + self.assertEqual(resp.status, 200) # the legacy page reads a stream + events = [json.loads(line[6:]) for line in text.split("\n\n") if line] + # The admin page's modal shows ``status`` and closes on ``type: error``. + message = "Scan for metadata already running" + self.assertEqual( + events, + [{"type": "error", "status": message, "progress": 0, "message": message}], + ) + self.assertEqual(imported, []) + + async def test_legacy_autotag_refused_while_scan_runs(self): + # Both pages read this route as a stream (a 409 reads as a lost + # connection there), so a refusal by another job is one error frame: + # the admin page keys on ``type``, the gallery on ``status``. + self._run_job() + resp = await self.client.request( + "POST", "/prompt_manager/autotag/start", json={} + ) + text = await resp.text() + self.assertEqual(resp.status, 200) + self.assertEqual(resp.headers["Content-Type"], "text/event-stream") + events = [json.loads(line[6:]) for line in text.split("\n\n") if line] + message = "Scan for metadata already running" + self.assertEqual( + events, [{"type": "error", "status": "error", "message": message}] + ) + self.assertEqual( + [j.kind for j in self.api._jobs.list() if j.kind == "autotag"], [] + ) + + async def test_second_legacy_autotag_batch_keeps_its_json_409(self): + from py.api.autotag_routes import _BUSY_ERROR + + self._run_job("autotag", "Auto-tag images (legacy page)") + resp = await self.client.request( + "POST", "/prompt_manager/autotag/start", json={} + ) + self.assertEqual(resp.status, 409) + self.assertEqual(await resp.json(), {"success": False, "error": _BUSY_ERROR}) + + async def test_legacy_payloads_unchanged_when_idle(self): + resp = await self.client.request( + "POST", "/prompt_manager/maintenance", json={"operations": ["vacuum"]} + ) + body = await resp.json() + self.assertEqual(resp.status, 200) + self.assertEqual( + set(body), + {"success", "operations_completed", "all_successful", "results", "message"}, + ) + resp = await self.client.request("POST", "/prompt_manager/cleanup") + body = await resp.json() + self.assertEqual(resp.status, 200) + self.assertEqual(set(body), {"success", "message", "duplicates_removed"}) + resp = await self.client.request( + "POST", "/prompt_manager/delete_duplicate_images", json={"image_paths": []} + ) + self.assertEqual( + (resp.status, await resp.json()), + (400, {"success": False, "error": "No image paths provided"}), + ) + # Each hold ended with the request; refused input never took one. + listed = await self._listed() + self.assertEqual( + [(j["kind"], j["state"]) for j in listed], + [("maintenance", "finished"), ("maintenance", "finished")], + ) + self.assertEqual( + [j["title"] for j in listed], + ["Prompt cleanup (admin page)", "Maintenance (admin page)"], + ) + + async def _listed(self): + resp = await self.client.request("GET", "/prompt_manager/jobs") + return (await resp.json())["jobs"] + + +class TestHeldJobStates(_LegacyHoldCase): + """Activity lists what a held legacy operation really did (Minor 7).""" + + async def _listed(self): + resp = await self.client.request("GET", "/prompt_manager/jobs") + return [(j["kind"], j["state"]) for j in (await resp.json())["jobs"]] + + async def test_refused_input_takes_no_hold(self): + resp = await self.client.request( + "POST", "/prompt_manager/delete_duplicate_images", json={} + ) + self.assertEqual(resp.status, 400) + resp = await self.client.request( + "POST", + "/prompt_manager/images/generate-thumbnails", + data=b"{not json", + headers={"Content-Type": "application/json"}, + ) + self.assertEqual(resp.status, 400) + self.assertEqual(await self._listed(), []) + + async def test_thumbnail_stream_that_ends_in_error_is_failed(self): + with patch.object(self.api, "_get_all_output_dirs", return_value=[]): + resp = await self.client.request( + "POST", "/prompt_manager/images/generate-thumbnails/progress" + ) + text = await resp.text() + self.assertEqual(resp.status, 200) + self.assertIn("event: error", text) + self.assertEqual(await self._listed(), [("thumbnails", "failed")]) + + async def test_legacy_autotag_that_ends_in_error_is_failed(self): + service = MagicMock() + service.get_models_status.return_value = {} # model not downloaded + with patch("py.autotag.get_autotag_service", return_value=service): + resp = await self.client.request( + "POST", "/prompt_manager/autotag/start", json={} + ) + text = await resp.text() + self.assertEqual(resp.status, 200) + self.assertIn('"type": "error"', text) + self.assertEqual(await self._listed(), [("autotag", "failed")]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_jobs.py b/tests/test_jobs.py index 7f87f85..6848fa8 100644 --- a/tests/test_jobs.py +++ b/tests/test_jobs.py @@ -12,6 +12,7 @@ import unittest sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from py.api import jobs as jobs_module # noqa: E402 from py.api.jobs import ( # noqa: E402 DEFAULT_SCAN_SCOPE, LOG_CAP_LINE, @@ -181,8 +182,13 @@ class TestJobRegistry(unittest.IsolatedAsyncioTestCase): "scope", "listed", "log_length", + "cancellable", + "admission", }, ) + self.assertEqual( + (summary["cancellable"], summary["admission"]), (True, "records") + ) self.assertEqual(summary["scope"], {"files": "new", "output_folders": 2}) # What the detail would list, so a view reads it only when that changed. self.assertEqual((summary["listed"], summary["log_length"]), ({}, 0)) @@ -415,6 +421,14 @@ class TestJobOutcomesAndLog(unittest.TestCase): SCAN_GROUP_NAMES, ("added", "known", "review", "no_metadata", "errors") ) + def test_image_tag_job_kind_and_groups(self): + # #249 D1: bulk Add tags / Auto-tag, counter-tile order. + self.assertEqual(getattr(jobs_module, "IMAGE_TAG_JOB_KIND", None), "image-tags") + self.assertEqual( + getattr(jobs_module, "IMAGE_TAG_GROUP_NAMES", None), + ("tagged", "unchanged", "skipped", "errors"), + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_local_inspect_parity.py b/tests/test_local_inspect_parity.py index 9fdfdd9..04d4ad5 100644 --- a/tests/test_local_inspect_parity.py +++ b/tests/test_local_inspect_parity.py @@ -51,12 +51,19 @@ from utils.prompt_inspector import ( read_inspection_file, ) +from utils.prompt_inspector import inspect_prompt_evidence, unreadable_evidence +from utils.prompt_pair_resolver import INSPECTION_PROFILE + try: from tests import inspection_parity as parity + from tests import local_resolver_cases as resolver_cases + from tests import local_resolver_selection_cases as selection_cases from tests.open_handles import assert_closed from tests.test_utils_video_metadata import HAVE_FFMPEG, write_video except ImportError: # pragma: no cover - discover -s tests puts tests/ first import inspection_parity as parity + import local_resolver_cases as resolver_cases + import local_resolver_selection_cases as selection_cases from open_handles import assert_closed from test_utils_video_metadata import HAVE_FFMPEG, write_video @@ -1111,5 +1118,306 @@ class LocalProjectionParityTests(unittest.TestCase): self.assertTrue({"parsed", "malformed", "limited"} <= seen) +def _server_evidence(records): + return inspect_prompt_evidence(records, None, profile=INSPECTION_PROFILE) + + +class LocalResolverParityTests(unittest.TestCase): + """``resolveLocalEvidence`` answers as ``inspect_prompt_evidence`` (L3 Tasks 8-9). + + Python is the oracle: every expected value is the server's own answer on the + same record texts. Task 8 ported one sampler's tracing; Task 9 ports + candidate selection (several samplers, the identical-branch rule of #270, + preview-only stages, detailer stages) and A1111 ``parameters``, so no + record set is left to the server any more. + """ + + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.addCleanup(assert_closed, self, temporary.name) + self.directory = Path(temporary.name) + + def _compare(self, cases): + """Assert each record set's local evidence equal; return the server answers.""" + observed = _run_oracle( + self, + [ + {"name": name, "mode": "resolve", "records": records} + for name, records in cases.items() + ], + ) + answers = {} + for name, records in cases.items(): + with self.subTest(case=name): + local = observed[name] + server = _server_evidence(records) + answers[name] = server + self.assertEqual( + _canonical_bytes(local["evidence"]), _canonical_bytes(server) + ) + return answers + + def test_local_roles_match_execution_and_single_chain_vectors(self): + files = {} + for name in parity.COMMITTED: + files[name] = parity.fixture_path(name) + for name in parity.TEST_TIME: + path = parity.fixture_path(name, self.directory) + parity.write_png(path, parity.build(name)) + files[name] = path + observed = _run_oracle( + self, + [ + {"name": name, "mode": "resolve", "path": str(path)} + for name, path in files.items() + ], + ) + vectors = { + vector["fixture"]: vector + for vector in json.loads(parity.VECTORS_PATH.read_text(encoding="utf-8")) + if vector["profile"] == "inspection" + } + compared = 0 + for name, path in files.items(): + with self.subTest(fixture=name): + local = observed[name] + try: + records = read_inspection_file(path).metadata + except MetadataReadError: + self.assertTrue(local["unreadable"]) + continue + # The single-chain subset (Task 8); the rest is Task 9's test below. + if not resolver_cases.single_chain(records): + continue + server = _server_evidence(records) + self.assertEqual( + _canonical_bytes(local["evidence"]), _canonical_bytes(server) + ) + # The committed vector agrees with the server read here. + expect = vectors[name]["expect"] + self.assertEqual(server["resolution"]["state"], expect["state"]) + self.assertEqual( + server["resolution"]["reason_code"], expect["reason_code"] + ) + compared += 1 + self.assertGreaterEqual(compared, 20) + + def test_local_roles_match_inspection_vectors(self): + """Every inspection vector: local prompts, resolution and facts equal Images'. + + The facts are ``extractGenerationFacts`` (with the legacy fill) on the + local payload, against the facts the committed vectors pin for the + server's response on the same file. + """ + vectors = [ + vector + for vector in json.loads(parity.VECTORS_PATH.read_text(encoding="utf-8")) + if vector["profile"] == "inspection" + ] + files = {} + for vector in vectors: + name = vector["fixture"] + if name in parity.TEST_TIME: + path = parity.fixture_path(name, self.directory) + parity.write_png(path, parity.build(name)) + else: + path = parity.fixture_path(name) + files[name] = path + observed = _run_oracle( + self, + [ + {"name": name, "mode": "resolve", "path": str(path)} + for name, path in files.items() + ], + ) + for vector in vectors: + name = vector["fixture"] + with self.subTest(fixture=name): + local, expect = observed[name], vector["expect"] + try: + server = _server_evidence( + read_inspection_file(files[name]).metadata + ) + except MetadataReadError: + server = unreadable_evidence() + self.assertTrue(local["unreadable"]) + self.assertEqual( + _canonical_bytes(local["evidence"]), _canonical_bytes(server) + ) + resolution, prompts = ( + local["evidence"]["resolution"], + local["evidence"]["prompts"], + ) + self.assertEqual( + ( + resolution["state"], + resolution["reason"], + resolution["reason_code"], + ), + (expect["state"], expect["reason"], expect["reason_code"]), + ) + self.assertEqual( + [ + parity._role_text( + prompts[role]["status"], prompts[role]["text"] + ) + for role in ("positive", "negative") + ], + [expect["positive"], expect["negative"]], + ) + self.assertEqual(local["facts"], expect["facts"]) + self.assertEqual(len(vectors), 28) + + def test_local_roles_match_selection_record_sets(self): + answers = self._compare(selection_cases.selection_cases()) + reasons = {answer["resolution"]["reason"] for answer in answers.values()} + self.assertTrue( + { + "Every sampler branch uses the same prompt pair", + "Multiple sampler branches", + "Sampler stages have different prompts", + "Cyclic sampler stage ancestry", + "Expected an image link", + "Unsupported sampler stage ancestry", + "Resolution depth limit reached", + "A1111 settings footer is not unique", + "Multiple negative prompt delimiters", + "Negative prompt delimiter is absent", + "Parameters exceed the length limit", + "No A1111 parameters", + } + <= reasons + ) + # A stage reaching only previews is dropped: one sampler is named. + self.assertEqual( + answers["select-preview-dropped"]["resolution"]["sampler_id"], "3" + ) + + def test_local_roles_match_fuzzed_selection_sets(self): + cases = {} + for seed in range(4): + cases.update(selection_cases.selection_fuzz_cases(seed, 150)) + answers = self._compare(cases) + reasons = [answer["resolution"]["reason"] for answer in answers.values()] + self.assertGreater(reasons.count("Multiple sampler branches"), 20) + self.assertGreater(reasons.count("Sampler stages have different prompts"), 0) + self.assertGreater( + reasons.count("Every sampler branch uses the same prompt pair"), 0 + ) + self.assertGreater(reasons.count("A1111 settings footer is not unique"), 0) + + def test_local_roles_match_golden_record_sets(self): + answers = self._compare(resolver_cases.golden_cases()) + codes = {answer["resolution"]["reason_code"] for answer in answers.values()} + self.assertTrue( + { + "malformed", + "unsupported-node", + "runtime-value", + "composite", + "work-limit", + "depth-limit", + "text-limit", + "graph-ceiling", + "no-sampler", + None, + } + <= codes + ) + sources = { + side["source"] + for answer in answers.values() + for side in answer["prompts"].values() + } + self.assertTrue( + {"execution", "graph-inferred", "zeroed-conditioning"} <= sources + ) + + def test_committed_resolver_vectors_are_current(self): + """The Node unit test's committed answers are still the server's.""" + committed = json.loads(resolver_cases.VECTORS_PATH.read_text(encoding="utf-8")) + self.assertEqual( + committed, + json.loads(json.dumps(resolver_cases.committed_vectors())), + "regenerate with: python3 -m tests.local_resolver_cases --write", + ) + + def test_local_roles_match_at_every_work_bound(self): + answers = self._compare(resolver_cases.boundary_cases()) + for name in ("visits", "depth", "scanned"): + self.assertEqual(answers[f"{name}-edge"]["resolution"]["state"], "resolved") + self.assertIn( + answers[f"{name}-over"]["resolution"]["reason_code"], + ("work-limit", "depth-limit"), + ) + self.assertEqual( + answers["named-astral"]["resolution"]["node"]["class_type"], + "\U0001f600" * 64, + ) + + def test_local_json_depth_cap_at_its_boundary(self): + """Both kinds of deep record at the browser's JSON depth cap (1,000). + + Locally a record nested past the cap is invalid JSON: a prompt is + malformed and a workflow is ignored, as the server answers when + ``json.loads`` raises RecursionError. The server's own limit depends on + the Python (TESTING.md, "Deep JSON"): about 990 on 3.10 and 3.11, so it + agrees past the cap there; about 10,000 on 3.12 and 3.13 and over + 50,000 on 3.14, so it agrees up to the cap there and reads deeper. + """ + cases = resolver_cases.depth_cases() + observed = _run_oracle( + self, + [ + {"name": name, "mode": "resolve", "records": records} + for name, _kind, _depth, records in cases + ], + ) + newer = sys.version_info >= (3, 12) + compared = 0 + for name, kind, depth, records in cases: + with self.subTest(case=name): + local = observed[name]["evidence"]["resolution"] + within = depth <= resolver_cases.JSON_MAX_DEPTH + if kind == "prompt": + expected = ( + ("resolved", None) if within else ("unresolved", "malformed") + ) + else: + # A workflow that is read finds the overwritten pair (R1). + expected = ( + ("unresolved", "malformed") if within else ("resolved", None) + ) + self.assertEqual((local["state"], local["reason_code"]), expected) + server = _server_evidence(records) + if server["resolution"]["state"] == local["state"]: + self.assertEqual( + _canonical_bytes(observed[name]["evidence"]), + _canonical_bytes(server), + ) + compared += 1 + else: + # The documented divergence only: an older server refuses + # a little below the cap, a newer one reads past it. + self.assertEqual(within, not newer) + self.assertGreaterEqual(compared, 2) + + def test_local_roles_match_fuzzed_record_sets(self): + cases = {} + for seed in range(4): + cases.update(resolver_cases.fuzz_cases(seed, 150)) + answers = self._compare(cases) + states = [answer["resolution"]["state"] for answer in answers.values()] + # Coherent graphs reach real results, not only early refusals. + self.assertGreater(states.count("resolved"), len(states) // 5) + self.assertGreater(states.count("ambiguous"), 0) + self.assertGreater(states.count("unresolved"), len(states) // 10) + self.assertGreaterEqual( + len({answer["resolution"]["reason_code"] for answer in answers.values()}), + 5, + ) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_workspace_concept_fixture.py b/tests/test_workspace_concept_fixture.py index f301de6..f855211 100644 --- a/tests/test_workspace_concept_fixture.py +++ b/tests/test_workspace_concept_fixture.py @@ -1,7 +1,9 @@ """The concept fixture files carry the concept inspector facts (app.js:400-414).""" import json +import math import tempfile +import time import unittest from pathlib import Path @@ -15,6 +17,8 @@ except ImportError: # discovered with tests/ as the top-level directory from tests.browser.workspace_concept_fixture import ( CONCEPT_MODEL, CONCEPT_PROMPTS, + MAX_EPOCH_SECONDS, + _instant, write_concept_png, ) from utils.prompt_inspector import inspect_prompt_evidence, read_inspection_file @@ -80,5 +84,29 @@ class TestWorkspaceConceptFixture(unittest.TestCase): self.assertEqual((image["width"], image["height"]), size) +class TestConceptFixtureInstant(unittest.TestCase): + """One "now" for every relative time the fixture writes (PR #284's midnight).""" + + def test_a_chosen_instant_is_kept(self): + self.assertEqual(_instant(1_792_713_598), 1_792_713_598.0) + self.assertEqual(_instant(1_792_713_598.25), 1_792_713_598.25) + + def test_the_wall_clock_when_omitted(self): + before = time.time() + now = _instant(None) + self.assertLessEqual(before, now) + self.assertLessEqual(now, time.time()) + + def test_a_non_number_is_refused(self): + for value in (True, "1792713598", [1]): + with self.subTest(value=value), self.assertRaises(TypeError): + _instant(value) + + def test_an_instant_out_of_range_is_refused(self): + for value in (0, -1, MAX_EPOCH_SECONDS, math.inf, math.nan): + with self.subTest(value=value), self.assertRaises(ValueError): + _instant(value) + + if __name__ == "__main__": unittest.main() diff --git a/tests/vendor/browser-server-fixture.test.mjs b/tests/vendor/browser-server-fixture.test.mjs index 9b05c4b..e0e20be 100644 --- a/tests/vendor/browser-server-fixture.test.mjs +++ b/tests/vendor/browser-server-fixture.test.mjs @@ -6,7 +6,7 @@ import { once } from 'node:events'; import { readFileSync } from 'node:fs'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { setTimeout as delay } from 'node:timers/promises'; -import { startBrowserServer, createServerSupervisor, attachServerStderr } from '../../tools/vendor/start-browser-server.mjs'; +import { startBrowserServer, createServerSupervisor, attachServerStderr, forwardPrefixedLines, SLOW_RESET_PREFIX } from '../../tools/vendor/start-browser-server.mjs'; import { workflowWorkers } from '../../tools/vendor/test-workers.mjs'; const fakeServer = fileURLToPath(new URL('./fake-browser-server.mjs', import.meta.url)); @@ -193,6 +193,29 @@ test('a failed test gets the server stderr written during it attached; a passing assert.equal(String(attached[0].body), 'Traceback (most recent call last): boom\n'); }); +test('only complete slow-reset report lines are forwarded, however the chunks split them', () => { + const written = []; + const forward = forwardPrefixedLines(SLOW_RESET_PREFIX, text => written.push(text)); + forward(`aiohttp access line\n${SLOW_RESET_PREFIX}POST /__test__/reset still`); + assert.deepEqual(written, [], 'a report line waits for its newline'); + forward(` running after 2.000s\nplain stderr\n${SLOW_RESET_PREFIX}Thread 0x1 [asyncio_0]\n${SLOW_RESET_PREFIX.slice(0, 5)}`); + forward(`${SLOW_RESET_PREFIX.slice(5)} File "server.py", line 1\nnot a report ${SLOW_RESET_PREFIX}\n`); + assert.deepEqual(written, [ + `${SLOW_RESET_PREFIX}POST /__test__/reset still running after 2.000s\n${SLOW_RESET_PREFIX}Thread 0x1 [asyncio_0]\n`, + `${SLOW_RESET_PREFIX} File "server.py", line 1\n`, + ]); +}); + +test('a slow-reset report on server stderr reaches the worker stderr; other stderr does not', async t => { + const reported = []; + const server = await startBrowserServer({ ...fake(), report: text => reported.push(text) }); + t.after(() => server.stop()); + await writeStderr(server, 'Traceback: ordinary stderr\n'); + await writeStderr(server, `${SLOW_RESET_PREFIX}POST /__test__/reset answered 200 after 16.064s\n`); + assert.deepEqual(reported, [`${SLOW_RESET_PREFIX}POST /__test__/reset answered 200 after 16.064s\n`]); + assert.ok(server.stderrTail().includes('Traceback: ordinary stderr'), 'the stderr tail still keeps everything'); +}); + test('a server exits with its worker even when the worker is SIGKILLed', async () => { const pid = await serverOfKilledWorker(`{ command: process.execPath, args: port => [${JSON.stringify(fakeServer)}, '--port', String(port), '--exit-on-stdin-eof'] }`); await waitFor(() => !alive(pid), `server ${pid} to exit after its worker died`); diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json index 9fc588f..7d82941 100644 --- a/tools/vendor/frontend-coverage-baseline.json +++ b/tools/vendor/frontend-coverage-baseline.json @@ -1,9 +1,9 @@ { "admin.js": { - "lines": 28, + "lines": 31, "branches": 58, - "functions": 23, - "statements": 28 + "functions": 26, + "statements": 31 }, "api-client.js": { "lines": 98, @@ -42,10 +42,10 @@ "statements": 98 }, "gallery.js": { - "lines": 39, + "lines": 40, "branches": 63, - "functions": 43, - "statements": 39 + "functions": 44, + "statements": 40 }, "html-escape.js": { "lines": 98, @@ -157,7 +157,7 @@ }, "workspace/activity-job-view.mjs": { "lines": 97, - "branches": 83, + "branches": 86, "functions": 98, "statements": 97 }, @@ -169,19 +169,19 @@ }, "workspace/activity-store.mjs": { "lines": 98, - "branches": 95, + "branches": 96, "functions": 98, "statements": 98 }, "workspace/activity.mjs": { - "lines": 95, + "lines": 96, "branches": 87, "functions": 98, - "statements": 95 + "statements": 96 }, "workspace/file-inspector.mjs": { "lines": 96, - "branches": 92, + "branches": 93, "functions": 92, "statements": 96 }, @@ -229,8 +229,8 @@ }, "workspace/image-tagging.mjs": { "lines": 97, - "branches": 88, - "functions": 87, + "branches": 90, + "functions": 88, "statements": 97 }, "workspace/images-category.mjs": { @@ -253,7 +253,7 @@ }, "workspace/images-selection.mjs": { "lines": 98, - "branches": 92, + "branches": 93, "functions": 98, "statements": 98 }, @@ -263,6 +263,18 @@ "functions": 98, "statements": 98 }, + "workspace/images-tagging-bulk.mjs": { + "lines": 98, + "branches": 93, + "functions": 86, + "statements": 98 + }, + "workspace/images-tagging-review.mjs": { + "lines": 98, + "branches": 93, + "functions": 98, + "statements": 98 + }, "workspace/images-tagging.mjs": { "lines": 98, "branches": 98, @@ -284,7 +296,7 @@ "workspace/inspect.mjs": { "lines": 98, "branches": 92, - "functions": 92, + "functions": 93, "statements": 98 }, "workspace/inspection-state.mjs": { @@ -329,12 +341,42 @@ "functions": 98, "statements": 98 }, + "workspace/local-parameters.mjs": { + "lines": 98, + "branches": 94, + "functions": 98, + "statements": 98 + }, "workspace/local-projection.mjs": { "lines": 97, "branches": 96, "functions": 98, "statements": 97 }, + "workspace/local-resolver-stages.mjs": { + "lines": 94, + "branches": 93, + "functions": 98, + "statements": 94 + }, + "workspace/local-resolver-support.mjs": { + "lines": 96, + "branches": 80, + "functions": 98, + "statements": 96 + }, + "workspace/local-resolver.mjs": { + "lines": 96, + "branches": 86, + "functions": 98, + "statements": 96 + }, + "workspace/local-semantics.mjs": { + "lines": 98, + "branches": 98, + "functions": 98, + "statements": 98 + }, "workspace/local-session.mjs": { "lines": 98, "branches": 92, @@ -377,6 +419,12 @@ "functions": 98, "statements": 98 }, + "workspace/py-json.mjs": { + "lines": 97, + "branches": 92, + "functions": 98, + "statements": 97 + }, "workspace/recorded-run.mjs": { "lines": 95, "branches": 83, diff --git a/tools/vendor/start-browser-server.mjs b/tools/vendor/start-browser-server.mjs index f307016..75d4712 100644 --- a/tools/vendor/start-browser-server.mjs +++ b/tools/vendor/start-browser-server.mjs @@ -17,6 +17,22 @@ const HOST = '127.0.0.1'; // tests/browser/server.py prints this once it holds its listening socket. Only a // complete line counts: a chunk may end mid-number. const PORT_ANNOUNCEMENT = /^PM_TEST_SERVER_PORT=(\d+)\r?\n/m; +// tests/browser/slow_reset_watch.py starts every line of a slow-reset report with +// this. Those lines go to the worker's stderr, so the job log shows a slow reset +// even when its test passes. +export const SLOW_RESET_PREFIX = 'pm-slow-reset| '; + +// A stderr chunk handler that passes every complete line starting with `prefix` +// to `write`. A line split across chunks is held until its newline arrives. +export function forwardPrefixedLines(prefix, write) { + let pending = ''; + return chunk => { + const lines = (pending + chunk).split('\n'); + pending = lines.pop().slice(-STDERR_TAIL_BYTES); + const matched = lines.filter(line => line.startsWith(prefix)); + if (matched.length) write(matched.map(line => `${line}\n`).join('')); + }; +} // The server exits when its stdin closes, so a worker that dies without teardown // (SIGKILL) leaves no server behind; the launcher holds the pipe's other end. @@ -40,6 +56,7 @@ export async function startBrowserServer({ cwd = repositoryRoot, timeoutMs = STARTUP_TIMEOUT_MS, stderrLogChars = STDERR_LOG_CHARS, + report = chunk => process.stderr.write(chunk), } = {}) { if (!Number.isInteger(port) || port < 0 || port > 65535) { throw new Error(`startBrowserServer needs a port from 0 to 65535, got ${port}`); @@ -50,7 +67,9 @@ export async function startBrowserServer({ let stderr = ''; let stderrTotal = 0; child.stderr.setEncoding('utf8'); + const forwardReport = forwardPrefixedLines(SLOW_RESET_PREFIX, report); child.stderr.on('data', chunk => { + forwardReport(chunk); stderr = (stderr + chunk).slice(-Math.max(stderrLogChars, STDERR_TAIL_BYTES)); stderrTotal += chunk.length; }); diff --git a/utils/prompt_inspector.py b/utils/prompt_inspector.py index 9c1bb08..e7ab375 100644 --- a/utils/prompt_inspector.py +++ b/utils/prompt_inspector.py @@ -20,6 +20,7 @@ from .prompt_pair_resolver import ( _FOOTER, EMPTY_POSITIVE_REASON, INSPECTION_PROFILE, + MALFORMED_EVIDENCE_REASON, ResolverProfile, resolve_prompt_pair, ) @@ -548,7 +549,7 @@ def inspect_prompt_evidence( resolution = { "state": "unresolved", "sampler_id": None, - "reason": "Prompt evidence is malformed", + "reason": MALFORMED_EVIDENCE_REASON, "reason_code": "malformed", "node": None, } diff --git a/utils/prompt_pair_resolver.py b/utils/prompt_pair_resolver.py index dfcdf7a..ed98ce5 100644 --- a/utils/prompt_pair_resolver.py +++ b/utils/prompt_pair_resolver.py @@ -1243,10 +1243,13 @@ class _GraphResolver: def non_file_outputs(self) -> set: names = self.semantics.non_file_outputs + # Only a string names a class: a list or object is never looked up (#286). return { node_id for node_id, node in self.graph.items() - if isinstance(node, dict) and node.get("class_type") in names + if isinstance(node, dict) + and isinstance(node.get("class_type"), str) + and node["class_type"] in names } def producers_of(self, starts: list, previews: set) -> set: @@ -1838,6 +1841,8 @@ def _shared_pair(candidates: list) -> Optional[Dict[str, Any]]: # Bounds for walking a UI workflow's subgraph instances (R1 only). _MAX_SUBGRAPH_DEPTH = 8 +# The inspector's own line for evidence it cannot read (``inspect_prompt_evidence``). +MALFORMED_EVIDENCE_REASON = "Prompt evidence is malformed" _MAX_WORKFLOW_NODES = 100_000 @@ -1868,7 +1873,9 @@ def _prompt_manager_widgets(workflow: Any) -> Dict[str, str]: Nodes inside a subgraph run as ``"<instance>:<inner>"`` API IDs (nested instances chain their IDs); a definition without an instance runs nothing. - The walk is bounded in depth and in nodes visited. + The walk is bounded in depth and in nodes visited. A node whose ``type`` is + a list or an object is skipped: it is neither a subgraph instance nor a + PromptManager node (#286). """ nodes = workflow.get("nodes") if isinstance(workflow, dict) else None if not isinstance(nodes, list): @@ -1888,6 +1895,9 @@ def _prompt_manager_widgets(workflow: Any) -> Dict[str, str]: if not isinstance(node_id, (str, int)) or isinstance(node_id, bool): continue api_id = f"{prefix}{node_id}" + if isinstance(kind, (list, dict)): + # JSON's unhashable values are never a node type (#286). + continue if kind in subgraphs and depth < _MAX_SUBGRAPH_DEPTH: stack.append((subgraphs[kind], api_id + ":", depth + 1)) continue diff --git a/web/js/admin.js b/web/js/admin.js index 5aba7aa..e74d347 100644 --- a/web/js/admin.js +++ b/web/js/admin.js @@ -25,8 +25,10 @@ this.tagsPage = null; this.api = ApiClient.createApiClient(); - // Search and page loads share one guard: whichever list request is newest wins - this.listRequest = ApiClient.latestOnly(); + // The page's lifetime ends when it starts to leave. WebKit cancels the page's reads + // between beforeunload and pagehide; a save's follow-up read started then fails and is + // logged as a page error ("Fetch API cannot load … due to access control checks."). + this.startPageLifetime(); this.infiniteScroll = false; // session value; seeded from settings, toggled in the header this.infiniteScrollChosen = false; this.loadingMore = false; @@ -150,7 +152,18 @@ // Output scan: a server-side job that survives modal dismissal and reloads this.scanState = ScanProgress.initialState(); this.scanSource = null; - window.addEventListener("beforeunload", (e) => this.warnIfScanRunning(e)); + window.addEventListener("beforeunload", (e) => { + this.warnIfScanRunning(e); + // A page that asks to stay (a running scan) keeps its reads until it really goes. + if (!e.defaultPrevented) this.pageLifetime.abort(); + }); + window.addEventListener("pagehide", () => this.pageLifetime.abort()); + // A page restored from the back/forward cache lives again, and so does one whose + // navigation never left (stopped, or answered with a download): its next input revives it. + window.addEventListener("pageshow", (e) => { if (e.persisted) this.revivePageLifetime(); }); + for (const type of ["pointerdown", "keydown"]) { + window.addEventListener(type, () => this.revivePageLifetime(), true); + } this.bindModalEvents(); @@ -319,9 +332,24 @@ } } + /** A new lifetime once the old one has ended; a living page keeps its list guard. */ + revivePageLifetime() { + if (this.pageLifetime.signal.aborted) this.startPageLifetime(); + } + + /** A fresh page lifetime, and the list guard bound to it. */ + startPageLifetime() { + this.pageLifetime = new AbortController(); + // Search and page loads share one guard: whichever list request is newest wins + this.listRequest = ApiClient.latestOnly({ lifetime: this.pageLifetime.signal }); + } + async loadStatistics() { + // A leaving page starts no read; one in flight is aborted with the page and not reported. + const { signal } = this.pageLifetime; + if (signal.aborted) return; try { - const response = await ApiClient.fetchUrl("/prompt_manager/stats"); + const response = await ApiClient.fetchUrl("/prompt_manager/stats", { signal }); if (response.ok) { const data = await response.json(); @@ -340,6 +368,7 @@ console.error("Stats API HTTP error:", response.status, response.statusText); } } catch (error) { + if (signal.aborted) return; console.error("Stats error:", error); // Set some default values if API fails document.getElementById("totalPrompts").textContent = this.prompts.length || 0; @@ -1151,6 +1180,11 @@ `Done — ${data.imported} imported, ${data.skipped} skipped`; this.loadStatistics(); setTimeout(() => this.hideModal("loraImportModal"), 2000); + } else if (data.type === "error") { + // A refusal (another job runs) or a failed import: name it and close the modal. + document.getElementById("loraImportStatus").textContent = `Error: ${data.message}`; + this.showNotification(`LoRA import failed: ${data.message}`, "error"); + setTimeout(() => this.hideModal("loraImportModal"), 3000); } } catch (e) { /* skip malformed SSE */ } } @@ -1202,10 +1236,13 @@ } this.hideModal("settingsModal"); } else { - throw new Error("Failed to save settings"); + // A refusal names its reason (a corrupt config.json, a changed file): show it. + const data = await response.json().catch(() => ({})); + throw new Error(data.error ? `Failed to save settings: ${data.error}` : "Failed to save settings"); } } catch (error) { - this.showNotification("Failed to save settings", "error"); + const reason = error.message.startsWith("Failed to save settings:") ? error.message : "Failed to save settings"; + this.showNotification(reason, "error"); } } @@ -1819,7 +1856,9 @@ throw new Error(data.error || 'Maintenance failed'); } } else { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); + // A refusal names the job that runs ("Scan for metadata already running"). + const data = await response.json().catch(() => ({})); + throw new Error(data.error || `HTTP ${response.status}: ${response.statusText}`); } } catch (error) { console.error('Maintenance error:', error); diff --git a/web/js/api-client.js b/web/js/api-client.js index df1f7a0..b334467 100644 --- a/web/js/api-client.js +++ b/web/js/api-client.js @@ -107,20 +107,28 @@ } /** + * @param {{lifetime?: AbortSignal}} [options] - `lifetime` ends with the page (its pagehide): + * then no call starts a request, the one in flight is aborted, and both resolve to STALE. + * WebKit fails a fetch that a leaving page starts and logs it as a page error. * @returns {(run: (signal: AbortSignal) => Promise<*>) => Promise<*>} wrapper that * aborts the previous call's signal and resolves superseded calls to STALE */ - function latestOnly() { + function latestOnly({ lifetime } = {}) { let current = null; + lifetime?.addEventListener("abort", () => current?.abort(), { once: true }); return async function (run) { if (current) current.abort(); + if (lifetime?.aborted) { + current = null; + return STALE; + } const controller = new AbortController(); current = controller; try { const result = await run(controller.signal); - return controller === current ? result : STALE; + return controller === current && !lifetime?.aborted ? result : STALE; } catch (error) { - if (controller !== current) return STALE; + if (controller !== current || lifetime?.aborted) return STALE; throw error; } }; diff --git a/web/js/gallery.js b/web/js/gallery.js index 56596f7..ee9f24b 100644 --- a/web/js/gallery.js +++ b/web/js/gallery.js @@ -17,6 +17,14 @@ this.dupResults = null; this.api = ApiClient.createApiClient(); + // The page's lifetime ends when it starts to leave: WebKit fails a fetch a leaving page starts + // ("Fetch API cannot load … due to access control checks.") and logs it as a page error. + this.pageLifetime = new AbortController(); + window.addEventListener("beforeunload", () => this.pageLifetime.abort()); + window.addEventListener("pagehide", () => this.pageLifetime.abort()); + window.addEventListener("pageshow", (e) => { + if (e.persisted && this.pageLifetime.signal.aborted) this.pageLifetime = new AbortController(); + }); // Only the newest page request may update the grid this.imagesRequest = ApiClient.latestOnly(); @@ -2129,8 +2137,11 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`; headers: { 'Content-Type': 'application/json' } }); - if (response.ok) { - const data = await response.json(); + if (!response.ok) { + // Refused (a running job holds the thumbnails): say why and claim nothing. + const data = await response.json().catch(() => ({})); + this.showNotification(`Thumbnails were not cleared: ${data.error || `HTTP ${response.status}`}`, 'error'); + return; } } catch (e) { console.warn('Could not clear server thumbnails:', e); @@ -2531,8 +2542,11 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`; return; } + // A leaving page starts no read, and one it aborts is not reported. + const signal = this.pageLifetime?.signal; + if (signal?.aborted) return; try { - const data = await this.api.get('/prompt_manager/images/thumbnails/status'); + const data = await this.api.get('/prompt_manager/images/thumbnails/status', { signal }); const total = data.total_images; const pending = data.pending; if (!data.success || !Number.isSafeInteger(total) || diff --git a/web/js/workspace/activity-job-view.mjs b/web/js/workspace/activity-job-view.mjs index 0732989..f853cb4 100644 --- a/web/js/workspace/activity-job-view.mjs +++ b/web/js/workspace/activity-job-view.mjs @@ -11,6 +11,9 @@ * filter, and the selected outcome's listed rows (at most 200 per outcome, so "Showing N of M"). Rows under Added and * Already known open the file in the existing viewer through its listing identity (D57); the others are plain text. * - Log: `details.term` "Scan log", collapsed, holding the raw timestamped lines in `pre[tabindex=0]`. + * - D89: the tiles, the outcome list, the openable rows and the log and cancel labels are the job kind's (`jobView`): + * an image tagging job shows Tagged, Unchanged, Skipped and Errors, opens Tagged and Unchanged rows, and reads + * "Tagging log" and "Cancel tagging"; another kind keeps the scan's tiles as "Job log" and "Cancel job". * * The summary follows the Activity store (one SSE stream per running job); the listed rows and the log are read from * `GET /prompt_manager/jobs/<id>` at most once a second while the job runs, and once more when it ends. @@ -19,7 +22,7 @@ import { createButton } from '../shared/toolkit/core.js'; import { pageHeading } from './heading.mjs'; import { showWorkspaceToast } from './toast.mjs'; import { noun } from './plural.mjs'; -import { parseJob, JOBS_URL, LOST_DETAIL } from './activity-store.mjs'; +import { parseJob, jobView, JOB_VIEWS, JOBS_URL, LOST_DETAIL } from './activity-store.mjs'; import { jobDetail, CANCEL_FAILED_TOAST } from './activity.mjs'; import { downloadJobReport } from './activity-scan.mjs'; import { createMediaViewer } from './media-viewer.mjs'; @@ -36,18 +39,6 @@ export const FILE_GONE = 'This file is no longer in Images. Rescan from Import a export const OPEN_FAILED = 'The file could not be opened. Try again.'; const LISTING_LIMIT = 5; -/** The counter tiles, in the spec's order and the mockup's tones. */ -export const TILES = Object.freeze([ - ['added', 'Added to Library', 'added'], ['known', 'Already known', ''], ['review', 'Needs review', 'warn'], - ['no_metadata', 'No usable metadata', ''], ['errors', 'Errors', 'bad'], -]); -/** The outcome list, in the mockup's order: what got added and what went wrong first. */ -export const OUTCOMES = Object.freeze([ - ['added', 'Added to Library'], ['review', 'Needs review'], ['errors', 'Errors'], - ['no_metadata', 'No usable metadata'], ['known', 'Already known'], -]); -const OPENABLE = new Set(['added', 'known']); - function element(tag, text, className) { const node = document.createElement(tag); if (text !== undefined) node.textContent = text; @@ -103,9 +94,9 @@ export function filterRows(rows, filter) { return rows.filter(row => `${row.root}/${row.path}`.toLocaleLowerCase().includes(wanted)); } -/** The first outcome with files, so a view opens on what happened; Added when nothing has yet. */ -export function defaultOutcome(counters) { - return OUTCOMES.find(([group]) => (counters[group] ?? 0) > 0)?.[0] ?? 'added'; +/** The first outcome with files, so a view opens on what happened; the first outcome when nothing has yet. */ +export function defaultOutcome(counters, outcomes = JOB_VIEWS.scan.outcomes) { + return (outcomes.find(([group]) => (counters[group] ?? 0) > 0) ?? outcomes[0])[0]; } function section(title, id) { @@ -132,13 +123,6 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) { bar.max = 100; const stats = element('dl', undefined, 'stat-row'); const tileValues = new Map(); - for (const [group, label, tone] of TILES) { - const tile = element('div', undefined, tone ? `stat ${tone}` : 'stat'); - const value = element('dd', '0'); - tile.append(element('dt', label), value); - tileValues.set(group, value); - stats.append(tile); - } progressPart.node.append(bar, stats); const resultsPart = section('Results', 'job-results-title'); @@ -155,15 +139,6 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) { list.setAttribute('aria-label', 'Outcomes'); const files = element('div', undefined, 'outcome-files'); const buttons = new Map(); - for (const [group, label] of OUTCOMES) { - const button = createButton(''); - const count = element('span', '0'); - button.append(element('span', label), count); - button.setAttribute('aria-pressed', 'false'); - button.count = count; - buttons.set(group, button); - list.append(button); - } outcomes.append(list, files); resultsPart.node.append(outcomes); @@ -181,6 +156,8 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) { missing.append(element('p', NOT_FOUND, 'empty-state'), back); let detail = null; + // The job's kind decides its tiles, outcomes and labels (D89); it is known once the job is. + let view = null; let selected = null; let mounted = false; let lastRead = 0; @@ -200,13 +177,35 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) { const viewer = createMediaViewer({ api, container: main, parentScope: scope, inspectionController: inspection, sender: createPromptSender({ scope }), onSelect: image => inspection.select(image) }); + /** The tiles and the outcome list of the job's kind, built once. */ + function layout(kind) { + view = jobView(kind); // D89 + for (const [group, label, tone] of view.tiles) { + const tile = element('div', undefined, tone ? `stat ${tone}` : 'stat'); + const value = element('dd', '0'); + tile.append(element('dt', label), value); + tileValues.set(group, value); + stats.append(tile); + } + for (const [group, label] of view.outcomes) { + const button = createButton(''); + const count = element('span', '0'); + button.append(element('span', label), count); + button.setAttribute('aria-pressed', 'false'); + button.count = count; + buttons.set(group, button); + list.append(button); + } + } + function showBody(job) { if (mounted) return; mounted = true; + if (!view) layout(job.kind); missing.remove(); parts.actions.append(download, cancel); main.append(progressPart.node, resultsPart.node, log); - selected ??= defaultOutcome(job.counters); + selected ??= defaultOutcome(job.counters, view.outcomes); } /** The job is not (or no longer) in this session: say so instead of an empty view. */ @@ -242,7 +241,7 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) { function renderRows(job) { const rows = detail?.outcomes[selected] ?? []; - const openable = OPENABLE.has(selected); + const openable = view.openable.has(selected); const wanted = filterRows(rows.map((row, index) => ({ ...row, index })), input.value); const kept = new Map(wanted.map(row => rowNode(row, row.index, openable))); rowNodes = kept; @@ -270,12 +269,13 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) { parts.h1.textContent = job.title; document.title = `${job.title} · Prompt Manager`; parts.description.textContent = jobDetail(job); - cancel.textContent = scan ? 'Cancel scan' : 'Cancel job'; + cancel.textContent = view.cancel; // A job lost with the server has no report there (review M9). const lost = job.state === 'lost'; if (lost && document.activeElement === download) parts.h1.focus(); download.disabled = lost; - if (job.state === 'running') { + // Truth rule: a job that cannot be cancelled (a held legacy-page job) shows no Cancel. + if (job.state === 'running' && job.cancellable) { if (!cancel.isConnected) parts.actions.append(cancel); } else if (cancel.isConnected) { // A focused Cancel that goes away hands its focus to the job's heading, never to the page body. @@ -291,7 +291,7 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) { button.count.textContent = number(job.counters[group] ?? 0); button.setAttribute('aria-pressed', String(group === selected)); } - logLabel.textContent = `● ${scan ? 'Scan' : 'Job'} log`; + logLabel.textContent = `● ${view.log}`; const text = detail?.log.length ? detail.log.join('\n') : 'No log lines yet.'; if (pre.textContent !== text) { // Replacing the text clamps the scroll position, so keep it: pinned readers follow the end, others stay. diff --git a/web/js/workspace/activity-store.mjs b/web/js/workspace/activity-store.mjs index 2e1f976..72eb6ab 100644 --- a/web/js/workspace/activity-store.mjs +++ b/web/js/workspace/activity-store.mjs @@ -23,10 +23,52 @@ export const LOST_DETAIL = 'This job ended when ComfyUI restarted.'; const SERVER_STATES = new Set(['running', 'finished', 'cancelled', 'failed']); const FINAL_STATES = new Set(['finished', 'cancelled', 'failed']); const JOB_ID = /^[0-9a-f]{32}$/; -// The pill's verb for each job kind; a kind without one shows its title. -const PILL_VERBS = Object.freeze({ scan: 'Scanning' }); +// The pill's verb for each job kind; a kind without one shows its title (D89: image tagging jobs read "Tagging"). +const PILL_VERBS = Object.freeze({ scan: 'Scanning', 'image-tags': 'Tagging' }); const START_TOASTS = Object.freeze({ scan: 'Scan started.' }); +/** The scan's job view (D56): its counter tiles `[group, label, tone]` and outcome list in the mockup's orders. */ +const SCAN_VIEW = Object.freeze({ + tiles: Object.freeze([ + ['added', 'Added to Library', 'added'], ['known', 'Already known', ''], ['review', 'Needs review', 'warn'], + ['no_metadata', 'No usable metadata', ''], ['errors', 'Errors', 'bad'], + ].map(Object.freeze)), + // What got added and what went wrong first. + outcomes: Object.freeze([ + ['added', 'Added to Library'], ['review', 'Needs review'], ['errors', 'Errors'], + ['no_metadata', 'No usable metadata'], ['known', 'Already known'], + ].map(Object.freeze)), + // Rows that open the file in the viewer (D57); the others are plain text. + openable: Object.freeze(new Set(['added', 'known'])), + log: 'Scan log', + cancel: 'Cancel scan', +}); + +/** + * Each job kind's view: its tiles, outcome list, openable rows, log label and cancel label. D89: an image tagging job + * (`image-tags`) counts Tagged (green), Unchanged, Skipped (warning) and Errors (red), lists what got tagged and what + * went wrong first, and opens Tagged and Unchanged rows in the viewer like the scan's (D57). + */ +export const JOB_VIEWS = Object.freeze({ + scan: SCAN_VIEW, + 'image-tags': Object.freeze({ + tiles: Object.freeze([['tagged', 'Tagged', 'added'], ['unchanged', 'Unchanged', ''], ['skipped', 'Skipped', 'warn'], + ['errors', 'Errors', 'bad']].map(Object.freeze)), + outcomes: Object.freeze([['tagged', 'Tagged'], ['errors', 'Errors'], ['skipped', 'Skipped'], + ['unchanged', 'Unchanged']].map(Object.freeze)), + openable: Object.freeze(new Set(['tagged', 'unchanged'])), + log: 'Tagging log', + cancel: 'Cancel tagging', + }), +}); +// Another kind keeps the scan's tiles and outcomes under the generic labels. +const GENERIC_VIEW = Object.freeze({ ...SCAN_VIEW, log: 'Job log', cancel: 'Cancel job' }); + +/** The view of a job `kind`; an unknown kind gets the generic one. */ +export function jobView(kind) { + return Object.hasOwn(JOB_VIEWS, kind) ? JOB_VIEWS[kind] : GENERIC_VIEW; +} + /** The wait before the `attempt`-th re-read (0-based): 1 s, 2 s, 4 s … never more than 30 s. */ export function backoffDelay(attempt) { const step = Number.isSafeInteger(attempt) && attempt > 0 ? Math.min(attempt, 16) : 0; @@ -61,6 +103,8 @@ export function parseJob(raw) { scope: plainObject(raw.scope) ? { ...raw.scope } : {}, listed, log_length: count(raw.log_length), + // A held legacy-page job cannot be cancelled; an older server sends no key (cancellable then). + cancellable: raw.cancellable !== false, }; } @@ -70,20 +114,31 @@ const errorsOf = job => `${number(job.counters.errors ?? 0)} ${noun(job.counters /** "1,420 added". */ export const addedOf = job => `${number(job.counters.added ?? 0)} added`; -/** The toast when a job ends: "Scan finished · N added · M errors", "Scan cancelled · N added". */ +/** + * The toast when a job ends: "Scan finished · N added · M errors", "Scan cancelled · N added"; an image tagging job + * the same way with its own title and count (D89): "Auto-tag images finished · N tagged · M errors". + */ export function finishToast(job) { if (job.kind === 'scan') { if (job.state === 'cancelled') return `Scan cancelled · ${addedOf(job)}`; if (job.state === 'failed') return `Scan failed · ${addedOf(job)} · ${errorsOf(job)}`; return `Scan finished · ${addedOf(job)} · ${errorsOf(job)}`; } + if (job.kind === 'image-tags') { + if (job.state === 'cancelled') return `${job.title} cancelled · ${taggedOf(job)}`; + return `${job.title} ${job.state === 'failed' ? 'failed' : 'finished'} · ${outcomeLine(job)}`; + } if (job.state === 'cancelled') return `${job.title} was cancelled.`; if (job.state === 'failed') return `${job.title} failed.`; return `${job.title} finished.`; } +// D85: an image tagging job says which of its two modes started. +const IMAGE_TAG_START_TOASTS = Object.freeze({ add: 'Tagging started.', auto: 'Auto-tagging started.' }); + /** The toast after this tab starts a job. */ export function startToast(job) { + if (job.kind === 'image-tags' && IMAGE_TAG_START_TOASTS[job.scope.mode]) return IMAGE_TAG_START_TOASTS[job.scope.mode]; return START_TOASTS[job.kind] ?? `${job.title} started.`; } @@ -94,9 +149,14 @@ export function pillParts(job) { return { label, files, text: label + files }; } -/** The scan's counters as one line: "N added · M errors"; other kinds have none yet. */ +/** "12 tagged" (D89). */ +export const taggedOf = job => `${number(job.counters.tagged ?? 0)} tagged`; + +/** The counters as one line: "N added · M errors" for a scan, "N tagged · M errors" for image tagging (D89). */ export function outcomeLine(job) { - return job.kind === 'scan' ? `${addedOf(job)} · ${errorsOf(job)}` : ''; + if (job.kind === 'scan') return `${addedOf(job)} · ${errorsOf(job)}`; + if (job.kind === 'image-tags') return `${taggedOf(job)} · ${errorsOf(job)}`; + return ''; } function defaultConnect(url) { diff --git a/web/js/workspace/activity.mjs b/web/js/workspace/activity.mjs index 1a192aa..eb7bc14 100644 --- a/web/js/workspace/activity.mjs +++ b/web/js/workspace/activity.mjs @@ -11,13 +11,14 @@ * - D54: each `.activity-item` is a real job. Its chip names the state (green "Completed", or blue "Running", * "Cancelled", "Failed", "Ended"), its line says when and what, and a running job shows its `<progress>` and * "Cancel job" (no "Simulate completion"). + * - D89: an image tagging job's line names its images and, once ended, "N tagged · M errors". */ import { createButton } from '../shared/toolkit/core.js'; import { navigateTo } from './state.mjs'; import { pageHeading } from './heading.mjs'; import { showWorkspaceToast } from './toast.mjs'; -import { countOf } from './plural.mjs'; -import { pillParts, outcomeLine, addedOf, LOST_DETAIL } from './activity-store.mjs'; +import { countOf, noun } from './plural.mjs'; +import { pillParts, outcomeLine, addedOf, taggedOf, LOST_DETAIL } from './activity-store.mjs'; export const ACTIVITY_DESCRIPTION = 'Scans, tagging, downloads and maintenance share one history.'; export const EMPTY_ACTIVITY = 'No jobs have run since ComfyUI started. Scans and other long operations appear here.'; @@ -44,12 +45,21 @@ export function clockTime(seconds) { const join = parts => parts.filter(Boolean).join(' · '); -/** What a job covers: "2 output folders" for a scan. */ +const whole = value => Number.isSafeInteger(value) && value >= 0; + +/** What a job covers: "2 output folders" for a scan, "4 images" for image tagging (D89). */ function scopeLine(job) { + // D89: grouped as the server writes its lines ("19,622 images"). + const images = job.scope?.images; + if (job.kind === 'image-tags') return whole(images) ? `${images.toLocaleString('en-US')} ${noun(images, 'image')}` : ''; const folders = job.scope?.output_folders; - return Number.isSafeInteger(folders) && folders >= 0 ? countOf(folders, 'output folder') : ''; + return whole(folders) ? countOf(folders, 'output folder') : ''; } +/** What a cancelled job kept: "N added" for a scan, "N tagged" for image tagging (D89). */ +const KEPT = Object.freeze({ __proto__: null, scan: addedOf, 'image-tags': taggedOf }); // D89 +const keptOf = job => KEPT[job.kind]?.(job) ?? ''; + /** The item's line under its title, by state. */ export function jobDetail(job) { const at = clockTime(job.finished_at); @@ -58,8 +68,7 @@ export function jobDetail(job) { } if (job.state === 'lost') return LOST_DETAIL; if (job.state === 'cancelled') { - return join([`Cancelled ${at} at a file boundary`, job.kind === 'scan' ? addedOf(job) : '', - 'completed work is kept']); + return join([`Cancelled ${at} at a file boundary`, keptOf(job), 'completed work is kept']); } if (job.state === 'failed') return join([`Stopped on an error at ${at}`, outcomeLine(job)]); return join([`Finished ${at}`, outcomeLine(job)]); @@ -144,11 +153,15 @@ function itemFor(job, scope) { progress.textContent = `${next.progress}%`; progress.setAttribute('aria-label', `${next.title} progress`); if (!progress.isConnected) item.insertBefore(progress, actions.isConnected ? actions : null); - if (!actions.isConnected) item.append(actions); } else { + progress.remove(); + } + // Truth rule: a job that cannot be cancelled (a held legacy-page job) shows no Cancel job. + if (running && next.cancellable) { + if (!actions.isConnected) item.append(actions); + } else if (actions.isConnected) { // A focused Cancel job that goes away hands its focus to the item's title link, never to the page body. if (actions.contains(document.activeElement)) link.focus(); - progress.remove(); actions.remove(); } }; diff --git a/web/js/workspace/file-inspector.mjs b/web/js/workspace/file-inspector.mjs index 11309cb..2d7a032 100644 --- a/web/js/workspace/file-inspector.mjs +++ b/web/js/workspace/file-inspector.mjs @@ -5,6 +5,7 @@ import { createSendButton } from './prompt-sender.mjs'; import { createWorkspaceIcon } from './icons.mjs'; import { navigateTo } from './state.mjs'; import { showWorkspaceToast } from './toast.mjs'; +import { openCopyFallback } from './workspace-dialogs.mjs'; const RAW_KEYS = ['prompt', 'workflow', 'parameters', 'comfyassets_prompt_pairs']; const RAW_LIMIT = 'Raw metadata exceeds the response limit. Download the original file to inspect it externally.'; @@ -26,6 +27,26 @@ const REVEAL_BUSY = 'The folder is already opening.'; const REVEAL_FAILED = 'The folder could not be opened. Try again.'; const FACTS = [['seed', 'Seed'], ['steps', 'Steps'], ['cfg', 'CFG'], ['sampler', 'Sampler'], ['scheduler', 'Scheduler'], ['model', 'Model']]; +// D117 (LI-4): the legacy metadata page's COPY ALL (web/metadata.html copyAllData), its lines and placeholders. +const SUMMARY_LINES = [['Checkpoint', 'model'], ['Positive Prompt', 'positive'], ['Negative Prompt', 'negative'], ['Steps', 'steps'], + ['CFG Scale', 'cfg'], ['Sampler', 'sampler'], ['Seed', 'seed']]; +const SUMMARY_MISSING = {positive:'No prompt found', negative:'No negative prompt found'}; + +/** + * The one-click settings summary (D117): the legacy COPY ALL text for what this inspector shows. A confirmed prompt + * and a known fact are copied verbatim; anything else, an explicitly empty prompt included, takes the legacy page's + * placeholder, as its parse does (`hasValue` treats '' as absent). + */ +export function settingsSummary(record) { + const roles = promptRoles(record?.prompts); + const value = key => { + if (Object.hasOwn(SUMMARY_MISSING, key)) return roles[key] ? roles[key] : SUMMARY_MISSING[key]; + const fact = record?.generation?.[key]; + return fact?.status === 'known' && typeof fact.value === 'string' && fact.value !== '' ? fact.value : 'Unknown'; + }; + return SUMMARY_LINES.map(([label, key]) => `${label}: ${value(key)}`).join('\n'); +} + function safeFilename(value) { return String(value || 'file').replace(/[\\/\x00-\x1f\x7f<>:"|?*]/g, '_').replace(/^\.+|[. ]+$/g, '') || 'file'; } @@ -175,7 +196,7 @@ function prefixedChip(prefix, text) { * `variant: 'full'` (the viewer's `#viewer-details`, imageDetails(i, true)) leaves out the cover and adds "File & * provenance" and "Workflow & raw metadata" with the downloads. `inline: true` means `panel`; neither means `full`. */ -export function mountFileInspector(container, {api, sender, parentScope, variant, inline = false, promptContext = '', onTagFilter, onRetry, onRefresh, onInteraction = () => {}}) { +export function mountFileInspector(container, {api, sender, parentScope, variant, inline = false, promptContext = '', onTagFilter, onRetry, onRefresh, onInteraction = () => {}, copyFallback = openCopyFallback}) { if (parentScope.signal.aborted) return {setState() {}, dispose() {}}; const scope = childScope(parentScope); let contentScope; @@ -184,7 +205,7 @@ export function mountFileInspector(container, {api, sender, parentScope, variant scope.listen(container, 'keydown', onInteraction); scope.listen(container, 'click', onInteraction); scope.listen(container, 'wheel', onInteraction, {passive:true}); - scope.cleanup(() => {contentScope?.dispose(); container.replaceChildren(); container.classList.remove('file-inspector');}); + scope.cleanup(() => {contentScope?.dispose(); container.replaceChildren(); container.classList.remove('file-inspector'); container.removeAttribute('aria-busy');}); const full = (variant ?? (inline ? 'panel' : 'full')) === 'full'; function statusView(state, parent) { @@ -203,6 +224,8 @@ export function mountFileInspector(container, {api, sender, parentScope, variant if (scope.signal.aborted) return; contentScope?.dispose(); contentScope = childScope(scope); container.replaceChildren(); + // A local read resolves on this thread in bounded steps; the busy state stays until its answer lands. + if (state.loading) container.setAttribute('aria-busy', 'true'); else container.removeAttribute('aria-busy'); render(state); } /** @@ -244,7 +267,7 @@ export function mountFileInspector(container, {api, sender, parentScope, variant inner.append(tags); } inner.append(...generationFacts(record, 'meta-grid', 'small')); - if (full) inner.append(provenance(record, badge), rawMetadata(record)); + if (full) inner.append(summaryButton(record), provenance(record, badge), rawMetadata(record)); const footer = full ? container : el('div', undefined, 'detail-footer'), roles = promptRoles(record.prompts); if (Object.hasOwn(roles, 'positive') && Object.hasOwn(roles, 'negative')) { const pair = createButton('', 'send'); @@ -264,6 +287,23 @@ export function mountFileInspector(container, {api, sender, parentScope, variant } if (!full && footer.childNodes.length) container.append(footer); } + /** + * D117 (LI-4): "Copy settings summary" after the six facts, the legacy COPY ALL. It copies as the concept's prompt + * copy does: the shared toast, or the select-to-copy dialog where the clipboard is unavailable or refused. + */ + function summaryButton(record) { + const button = createButton('Copy settings summary', 'settings-summary'), text = settingsSummary(record); + contentScope.listen(button, 'click', async () => { + try { + if (!navigator.clipboard) throw new Error('Clipboard requires a secure context'); + await navigator.clipboard.writeText(text); + if (!contentScope.signal.aborted) showWorkspaceToast('Settings summary copied.'); + } catch { + if (!contentScope.signal.aborted) copyFallback(text, {scope:contentScope, trigger:button, title:'Copy settings summary'}); + } + }); + return button; + } /** The concept's plain cover; the card image opens the viewer (D19). Video keeps its own element. */ function cover(record) { const {file} = record, video = file?.media_type === 'video'; diff --git a/web/js/workspace/image-tagging-state.mjs b/web/js/workspace/image-tagging-state.mjs index e37b6b6..e85b959 100644 --- a/web/js/workspace/image-tagging-state.mjs +++ b/web/js/workspace/image-tagging-state.mjs @@ -128,7 +128,8 @@ export function optionError(model, options) { return null; } -function requestOptions(model, options) { +/** The options a request sends for `model`: numbers parsed, only the model's own keys. */ +export function requestOptions(model, options) { const body = {suggestion_limit: numberOf(options.suggestion_limit)}; if (modelOf(model).classifier) { return {general_threshold: numberOf(options.general_threshold), character_threshold: numberOf(options.character_threshold), ...body}; @@ -177,28 +178,48 @@ function validateReceipt(value) { return value; } -/** Thresholds, instructions and suggestion count from the server, else the documented defaults. */ +/** + * Thresholds, instructions and suggestion count from the server, else the documented defaults, and the models' + * download status (`models`, null when unknown). + */ async function readDefaults(api, signal) { try { const value = await api.get(MODELS_URL, {signal, cache: 'no-store'}); - if (!object(value)) return DEFAULTS; + if (!object(value)) return {defaults: DEFAULTS, models: null}; return { - general_threshold: unit(value.wd14_general_threshold) ? value.wd14_general_threshold : DEFAULTS.general_threshold, - character_threshold: unit(value.wd14_character_threshold) ? value.wd14_character_threshold : DEFAULTS.character_threshold, - instructions: typeof value.default_prompt === 'string' ? value.default_prompt : DEFAULTS.instructions, - suggestion_limit: limit(value.suggestion_limit) ? value.suggestion_limit : DEFAULTS.suggestion_limit, + defaults: { + general_threshold: unit(value.wd14_general_threshold) ? value.wd14_general_threshold : DEFAULTS.general_threshold, + character_threshold: unit(value.wd14_character_threshold) ? value.wd14_character_threshold : DEFAULTS.character_threshold, + instructions: typeof value.default_prompt === 'string' ? value.default_prompt : DEFAULTS.instructions, + suggestion_limit: limit(value.suggestion_limit) ? value.suggestion_limit : DEFAULTS.suggestion_limit, + }, + models: object(value.models) ? value.models : null, }; } catch { // The defaults keep the dialog usable; Generate reports a real failure itself. - return DEFAULTS; + return {defaults: DEFAULTS, models: null}; } } -function initialState(source) { +const UNAVAILABLE_HINT = 'Download it from the legacy Auto-tag page.'; +/** + * Why `model` cannot run, from the `/autotag/models` status (the server's own `model-unavailable` sentence), else '' + * (downloaded, or the status is unknown: the job then reports a missing model itself). + */ +export function modelUnavailable(models, model) { + const option = modelOf(model); + requireValue(option, 'Unknown tagging model'); + const status = object(models) && object(models[model]) ? models[model] : null; + if (!status || status.downloaded !== false) return ''; + const name = typeof status.name === 'string' && status.name.trim() ? status.name : option.label.split(' · ')[0]; + return `${name} is not downloaded. ${UNAVAILABLE_HINT}`; +} + +function initialState(source, preset) { return { phase: 'observing', closed: false, source, showScopeNote: source === 'review', image: null, savedTags: [], tagRevision: 0, currentTags: null, manual: '', - model: MODEL_OPTIONS[0].id, options: {...DEFAULTS}, generated: false, suggestions: [], + model: preset?.model ?? MODEL_OPTIONS[0].id, options: {...DEFAULTS, ...preset?.options}, generated: false, suggestions: [], suggestionsText: COPY.modelChanged, statusText: '', generateSupported: false, generateReason: null, toast: null, // Bumped whenever the controller itself rewrites the option fields or the manual draft, never while typing. optionsRevision: 0, draftRevision: 0, @@ -219,13 +240,31 @@ function derive(state) { }; } -export function createImageTaggingController({api, target, source = 'images', onLateSave = () => {}} = {}) { +/** A `{model, options}` preset (the bulk review's choice) when valid, else a TypeError. */ +function validatePreset(preset) { + if (preset === null || preset === undefined) return null; + requireValue(object(preset) && modelOf(preset.model) && object(preset.options), 'Invalid tagging preset'); + const options = {}; + for (const [key, value] of Object.entries(preset.options)) { + requireValue(OPTION_KEYS.has(key), 'Unknown tagging option'); + options[key] = value; + } + requireValue(!optionError(preset.model, {...DEFAULTS, ...options}), 'Invalid tagging preset'); + return Object.freeze({model: preset.model, options: Object.freeze(options)}); +} + +/** + * `preset` ({model, options}, D90's review session) starts the dialog on that model, and every observation applies + * those options over the server's defaults. + */ +export function createImageTaggingController({api, target, source = 'images', onLateSave = () => {}, preset = null} = {}) { requireValue(object(api) && ['get', 'post', 'put', 'del'].every(name => typeof api[name] === 'function'), 'An api client is required'); const imageTarget = validateTarget(target); requireValue(SOURCES.has(source), 'Unknown tagging source'); requireValue(typeof onLateSave === 'function', 'onLateSave must be a function'); + const chosen = validatePreset(preset); - let state = freeze(derive(initialState(source))); + let state = freeze(derive(initialState(source, chosen))); const listeners = new Set(); let closed = false; let capture = null; @@ -297,7 +336,7 @@ export function createImageTaggingController({api, target, source = 'images', on } capture = {token: value.capture.token, sha256: value.capture.sha256}; epoch = value.library_epoch; - defaults = await defaultsRead; + ({defaults} = await defaultsRead); if (closed || serial !== openSerial) return; observedOnce = true; const {width, height, media_type: mediaType, preview_url: previewUrl, generate} = value.capture; @@ -311,7 +350,7 @@ export function createImageTaggingController({api, target, source = 'images', on savedTags: value.tags.slice(), tagRevision: value.tag_revision, manual: draft === null ? value.tags.join(', ') : changed ? mergeCurrent(value.tags, draft) : draft, draftRevision: state.draftRevision + 1, - options: {...defaults}, optionsRevision: state.optionsRevision + 1, + options: {...defaults, ...chosen?.options}, optionsRevision: state.optionsRevision + 1, generateSupported: generate.supported, generateReason: generate.reason, statusText: changed ? `${COPY.changedElsewhere} ${savedNow(value.tags)}${reason ? ` ${reason}` : ''}` : reason, }); @@ -482,3 +521,66 @@ export function createImageTaggingController({api, target, source = 'images', on subscribe, open, setManual, setModel, setOption, generate, remove, save, retry, cancel, dispose, }; } + +/** + * The model and options of the bulk "Auto-tag selected images" dialog (D85): the same fields as the Tag image dialog's + * model panel, without an image. open() reads the server's defaults and the models' download status once; until it + * lands Start waits (`canStart` false). `unavailable` is why the chosen model cannot run ('' when it can), so the + * dialog refuses to start a job that would fail at once. + */ +export function createTaggingOptionsController({api} = {}) { + requireValue(object(api) && typeof api.get === 'function', 'An api client is required'); + const listeners = new Set(); + let closed = false; + let defaults = DEFAULTS; + let models = null; + let abort = null; + const deriveOptions = value => ({...value, canGenerate: false, unavailable: modelUnavailable(models, value.model), + canStart: value.phase === 'ready' && !modelUnavailable(models, value.model)}); + let state = freeze(deriveOptions({phase: 'loading', model: MODEL_OPTIONS[0].id, options: {...DEFAULTS}, optionsRevision: 0})); + + function set(patch) { + if (closed) return; + state = freeze(deriveOptions({...state, ...patch})); + for (const listener of [...listeners]) listener(state); + } + async function open() { + if (closed || abort) return; + abort = new AbortController(); + const read = await readDefaults(api, abort.signal); + if (closed) return; + ({defaults, models} = read); + set({phase: 'ready', options: {...defaults}, optionsRevision: state.optionsRevision + 1}); + } + function setModel(id) { + requireValue(modelOf(id), 'Unknown tagging model'); + if (closed || id === state.model) return; + // As in the Tag image dialog: the model's fields start from the defaults again; the count is kept (D91). + set({model: id, optionsRevision: state.optionsRevision + 1, + options: {...state.options, general_threshold: defaults.general_threshold, + character_threshold: defaults.character_threshold, instructions: defaults.instructions}}); + } + function setOption(key, value) { + requireValue(OPTION_KEYS.has(key), 'Unknown tagging option'); + set({options: {...state.options, [key]: value}}); + } + /** `{model_type, options}` for a job or a review session, or null while the options are invalid. */ + function request() { + if (optionError(state.model, state.options)) return null; + return {model_type: state.model, options: requestOptions(state.model, state.options)}; + } + function dispose() { + closed = true; + listeners.clear(); + abort?.abort(); + } + function subscribe(listener) { + requireValue(typeof listener === 'function', 'A listener function is required'); + listeners.add(listener); + return () => listeners.delete(listener); + } + return { + get state() {return state;}, + subscribe, open, setModel, setOption, request, dispose, + }; +} diff --git a/web/js/workspace/image-tagging.mjs b/web/js/workspace/image-tagging.mjs index 8993c27..52d31c2 100644 --- a/web/js/workspace/image-tagging.mjs +++ b/web/js/workspace/image-tagging.mjs @@ -101,10 +101,12 @@ function numberInput(value, {min, max, step}) { /** * The concept's `.tag-model-panel` (a1049): `heading`, the Tagging model select, the model's options and Generate. - * It follows `controller` until `scope` is disposed (reused by the bulk Auto-tag dialog). Returns the `element` and - * `requestGenerate()`, Generate's own path (field validity first), for a Retry after a failed Generate. + * It follows `controller` until `scope` is disposed (reused by the bulk Auto-tag dialog, D85, without Generate: + * `generate: false`). Returns the `element`, `validate()` (the fields' native validity, then what the controller would + * refuse; true when valid, else the first invalid field reports) and `requestGenerate()`, Generate's own path (field + * validity first), for a Retry after a failed Generate. */ -export function renderModelPanel({scope, controller, heading = 'Auto-tag this image'}) { +export function renderModelPanel({scope, controller, heading = 'Auto-tag this image', generate: withGenerate = true}) { const panel = el('div', undefined, 'tag-model-panel'); const select = el('select'); for (const option of MODEL_OPTIONS) { @@ -116,7 +118,8 @@ export function renderModelPanel({scope, controller, heading = 'Auto-tag this im options.id = 'image-tag-model-options'; const generate = createButton('', 'ca-primary'); generate.append(createWorkspaceIcon('tagImage'), ' Generate suggestions'); - panel.append(el('h3', heading), field('Tagging model', select, 'image-tag-model'), options, generate); + panel.append(el('h3', heading), field('Tagging model', select, 'image-tag-model'), options); + if (withGenerate) panel.append(generate); const inputs = new Map(); let shownModel = null, shownOptions = null; @@ -161,26 +164,28 @@ export function renderModelPanel({scope, controller, heading = 'Auto-tag this im setDisabled(generate, !state.canGenerate); } select.addEventListener('change', () => controller.setModel(select.value)); - function requestGenerate() { + function validate() { const {state} = controller; - if (!state.canGenerate) return; // The concept's native validity first (a1104–1114), then what the controller would refuse (a blank field). for (const input of inputs.values()) input.setCustomValidity(''); const invalid = [...inputs.values()].find(input => !input.checkValidity()); - if (invalid) { invalid.reportValidity(); return; } + if (invalid) { invalid.reportValidity(); return false; } const error = optionError(state.model, state.options); if (error) { const input = inputs.get(error.field); input.setCustomValidity(error.message); input.reportValidity(); - return; + return false; } - controller.generate(); + return true; + } + function requestGenerate() { + if (controller.state.canGenerate && validate()) controller.generate(); } generate.addEventListener('click', () => { if (!disabled(generate)) requestGenerate(); }); scope.cleanup(controller.subscribe(update)); update(controller.state); - return {element: panel, requestGenerate}; + return {element: panel, requestGenerate, validate}; } function previewOf(image, state) { @@ -192,20 +197,33 @@ function previewOf(image, state) { /** * Open "Tag image" for one listed `image` (its tagging target). `source` is the page (`images`, `library`, `review`, - * `tags`); `onSaved(event)` runs after a confirmed save, as every `onImageTagsSaved` listener does. + * `tags`, `bulk`); `onSaved(event)` runs after a confirmed save, as every `onImageTagsSaved` listener does. + * + * D90 (the bulk review session, `images-tagging-review.mjs`): `actions: 'review'` with `position: {index, total}` + * titles the dialog "Tag image · Image <index> of <total>" and makes its actions Stop reviewing, Skip and Approve and + * next (Approve and finish on the last image). `preset` ({model, options}) applies the session's model and options, + * and `autoGenerate` runs Generate as soon as the file is observed. Approve saves as Save does, without the per-image + * toast (the session toasts its count), and calls `onSaved`; Skip writes nothing and calls `onSkip`; Stop reviewing, + * Escape and × write nothing and call `onStop` once. Swapping the next image's dialog in is not a stop. */ -export function openImageTagging({api, scope, image, source = 'images', trigger = null, onSaved = null}) { +export function openImageTagging({api, scope, image, source = 'images', trigger = null, onSaved = null, preset = null, + autoGenerate = false, actions: mode = 'tag', position = null, onSkip = null, onStop = null}) { const target = imageTarget(image); if (!target) throw new TypeError('The image has no tagging target'); + const review = mode === 'review'; + if (review && !(Number.isSafeInteger(position?.index) && Number.isSafeInteger(position?.total) && + position.index >= 1 && position.index <= position.total)) throw new TypeError('A review needs its position'); + // Set once this dialog hands over to the session (Approve, Skip): its close is then not a Stop. + let advancing = false; const saved = ({filename: name, tags, toast}) => { const event = {target, filename: name, tags, source}; announceSaved(event); + if (toast) showWorkspaceToast(toast); onSaved?.(event); - showWorkspaceToast(toast); }; // A save that settles after the dialog closed is still reported: its toast, and the pages' refresh (I5). const onLateSave = outcome => (outcome.saved ? saved(outcome) : showWorkspaceToast(outcome.message)); - const controller = createImageTaggingController({api, target, source, onLateSave}); + const controller = createImageTaggingController({api, target, source, onLateSave, preset}); const local = createScope(); let open = true; let completed = false; @@ -253,9 +271,15 @@ export function openImageTagging({api, scope, image, source = 'images', trigger // 7. The actions; Retry leads them only while the controller allows it. const actions = el('div', undefined, 'dialog-actions'); const retry = createButton('Retry'); - const cancel = createButton('Cancel'); + const cancel = createButton(review ? 'Stop reviewing' : 'Cancel'); // D90 + const skip = createButton('Skip'); const primary = createButton('Save manual tags', 'ca-primary'); - actions.append(cancel, primary); + const last = review && position.index === position.total; + // D90: Approve with suggestions on screen; after a model change cleared them (M2) Save keeps only the manual field. + const reviewLabel = state => (state.generated ? (last ? 'Approve and finish' : 'Approve and next') + : (last ? 'Save and finish' : 'Save and next')); + if (review) actions.append(cancel, skip, primary); + else actions.append(cancel, primary); const nodes = [heading]; if (controller.state.showScopeNote) nodes.push(el('p', SCOPE_NOTE, 'tag-scope-note small')); @@ -301,6 +325,12 @@ export function openImageTagging({api, scope, image, source = 'images', trigger } function complete(state) { completed = true; + if (review) { + // D90: the session swaps the next image in (or ends), so this dialog is not closed here. + advancing = true; + saved({filename: state.image.filename, tags: state.savedTags.slice(), toast: null}); + return; + } closeWorkspaceDialog(); saved({filename: state.image.filename, tags: state.savedTags.slice(), toast: state.toast}); } @@ -315,8 +345,10 @@ export function openImageTagging({api, scope, image, source = 'images', trigger if (state.suggestions !== shownRows || state.suggestionsText !== shownText) renderSuggestions(state); setText(count, state.countText); setText(status, state.statusText); - setText(primary, state.primaryLabel); + setText(primary, review ? reviewLabel(state) : state.primaryLabel); setDisabled(primary, !state.canSave); + // D90: while Approve's save is in flight the session cannot be left, so its count includes that save. + if (review) for (const button of [skip, cancel]) setDisabled(button, state.phase === 'saving'); if (state.canRetry !== retry.isConnected) { if (state.canRetry) actions.prepend(retry); // D116 else { @@ -327,7 +359,12 @@ export function openImageTagging({api, scope, image, source = 'images', trigger if (state.phase === 'saved') complete(state); } - cancel.addEventListener('click', () => closeWorkspaceDialog()); + cancel.addEventListener('click', () => { if (!disabled(cancel)) closeWorkspaceDialog(); }); + skip.addEventListener('click', () => { + if (!open || advancing || disabled(skip)) return; + advancing = true; + onSkip?.(); + }); // Retry after a failed Generate takes Generate's own path, so an invalid field reports its validity (M4). retry.addEventListener('click', () => (controller.state.phase === 'generate-error' ? requestGenerate() : controller.retry())); primary.addEventListener('click', () => { @@ -345,12 +382,17 @@ export function openImageTagging({api, scope, image, source = 'images', trigger document.getElementById('modal')?.classList.remove('tagging-modal'); local.dispose(); controller.dispose(); + if (review && !advancing) onStop?.(); } - openContentDialog({scope, title: 'Tag image', nodes, trigger, onClose: release}); + const dialogTitle = review ? `Tag image · Image ${position.index} of ${position.total}` : 'Tag image'; // D90 + openContentDialog({scope, title: dialogTitle, nodes, trigger, onClose: release, + canClose: review ? () => controller.state.phase !== 'saving' : null}); document.getElementById('modal')?.classList.add('tagging-modal'); local.cleanup(controller.subscribe(render)); render(controller.state); - controller.open(); + controller.open().then(() => { + if (open && autoGenerate && controller.state.canGenerate) controller.generate(); + }); return { close() { if (open) closeWorkspaceDialog(); }, get controller() { return controller; }, diff --git a/web/js/workspace/images-selection.mjs b/web/js/workspace/images-selection.mjs index 6938e52..c30894a 100644 --- a/web/js/workspace/images-selection.mjs +++ b/web/js/workspace/images-selection.mjs @@ -4,6 +4,7 @@ import { inspectPath, toInspectionRecord } from './inspection-state.mjs'; import { BUNDLE_FILENAME, metadataBundle, collectImagesMetadata, exportOutcome } from './images-export.mjs'; import { CATEGORY_DIALOG, CATEGORY_FAILED, CATEGORY_MAX_LENGTH, CATEGORY_NONE_LINKED, categoryOutcome, categoryTargets, updatedCount } from './images-category.mjs'; import { openInputDialog } from './workspace-dialogs.mjs'; +import { openAddTags, openAutoTag } from './images-tagging-bulk.mjs'; import { showWorkspaceToast } from './toast.mjs'; const CHECKBOX = '.image-overlays input[type="checkbox"]'; @@ -26,8 +27,11 @@ function el(tag, text, className) { * - `onCategorySet()` reloads the categories and the listing after a successful Set category (the reload clears the * selection through `clear`); focus then moves to `focusFallback` if it was on the hidden action. * - `retain(kept)` drops the checked files a refreshed listing no longer holds (a tag save under a filter, I7). + * - With the Activity store `activity`, Add tags and Auto-tag selected (D85) follow the count, as the concept's Add tags + * leads its actions; `onTagged(keys)` re-reads the files a finished tagging job tagged. The selection stays. */ -export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, onCategorySet, checked: initial = Object.freeze(new Set()) }) { +export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, onCategorySet, activity = null, onTagged, + checked: initial = Object.freeze(new Set()) }) { let checked = initial; let exporting = false, categorizing = false; const element = el('div'); @@ -40,7 +44,15 @@ export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, o const clearButton = createButton('Clear'); // Clear hides the bar it sits in, so focus moves to the page heading instead of falling to the page. scope.listen(clearButton, 'click', () => { clear(); focusFallback.focus({ preventScroll: true }); }); - bulk.append(count, setCategory, exportMetadata, clearButton); + bulk.append(count); + if (activity) { + const addTags = createButton('Add tags'); + scope.listen(addTags, 'click', () => openAddTags({ scope, store: activity, images: checkedImages(), trigger: addTags, onTagged })); + const autoTag = createButton('Auto-tag selected'); // D85 + scope.listen(autoTag, 'click', () => openAutoTag({ api, scope, store: activity, images: checkedImages(), trigger: autoTag, onTagged })); + bulk.append(addTags, autoTag); + } + bulk.append(setCategory, exportMetadata, clearButton); /** The count updates in place: the same bar and `strong`, its text written only when it changes. */ function render() { diff --git a/web/js/workspace/images-tagging-bulk.mjs b/web/js/workspace/images-tagging-bulk.mjs new file mode 100644 index 0000000..b4788be --- /dev/null +++ b/web/js/workspace/images-tagging-bulk.mjs @@ -0,0 +1,192 @@ +/** + * The Images selection bar's tagging actions (#249 PR D, Mock inventory M3): + * + * - **Add tags** (the concept's `bulk-tags` simpleInput, app.js a1378–1383): "Tag selected images", "Tags, separated + * by commas", Apply. The names are parsed as the Tag image dialog's manual field parses them, then the `image-tags` + * Activity job adds them to every selected image. + * - **Auto-tag selected** (D85, ruling L1, beyond the concept): "Auto-tag selected images" with the Tag image dialog's + * model panel (its fields show the server's defaults, D91's Suggestions included) and no Generate, then **How to + * apply** (D90, ruling T3-bulk): **Apply automatically** starts the job, **Review each image** starts the browser + * review session (images-tagging-review.mjs). A model that is not downloaded keeps Start disabled and says why. + * + * The toast says the job started (the Activity store's "Tagging started." / "Auto-tagging started."), or why it did + * not: a running record-changing job (named, with its link), a held model, a refused body. Activity shows progress. + * The selection stays either way. When a started job ends having tagged files, `onTagged(keys)` re-reads them. + */ +import { createButton, createScope } from '../shared/toolkit/core.js'; +import { createTaggingOptionsController, parseManualTags } from './image-tagging-state.mjs'; +import { imageTarget, renderModelPanel } from './image-tagging.mjs'; +import { startReviewSession } from './images-tagging-review.mjs'; +import { closeWorkspaceDialog, openContentDialog, openInputDialog } from './workspace-dialogs.mjs'; +import { busyToast } from './activity-scan.mjs'; +import { imageKey } from './images-state.mjs'; +import { countOf } from './plural.mjs'; +import { showWorkspaceToast } from './toast.mjs'; + +export const IMAGE_TAG_JOB = 'image-tags'; +export const MAX_BULK_IMAGES = 500; +export const TOO_MANY = 'Select at most 500 images.'; +export const NO_TAGS = 'Enter at least one tag.'; // D118 +export const ADD_DIALOG = Object.freeze({title: 'Tag selected images', label: 'Tags, separated by commas', action: 'Apply'}); +export const AUTO_TITLE = 'Auto-tag selected images'; +export const APPLY_CHOICES = Object.freeze([ + Object.freeze({value: 'auto', label: 'Apply automatically', action: 'Start auto-tagging'}), + Object.freeze({value: 'review', label: 'Review each image', action: 'Start review'}), +]); +// D118: the concept has no copy for these; they follow D55's "The scan could not start. Try again.". +const START_FAILED = Object.freeze({add: 'Tagging could not start. Try again.', auto: 'Auto-tagging could not start. Try again.'}); +const FINAL = new Set(['finished', 'cancelled', 'failed']); + +function el(tag, text, className) { + const node = document.createElement(tag); + if (text !== undefined) node.textContent = text; + if (className) node.className = className; + return node; +} + +/** + * The job targets of the checked `images` in listing order (their reveal identity), or null when more than 500 are + * checked. A file without a tagging identity is left out. + */ +export function bulkTargets(images) { + if (images.length > MAX_BULK_IMAGES) return null; + return images.map(imageTarget).filter(Boolean); +} + +/** The Add tags field's names, deduplicated as the Tag image dialog's manual field is. */ +export const parseBulkTags = parseManualTags; + +// Duck-typed: this ES module cannot import the UMD api-client.js. +const apiMessage = error => (error?.name === 'ApiError' && error.code && error.message ? error.message : null); + +/** + * Start one `image-tags` job with `body` through `store.start`. Returns the job, or null after toasting why not: the + * busy answer naming the running job, a coded refusal's own sentence (`model-busy`, a bad body), else a fixed one. + */ +export async function startTagJob({store, scope, body, notify = showWorkspaceToast}) { + try { + const result = await store.start(IMAGE_TAG_JOB, body); + if (scope.signal.aborted) return null; + if (result.busy !== undefined) { + notify(busyToast(result.busy)); + return null; + } + return result.job; + } catch (error) { + if (!scope.signal.aborted) notify(apiMessage(error) ?? START_FAILED[body.mode]); + return null; + } +} + +/** Call `onEnd(job)` once the job `id` has ended in `store`; returns the unsubscribe. */ +export function followJobEnd({store, scope, id, onEnd}) { + let done = false; + const check = () => { + const job = store.find(id); + if (done || !job || !FINAL.has(job.state)) return; + done = true; + stop(); + onEnd(job); + }; + const stop = store.subscribe(check); + scope.cleanup(stop); + check(); + return stop; +} + +/** + * Wire one started job: when it ends having tagged files, `onTagged(keys)` gets the image keys it was started for. + */ +function followTagged({store, scope, job, images, onTagged}) { + const keys = new Set(images.map(imageKey)); + followJobEnd({store, scope, id: job.id, onEnd: ended => { if ((ended.counters.tagged ?? 0) > 0) onTagged(keys); }}); +} + +/** + * Add tags: the concept's simpleInput, then the job over `images` (the checked files, taken now). A field that parses + * to no names keeps the dialog and its text, and reports NO_TAGS on the field (D118). `openInput` is the dialog seam. + */ +export function openAddTags({scope, store, images, trigger, onTagged = () => {}, notify = showWorkspaceToast, + openInput = openInputDialog}) { + const targets = bulkTargets(images); + if (!targets) { notify(TOO_MANY); return; } + openInput({scope, trigger, ...ADD_DIALOG, validate: text => (parseBulkTags(text).length ? null : NO_TAGS), + onApply: async text => { + const tags = parseBulkTags(text); + const job = await startTagJob({store, scope, body: {mode: 'add', targets, tags}, notify}); + if (job) followTagged({store, scope, job, images, onTagged}); + }}); +} + +/** The D90 How to apply radio group: Apply automatically (checked) or Review each image. */ +function applyChoice(onChange) { + const group = el('div', undefined, 'field'); + group.setAttribute('role', 'radiogroup'); + const caption = el('span', 'How to apply'); + caption.id = 'auto-tag-apply-label'; + group.setAttribute('aria-labelledby', caption.id); + group.append(caption); + for (const choice of APPLY_CHOICES) { + const label = el('label'); + const input = el('input'); + Object.assign(input, {type: 'radio', name: 'auto-tag-apply', value: choice.value, checked: choice.value === 'auto'}); + input.addEventListener('change', () => { if (input.checked) onChange(choice); }); + label.append(input, ` ${choice.label}`); + group.append(label); + } + return group; +} + +/** + * Auto-tag selected (D85): the model panel, How to apply (D90), Cancel and Start. Start validates the fields as + * Generate does, then starts the job or the review session over `images` (the checked files, taken now). + */ +export function openAutoTag({api, scope, store, images, trigger, onTagged = () => {}, notify = showWorkspaceToast}) { + const targets = bulkTargets(images); + if (!targets) { notify(TOO_MANY); return; } + const listed = images.filter(imageTarget); + const local = createScope(); + const options = createTaggingOptionsController({api}); + // D85: the Tag image dialog's model panel without Generate; its Suggestions field is the job's count (D91). + const {element: panel, validate} = renderModelPanel({scope: local, controller: options, + heading: `Auto-tag ${countOf(listed.length, 'image')}`, generate: false}); // D118 + let choice = APPLY_CHOICES[0]; + const actions = el('div', undefined, 'dialog-actions'); + const cancel = createButton('Cancel'); + const start = createButton(choice.action, 'ca-primary'); + actions.append(cancel, start); + // A model that is not downloaded: the reason, as the Tag image dialog's status line words it. + const status = el('div', undefined, 'small'); + status.id = 'tag-generation-status'; + status.setAttribute('role', 'status'); + const choices = applyChoice(next => { choice = next; start.textContent = next.action; }); // D90 + const render = state => { + if (status.textContent !== state.unavailable) status.textContent = state.unavailable; + if (state.canStart) start.removeAttribute('aria-disabled'); + else start.setAttribute('aria-disabled', 'true'); + }; + local.cleanup(options.subscribe(render)); + render(options.state); + cancel.addEventListener('click', () => closeWorkspaceDialog()); + start.addEventListener('click', () => { + if (start.getAttribute('aria-disabled') === 'true' || !options.state.canStart || !validate()) return; + const request = options.request(); + if (!request) return; + if (choice.value === 'review') { + // The first image's dialog replaces this one in the shell (D90). + startReviewSession({api, scope, images: listed, ...request, trigger}); + return; + } + closeWorkspaceDialog(); + startTagJob({store, scope, body: {mode: 'auto', targets, ...request}, notify}).then(job => { + if (job) followTagged({store, scope, job, images: listed, onTagged}); + }); + }); + openContentDialog({scope, title: AUTO_TITLE, nodes: [panel, choices, status, actions], trigger, onClose: () => { + document.getElementById('modal')?.classList.remove('tagging-modal'); + local.dispose(); + options.dispose(); + }}); + document.getElementById('modal')?.classList.add('tagging-modal'); + options.open(); +} diff --git a/web/js/workspace/images-tagging-review.mjs b/web/js/workspace/images-tagging-review.mjs new file mode 100644 index 0000000..f00aabe --- /dev/null +++ b/web/js/workspace/images-tagging-review.mjs @@ -0,0 +1,75 @@ +/** + * D90 (ruling T3-bulk): "Review each image", the bulk Auto-tag session in the browser. The selected images are walked + * one Tag image dialog at a time (image-tagging.mjs `actions: 'review'`), each opened with the session's model and + * options and Generate already running. Approve saves that image as the dialog's Save does and advances; Skip + * advances without a write; Stop reviewing, Escape and × end the session. Each image is one gated `suggest` call, so + * the session holds no model between images; a refused one (`model-busy`) stays on its image with the dialog's own + * status and Retry until the user retries or skips. Ending toasts "<n> of <M> images tagged."; the selection stays. + */ +import { closeWorkspaceDialog } from './workspace-dialogs.mjs'; +import { openImageTagging } from './image-tagging.mjs'; +import { showWorkspaceToast } from './toast.mjs'; + +/** The closing toast: approved saves of the session's images (the plan's copy verbatim). */ +export function reviewOutcome(tagged, total) { + return `${tagged} of ${total} images tagged.`; +} + +/** + * Walk `images` (listing items with a tagging target, in selection order) with `model_type` and `options`. + * `onSaved(event)` follows every approved save. Returns `{stop()}`, which ends the session (its open dialog closes and + * writes nothing more). `open`, `close` and `notify` are the dialog seams (the defaults in the page). + */ +export function startReviewSession({api, scope, images, model_type: model, options, trigger = null, onSaved = null, + open = openImageTagging, close = closeWorkspaceDialog, notify = showWorkspaceToast}) { + if (!Array.isArray(images) || !images.length) throw new TypeError('A review needs its images'); + const total = images.length; + let index = 0, tagged = 0, ended = false, handle = null; + + function end() { + if (ended) return; + ended = true; + handle = null; + notify(reviewOutcome(tagged, total)); + } + function advance() { + if (ended) return; + index += 1; + if (index >= total) { + // The last dialog handed over (advancing), so closing it is not a Stop. + end(); + close(); + return; + } + show(); + } + function show() { + let current = true; + const settled = () => { + const was = current; + current = false; + return was; + }; + handle = open({api, scope, image: images[index], source: 'bulk', trigger, preset: {model, options}, + autoGenerate: true, actions: 'review', position: {index: index + 1, total}, + onSaved: event => { + // A save that settles after its dialog was left (Skip or Stop while it ran) still counts. + tagged += 1; + onSaved?.(event); + if (settled()) advance(); + }, + onSkip: () => { if (settled()) advance(); }, + onStop: () => { if (settled()) end(); }, + }); + } + + show(); + return { + stop() { + if (ended) return; + const open = handle; + end(); + open?.close(); + }, + }; +} diff --git a/web/js/workspace/images-tagging.mjs b/web/js/workspace/images-tagging.mjs index babfd78..dc3f0f9 100644 --- a/web/js/workspace/images-tagging.mjs +++ b/web/js/workspace/images-tagging.mjs @@ -23,10 +23,13 @@ export function openCardTagging({api, scope, image, action, embedded}) { /** * Follow confirmed tag saves until `scope` ends. `filtered()` tells whether a Tags filter or a search is active, * `selection()` is the selected image key, and `onRefreshing(busy)` marks Load more busy while the re-read runs. + * Returns `refresh(keys)`, the same re-read for the files a bulk tagging job tagged (once per job, D85). */ export function followImageTagSaves({scope, stream, inspection, filtered, selection, onRefreshing}) { - scope.cleanup(onImageTagsSaved(async ({target}) => { - const key = `${target.root_index}:${target.path}`; + scope.cleanup(onImageTagsSaved(({target}) => refresh(new Set([`${target.root_index}:${target.path}`])))); + return refresh; + + async function refresh(keys) { if (filtered()) { onRefreshing(true); try { @@ -37,6 +40,6 @@ export function followImageTagSaves({scope, stream, inspection, filtered, select if (!scope.signal.aborted) onRefreshing(false); } } - if (!scope.signal.aborted && selection() === key) inspection.refresh(); - })); + if (!scope.signal.aborted && keys.has(selection())) inspection.refresh(); + } } diff --git a/web/js/workspace/images.mjs b/web/js/workspace/images.mjs index 19045c0..1ac497a 100644 --- a/web/js/workspace/images.mjs +++ b/web/js/workspace/images.mjs @@ -254,7 +254,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage // The checked cards and the concept's selection bar (#249 D8), between the filter row and the gallery layout. The // selection is kept across appended batches and cleared with the collection; the Library-embedded gallery has none. const bar = embedded ? null : mountSelectionBar({scope, api, grid, getItems:() => state.items, focusFallback, - checked:visit?.checked, onCategorySet:() => { loadCategories(); reset(); }}); + checked:visit?.checked, onCategorySet:() => { loadCategories(); reset(); }, activity, onTagged:keys => refreshTagged(keys)}); if (bar) main.append(bar.element); main.append(body); const mediaViewer = createMediaViewer({api,container:main,parentScope:scope,inspectionController:inspection,sender, @@ -529,7 +529,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage scheduleLoad(); } const stream = createImageStream({ api, fetchPage, onChange: renderState, restore: visit ? {filters, state:visit.state} : undefined }); - followImageTagSaves({scope, stream, inspection, selection:() => selection, filtered:() => !embedded && !recovering && Boolean(filters.tags.length || filters.q), + const refreshTagged = followImageTagSaves({scope, stream, inspection, selection:() => selection, filtered:() => !embedded && !recovering && Boolean(filters.tags.length || filters.q), onRefreshing:busy => more.setAttribute('aria-disabled', String(busy || Boolean(state?.loading) || recovering))}); scope.cleanup(() => { stream.dispose(); diff --git a/web/js/workspace/inspect.mjs b/web/js/workspace/inspect.mjs index cd6f2f7..719b067 100644 --- a/web/js/workspace/inspect.mjs +++ b/web/js/workspace/inspect.mjs @@ -7,6 +7,7 @@ import { pageHeading } from './heading.mjs'; import { createWorkspaceIcon } from './icons.mjs'; import { createLocalSession, LOCAL_SESSION_MESSAGES } from './local-session.mjs'; import { localFileAdapter } from './local-inspection.mjs'; +import { loadInspectionSemantics } from './local-semantics.mjs'; import { createMediaViewer } from './media-viewer.mjs'; import { showWorkspaceToast, TOAST_MS } from './toast.mjs'; @@ -39,36 +40,24 @@ function element(tag, className, text) { // The status shown for the selection; AVI/WMV's note and an empty status keep the toast's "metadata is available". const TOASTABLE = new Set(['', LOCAL_SESSION_MESSAGES.containerOnly]); -// How long a released video still starting may take to settle before its deferred reset is dropped. -const DEFERRED_RESET_MS = 15000; +// How long a replaced video still starting may stay in place before it is released anyway. +const STARTING_VIDEO_MS = 15000; -/** - * Resets a released video once it settles. Removing `src` alone never aborts a load already under way, and WebKit - * (GStreamer) blocked the page when a pipeline still starting was reset, so load() waits for metadata or an error. - * The fallback only drops the waiting listeners (a WebKit load cut off by the revoked URL stalls and never settles), - * so the detached node is never pinned. - */ -function resetWhenSettled(node) { - const waiting = new AbortController(); - const fallback = setTimeout(() => waiting.abort(), DEFERRED_RESET_MS); - const reset = () => { waiting.abort(); clearTimeout(fallback); node.load(); }; - for (const type of ['loadedmetadata', 'error']) node.addEventListener(type, reset, { once: true, signal: waiting.signal }); +/** A video whose pipeline is still starting: it has a source but neither metadata nor an error yet. */ +function isStarting(node) { + return node instanceof HTMLMediaElement && node.hasAttribute('src') && node.readyState < HTMLMediaElement.HAVE_METADATA && node.error === null; } /** - * Stops a preview from holding its blob: URL: its own listeners end, media is paused and its src dropped before the - * session revokes the URL, and the node leaves the page. A settled video (metadata read, or failed) is then reset with - * load() to free its decoder at once; one still starting is reset as soon as it settles. + * Stops a preview from holding its blob: URL: its own listeners end, media is paused, its src dropped and it is reset + * with load() (which frees its decoder), and the node leaves the page, all before its URL is revoked. */ function releaseMedia(preview) { if (!preview) return; const { node, listeners } = preview; listeners.abort(); if (node instanceof HTMLMediaElement) { - const settled = node.readyState >= HTMLMediaElement.HAVE_METADATA || node.error !== null; - node.pause(); node.removeAttribute('src'); - if (settled) node.load(); - else resetWhenSettled(node); + node.pause(); node.removeAttribute('src'); node.load(); } else node.removeAttribute('src'); node.remove(); } @@ -84,6 +73,8 @@ export function mountInspect(main, { parentScope, api, sender }) { if (parentScope.signal.aborted) { scope.dispose(); return { dispose }; } parentScope.signal.addEventListener('abort', dispose, { once: true }); scope.cleanup(() => parentScope.signal.removeEventListener('abort', dispose)); + // The node table the local roles resolve with, read before any file is chosen (one request per page). + loadInspectionSemantics(); // The concept DOM: section.surface > h2, p; div.file-drop.spaced-20 > icon, label, input, span.small; #local-preview. const surface = element('section', 'surface'); @@ -108,7 +99,33 @@ export function mountInspect(main, { parentScope, api, sender }) { // The session ends after the viewer has closed and the inline media is gone, so no element holds its URL then. // The page owns page leave for the same reason: the session listens for no `pagehide` of its own. const sessionScope = createScope(); - const session = createLocalSession({ parentScope: sessionScope, pageTarget: null }); + // Replaced videos still starting, by URL. WebKit (GStreamer) deadlocked the page when a video left the page or + // the viewport before its pipeline had started, so such a video stays where it is, inert, until it reports + // metadata or an error; only then is it released and its URL, which the session asked to revoke, revoked. + const retiring = new Map(); + function finishRetiring(url) { + const entry = retiring.get(url); + if (!entry) return; + retiring.delete(url); + entry.waiting.abort(); clearTimeout(entry.timer); + releaseMedia(entry.preview); + if (entry.revoke) URL.revokeObjectURL(url); + } + function retire(preview, url) { + const waiting = new AbortController(); + const entry = { preview, waiting, revoke: false, timer: setTimeout(() => finishRetiring(url), STARTING_VIDEO_MS) }; + retiring.set(url, entry); + preview.listeners.abort(); + preview.node.inert = true; preview.node.setAttribute('aria-hidden', 'true'); + for (const type of ['loadedmetadata', 'error']) preview.node.addEventListener(type, () => finishRetiring(url), { once: true, signal: waiting.signal }); + } + const finishAllRetiring = () => { for (const url of [...retiring.keys()]) finishRetiring(url); }; + const revokeObjectURL = url => { + const entry = retiring.get(url); + if (entry) entry.revoke = true; + else URL.revokeObjectURL(url); + }; + const session = createLocalSession({ parentScope: sessionScope, pageTarget: null, revokeObjectURL }); const viewer = createMediaViewer({ api, container: main, parentScope: scope, inspectionController: session.controller, sender, adapter: localFileAdapter, onRestoreFocus: () => { if (open.isConnected) open.focus({ preventScroll: true }); } }); @@ -153,7 +170,9 @@ export function mountInspect(main, { parentScope, api, sender }) { function showMedia(state) { const url = state.preview?.url ?? null; if (url === mediaUrl) return; - releaseMedia(media); media = null; mediaUrl = url; + if (media && isStarting(media.node) && !retiring.has(mediaUrl)) retire(media, mediaUrl); + else releaseMedia(media); + media = null; mediaUrl = url; if (!url) return; const video = state.preview.media === 'video'; const node = element(video ? 'video' : 'img', 'local-file-preview'); @@ -173,7 +192,10 @@ export function mountInspect(main, { parentScope, api, sender }) { }, { once: true, signal: current.listeners.signal }); node.src = url; media = current; - preview.prepend(node); + // A video still starting keeps its place, so the new preview goes after it and never moves it. + const anchor = [...retiring.values()].map(entry => entry.preview.node).filter(item => item.parentNode === preview).at(-1); + if (anchor) anchor.after(node); + else preview.prepend(node); } scope.cleanup(session.subscribe(state => { showMedia(state); @@ -190,6 +212,7 @@ export function mountInspect(main, { parentScope, api, sender }) { // viewer (on abort), drops the media, then ends the session. scope.cleanup(() => viewer.dispose()); scope.cleanup(() => { releaseMedia(media); media = null; mediaUrl = null; }); + scope.cleanup(finishAllRetiring); scope.cleanup(() => sessionScope.dispose()); // Page leave: the viewer closes and the inline media goes before the session revokes the URL, and the chooser is @@ -198,6 +221,7 @@ export function mountInspect(main, { parentScope, api, sender }) { clearRejection(); viewer.close(); releaseMedia(media); media = null; mediaUrl = null; + finishAllRetiring(); session.clear(); chosen = null; input.value = ''; } diff --git a/web/js/workspace/local-inspection.mjs b/web/js/workspace/local-inspection.mjs index 3ccae32..e68889b 100644 --- a/web/js/workspace/local-inspection.mjs +++ b/web/js/workspace/local-inspection.mjs @@ -6,12 +6,11 @@ import { readVideoTags, selectVideoCarrier } from './local-video.mjs'; import { projectMetadata, fitInspectionPayload, MAX_RESPONSE_BYTES } from './local-projection.mjs'; import { projectInspectionPayload } from './inspection-state.mjs'; +import { resolveLocalEvidence, unreadableEvidence } from './local-resolver.mjs'; +import { loadInspectionSemantics } from './local-semantics.mjs'; export const LOCAL_OBSERVATION = 'selected-local-file'; export const LOCAL_SOURCE_LABEL = 'Selected local file'; -// Interim roles until PR L3 ports the inspection resolver: every role is open, and -// the legacy fill in projectInspectionPayload shows what the legacy pages show. -const LOCAL_RESOLUTION_REASON = 'Resolved in this browser from the workflow.'; const LOCAL_LIBRARY = Object.freeze({ status: 'unavailable', scope: 'saved-library', tags: [], category_scope: null, tags_scope: null }); const LOCAL_SCOPE = 'local'; const PREFIX_BYTES = 32; @@ -19,6 +18,24 @@ const MAX_FILENAME_CHARS = 255; const MEDIA_TYPES = new Set(['image', 'video']); const object = value => value !== null && typeof value === 'object' && !Array.isArray(value); +const abortError = () => new DOMException('The local selection ended', 'AbortError'); + +/** `promise`, or an AbortError as soon as `signal` aborts (the work behind `promise` is shared and keeps going). */ +function untilAborted(promise, signal) { + if (!signal) return promise; + if (signal.aborted) return Promise.reject(abortError()); + return new Promise((resolve, reject) => { + const onAbort = () => reject(abortError()); + signal.addEventListener('abort', onAbort, { once: true }); + promise.then(value => { signal.removeEventListener('abort', onAbort); resolve(value); }, + error => { signal.removeEventListener('abort', onAbort); reject(error); }); + }); +} + +/** One turn of the event loop between bounded steps, so the busy state paints and input is handled. */ +function nextTurn(signal) { + return untilAborted(new Promise(resolve => setTimeout(resolve, 0)), signal); +} const pngReader = () => globalThis.PngMetadata || globalThis.window?.PngMetadata; /** A display name with no path separators or control characters; never empty. */ @@ -88,8 +105,19 @@ export function toInspectionRead(classification, result) { const dimension = value => Number.isSafeInteger(value) && value > 0 ? value : null; -/** The `inspection_version: 1` wire for one local read, before the response budget. */ -export function buildLocalPayload({ read, file, classification }) { +/** The roles for one local read (`{prompts, resolution}`): the bounded parse and resolution step of buildLocalPayload. */ +export function localEvidence(read, classification, semantics = null) { + const inspection = toInspectionRead(classificationOf(classification), read); + return inspection.read === null ? unreadableEvidence() : resolveLocalEvidence(inspection.read.records, semantics); +} + +/** + * The `inspection_version: 1` wire for one local read, before the response budget. Its roles are the server + * inspector's for the same records (local-resolver.mjs with `semantics`, the compiled node table; null answers + * "unavailable" as the server does), so the legacy fill in projectInspectionPayload applies exactly where it does + * for Images. + */ +export function buildLocalPayload({ read, file, classification, semantics = null, evidence = localEvidence(read, classification, semantics) }) { const classified = classificationOf(classification); const inspection = toInspectionRead(classified, read); const filename = sanitizeLocalFilename(file?.name); @@ -101,8 +129,8 @@ export function buildLocalPayload({ read, file, classification }) { observation: LOCAL_OBSERVATION, source_label: LOCAL_SOURCE_LABEL, media_type: classified.media, extension: localExtension(filename), format: inspection.format, size, width: dimension(inspection.width), height: dimension(inspection.height), modified_time: modified, }, - prompts: { positive: { status: 'unresolved' }, negative: { status: 'unresolved' } }, - resolution: { state: 'unresolved', sampler_id: null, reason: LOCAL_RESOLUTION_REASON, reason_code: null, node: null }, + prompts: evidence.prompts, + resolution: evidence.resolution, metadata: projectMetadata(inspection.read), library: { ...LOCAL_LIBRARY, tags: [] }, }; @@ -166,7 +194,12 @@ export const localFileAdapter = Object.freeze({ scope(handle) { owned(handle); return LOCAL_SCOPE; }, // An inert sentinel: the controller's same-key check works and no URL is ever built. inspectionPath(handle) { return `local-file:${owned(handle).key}`; }, - async read(handle, { signal } = {}) { + /** + * Reads, resolves and projects one selection on the page's thread in bounded steps (each bound by the 2 MiB role + * and 4 MiB workflow budgets and the resolver's work limits) with a turn of the event loop between them, so the + * inspector's busy state paints. Every wait ends with the selection's `signal`; `yieldControl` is for tests. + */ + async read(handle, { signal, yieldControl = nextTurn } = {}) { const secret = owned(handle); let result = null; try { @@ -176,7 +209,14 @@ export const localFileAdapter = Object.freeze({ // MetadataReadError, an unreadable record. Abort and programming errors still reject. if (!(error instanceof DOMException) || error.name === 'AbortError' || signal?.aborted) throw error; } - return fitLocalPayload(buildLocalPayload({ read: result, file: { name: secret.filename, size: secret.file.size, lastModified: secret.file.lastModified }, classification: secret.classification }), MAX_RESPONSE_BYTES, { signal }); + const semantics = await untilAborted(loadInspectionSemantics(), signal); + await yieldControl(signal); + const evidence = localEvidence(result, secret.classification, semantics); + await yieldControl(signal); + const file = { name: secret.filename, size: secret.file.size, lastModified: secret.file.lastModified }; + const payload = buildLocalPayload({ read: result, file, classification: secret.classification, semantics, evidence }); + await yieldControl(signal); + return fitLocalPayload(payload, MAX_RESPONSE_BYTES, { signal }); }, toRecord(result, handle, extractFacts) { const secret = owned(handle); diff --git a/web/js/workspace/local-parameters.mjs b/web/js/workspace/local-parameters.mjs new file mode 100644 index 0000000..5e268fd --- /dev/null +++ b/web/js/workspace/local-parameters.mjs @@ -0,0 +1,55 @@ +// A1111 `parameters` for local-resolver.mjs (#249 local Inspect, PR L3 Task 9): a port of +// utils/prompt_pair_resolver.py `_parameters`, `_footer` and `_trailer_lines` for the inspection profile. +// Python's `re` differs from JavaScript's in three ways that matter here, so the patterns spell them out: +// multiline `^`/`$` break only at "\n" (not "\r", U+2028 or U+2029), `\s` is the `str.isspace()` set and `\d` +// is any Unicode decimal digit (Nd). Lengths count code points. No DOM. +import { PY_SPACE, pyLen, pyStrip } from './py-json.mjs'; +import { EMPTY_POSITIVE_REASON, result } from './local-resolver-support.mjs'; + +const START = '(?<=^|\\n)', END = '(?=\\n|$)', SPACE = `[${PY_SPACE}]*`; +// `_FOOTER` and `_BARE_FOOTER` (inspection reads a bare `Steps: N` when there is no full footer). +const FOOTER = new RegExp(`${START}Steps:${SPACE}\\p{Nd}+${SPACE},[^\\n]*${END}`, 'gu'); +const BARE_FOOTER = new RegExp(`${START}Steps:${SPACE}\\p{Nd}+${SPACE}${END}`, 'gu'); +const TRAILERS = ['Template:', 'Negative Template:']; +const DELIMITER = '\nNegative prompt:'; + +/** `_trailer_lines`: empty, or at most one `Template:` line then at most one `Negative Template:` line. */ +function trailerLines(text) { + const stripped = pyStrip(text); + const lines = stripped ? stripped.split(/\r?\n|\r/) : []; + const prefixes = lines.map(line => { + const index = TRAILERS.findIndex(name => line.startsWith(name)); + return index === -1 ? null : index; + }); + return !prefixes.includes(null) && prefixes.every((prefix, index) => index === 0 || prefix > prefixes[index - 1]); +} + +/** `_footer(value, inspection=True)`: the unique settings footer match, or null. */ +function footer(value) { + let footers = [...value.matchAll(FOOTER)]; + if (!footers.length) footers = [...value.matchAll(BARE_FOOTER)]; + if (footers.length !== 1) return null; + const [match] = footers; + return trailerLines(value.slice(match.index + match[0].length)) ? match : null; +} + +const rstripLineBreaks = text => text.replace(/[\r\n]+$/, ''); + +/** `_parameters(value, max_text_length, inspection=True)`. */ +export function parameters(value, maxTextLength) { + if (typeof value !== 'string' || !value) return result('absent', { reason: 'No A1111 parameters' }); + if (pyLen(value) > maxTextLength) return result('unresolved', { reason: 'Parameters exceed the length limit', reasonCode: 'text-limit' }); + const match = footer(value); + if (match === null) return result('unresolved', { reason: 'A1111 settings footer is not unique', reasonCode: 'malformed' }); + // R4: text that starts with the delimiter has an empty positive (#165). + const body = `\n${rstripLineBreaks(value.slice(0, match.index))}`; + const parts = body.split(DELIMITER); + if (parts.length > 2) return result('ambiguous', { reason: 'Multiple negative prompt delimiters' }); + const positive = pyStrip(parts[0]); + if (!positive) return result('unresolved', { reason: EMPTY_POSITIVE_REASON }); + const provenance = { format: 'a1111', field: 'parameters', text_source: 'embedded-parameters' }; + const side = (text, role) => ({ text, storage_text: text, provenance: { ...provenance, role } }); + if (parts.length === 1) return result('absent', { positive: side(positive, 'positive'), reason: 'Negative prompt delimiter is absent' }); + const negative = pyStrip(parts[1]); + return result('resolved', { positive: side(positive, 'positive'), negative: side(negative, 'negative') }); +} diff --git a/web/js/workspace/local-resolver-stages.mjs b/web/js/workspace/local-resolver-stages.mjs new file mode 100644 index 0000000..351d560 --- /dev/null +++ b/web/js/workspace/local-resolver-stages.mjs @@ -0,0 +1,174 @@ +// Candidate selection for local-resolver.mjs (#249 local Inspect, PR L3 Task 9): ports of +// utils/prompt_pair_resolver.py `_GraphResolver` `stage`, `upstream_stages`, `final_stages`, `file_stages`, +// `non_file_outputs`, `producers_of`, `consumers`, `reaches_output`, plus `_outcome_key`, `_recipe_key` and +// `_shared_pair` (#270). Each function takes the resolver whose budgets and caches it spends, so the order of +// work, and with it every limit, is the server's. No DOM. +import { isDict, pyGet, pyStr } from './py-json.mjs'; +import { DETAILER, IDENTICAL_BRANCHES_REASON, Unresolved, isLink, result } from './local-resolver-support.mjs'; + +const roleText = side => side !== null && typeof side === 'object' ? side.text : null; +const storageText = side => side !== null && typeof side === 'object' ? side.storage_text : null; +/** `_outcome_key`: what a reader sees of a result, the state and the two role texts. */ +export const outcomeKey = item => JSON.stringify([item.state, roleText(item.positive), roleText(item.negative)]); +/** `_recipe_key`: the outcome plus each side's stored recipe. */ +const recipeKey = item => JSON.stringify([item.state, roleText(item.positive), roleText(item.negative), storageText(item.positive), storageText(item.negative)]); + +/** `_shared_pair`: the one pair every sampler branch resolved to, or null (#270). */ +export function sharedPair(candidates) { + const first = candidates[0]; + if (first.state !== 'resolved') return null; + if (new Set(candidates.map(recipeKey)).size !== 1) return null; + return result('resolved', { positive: first.positive, negative: first.negative, reason: IDENTICAL_BRANCHES_REASON, candidates }); +} + +/** `stage`: one sampler's result; a detailer is compared with its upstream stages. */ +export function stage(resolver, samplerId, later) { + if (later.size >= resolver.maxDepth) return result('unresolved', { samplerId, reason: 'Resolution depth limit reached', reasonCode: 'depth-limit' }); + const own = resolver.sides(samplerId); + if (own.state === 'unresolved') return own; + const [name, inputs] = resolver.node(samplerId); + const entry = resolver.semantics.samplers.get(name); + if (entry === undefined || entry.strategy !== DETAILER) return own; + let upstream; + try { + upstream = upstreamStages(resolver, samplerId, pyGet(inputs, entry.image_stage)); + if (upstream.some(nodeId => later.has(nodeId))) throw new Unresolved('Cyclic sampler stage ancestry', 'malformed'); + } catch (error) { + if (!(error instanceof Unresolved)) throw error; + return result('unresolved', { samplerId, reason: error.message, reasonCode: error.code, node: error.node }); + } + if (!upstream.length) return own; + const deeper = new Set([...later, samplerId]); + const stages = upstream.map(nodeId => stage(resolver, nodeId, deeper)); + const blocked = stages.find(item => item.state === 'unresolved'); + // A stage that cannot be read cannot be compared: never pick one. + if (blocked !== undefined) return result('unresolved', { samplerId, reason: blocked.reason, reasonCode: blocked.reason_code, node: blocked.node }); + const flat = []; + for (const item of [own, ...stages]) flat.push(...(item.state === 'ambiguous' ? item.candidates : [item])); + if (new Set(flat.map(outcomeKey)).size === 1) return own; + return result('ambiguous', { samplerId, reason: 'Sampler stages have different prompts', candidates: flat, reasonCode: 'multistage-differs' }); +} + +/** `upstream_stages`: samplers reached from a detailer's image input, breadth first; each node draws a visit. */ +function upstreamStages(resolver, origin, value) { + if (!isLink(value)) throw new Unresolved('Expected an image link', 'malformed'); + const found = [], start = String(value[0]), queue = [[start, 0]], queued = new Set([start]); + for (let head = 0; head < queue.length; head++) { + const [nodeId, depth] = queue[head]; + if (nodeId === origin) throw new Unresolved('Cyclic sampler stage ancestry', 'malformed'); + if (depth >= resolver.maxDepth) throw new Unresolved('Resolution depth limit reached', 'depth-limit'); + resolver.budget.spend(); + const [name, inputs] = resolver.node(nodeId); + if (resolver.stageClasses.has(name)) { found.push(nodeId); continue; } + if (resolver.stageShaped(name, inputs)) throw resolver.unsupported('Unsupported sampler stage ancestry', nodeId); + resolver.enqueue(queue, queued, inputs, depth + 1); + } + return found; +} + +const strategyOf = (resolver, nodeId) => resolver.semantics.samplers.get(resolver.graph.get(nodeId).get('class_type')).strategy; + +/** `final_stages`: drop samplers that only feed detailers (each is judged in that chain). */ +export function finalStages(resolver, samplerIds) { + samplerIds = fileStages(resolver, samplerIds); + const detailers = samplerIds.filter(nodeId => strategyOf(resolver, nodeId) === DETAILER); + if (!detailers.length) return samplerIds; + let reverse; + try { reverse = consumers(resolver); } catch (error) { + if (!(error instanceof Unresolved)) throw error; + return samplerIds; + } + const absorbed = new Set(); + for (const detailer of detailers) { + resolver.budget.reset(resolver.perCandidateVisits); + try { + const [name, inputs] = resolver.node(detailer); + const image = pyGet(inputs, resolver.semantics.samplers.get(name).image_stage); + for (const nodeId of upstreamStages(resolver, detailer, image)) { + if (!reachesOutput(resolver, nodeId, reverse)) absorbed.add(nodeId); + } + } catch (error) { + if (!(error instanceof Unresolved)) throw error; + } + } + const kept = samplerIds.filter(nodeId => !absorbed.has(nodeId)); + return kept.length ? kept : samplerIds; +} + +/** `file_stages`: stages not proven preview-only; all of them when every one is. */ +function fileStages(resolver, samplerIds) { + if (samplerIds.length < 2) return samplerIds; + let toFile, toPreview; + try { + const reverse = consumers(resolver), previews = nonFileOutputs(resolver); + const terminals = [...resolver.graph].filter(([nodeId, node]) => isDict(node) && !previews.has(nodeId) && !reverse.get(nodeId)?.length).map(([nodeId]) => nodeId); + toFile = producersOf(resolver, terminals, previews); + toPreview = producersOf(resolver, [...previews], previews); + } catch (error) { + if (!(error instanceof Unresolved)) throw error; + return samplerIds; + } + const kept = samplerIds.filter(nodeId => toFile.has(nodeId) || !toPreview.has(nodeId)); + return kept.length ? kept : samplerIds; +} + +function nonFileOutputs(resolver) { + const names = resolver.semantics.nonFileOutputs, found = new Set(); + for (const [nodeId, node] of resolver.graph) { + // `node.get("class_type") in names`: only a string can be a listed class. + if (isDict(node) && names.has(pyGet(node, 'class_type'))) found.add(nodeId); + } + return found; +} + +/** `producers_of`: nodes whose output reaches one of `starts`, never through a preview. */ +function producersOf(resolver, starts, previews) { + const found = new Set(starts), queue = [...starts]; + for (let head = 0; head < queue.length; head++) { + const inputs = pyGet(resolver.graph.get(queue[head]), 'inputs'); + if (!isDict(inputs)) continue; + resolver.scan(inputs.size); + for (const value of inputs.values()) { + if (!isLink(value)) continue; + const producer = String(value[0]); + if (found.has(producer) || previews.has(producer) || !isDict(pyGet(resolver.graph, producer))) continue; + found.add(producer); + queue.push(producer); + } + } + return found; +} + +/** `consumers`: reverse links, node ID to the IDs of nodes reading one of its outputs. */ +function consumers(resolver) { + const reverse = new Map(); + for (const [nodeId, node] of resolver.graph) { + if (!isDict(node) || !isDict(pyGet(node, 'inputs'))) continue; + const inputs = node.get('inputs'); + resolver.scan(inputs.size); + for (const value of inputs.values()) { + if (!isLink(value)) continue; + const producer = String(value[0]); + if (!reverse.has(producer)) reverse.set(producer, []); + reverse.get(producer).push(nodeId); + } + } + return reverse; +} + +/** `reaches_output`: whether a sampler's output reaches a terminal node besides later stages. */ +function reachesOutput(resolver, nodeId, reverse) { + const seen = new Set([nodeId]), queue = [nodeId]; + for (let head = 0; head < queue.length; head++) { + for (const consumer of reverse.get(queue[head]) ?? []) { + if (seen.has(consumer)) continue; + seen.add(consumer); + resolver.budget.spend(); + const name = pyStr(pyGet(resolver.graph.get(consumer), 'class_type', '')); + if (resolver.stageClasses.has(name) || resolver.semantics.nonFileOutputs.has(name)) continue; + if (!reverse.get(consumer)?.length) return true; + queue.push(consumer); + } + } + return false; +} diff --git a/web/js/workspace/local-resolver-support.mjs b/web/js/workspace/local-resolver-support.mjs new file mode 100644 index 0000000..f180e07 --- /dev/null +++ b/web/js/workspace/local-resolver-support.mjs @@ -0,0 +1,255 @@ +// Tables, evidence reader, bounds and outcome lines for local-resolver.mjs (#249 local Inspect, PR L3 Tasks 8-9): +// the constants of utils/prompt_pair_resolver.py and utils/inspection_outcomes.py, utils/pair_execution.py +// `read_pair_execution` and the semantics table check of utils/inspection_semantics.py. No DOM. +import { PyFloat, PyValueError, isDict, isList, isInt, pyJsonLoads, pyLen, pySlice, pyStrip, pyTruthy, pyEqualsOne, pyStr, pyGet } from './py-json.mjs'; + +/** The semantics table is unusable: the server's `SemanticsError`. */ +export class SemanticsError extends Error { + constructor(message) { + super(message); + this.name = 'SemanticsError'; + } +} + +// INSPECTION_PROFILE (prompt_pair_resolver.py 152-166); its drop_preview_only_stages is always on here. +export const PROFILE = Object.freeze({ + perCandidateVisits: 2000, totalVisits: 20000, maxDepth: 64, maxTextLength: 100000, nodeCeiling: 20000, + maxPathEntries: 64 * 20000, maxBuiltChars: 16 * 100000, maxScannedUnits: 8000000, +}); +export const EXECUTION_KEY = 'comfyassets_prompt_pairs'; +export const ROLE_METADATA_KEYS = new Set(['prompt', 'parameters', EXECUTION_KEY]); +// pair_execution.py +export const MAX_EXECUTION_RECORDS = 16, MAX_EXECUTION_NODES = 64, MAX_EXECUTION_TEXT = 100000, MAX_EXECUTION_JSON = 500000; +export const EXECUTION_FIELDS = ['text', 'negative_text', 'positive_text', 'negative_effective_text', 'prepend_text', 'append_text']; + +export const CANDIDATE_STRATEGIES = new Set(['ksampler', 'custom-advanced', 'detailer-stage']); +export const DETAILER = 'detailer-stage'; +export const GUIDER_ABSENT_REASON = 'Guider has no negative input'; +export const ZEROED_SOURCE = 'zeroed-conditioning'; +export const MAX_BRANCH_CANDIDATES = 32; +export const RUNTIME_REASON = 'Text is decided at run time'; +export const EMPTY_POSITIVE_REASON = 'Positive prompt is empty'; +export const IDENTICAL_BRANCHES_REASON = 'Every sampler branch uses the same prompt pair'; +export const MULTISTAGE_UNATTRIBUTED_REASON = 'Multistage sampler ancestry is ambiguous'; +export const MALFORMED_EVIDENCE_REASON = 'Prompt evidence is malformed'; +export const UNREADABLE_METADATA_REASON = 'Media metadata could not be read'; +export const SEMANTICS_UNAVAILABLE_REASON = 'Inspection node semantics are unavailable'; +export const INCOMPATIBLE_SLOT = 'Output slot has an incompatible type'; +export const OVERWRITTEN_PROMPT_REASON = 'Saved PromptManager text was overwritten by another node'; +export const UNVERIFIABLE_CODES = new Set(['unsupported-node', 'runtime-value']); +export const MAX_JOIN_INPUTS = 1000; +export const SAMPLER_LIKE_INPUTS = ['latent_image', 'guider', 'positive', 'negative']; +export const NO_SAMPLER_NAMES = 3; +export const CLASS_NAME_LENGTH = 64; +export const MAX_SUBGRAPH_DEPTH = 8, MAX_WORKFLOW_NODES = 100000; + +// inspection_outcomes.py +export const OUTCOME_NAME_LENGTH = 200; +export const LIMIT_CODES = ['work-limit', 'depth-limit', 'text-limit', 'graph-ceiling']; +export const 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', +}; +export const CODED_LINES = { + ...Object.fromEntries(LIMIT_CODES.map(code => [code, '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.', +}; +export const 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.', +}; + +// Output adapters (prompt_pair_resolver.py 44-105): [inputName, kind, {compose, storageInput, comma, strip, default}]. +export const adapter = (inputName, kind, options = {}) => Object.freeze({ inputName, kind, compose: false, storageInput: false, comma: false, strip: false, default: null, ...options }); +export const slotKey = (name, slot) => `${name}\u0000${String(slot)}`; +export const ADAPTERS = new Map(); +for (const name of ['PromptManager', 'PromptManagerFocused', 'PromptManagerSplit']) { + ADAPTERS.set(slotKey(name, 0), adapter('text', 'conditioning', { compose: true })); + ADAPTERS.set(slotKey(name, 1), adapter('text', 'string', { compose: true })); +} +ADAPTERS.set(slotKey('PromptManagerPair', 0), adapter('text', 'conditioning', { compose: true })); +ADAPTERS.set(slotKey('PromptManagerPair', 2), adapter('text', 'string', { compose: true })); +ADAPTERS.set(slotKey('PromptManagerPair', 1), adapter('negative_text', 'conditioning', { storageInput: true })); +ADAPTERS.set(slotKey('PromptManagerPair', 3), adapter('negative_text', 'string', { storageInput: true })); +ADAPTERS.set(slotKey('PromptManagerText', 0), adapter('text', 'string', { compose: true })); +ADAPTERS.set(slotKey('CLIPTextEncode', 0), adapter('text', 'conditioning')); +for (const name of ['PrimitiveString', 'PrimitiveStringMultiline']) ADAPTERS.set(slotKey(name, 0), adapter('value', 'string')); +for (const name of ['Text Multiline', 'String Literal', 'ShowText|pysssss']) ADAPTERS.set(slotKey(name, 0), adapter('text', 'string')); +// Historical PromptManager classes (inspection only). +export const COMMA = { comma: true }, NEGATIVE = { strip: true, default: '' }; +const historical = [ + ['PromptManagerV2', 0, 'text', 'conditioning', COMMA], ['PromptManagerV2', 1, 'negative_text', 'conditioning', NEGATIVE], + ['PromptManagerV2', 2, 'text', 'string', COMMA], ['PromptManagerV2', 3, 'negative_text', 'string', NEGATIVE], + ['PromptManagerV2Text', 0, 'text', 'string', COMMA], ['PromptManagerV2Text', 1, 'negative_text', 'string', NEGATIVE], + ['PromptManagerNegative', 0, 'text', 'conditioning', { strip: true }], ['PromptManagerNegative', 1, 'text', 'string', { strip: true }], + ['PromptManagerPositiveText', 0, 'text', 'string', COMMA], ['PromptManagerNegativeText', 0, 'text', 'string', COMMA], +]; +for (const name of ['PromptManagerPositive', 'PromptManagerDetailer']) historical.push([name, 0, 'text', 'conditioning', COMMA], [name, 1, 'text', 'string', COMMA]); +for (const [name, slot, input, kind, options] of historical) ADAPTERS.set(slotKey(name, slot), adapter(input, kind, options)); +// `known_slots` in execution_record: every adapter slot per class. +export const ADAPTER_SLOTS = new Map(); +for (const key of ADAPTERS.keys()) { + const [name, slot] = key.split('\u0000'); + if (!ADAPTER_SLOTS.has(name)) ADAPTER_SLOTS.set(name, new Set()); + ADAPTER_SLOTS.get(name).add(slot); +} + +export class Unresolved extends Error { + constructor(message, code = null, node = null) { + super(message); + this.code = code; + this.node = node; + } +} +export class Diverging extends Error { + constructor(key) { + super('diverging'); + this.key = key; + } +} + +/** `_link`: a two-item list of (str or int) and a non-negative int, never a bool. */ +export function isLink(value) { + if (!isList(value) || value.length !== 2) return false; + const [source, slot] = value; + return (typeof source === 'string' || isInt(source)) && isInt(slot) && (typeof slot === 'bigint' ? slot >= 0n : slot >= 0); +} +export const nodeRef = (nodeId, classType) => ({ id: pySlice(nodeId, CLASS_NAME_LENGTH), class_type: typeof classType === 'string' ? pySlice(classType, CLASS_NAME_LENGTH) : null }); + +export function result(state, { positive = null, negative = null, samplerId = null, reason = '', candidates = [], reasonCode = null, node = null } = {}) { + return { state, positive, negative, sampler_id: samplerId, reason, candidates, reason_code: reasonCode, node }; +} + +export class Budget { + constructor(total) { + this.total = total; + this.allowance = total; + } + reset(allowance) { this.allowance = allowance; } + spend() { + if (this.total <= 0 || this.allowance <= 0) throw new Unresolved('Resolution visit limit reached', 'work-limit'); + this.total -= 1; + this.allowance -= 1; + } + charge(cost) { + if (cost > this.allowance) throw new Unresolved('Resolution visit limit reached', 'work-limit'); + this.allowance -= cost; + } +} + +/** `read_pair_execution`: one distinct record for a traced node, or null; malformed evidence throws `Unresolved`. */ +export function readPairExecution(metadata, nodeId) { + const evidence = message => new Unresolved(message, 'malformed'); + if (!isDict(metadata) || !metadata.has(EXECUTION_KEY)) return null; + let envelope = metadata.get(EXECUTION_KEY); + if (typeof envelope === 'string') { + if (pyLen(envelope) > MAX_EXECUTION_JSON) throw evidence('Execution evidence exceeds the size limit'); + try { envelope = pyJsonLoads(envelope); } catch (error) { + if (error instanceof PyValueError) throw evidence('Malformed execution evidence JSON'); + throw error; + } + } + if (!isDict(envelope) || !pyEqualsOne(pyGet(envelope, 'version'))) throw evidence('Unsupported execution evidence format'); + const nodes = pyGet(envelope, 'nodes'); + if (pyTruthy(pyGet(envelope, 'truncated')) || !isDict(nodes) || nodes.size > MAX_EXECUTION_NODES) throw evidence('Truncated or malformed execution evidence'); + if (!nodes.has(nodeId)) return null; + const node = nodes.get(nodeId); + if (!isDict(node) || pyTruthy(pyGet(node, 'truncated'))) throw evidence('Truncated or malformed node execution evidence'); + const records = pyGet(node, 'records'); + if (!isList(records) || !records.length || records.length > MAX_EXECUTION_RECORDS) throw evidence('Malformed node execution records'); + const unique = new Map(); + for (const record of records) { + if (!isDict(record) || EXECUTION_FIELDS.some(key => typeof pyGet(record, key) !== 'string' || pyLen(record.get(key)) > MAX_EXECUTION_TEXT)) { + throw evidence('Malformed execution text values'); + } + const clean = Object.fromEntries(EXECUTION_FIELDS.map(key => [key, record.get(key)])); + if (clean.negative_effective_text !== clean.negative_text) throw evidence('Negative execution text must be unchanged'); + unique.set(JSON.stringify(EXECUTION_FIELDS.map(key => clean[key])), clean); + } + if (unique.size !== 1) throw evidence('Multiple distinct executions have no image anchor'); + return [...unique.values()][0]; +} + +/** + * The validated semantics table (utils/inspection_semantics.py), from the parsed JSON document. Throws + * `SemanticsError` for any shape the server rejects; the caller then answers "semantics unavailable". + */ +export function compileSemantics(doc) { + const fail = message => { throw new SemanticsError(message); }; + const plain = value => value !== null && typeof value === 'object' && !Array.isArray(value); + if (!plain(doc) || doc.schema !== 1) fail('unsupported semantics table'); + const owners = new Map(); + const claim = (name, category) => { + if (owners.has(name) && owners.get(name) !== category) fail(`duplicate class ${name}`); + owners.set(name, category); + }; + const byClass = category => { + if (!plain(doc[category])) fail(`category ${category} must be an object`); + const table = new Map(); + for (const [name, entry] of Object.entries(doc[category])) { + if (!plain(entry) || typeof entry.strategy !== 'string') fail(`${category}[${name}]`); + claim(name, category); + table.set(name, entry); + } + return table; + }; + const bySlot = category => { + if (!Array.isArray(doc[category])) fail(`category ${category} must be a list`); + const table = new Map(); + for (const row of doc[category]) { + if (!plain(row) || typeof row.class !== 'string' || !Number.isInteger(row.slot) || row.slot < 0 || row.slot > 255) fail(`${category} row`); + const key = slotKey(row.class, row.slot); + if (table.has(key)) fail(`${category}: duplicate output key`); + claim(row.class, category); + const { class: _name, slot: _slot, ...entry } = row; + table.set(key, entry); + } + return table; + }; + const bySet = category => { + if (!Array.isArray(doc[category])) fail(`category ${category} must be a list`); + const set = new Set(); + for (const name of doc[category]) { + if (typeof name !== 'string' || set.has(name)) fail(`${category}: duplicate class`); + claim(name, category); + set.add(name); + } + return set; + }; + const samplers = byClass('samplers'), guiders = byClass('guiders'), selectors = byClass('selectors'); + const composites = byClass('composites'), savers = byClass('savers'); + const conditioning = bySlot('conditioning'), encoders = bySlot('encoders'), strings = bySlot('strings'); + const runtime = bySet('runtime'), nonFileOutputs = bySet('non_file_outputs'); + const stageInputs = doc.stage_inputs; + if (!plain(stageInputs) || !Array.isArray(stageInputs.shaped) || !Array.isArray(stageInputs.with_model)) fail('stage_inputs'); + return Object.freeze({ + samplers, guiders, selectors, composites, savers, conditioning, encoders, strings, runtime, nonFileOutputs, + stageInputs: { shaped: [...stageInputs.shaped], withModel: [...stageInputs.with_model] }, allClasses: new Set(owners.keys()), + }); +} + +/** `outcome_message`: one line for an unresolved outcome, or null to keep the status copy. */ +export function outcomeMessage(resolution) { + if (resolution?.state !== 'unresolved') return null; + const code = typeof resolution.reason_code === 'string' ? resolution.reason_code : null; + if (Object.hasOwn(NAMED_LINES, code)) { + const node = resolution.node, raw = node !== null && typeof node === 'object' ? node.class_type : null; + const name = typeof raw === 'string' && raw ? pySlice(raw, OUTCOME_NAME_LENGTH) : null; + return name ? `${NAMED_LINES[code]} (${name}).` : `${NAMED_LINES[code]}.`; + } + if (code === null) return Object.hasOwn(UNCODED_LINES, resolution.reason) ? UNCODED_LINES[resolution.reason] : null; + return Object.hasOwn(CODED_LINES, code) ? CODED_LINES[code] : null; +} + +export const sideOf = (status, text = null, source = null) => ({ status, text, source }); +export function explained(prompts, resolution) { + const message = outcomeMessage(resolution); + if (message === null) return prompts; + return Object.fromEntries(Object.entries(prompts).map(([role, side]) => [role, side.status === 'unresolved' && !Object.hasOwn(side, 'message') ? { ...side, message } : side])); +} +export const EMPTY_POSITIVE = outcomeMessage({ state: 'unresolved', reason: EMPTY_POSITIVE_REASON }); diff --git a/web/js/workspace/local-resolver.mjs b/web/js/workspace/local-resolver.mjs new file mode 100644 index 0000000..3e2b5bb --- /dev/null +++ b/web/js/workspace/local-resolver.mjs @@ -0,0 +1,772 @@ +// The server's inspection resolver in this browser (#249 local Inspect, PR L3 Task 8, decision LI-1): a port of +// utils/prompt_inspector.py `inspect_prompt_evidence` with utils/prompt_pair_resolver.py (INSPECTION_PROFILE) and +// utils/pair_execution.py `read_pair_execution`, driven by web/js/inspection-semantics.json. Python is the oracle: +// tests/test_local_inspect_parity.py compares every answer with the server's on the same records. Values follow +// Python's model (py-json.mjs): dict order, int versus float, code-point lengths, `str.strip()`. +// +// Task 8 ported execution evidence and graph tracing for one sampler; Task 9 adds candidate selection among several +// samplers and detailer stages (local-resolver-stages.mjs) and A1111 `parameters` (local-parameters.mjs). No DOM. +import { PyValueError, isDict, isList, isInt, pyJsonLoads, pyLen, pySlice, pyStrip, pyStr, pyGet } from './py-json.mjs'; +import { + ADAPTERS, + ADAPTER_SLOTS, + Budget, + CANDIDATE_STRATEGIES, + CLASS_NAME_LENGTH, + Diverging, + EMPTY_POSITIVE, + EMPTY_POSITIVE_REASON, + GUIDER_ABSENT_REASON, + INCOMPATIBLE_SLOT, + MALFORMED_EVIDENCE_REASON, + MAX_BRANCH_CANDIDATES, + MAX_JOIN_INPUTS, + MAX_SUBGRAPH_DEPTH, + MAX_WORKFLOW_NODES, + MULTISTAGE_UNATTRIBUTED_REASON, + NO_SAMPLER_NAMES, + OVERWRITTEN_PROMPT_REASON, + PROFILE, + ROLE_METADATA_KEYS, + RUNTIME_REASON, + SAMPLER_LIKE_INPUTS, + SEMANTICS_UNAVAILABLE_REASON, + UNREADABLE_METADATA_REASON, + UNVERIFIABLE_CODES, + Unresolved, + ZEROED_SOURCE, + compileSemantics, + explained, + isLink, + nodeRef, + readPairExecution, + result, + sideOf, + slotKey, +} from './local-resolver-support.mjs'; +import { finalStages, outcomeKey, sharedPair, stage } from './local-resolver-stages.mjs'; +import { parameters } from './local-parameters.mjs'; + +export { SemanticsError, compileSemantics, readPairExecution, outcomeMessage, isLink } from './local-resolver-support.mjs'; + +class GraphResolver { + constructor(graph, metadata, semantics, overwritten) { + this.graph = graph; + this.overwritten = overwritten; + this.perCandidateVisits = PROFILE.perCandidateVisits; + this.budget = new Budget(PROFILE.totalVisits); + this.budget.reset(PROFILE.perCandidateVisits); + this.pathEntries = PROFILE.maxPathEntries; + this.builtChars = PROFILE.maxBuiltChars; + this.scanned = PROFILE.maxScannedUnits; + this.checked = new Map(); + this.costs = new Map(); + this.heights = new Map(); + this.deepest = 0; + this.maxDepth = PROFILE.maxDepth; + this.maxTextLength = PROFILE.maxTextLength; + this.memo = new Map(); + this.active = new Set(); + this.metadata = metadata ?? new Map(); + this.textSources = new Map(); + this.choices = new Map(); + this.tainted = false; + this.latentWalks = new Map(); + this.latentCharged = new Set(); + this.semantics = semantics; + this.samplerClasses = new Set([...semantics.samplers].filter(([, entry]) => CANDIDATE_STRATEGIES.has(entry.strategy)).map(([name]) => name)); + this.stageClasses = new Set(semantics.samplers.keys()); + } + + node(nodeId) { + if (!this.checked.has(nodeId)) { + try { + this.checked.set(nodeId, this.checkedNode(nodeId)); + } catch (error) { + if (!(error instanceof Unresolved) || error.code === 'work-limit') throw error; + this.checked.set(nodeId, error); + } + } + const checked = this.checked.get(nodeId); + if (checked instanceof Unresolved) throw new Unresolved(checked.message, checked.code, checked.node); + return checked; + } + checkedNode(nodeId) { + const node = pyGet(this.graph, nodeId); + if (!isDict(node) || !isDict(pyGet(node, 'inputs'))) throw new Unresolved('Missing or malformed graph node', 'malformed'); + const inputs = node.get('inputs'); + this.scan(inputs.size); + return [pyStr(pyGet(node, 'class_type', '')), inputs]; + } + scan(units) { + if (units > this.scanned) { + this.scanned = -1; + throw new Unresolved('Resolution work limit reached', 'work-limit'); + } + this.scanned -= units; + } + unsupported(message, nodeId) { return this.named(message, 'unsupported-node', nodeId); } + named(message, code, nodeId) { + const node = pyGet(this.graph, nodeId); + const classType = isDict(node) ? pyGet(node, 'class_type') : null; + return new Unresolved(message, code, nodeRef(nodeId, classType)); + } + bounded(text) { + if (pyLen(text) > this.maxTextLength) throw new Unresolved('Resolved text exceeds the length limit', 'text-limit'); + return text; + } + built(text) { + this.builtChars -= pyLen(text); + if (this.builtChars < 0) throw new Unresolved('Resolution work limit reached', 'work-limit'); + return text; + } + + /** `text`: [text, path, storageText] for a string value or a typed link. */ + text(value, kind, depth = 0) { + this.deepest = Math.max(this.deepest, depth); + if (depth >= this.maxDepth) throw new Unresolved('Resolution depth limit reached', 'depth-limit'); + if (typeof value === 'string' && kind === 'string') return [this.bounded(value), [], value]; + if (!isLink(value)) throw new Unresolved('Expected a typed graph link or text input', 'malformed'); + const nodeId = String(value[0]), slot = value[1]; + const key = `${slotKey(nodeId, slot)}\u0000${kind}`; + if (this.active.has(key)) throw new Unresolved('Cyclic prompt graph', 'malformed'); + if (this.memo.has(key)) { + const height = this.heights.get(key); + if (depth + height >= this.maxDepth) throw new Unresolved('Resolution depth limit reached', 'depth-limit'); + this.budget.charge(this.costs.get(key)); + this.deepest = Math.max(this.deepest, depth + height); + return this.memo.get(key); + } + const allowance = this.budget.allowance, outerDeepest = this.deepest, outerTainted = this.tainted; + this.tainted = false; + this.deepest = depth; + try { + this.budget.spend(); + const [name, inputs] = this.node(nodeId); + this.scan(inputs.size); + if (this.overwritten.has(nodeId)) throw new Unresolved(OVERWRITTEN_PROMPT_REASON, 'malformed'); + this.active.add(key); + let text, path, storageText; + const adapted = ADAPTERS.get(slotKey(name, slot)); + if (name === 'Reroute' && slot === 0) { + const keys = ['value', 'input', 'in'].filter(item => inputs.has(item)); + if (keys.length !== 1) throw new Unresolved('Reroute input is not uniquely identifiable', 'malformed'); + [text, path, storageText] = this.text(inputs.get(keys[0]), kind, depth + 1); + this.inheritSource(nodeId, slot, [path]); + } else if (adapted) { + if (adapted.kind !== kind) throw new Unresolved(INCOMPATIBLE_SLOT, 'malformed'); + const evidence = name === 'PromptManagerPair' ? this.executionRecord(nodeId, inputs) : null; + if (evidence !== null) { + text = evidence[adapted.inputName === 'text' ? 'positive_text' : 'negative_effective_text']; + storageText = evidence[adapted.inputName]; + path = []; + this.textSources.set(slotKey(nodeId, slot), 'execution'); + } else if (adapted.comma || adapted.strip) { + [text, path, storageText] = this.historical(adapted, inputs, depth); + } else { + [text, path, storageText] = this.text(pyGet(inputs, adapted.inputName), 'string', depth + 1); + if (adapted.compose || adapted.storageInput) storageText = text; + if (adapted.compose) { + const [before, beforePath] = this.text(pyGet(inputs, 'prepend_text', ''), 'string', depth + 1); + const [after, afterPath] = this.text(pyGet(inputs, 'append_text', ''), 'string', depth + 1); + this.scan(pyLen(before) + pyLen(after) + beforePath.length + path.length + afterPath.length); + text = this.built(this.bounded([pyStrip(before), text, pyStrip(after)].filter(part => part).join(' '))); + path = [...beforePath, ...path, ...afterPath]; + } + } + } else if ((name === 'StringConcatenate' || name === 'Text Concatenate') && slot === 0 && kind === 'string') { + [text, path] = this.concatenate(name, inputs, depth); + storageText = text; + } else if (this.follows(name, slot, kind)) { + [text, path, storageText] = this.semantic(nodeId, name, slot, kind, inputs, depth); + } else { + throw this.unsupported('Unsupported node semantics or output slot', nodeId); + } + this.scan(path.length + 1); + path = [{ node_id: nodeId, output_slot: slot }, ...path]; + const pathLimit = this.maxDepth * Math.max(1, this.perCandidateVisits); + this.pathEntries -= path.length; + if (path.length > pathLimit || this.pathEntries < 0) throw new Unresolved('Provenance path limit reached', 'work-limit'); + const traced = [text, path, storageText]; + if (!this.tainted) { + this.memo.set(key, traced); + this.costs.set(key, allowance - this.budget.allowance); + this.heights.set(key, this.deepest - depth); + } + return traced; + } finally { + this.deepest = Math.max(outerDeepest, this.deepest); + this.tainted = outerTainted || this.tainted; + this.active.delete(key); + } + } + + /** `StringConcatenate` / `Text Concatenate` (the `text` branch). */ + concatenate(name, inputs, depth) { + let keys, delimiter, clean; + if (name === 'StringConcatenate') { + keys = ['string_a', 'string_b']; + delimiter = pyGet(inputs, 'delimiter', ''); + clean = false; + } else { + keys = [...inputs.keys()].filter(item => /^text_[a-z]$/.test(item)).sort(); + delimiter = pyGet(inputs, 'delimiter', ' '); + clean = pyStr(pyGet(inputs, 'clean_whitespace', 'true')).toLowerCase() === 'true'; + } + if (!keys.length || typeof delimiter !== 'string') throw new Unresolved('Malformed concatenation', 'malformed'); + const parts = [], path = []; + let size = 0; + for (const inputName of keys) { + let [part, followed] = this.text(pyGet(inputs, inputName, ''), 'string', depth + 1); + this.scan(pyLen(part) + followed.length); + if (clean) part = pyStrip(part); + path.push(...followed); + if (part || name === 'StringConcatenate') { + size += pyLen(part) + (parts.length ? pyLen(delimiter) : 0); + if (size > this.maxTextLength) throw new Unresolved('Resolved text exceeds the length limit', 'text-limit'); + parts.push(part); + } + } + return [this.built(parts.join(delimiter)), path]; + } + + /** `prompt_manager_*.py` at 07a5af3: strip, then join with ", ". */ + historical(adapted, inputs, depth) { + let [text, path] = this.text(pyGet(inputs, adapted.inputName, adapted.default), 'string', depth + 1); + this.scan(pyLen(text)); + text = pyStrip(text); + const storageText = text; + if (adapted.comma) { + let [before, beforePath] = this.text(pyGet(inputs, 'prepend_text') ?? '', 'string', depth + 1); + let [after, afterPath] = this.text(pyGet(inputs, 'append_text') ?? '', 'string', depth + 1); + this.scan(pyLen(before) + pyLen(after) + beforePath.length + afterPath.length); + before = pyStrip(before); after = pyStrip(after); + if (before) text = `${before}${text && !before.endsWith(',') ? ', ' : ''}${text}`; + if (after) text = `${text}${text && !text.endsWith(',') ? ', ' : ''}${after}`; + text = this.built(this.bounded(text)); + path = [...beforePath, ...path, ...afterPath]; + } + return [text, path, storageText]; + } + + follows(name, slot, kind) { + const semantics = this.semantics, key = slotKey(name, slot); + if (semantics.runtime.has(name)) return true; + if (semantics.encoders.has(key) || semantics.strings.has(key)) return true; + if (slot === 0 && semantics.selectors.has(name)) return true; + return kind === 'conditioning' && (semantics.conditioning.has(key) || (slot === 0 && semantics.composites.has(name))); + } + + semantic(nodeId, name, slot, kind, inputs, depth) { + const semantics = this.semantics, key = slotKey(name, slot); + if (semantics.runtime.has(name)) throw this.named(RUNTIME_REASON, 'runtime-value', nodeId); + for (const [table, expected, build] of [[semantics.encoders, 'conditioning', 'encoded'], [semantics.strings, 'string', 'stringed']]) { + const listed = table.get(key); + if (listed !== undefined) { + if (kind !== expected) throw new Unresolved(INCOMPATIBLE_SLOT, 'malformed'); + return this[build](nodeId, listed, inputs, depth); + } + } + const entry = kind === 'conditioning' ? semantics.conditioning.get(key) : undefined; + if (entry !== undefined && entry.strategy === 'zeroed') { + this.textSources.set(slotKey(nodeId, slot), ZEROED_SOURCE); + return ['', [], '']; + } + if (entry !== undefined) { + const traced = this.text(pyGet(inputs, entry.input), kind, depth + 1); + this.inheritSource(nodeId, slot, [traced[1]]); + return traced; + } + if (semantics.selectors.has(name)) return this.select(nodeId, semantics.selectors.get(name), inputs, kind, depth); + const parts = semantics.composites.get(name).inputs.map(inputName => this.text(pyGet(inputs, inputName), kind, depth + 1)); + if (new Set(parts.map(part => part[0])).size !== 1) throw this.named('Combined conditioning has different texts', 'composite', nodeId); + return this.merged(nodeId, slot, parts); + } + + select(nodeId, entry, inputs, kind, depth) { + const condition = pyGet(inputs, entry.condition), branches = entry.branches; + let choice; + if (typeof condition === 'boolean') choice = condition; + else if (!isLink(condition)) throw new Unresolved('Malformed selector condition', 'malformed'); + else if (this.choices.has(slotKey(String(condition[0]), condition[1]))) { + choice = this.choices.get(slotKey(String(condition[0]), condition[1])); + this.tainted = true; + } else choice = null; + if (choice !== null) { + const traced = this.text(pyGet(inputs, branches[choice ? 'true' : 'false']), kind, depth + 1); + this.inheritSource(nodeId, 0, [traced[1]]); + return traced; + } + const parts = ['true', 'false'].map(label => this.text(pyGet(inputs, branches[label]), kind, depth + 1)); + if (parts[0][0] !== parts[1][0]) throw new Diverging(slotKey(String(condition[0]), condition[1])); + return this.merged(nodeId, 0, parts); + } + + merged(nodeId, slot, parts) { + const traced = this.joinedPaths(parts, parts); + this.inheritSource(nodeId, slot, parts.map(part => part[1])); + return traced; + } + + joinedPaths(chosen, parts) { + const text = chosen[0][0]; + const storages = new Set(chosen.map(part => part[2])); + const storageText = storages.size === 1 ? chosen[0][2] : text; + const paths = parts.map(part => part[1]); + this.scan(paths.reduce((sum, path) => sum + path.length, 0)); + const merged = [], seen = new Set(); + for (const path of paths) { + for (const step of path) { + const identity = slotKey(step.node_id, step.output_slot); + if (!seen.has(identity)) { seen.add(identity); merged.push(step); } + } + } + return [text, merged, storageText]; + } + + encoded(nodeId, entry, inputs, depth) { + const names = entry.inputs; + if (entry.strategy === 'single') return this.text(pyGet(inputs, names[0]), 'string', depth + 1); + if (entry.strategy === 'flag-parts') return this.flagged(nodeId, entry, inputs, depth); + if (entry.strategy !== 'parts-equal') throw this.unsupported('Unsupported encoder strategy', nodeId); + const parts = names.map(name => this.text(pyGet(inputs, name), 'string', depth + 1)); + this.scan(parts.reduce((sum, part) => sum + pyLen(part[0]), 0)); + if (new Set(parts.map(part => part[0])).size === 1) return this.joinedPaths(parts, parts); + const filled = parts.filter(part => part[0] !== ''); + if (filled.length === 1) return this.joinedPaths(filled, parts); + throw this.named('Encoder parts have different texts', 'composite', nodeId); + } + + flagged(nodeId, entry, inputs, depth) { + const names = entry.inputs; + if (!this.setting(nodeId, inputs, entry.params.flag, 'bool')) return this.text(pyGet(inputs, names[0]), 'string', depth + 1); + const parts = names.map(name => this.text(pyGet(inputs, name), 'string', depth + 1)); + this.scan(parts.reduce((sum, part) => sum + pyLen(part[0]), 0)); + if (new Set(parts.map(part => part[0])).size === 1) return this.joinedPaths(parts, parts); + throw this.named(RUNTIME_REASON, 'runtime-value', nodeId); + } + + stringed(nodeId, entry, inputs, depth) { + const strategy = entry.strategy, source = entry.inputs[0]; + if (strategy === 'literal') return this.text(pyGet(inputs, source), 'string', depth + 1); + if (strategy === 'strip-newlines') { + const flag = this.setting(nodeId, inputs, entry.params.flag, 'bool'); + const traced = this.text(pyGet(inputs, source), 'string', depth + 1); + if (!flag) return traced; + this.scan(pyLen(traced[0])); + const text = this.built(this.bounded(pyStrip(traced[0].replaceAll('\n', '')))); + return [text, traced[1], text]; + } + if (strategy === 'join-multi') return this.joinMulti(nodeId, entry, inputs, depth); + throw this.unsupported('Unsupported string strategy', nodeId); + } + + /** A literal node setting of exactly `type(value) is kind`; linked is `unsupported-node`, else malformed. */ + setting(nodeId, inputs, name, kind) { + if (!inputs.has(name)) throw new Unresolved('Missing string node setting', 'malformed'); + const value = inputs.get(name); + if (isLink(value)) throw this.unsupported('Linked string node setting', nodeId); + const typed = { bool: typeof value === 'boolean', int: isInt(value), str: typeof value === 'string' }[kind]; + if (!typed) throw new Unresolved('Malformed string node setting', 'malformed'); + return value; + } + + joinMulti(nodeId, entry, inputs, depth) { + const params = entry.params, flag = params.list_flag; + if (!inputs.has(flag)) throw new Unresolved('Missing string node setting', 'malformed'); + if (isLink(inputs.get(flag)) || inputs.get(flag) === true) throw this.named(RUNTIME_REASON, 'runtime-value', nodeId); + if (inputs.get(flag) !== false) throw new Unresolved('Malformed string node setting', 'malformed'); + const count = this.setting(nodeId, inputs, params.count, 'int'); + const delimiter = this.setting(nodeId, inputs, params.delimiter, 'str'); + if (!(count >= 1 && count <= MAX_JOIN_INPUTS)) throw new Unresolved('Malformed string node setting', 'malformed'); + const total = Number(count); + this.scan(total); + const names = [entry.inputs[0]]; + for (let index = 2; index <= total; index++) names.push(`${params.input_prefix}${index}`); + const parts = [], path = [], seen = new Set(); + let size = 0; + names.forEach((inputName, position) => { + if (position && !inputs.has(inputName)) return; + const [part, followed] = this.text(pyGet(inputs, inputName), 'string', depth + 1); + this.scan(pyLen(part) + followed.length); + for (const step of followed) { + const identity = slotKey(step.node_id, step.output_slot); + if (!seen.has(identity)) { seen.add(identity); path.push(step); } + } + if (position && !part) return; + size += pyLen(part) + (parts.length ? pyLen(delimiter) : 0); + if (size > this.maxTextLength) throw new Unresolved('Resolved text exceeds the length limit', 'text-limit'); + parts.push(part); + }); + const text = this.built(parts.join(delimiter)); + return [text, path, text]; + } + + sourceOf(path) { + if (!path.length) return 'graph-inferred'; + return this.textSources.get(slotKey(path[0].node_id, path[0].output_slot)) ?? 'graph-inferred'; + } + inheritSource(nodeId, slot, paths) { + const sources = new Set(paths.map(path => this.sourceOf(path))); + if (sources.size === 1) this.textSources.set(slotKey(nodeId, slot), [...sources][0]); + else this.textSources.delete(slotKey(nodeId, slot)); + } + unverifiable(error) { return UNVERIFIABLE_CODES.has(error.code); } + + executionRecord(nodeId, inputs) { + const record = readPairExecution(this.metadata, nodeId); + if (record === null) return null; + for (const name of ['text', 'negative_text', 'prepend_text', 'append_text']) { + let expected; + try { + expected = this.text(pyGet(inputs, name, ''), 'string')[0]; + } catch (error) { + if (error instanceof Unresolved && this.unverifiable(error)) { + const value = pyGet(inputs, name); + if (isLink(value)) { + const [sourceName] = this.node(String(value[0])); + const known = ADAPTER_SLOTS.get(sourceName); + if (known && !known.has(String(value[1]))) throw error; + if (['StringConcatenate', 'Text Concatenate', 'Reroute'].includes(sourceName) && value[1] !== 0) throw error; + } + continue; + } + throw error; + } + if (name === 'prepend_text' || name === 'append_text') expected = pyStrip(expected); + if (expected !== record[name]) throw new Unresolved('Execution evidence does not match recorded node inputs', 'malformed'); + } + return record; + } + + side(value, samplerId, guiderId = null, depth = 0) { + const [text, path, storageText] = this.text(value, 'conditioning', depth); + const origin = path[0]; + const provenance = { + format: 'comfyui', sampler_id: samplerId, ...origin, path, + text_source: this.textSources.get(slotKey(origin.node_id, origin.output_slot)) ?? 'graph-inferred', + }; + if (guiderId !== null) provenance.guider_id = guiderId; + return { text, storage_text: storageText, provenance }; + } + + guider(value) { + const seen = new Set(); + let depth = 0; + for (;;) { + if (depth >= this.maxDepth) throw new Unresolved('Resolution depth limit reached', 'depth-limit'); + if (!isLink(value)) throw new Unresolved('Expected a guider link', 'malformed'); + const nodeId = String(value[0]), slot = value[1]; + if (seen.has(nodeId)) throw new Unresolved('Cyclic guider ancestry', 'malformed'); + seen.add(nodeId); + this.budget.spend(); + const [name, inputs] = this.node(nodeId); + this.scan(inputs.size); + depth += 1; + if (name === 'Reroute' && slot === 0) { + const keys = ['value', 'input', 'in'].filter(item => inputs.has(item)); + if (keys.length !== 1) throw new Unresolved('Reroute input is not uniquely identifiable', 'malformed'); + value = inputs.get(keys[0]); + continue; + } + const entry = this.semantics.guiders.get(name); + if (entry === undefined || slot !== 0) throw this.unsupported('Unsupported guider', nodeId); + return [nodeId, inputs, { positive: entry.positive, negative: entry.negative ?? null }, entry.absent_reason ?? GUIDER_ABSENT_REASON, depth]; + } + } + + pair(samplerId) { + this.budget.reset(this.perCandidateVisits); + this.latentCharged = new Set(); + if (this.budget.total <= 0) return result('unresolved', { samplerId, reason: 'Resolution visit limit reached', reasonCode: 'work-limit' }); + return this.branches(samplerId); + } + + branches(samplerId) { + const pending = [new Map()], results = []; + try { + while (pending.length) { + this.choices = pending.shift(); + try { + results.push(stage(this, samplerId, new Set())); + } catch (error) { + if (!(error instanceof Diverging)) throw error; + if (results.length + pending.length + 2 > MAX_BRANCH_CANDIDATES) throw new Unresolved('Too many selector branches', 'work-limit'); + for (const choice of [true, false]) pending.push(new Map([...this.choices, [error.key, choice]])); + } + } + } catch (error) { + if (!(error instanceof Unresolved)) throw error; + return result('unresolved', { samplerId, reason: error.message, reasonCode: error.code, node: error.node }); + } finally { + this.choices = new Map(); + } + if (results.every(item => item.state === 'unresolved')) return results[0]; + if (new Set(results.map(outcomeKey)).size === 1) return results[0]; + return result('ambiguous', { samplerId, reason: 'Selector branches have different prompts', candidates: results }); + } + + sides(samplerId) { + let positive = null, negative = null; + try { + let [name, inputs] = this.node(samplerId); + const entry = this.semantics.samplers.get(name); + this.checkOverrides(samplerId, entry, inputs); + const latent = entry.latent; + if (latent !== undefined && latent !== null && inputs.has(latent)) this.latentChecked(samplerId, inputs.get(latent)); + let guiderId = null, depth = 0, conditioning = entry.conditioning, absentReason = 'Negative input is absent'; + if (entry.strategy === 'custom-advanced') { + let guiderReason; + [guiderId, inputs, conditioning, guiderReason, depth] = this.guider(pyGet(inputs, conditioning.guider)); + if (conditioning.negative === null) absentReason = guiderReason; + } + positive = this.side(pyGet(inputs, conditioning.positive), samplerId, guiderId, depth); + if (!pyStrip(positive.text)) throw new Unresolved(EMPTY_POSITIVE_REASON); + const negativeName = conditioning.negative; + if (negativeName === null || negativeName === undefined || !inputs.has(negativeName)) { + return result('absent', { positive, samplerId, reason: absentReason }); + } + negative = this.side(inputs.get(negativeName), samplerId, guiderId, depth); + return result('resolved', { positive, negative, samplerId }); + } catch (error) { + if (!(error instanceof Unresolved)) throw error; + return result('unresolved', { positive, negative, samplerId, reason: error.message, reasonCode: error.code, node: error.node }); + } + } + + checkOverrides(samplerId, entry, inputs) { + for (const name of entry.prompt_overrides ?? []) { + const value = pyGet(inputs, name); + if (value === null || value === '') continue; + if (isLink(value)) throw this.unsupported('Unsupported prompt override', String(value[0])); + throw this.named(RUNTIME_REASON, 'runtime-value', samplerId); + } + } + + latentChecked(samplerId, value) { + let error; + if (this.latentWalks.has(samplerId)) { + const [walked, cost] = this.latentWalks.get(samplerId); + error = walked; + if (!this.latentCharged.has(samplerId)) { + this.budget.charge(cost); + this.latentCharged.add(samplerId); + } + } else { + const before = this.budget.allowance; + try { + this.latentStages(samplerId, value); + error = null; + } catch (raised) { + if (!(raised instanceof Unresolved) || raised.code === 'work-limit') throw raised; + error = raised; + } + this.latentWalks.set(samplerId, [error, before - this.budget.allowance]); + this.latentCharged.add(samplerId); + } + if (error !== null) throw new Unresolved(error.message, error.code, error.node === null ? null : { ...error.node }); + } + + enqueue(queue, queued, inputs, depth) { + this.scan(inputs.size); + for (const item of inputs.values()) { + if (!isLink(item)) continue; + const key = String(item[0]); + if (!queued.has(key)) { queued.add(key); queue.push([key, depth]); } + } + } + + stageShaped(name, inputs) { + if (this.semantics.allClasses.has(name)) return false; + const { shaped, withModel } = this.semantics.stageInputs; + if (shaped.some(key => isLink(pyGet(inputs, key)))) return true; + return isLink(pyGet(inputs, 'model')) && withModel.some(key => isLink(pyGet(inputs, key))); + } + + latentStages(origin, value) { + if (!isLink(value)) throw new Unresolved('Unknown latent ancestry', 'malformed'); + const start = String(value[0]), queue = [[start, 0]], queued = new Set([start]); + for (let head = 0; head < queue.length; head++) { + const [nodeId, depth] = queue[head]; + if (nodeId === origin) throw new Unresolved('Cyclic latent ancestry', 'malformed'); + if (depth >= this.maxDepth) throw new Unresolved('Resolution depth limit reached', 'depth-limit'); + this.budget.spend(); + const [name, inputs] = this.node(nodeId); + if (this.stageClasses.has(name)) throw new Unresolved(MULTISTAGE_UNATTRIBUTED_REASON); + if (this.stageShaped(name, inputs)) throw this.unsupported('Unsupported latent ancestry', nodeId); + this.enqueue(queue, queued, inputs, depth + 1); + } + } +} + + +/** `_sampler_like_classes`: distinct classes, in graph order, of nodes shaped like samplers or guiders. */ +function samplerLikeClasses(graph) { + const names = []; + for (const node of graph.values()) { + if (!isDict(node) || !isDict(pyGet(node, 'inputs'))) continue; + const name = pyGet(node, 'class_type'); + if (typeof name !== 'string' || !name || names.includes(name)) continue; + if (SAMPLER_LIKE_INPUTS.some(key => isLink(pyGet(node.get('inputs'), key)))) { + names.push(pySlice(name, CLASS_NAME_LENGTH)); + if (names.length === NO_SAMPLER_NAMES) break; + } + } + return names; +} + +function noSampler(graph) { + const seen = samplerLikeClasses(graph); + const reason = `No supported sampler pair found${seen.length ? ` (saw ${seen.join(', ')})` : ''}`; + return result('unresolved', { reason, reasonCode: 'no-sampler' }); +} + +/** `_prompt_manager_widgets`: API node ID to widget text for every PromptManager node that runs. */ +function promptManagerWidgets(workflow) { + const nodes = isDict(workflow) ? pyGet(workflow, 'nodes') : null; + if (!isList(nodes)) return new Map(); + const definitions = pyGet(workflow, 'definitions'); + const listed = isDict(definitions) ? pyGet(definitions, 'subgraphs') : null; + const subgraphs = new Map(); + for (const subgraph of isList(listed) ? listed : []) { + if (isDict(subgraph) && typeof pyGet(subgraph, 'id') === 'string' && isList(pyGet(subgraph, 'nodes'))) subgraphs.set(subgraph.get('id'), subgraph.get('nodes')); + } + const widgets = new Map(), stack = [[nodes, '', 0]]; + let visited = 0; + while (stack.length) { + const [members, prefix, depth] = stack.pop(); + for (const node of members) { + visited += 1; + if (visited > MAX_WORKFLOW_NODES) return widgets; + if (!isDict(node)) continue; + const kind = pyGet(node, 'type'), nodeId = pyGet(node, 'id'); + if (!(typeof nodeId === 'string' || isInt(nodeId))) continue; + const apiId = `${prefix}${String(nodeId)}`; + // A list or object `type` is neither a subgraph instance nor a PromptManager node (#286). + if (isList(kind) || isDict(kind)) continue; + if (typeof kind === 'string' && subgraphs.has(kind) && depth < MAX_SUBGRAPH_DEPTH) { + stack.push([subgraphs.get(kind), `${apiId}:`, depth + 1]); + continue; + } + const values = pyGet(node, 'widgets_values'); + const text = isList(values) && values.length && typeof values[0] === 'string' ? values[0] : + isDict(values) && typeof pyGet(values, 'text') === 'string' ? values.get('text') : null; + if (typeof kind === 'string' && kind !== 'PromptManagerPair' && kind.toLowerCase().includes('promptmanager') && text !== null) widgets.set(apiId, text); + } + } + return widgets; +} + +/** `_overwritten_prompt_nodes` (R1). */ +function overwrittenPromptNodes(workflow, graph) { + const widgets = promptManagerWidgets(workflow), byText = new Map(); + for (const [nodeId] of widgets) { + const node = pyGet(graph, nodeId); + const inputs = isDict(node) ? pyGet(node, 'inputs') : null; + const text = isDict(inputs) ? pyGet(inputs, 'text') : null; + if (typeof text === 'string') { + if (!byText.has(text)) byText.set(text, []); + byText.get(text).push(nodeId); + } + } + const found = new Set(); + for (const ids of byText.values()) { + if (ids.length > 1 && new Set(ids.map(id => widgets.get(id))).size > 1) ids.forEach(id => found.add(id)); + } + return found; +} + +/** `_resolve` for an unanchored inspection (output_node_id None). */ +function resolve(metadata, semantics) { + if (!isDict(metadata) || !metadata.size) return result('absent', { reason: 'No parsed prompt metadata' }); + let graph; + if (metadata.has('prompt')) graph = metadata.get('prompt'); + else if (metadata.has('parameters')) return parameters(metadata.get('parameters'), PROFILE.maxTextLength); + else graph = metadata; + if (!isDict(graph) || !graph.size) return result('unresolved', { reason: 'API graph is missing or malformed', reasonCode: 'malformed' }); + if (graph.size > PROFILE.nodeCeiling) return result('unresolved', { reason: 'Prompt graph exceeds the inspection node ceiling', reasonCode: 'graph-ceiling' }); + if (semantics === null) return result('unresolved', { reason: SEMANTICS_UNAVAILABLE_REASON }); + let overwritten = new Set(); + if (metadata.has('prompt')) { + overwritten = overwrittenPromptNodes(pyGet(metadata, 'workflow'), graph); + } + const resolver = new GraphResolver(graph, metadata.has('prompt') ? metadata : null, semantics, overwritten); + const samplerIds = finalStages(resolver, [...graph].filter(([, node]) => isDict(node) && typeof pyGet(node, 'class_type') === 'string' && + resolver.samplerClasses.has(node.get('class_type'))).map(([id]) => id)); + if (!samplerIds.length) return noSampler(graph); + if (samplerIds.length > 1) { + const candidates = samplerIds.map(id => resolver.pair(id)); + return sharedPair(candidates) ?? result('ambiguous', { reason: 'Multiple sampler branches', candidates }); + } + return resolver.pair(samplerIds[0]); +} + + +/** `_may_hold_prompt_manager`. */ +function mayHoldPromptManager(canonical) { + const graph = pyGet(canonical, 'prompt'); + if (!isDict(graph)) return false; + for (const node of graph.values()) { + if (!isDict(node)) continue; + const name = pyGet(node, 'class_type'), inputs = pyGet(node, 'inputs'); + if (typeof name === 'string' && name.toLowerCase().includes('promptmanager')) return true; + if (isDict(inputs) && inputs.has('search_text')) return true; + } + return false; +} + +/** `_parsed_workflow`: the UI workflow as a dict, or null. */ +function parsedWorkflow(value) { + if (typeof value === 'string') { + try { value = pyJsonLoads(value); } catch (error) { + if (error instanceof PyValueError) return null; + throw error; + } + } + return isDict(value) ? value : null; +} + +/** `unreadable_evidence`: both roles when the file's metadata could not be read at all. */ +export function unreadableEvidence() { + const resolution = { state: 'unresolved', sampler_id: null, reason: UNREADABLE_METADATA_REASON, reason_code: null, node: null }; + return { prompts: explained({ positive: sideOf('unresolved'), negative: sideOf('unresolved') }, resolution), resolution }; +} + +/** + * `inspect_prompt_evidence(records, None, profile=INSPECTION_PROFILE)`: `{prompts, resolution}` for the records a + * local read found (`{prompt, workflow, parameters, comfyassets_prompt_pairs}` texts). `semantics` is + * `compileSemantics(...)`'s table, or null when it could not be loaded. + */ +export function resolveLocalEvidence(records, semantics) { + const source = records !== null && typeof records === 'object' ? records : {}; + const metadata = new Map(Object.entries(source).filter(([key]) => ROLE_METADATA_KEYS.has(key))); + const malformedResolution = { state: 'unresolved', sampler_id: null, reason: MALFORMED_EVIDENCE_REASON, reason_code: 'malformed', node: null }; + if (!metadata.size) { + return { + prompts: { positive: sideOf('absent'), negative: sideOf('absent') }, + resolution: { state: 'absent', sampler_id: null, reason: 'No parsed prompt metadata', reason_code: null, node: null }, + }; + } + const malformed = { prompts: explained({ positive: sideOf('unresolved'), negative: sideOf('unresolved') }, malformedResolution), resolution: malformedResolution }; + if (!metadata.has('prompt') && !metadata.has('parameters')) return malformed; + if (typeof metadata.get('prompt') === 'string') { + try { metadata.set('prompt', pyJsonLoads(metadata.get('prompt'))); } catch (error) { + if (error instanceof PyValueError) return malformed; + throw error; + } + } + const workflow = mayHoldPromptManager(metadata) ? parsedWorkflow(source.workflow ?? null) : null; + if (workflow !== null && metadata.has('prompt')) metadata.set('workflow', workflow); + const resolved = resolve(metadata, semantics); + const state = resolved.state, prompts = {}; + for (const role of ['positive', 'negative']) { + const side = resolved[role]; + const present = side !== null && typeof side === 'object'; + if (state !== 'ambiguous' && present && (role === 'negative' || pyStrip(side.text))) prompts[role] = sideOf('resolved', side.text, side.provenance.text_source); + else if (state === 'resolved' && present) prompts[role] = { ...sideOf('unresolved'), message: EMPTY_POSITIVE }; + else prompts[role] = sideOf(state !== 'resolved' ? state : 'unresolved'); + } + const resolution = { state, sampler_id: resolved.sampler_id, reason: resolved.reason, reason_code: resolved.reason_code, node: resolved.node }; + return { prompts: explained(prompts, resolution), resolution }; +} + diff --git a/web/js/workspace/local-semantics.mjs b/web/js/workspace/local-semantics.mjs new file mode 100644 index 0000000..bfda05d --- /dev/null +++ b/web/js/workspace/local-semantics.mjs @@ -0,0 +1,27 @@ +// The node table local Inspect resolves with (#249 local Inspect, PR L3 Task 10): web/js/inspection-semantics.json, +// the very table the server's inspector reads, fetched once per page through the API client (the proxy-mount rule). +// The Inspect page asks for it as it mounts, before any file is chosen; it is the only static file Inspect fetches, +// and the request carries nothing about a selection. A table that cannot be fetched or used is null: the roles then +// answer as the server does when its table is unusable, and a later selection asks again. +import { compileSemantics } from './local-resolver.mjs'; + +export const SEMANTICS_PATH = '/prompt_manager/js/inspection-semantics.json'; +let pending = null; + +async function fetchSemantics() { + const client = globalThis.window?.ApiClient; + if (typeof client?.resolveUrl !== 'function' || typeof globalThis.fetch !== 'function') return null; + const response = await globalThis.fetch(client.resolveUrl(SEMANTICS_PATH), { credentials: 'same-origin' }); + if (!response.ok) throw new Error(`Inspection node table request failed (${response.status})`); + return compileSemantics(await response.json()); +} + +/** The compiled table (one request per page), or null when it is unavailable. */ +export function loadInspectionSemantics() { + if (pending !== null) return pending; + const current = pending = fetchSemantics().catch(() => null).then(table => { + if (table === null && pending === current) pending = null; + return table; + }); + return current; +} diff --git a/web/js/workspace/local-session.mjs b/web/js/workspace/local-session.mjs index 0765ff6..646f77e 100644 --- a/web/js/workspace/local-session.mjs +++ b/web/js/workspace/local-session.mjs @@ -70,7 +70,7 @@ function chosenFile(input) { } export function createLocalSession({ - parentScope, createObjectURL = file => URL.createObjectURL(file), revokeObjectURL = url => URL.revokeObjectURL(url), + parentScope, createObjectURL = file => URL.createObjectURL(file), revokeObjectURL = URL.revokeObjectURL.bind(URL), schedulerYield = defaultSchedulerYield(), setTimer = setTimeout, clearTimer = clearTimeout, pageTarget = globalThis.window, extractFacts, pngMetadata, } = {}) { diff --git a/web/js/workspace/local-video.mjs b/web/js/workspace/local-video.mjs index f414cd5..0cc26e4 100644 --- a/web/js/workspace/local-video.mjs +++ b/web/js/workspace/local-video.mjs @@ -17,6 +17,7 @@ // server folds them. import { INT_MAX_STR_DIGITS } from "./local-projection.mjs"; +import { JSON_MAX_DEPTH } from "./py-json.mjs"; // Role records (prompt, parameters, the pair execution record) share one budget: // past it the read fails. A workflow has its own budget; past it only the workflow @@ -40,7 +41,6 @@ const MAX_NAME_BYTES = 64; // the longest carrier name is 24 bytes; longer names export const VIDEO_READ_CEILING = MAX_VIDEO_ELEMENTS * (MAX_HEADER_BYTES + MAX_NAME_BYTES) + VIDEO_TAG_READ_BYTES; const PREFIX_BYTES = 32; const YIELD_INTERVAL_MS = 8; -const JSON_MAX_DEPTH = 1000; // Python's default recursion limit const ROLE_KEYS = ["prompt", "parameters", "comfyassets_prompt_pairs"]; const METADATA_KEYS = ["prompt", "workflow", "parameters", "comfyassets_prompt_pairs"]; @@ -826,8 +826,9 @@ export function pythonJsonLoads(text) { } } function value(depth) { - if (depth > JSON_MAX_DEPTH) throw new PythonJsonError("Maximum recursion depth exceeded", true); const char = text[pos]; + // The shared cap (py-json.mjs): `depth` containers enclose this value, and it would be one more. + if ((char === "{" || char === "[") && depth >= JSON_MAX_DEPTH) throw new PythonJsonError("Maximum recursion depth exceeded", true); if (char === '"') return string(); if (char === "{") { pos += 1; diff --git a/web/js/workspace/py-json.mjs b/web/js/workspace/py-json.mjs new file mode 100644 index 0000000..5c87291 --- /dev/null +++ b/web/js/workspace/py-json.mjs @@ -0,0 +1,249 @@ +// Python's value model for the local inspection resolver (#249 local Inspect, PR L3): the server's resolver +// (utils/prompt_pair_resolver.py) runs on `json.loads` output, so the browser port reads the same text the same way. +// Objects are `Map`s (insertion order, a repeated key keeps its first position and its last value, as a dict does), +// integers are numbers (or BigInt past 2^53 - 1), floats are `PyFloat`, None is null. Strings are measured, sliced and +// stripped as Python's `str` is: by code point, with `str.isspace()` whitespace. No DOM. + +/** A Python float: distinct from an int (`1.0` is not a link slot, `type(x) is int` is false). */ +export class PyFloat { + constructor(value) { + this.value = value; + Object.freeze(this); + } +} + +/** `ValueError` from `json.loads` (malformed text, or an integer past `sys.int_info.str_digits_check_threshold`). */ +export class PyValueError extends Error { + constructor(message) { + super(message); + this.name = 'PyValueError'; + } +} + +// CPython's default int max str digits (PEP 651 / gh-95778). +const INT_MAX_STR_DIGITS = 4300; +/** + * The nesting both browser CPython-JSON parsers accept (this one and local-video.mjs's): every array or object counts, + * an empty one too, as CPython's scanner enters a recursion level for each. Past it `json.loads` raises RecursionError, + * which the server treats as invalid JSON; its real limit depends on the Python (TESTING.md, "Deep JSON"). + */ +export const JSON_MAX_DEPTH = 1000; +const MAX_SAFE_DIGITS = '9007199254740991'; +const NUMBER = /-?(?:0|[1-9][0-9]*)(\.[0-9]+)?([eE][-+]?[0-9]+)?/y; +const STRING_STOP = /["\\\u0000-\u001f]/g; +const HEX4 = /^[0-9a-fA-F]{4}$/; +const ESCAPES = { '"': '"', '\\': '\\', '/': '/', b: '\b', f: '\f', n: '\n', r: '\r', t: '\t' }; +// The C scanner's constants, in its order of checks. +const CONSTANTS = [['null', null], ['true', true], ['false', false], ['NaN', new PyFloat(NaN)], + ['Infinity', new PyFloat(Infinity)], ['-Infinity', new PyFloat(-Infinity)]]; + +export const isDict = value => value instanceof Map; +export const isList = value => Array.isArray(value); +/** `isinstance(value, int) and not isinstance(value, bool)`. */ +export const isInt = value => (typeof value === 'number' && Number.isInteger(value)) || typeof value === 'bigint'; + +function numberValue(match) { + const [literal, fraction, exponent] = match; + if (fraction || exponent) return new PyFloat(Number(literal)); + const digits = literal[0] === '-' ? literal.slice(1) : literal; + if (digits.length > INT_MAX_STR_DIGITS) throw new PyValueError('Exceeds the limit for integer string conversion'); + if (digits.length > MAX_SAFE_DIGITS.length || (digits.length === MAX_SAFE_DIGITS.length && digits > MAX_SAFE_DIGITS)) return BigInt(literal); + // `-0` is the int 0. + return Number(literal) || 0; +} + +/** `json.loads(text)` with Python's defaults (C scanner): NaN/Infinity accepted, a repeated key's last value wins. */ +export function pyJsonLoads(text) { + if (typeof text !== 'string') throw new TypeError('JSON text must be a string'); + if (text.startsWith('')) throw new PyValueError('Unexpected UTF-8 BOM (decode using utf-8-sig)'); + let pos = 0; + const fail = message => new PyValueError(`${message}: char ${pos}`); + const skip = () => { + while (pos < text.length) { + const c = text.charCodeAt(pos); + if (c !== 0x20 && c !== 0x09 && c !== 0x0a && c !== 0x0d) break; + pos++; + } + }; + function string() { + pos++; + let out = ''; + for (;;) { + STRING_STOP.lastIndex = pos; + const stop = STRING_STOP.exec(text); + if (!stop) throw fail('Unterminated string starting at'); + out += text.slice(pos, stop.index); + pos = stop.index + 1; + if (stop[0] === '"') return out; + if (stop[0] !== '\\') throw fail('Invalid control character at'); + const escape = text[pos]; + if (escape === 'u') { + const hex = text.slice(pos + 1, pos + 5); + if (!HEX4.test(hex)) throw fail('Invalid \\uXXXX escape'); + pos += 5; + const code = parseInt(hex, 16); + if (code >= 0xd800 && code <= 0xdbff && text[pos] === '\\' && text[pos + 1] === 'u') { + const low = text.slice(pos + 2, pos + 6); + if (!HEX4.test(low)) throw fail('Invalid \\uXXXX escape'); + const lowCode = parseInt(low, 16); + if (lowCode >= 0xdc00 && lowCode <= 0xdfff) { + out += String.fromCharCode(code, lowCode); + pos += 6; + continue; + } + } + out += String.fromCharCode(code); + } else if (Object.hasOwn(ESCAPES, escape)) { + out += ESCAPES[escape]; + pos++; + } else throw fail('Invalid \\escape'); + } + } + function key() { + if (text[pos] !== '"') throw fail('Expecting property name enclosed in double quotes'); + const name = string(); + skip(); + if (text[pos] !== ':') throw fail("Expecting ':' delimiter"); + pos++; + skip(); + return name; + } + function scalar() { + if (text[pos] === '"') return string(); + for (const [literal, value] of CONSTANTS) { + if (text.startsWith(literal, pos)) { + pos += literal.length; + return value; + } + } + NUMBER.lastIndex = pos; + const match = NUMBER.exec(text); + if (!match) throw fail('Expecting value'); + pos += match[0].length; + return numberValue(match); + } + skip(); + const stack = []; + const enter = () => { + if (stack.length >= JSON_MAX_DEPTH) throw fail('Maximum JSON nesting depth exceeded'); + pos++; skip(); + }; + let result; + for (;;) { + let value; + if (text[pos] === '{') { + enter(); + const frame = { value: new Map(), object: true, key: null }; + if (text[pos] === '}') pos++; + else { frame.key = key(); stack.push(frame); continue; } + value = frame.value; + } else if (text[pos] === '[') { + enter(); + const frame = { value: [], object: false }; + if (text[pos] === ']') pos++; + else { stack.push(frame); continue; } + value = frame.value; + } else value = scalar(); + for (;;) { + if (!stack.length) { result = value; break; } + const frame = stack[stack.length - 1]; + if (frame.object) frame.value.set(frame.key, value); + else frame.value.push(value); + skip(); + if (text[pos] === ',') { + pos++; skip(); + if (frame.object) frame.key = key(); + break; + } + if (text[pos] !== (frame.object ? '}' : ']')) throw fail("Expecting ',' delimiter"); + pos++; + stack.pop(); + value = frame.value; + } + if (!stack.length) break; + } + skip(); + if (pos !== text.length) throw fail('Extra data'); + return result; +} + +/** `len(text)`: code points (a lone surrogate counts once). */ +export function pyLen(text) { + let pairs = 0; + for (let index = 0; index < text.length - 1; index++) { + const code = text.charCodeAt(index); + if (code >= 0xd800 && code <= 0xdbff) { + const next = text.charCodeAt(index + 1); + if (next >= 0xdc00 && next <= 0xdfff) { pairs++; index++; } + } + } + return text.length - pairs; +} + +/** `text[:count]` by code points. */ +export function pySlice(text, count) { + if (text.length <= count) return text; + let end = 0; + for (let taken = 0; taken < count && end < text.length; taken++) { + const code = text.charCodeAt(end); + const next = text.charCodeAt(end + 1); + end += code >= 0xd800 && code <= 0xdbff && next >= 0xdc00 && next <= 0xdfff ? 2 : 1; + } + return text.slice(0, end); +} + +// `str.isspace()`: Unicode Zs plus the bidi classes B, S and WS (not U+FEFF, unlike `trim()`). +export const PY_SPACE = '\\t\\n\\x0b\\x0c\\r\\x1c-\\x1f \\x85\\xa0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000'; +const LEADING = new RegExp(`^[${PY_SPACE}]+`), TRAILING = new RegExp(`[${PY_SPACE}]+$`); +/** `text.strip()`. */ +export const pyStrip = text => text.replace(LEADING, '').replace(TRAILING, ''); + +/** `bool(value)`. */ +export function pyTruthy(value) { + if (value === null || value === undefined || value === false) return false; + if (value === true) return true; + if (typeof value === 'number') return value !== 0; + if (typeof value === 'bigint') return value !== 0n; + if (value instanceof PyFloat) return value.value !== 0; + if (typeof value === 'string' || isList(value)) return value.length > 0; + if (isDict(value)) return value.size > 0; + return true; +} + +/** `value == 1` (True and 1.0 equal it too). */ +export const pyEqualsOne = value => value === 1 || value === 1n || value === true || (value instanceof PyFloat && value.value === 1); + +/** `repr(float)`: the shortest round-trip digits, fixed for exponents -4..15, else `d.ddde+XX`. */ +export function pyFloatRepr(value) { + if (Number.isNaN(value)) return 'nan'; + if (!Number.isFinite(value)) return value > 0 ? 'inf' : '-inf'; + if (value === 0) return Object.is(value, -0) ? '-0.0' : '0.0'; + const [mantissa, exponentText] = value.toExponential().split('e'); + const exponent = Number(exponentText); + const sign = mantissa.startsWith('-') ? '-' : ''; + const digits = mantissa.replace(/^-/, '').replace('.', ''); + if (exponent < -4 || exponent >= 16) { + const rest = digits.slice(1); + return `${sign}${digits[0]}${rest ? `.${rest}` : ''}e${exponent < 0 ? '-' : '+'}${String(Math.abs(exponent)).padStart(2, '0')}`; + } + if (exponent < 0) return `${sign}0.${'0'.repeat(-exponent - 1)}${digits}`; + const whole = digits.slice(0, exponent + 1).padEnd(exponent + 1, '0'), fraction = digits.slice(exponent + 1); + return `${sign}${whole}.${fraction || '0'}`; +} + +/** + * `str(value)` where the resolver compares the result with node class names (`str(node.get("class_type", ""))`). + * A container's text never equals a class name, so it is a marker only. + */ +export function pyStr(value) { + if (typeof value === 'string') return value; + if (value === null || value === undefined) return 'None'; + if (value === true) return 'True'; + if (value === false) return 'False'; + if (typeof value === 'number' || typeof value === 'bigint') return String(value); + if (value instanceof PyFloat) return pyFloatRepr(value.value); + return '\u0000container'; +} + +/** `mapping.get(key, fallback)`. */ +export const pyGet = (mapping, key, fallback = null) => mapping.has(key) ? mapping.get(key) : fallback; diff --git a/web/js/workspace/workspace-dialogs.mjs b/web/js/workspace/workspace-dialogs.mjs index 72b3e86..39b35e6 100644 --- a/web/js/workspace/workspace-dialogs.mjs +++ b/web/js/workspace/workspace-dialogs.mjs @@ -59,10 +59,10 @@ function createShell() { body.id = 'modal-body'; header.append(title, close); dialog.append(header, body); - close.addEventListener('click', () => finish()); + close.addEventListener('click', () => dismiss()); dialog.addEventListener('cancel', event => { event.preventDefault(); - finish(); + dismiss(); }); // A browser may still close a modal on its own; that is a dismissal too. `close` arrives as a later task, so it // is ignored once this shell was reopened or replaced. @@ -82,6 +82,12 @@ function finish() { if (trigger?.isConnected) trigger.focus(); } +/** × or Escape: close, unless the open dialog's `canClose()` holds it (a save in flight). */ +function dismiss() { + if (session?.canClose && !session.canClose()) return; + finish(); +} + const boundScopes = new WeakSet(); function requireScope(scope) { @@ -104,13 +110,13 @@ function bindScope(scope) { }); } -function show(heading, nodes, { scope, trigger, focusTarget, onClose = null }) { +function show(heading, nodes, { scope, trigger, focusTarget, onClose = null, canClose = null }) { shell ??= createShell(); if (!shell.dialog.isConnected) document.body.append(shell.dialog); bindScope(scope); // A swapped-out body is gone from the shell, so its owner releases it. const replaced = session?.onClose; - session = { scope, trigger: trigger ?? session?.trigger ?? null, onClose }; + session = { scope, trigger: trigger ?? session?.trigger ?? null, onClose, canClose }; replaced?.(); shell.title.textContent = heading; shell.body.replaceChildren(...nodes); @@ -280,14 +286,17 @@ export function openImportDialog({ scope, onInspect, onRescan, onMetadataScan = return { setRescanBusy }; } -/** "Copy prompt": the text in a readonly, pre-selected textarea for a manual copy. */ -export function openCopyFallback(text, { scope, trigger = null } = {}) { +/** + * "Copy prompt": the text in a readonly, pre-selected textarea for a manual copy. `title` names another copy's dialog + * (D117 "Copy settings summary"). + */ +export function openCopyFallback(text, { scope, trigger = null, title = 'Copy prompt' } = {}) { requireScope(scope); if (typeof text !== 'string') throw new TypeError('Copy text must be a string'); const area = el('textarea'); area.readOnly = true; area.value = text; - show('Copy prompt', [ + show(title, [ field('Select and copy this text', area, 'copy-text'), el('p', 'Automatic clipboard access requires a secure browser context.', 'small'), ], { scope, trigger, focusTarget: area }); @@ -298,10 +307,14 @@ export function openCopyFallback(text, { scope, trigger = null } = {}) { * The concept's `simpleInput`: one labelled `input#action-input` (starting at `value`), then Cancel and the primary * `action`. The action reports the trimmed value and closes; an empty value keeps the dialog open on its field, or * reports `fallback` when one is given (Save view's "Saved view", a1339). A positive `maxLength` limits the field. + * `validate(value)` may refuse a value with a message: the dialog then stays open with the typed text and the field + * reports the message (its native validity) until it is edited. */ -export function openInputDialog({ scope, title, label, action, value = '', maxLength, fallback, onApply, trigger = null }) { +export function openInputDialog({ scope, title, label, action, value = '', maxLength, fallback, onApply, trigger = null, + validate = null }) { requireScope(scope); requireCallbacks({ onApply }); + if (validate !== null) requireCallbacks({ validate }); for (const [name, text] of Object.entries({ title, label, action })) { if (typeof text !== 'string' || !text) throw new TypeError(`The input dialog needs a ${name}`); } @@ -314,9 +327,17 @@ export function openInputDialog({ scope, title, label, action, value = '', maxLe const cancel = createButton('Cancel'); const apply = createButton(action, 'ca-primary'); cancel.addEventListener('click', () => finish()); + input.addEventListener('input', () => input.setCustomValidity('')); apply.addEventListener('click', () => { const entered = input.value.trim() || fallback; if (!entered) { input.focus(); return; } + const problem = validate?.(entered); + if (problem) { + input.setCustomValidity(problem); + input.reportValidity(); + input.focus(); + return; + } finish(); onApply(entered); }); @@ -327,14 +348,16 @@ export function openInputDialog({ scope, title, label, action, value = '', maxLe /** * The concept's plain `dialog(title, body)` with page-built `nodes`. `onClose` runs once when the dialog closes or - * another dialog replaces its body, so the page can dispose what it mounted there. + * another dialog replaces its body, so the page can dispose what it mounted there. While `canClose()` answers false, + * × and Escape leave the dialog open (the page, a route change or a replacing dialog still close it). */ -export function openContentDialog({ scope, title, nodes, onClose = null, trigger = null }) { +export function openContentDialog({ scope, title, nodes, onClose = null, trigger = null, canClose = null }) { requireScope(scope); if (typeof title !== 'string' || !title) throw new TypeError('The dialog needs a title'); if (!Array.isArray(nodes) || nodes.some(node => !node || typeof node !== 'object')) throw new TypeError('The dialog needs its nodes'); if (onClose !== null && typeof onClose !== 'function') throw new TypeError('The dialog onClose must be a function'); - show(title, nodes, { scope, trigger, onClose }); + if (canClose !== null && typeof canClose !== 'function') throw new TypeError('The dialog canClose must be a function'); + show(title, nodes, { scope, trigger, onClose, canClose }); } export const SCAN_REVIEW_SENTENCE = 'Enrich positive / negative associations, reuse canonical text and report unchanged files. ' + diff --git a/web/js/workspace/workspace.css b/web/js/workspace/workspace.css index 7394f31..7117376 100644 --- a/web/js/workspace/workspace.css +++ b/web/js/workspace/workspace.css @@ -212,6 +212,8 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; } /* #249 D20: the viewer details' pair send and history buttons wrap inside the narrow rail at 200% zoom with wider fonts, where the toolkit's `nowrap` would push them past its edge. */ .ca-tools #viewer-details > button { white-space: normal; /* D20 */ } +/* LI-4: "Copy settings summary" after the facts wraps the same way in the narrow rail. */ +.ca-tools #viewer-details .settings-summary { white-space: normal; /* D117 */ } /* The Images page narrows to one column inside a narrow container (the Library-embedded gallery). */ .images-main { container: images-page / inline-size; } @container images-page (inline-size < 600px) { @@ -376,3 +378,9 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-bloc /* D83: the chip's × keeps the concept's 40px coarse target. */ .ca-tools #tag-suggestions .chip.tag-suggestion > button { min-block-size: 40px; min-inline-size: 40px; } } + +/* Auto-tag selected images (#249 PR D, D85/D90): the Tag image dialog's model panel, then How to apply, a radio group + in the concept's `.field` row whose radios keep their own size (the concept's `.field input` is a full-width text + field) beside their label, as the concept's own radio rows (s1160 `.compare-item label`). */ +.field[role="radiogroup"] > label { display: flex; align-items: center; gap: 8px; /* D90 */ } +.field[role="radiogroup"] input[type="radio"] { width: auto; margin: 0; /* D90 */ }