Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23af132532 | ||
|
|
a4b75c3eae |
+15
-182
@@ -121,13 +121,6 @@ npm run --prefix tools/vendor test:comfyui
|
||||
CI performs this setup automatically. `PM_COMFY_URL` can select another
|
||||
`http://127.0.0.1:PORT/` address (default port 8189). This is an owned managed
|
||||
server: a running host is never reused. Do not point it at your live checkout.
|
||||
One port serves one run. The launcher refuses a port that is already in use and
|
||||
reports ready only when `/system_stats` names its own base directory, so two runs
|
||||
started together on 8189 fail at startup ("already in use" or "answered by a
|
||||
ComfyUI host this run did not start") instead of one run testing the other's
|
||||
host and losing it mid-test (`ECONNREFUSED`, `ERR_CONNECTION_REFUSED`, Network
|
||||
Error). Every test checks the answering host again. Agents sharing a machine
|
||||
give each run its own `PM_COMFY_URL` port.
|
||||
The launcher verifies the upstream SHA and clean tracked files, snapshots only
|
||||
tracked PromptManager files, and creates temporary user/input/output/database/
|
||||
configuration/log directories. Personal untracked files are excluded. It runs
|
||||
@@ -392,8 +385,7 @@ with source `zeroed-conditioning`. `easy ifElse` with a literal boolean follows
|
||||
branch only; with a linked boolean both branches are traced, equal texts resolve and
|
||||
different texts make the sampler `ambiguous` with one candidate per choice (at most
|
||||
32, else `work-limit`). ConditioningCombine/Concat/Average resolve only when every
|
||||
part has the same text, otherwise `unresolved` with `composite` naming the node
|
||||
(but see the added-conditioning floor below for a later stage).
|
||||
part has the same text, otherwise `unresolved` with `composite` naming the node.
|
||||
Results that depend on a forced choice are never memoized. A `FaceDetailer`
|
||||
(`detailer-stage`) is a sampler stage: the samplers reached through its `image`
|
||||
ancestry are compared with it. Equal texts resolve with the detailer as the sampler;
|
||||
@@ -404,65 +396,9 @@ A sampler's latent ancestry is walked through every link input of non-sampler
|
||||
nodes within the same visit and depth budgets: with no earlier sampler it is a
|
||||
single stage whatever the origin (VAEEncode, EmptyLatentBatch, EmptyFlux2LatentImage,
|
||||
EmptyHunyuanLatentVideo, SetLatentNoiseMask, InpaintModelConditioning or
|
||||
WanImageToVideo slot 2). Pass-through lineage (#304 follow-up, maintainer option B,
|
||||
2026-10-23): the table's `lineage` section lists the outputs whose image or latent
|
||||
continues one input's (`VAEDecode`/`VAEDecodeTiled` `samples`, `VAEEncode`/
|
||||
`VAEEncodeTiled` `pixels`, `ImageUpscaleWithModel`, `ImageScale`, `ImageScaleBy`,
|
||||
`ImageScaleToTotalPixels` `image`, `LatentUpscale`/`LatentUpscaleBy` `samples`,
|
||||
cg-image-filter `Image Filter` slot 0 `images` and slot 1 `latents`; a selection of
|
||||
the same batch), plus Reroutes. Lineage describes no prompt, so its classes are not
|
||||
claimed by a prompt category. When the latent walk finds exactly one earlier sampler
|
||||
and the latent descends from that sampler's output slot 0 only through lineage
|
||||
nodes (a hires-fix or refiner chain, img2img from an in-graph decode, a direct
|
||||
latent link), the two stages are compared like a detailer and its upstream stage:
|
||||
equal role texts resolve with the later sampler named, different texts are
|
||||
`ambiguous` `multistage-differs` with one candidate per stage, and an unreadable
|
||||
earlier stage makes the chain `unresolved` with that stage's reason. Unanchored, the
|
||||
earlier sampler is judged inside the chain when its output reaches no other file
|
||||
output. A size or resolution node reading the same image beside the lineage (a
|
||||
`ResolutionCalculator` feeding `ImageScale` width and height) is allowed, since the
|
||||
walk then still finds only that sampler. Any other earlier sampler, or one reached
|
||||
only through an unlisted image node, stays `unresolved` "Multistage sampler ancestry
|
||||
is ambiguous". Added-conditioning floor: a later stage whose own positive fails only
|
||||
as `composite` keeps its earlier stages' shared pair ("A later stage adds
|
||||
conditioning to the base prompt", naming the earlier stage's sampler) when that
|
||||
positive reaches, through Reroutes and conditioning pass-throughs, a composite the
|
||||
table marks `additive` (ConditioningCombine, ConditioningConcat; never
|
||||
ConditioningAverage, which can weight a part away), one of its parts is exactly the
|
||||
earlier stages' positive text, and its negative text equals theirs. This is the
|
||||
three-stage Flux run whose UltimateSDUpscale combines the PromptManager prompt with
|
||||
a style-trigger encoder: legacy linked the PromptManager text and every stage is
|
||||
conditioned on it. The positive is always labelled (maintainer ruling): its source is
|
||||
`added-conditioning` (D126; with a D125 omission too,
|
||||
`runtime-parts-omitted-and-added-conditioning`) and its provenance names the
|
||||
composite (`added_by`), whatever the added text is; no length heuristic. A combine without the base text, a different negative, or
|
||||
different earlier stages stay as they were (`composite` or `multistage-differs`).
|
||||
An image-edit batch (several samplers with distinct prompts, one shared saver
|
||||
prefix) stays `ambiguous`, and text generated at run time stays untraceable
|
||||
(`tests/test_resolver_pass_through.py` on synthetic shapes from
|
||||
`tests/passthrough_graphs.py`; the browser port is compared on the named
|
||||
`select-*` chain cases and `passthrough_fuzz_cases`). Ingestion keeps the default
|
||||
profile's historical latent rule (D1), so the admin scan still reaches these files
|
||||
through its legacy-parser fallback. Run-time PromptManager parts (maintainer option
|
||||
A, 2026-10-23): LoraManager's `TriggerWord Toggle` is a `runtime` class (its output is
|
||||
the LoRA's trigger words as filtered at run time). A PromptManager (or
|
||||
PromptManagerPair) `prepend_text` or `append_text` linked directly to a `runtime`
|
||||
node is left out: the text is the node's own, combined with any other literal or
|
||||
traced part exactly as before, the side's source is `runtime-parts-omitted`, and a
|
||||
resolved pair's reason is "Text added at run time, such as trigger words, is not
|
||||
included". The prompt block shows that line under the text (D125; the evidence
|
||||
badge stays "Workflow inferred"). The label follows the text through encoders,
|
||||
concatenations and chained PromptManagers: a side is labelled when any node on its
|
||||
traced path left a part out, and its provenance lists those PromptManager nodes
|
||||
(`omitted_parts`). Comparisons include what a text leaves out, so a prompt that left
|
||||
out a part never equals a complete one (a branch with the toggle and one without stay
|
||||
ambiguous; such stages are `multistage-differs`). Only classes in the table's
|
||||
`runtime` list are left out, never another `runtime-value` source (a JoinStringMulti
|
||||
with a linked list flag stays `runtime-value`). The toggle's output is never computed. A run-time node further upstream (inside a
|
||||
concatenation), a run-time main `text`, or an unknown feeder keep their outcome
|
||||
(`runtime-value` or `unsupported-node`). This is what legacy linked for the June
|
||||
hires-fix runs (`TestRunTimePromptManagerParts`, the `select-runtime-*` parity
|
||||
cases); an unlisted node with sampler-shaped inputs (positive, negative, guider,
|
||||
WanImageToVideo slot 2). An earlier sampler (img2img from a decode, hires fix) stays
|
||||
`unresolved` "Multistage sampler ancestry is ambiguous" until stage texts are
|
||||
compared; an unlisted node with sampler-shaped inputs (positive, negative, guider,
|
||||
latent_image, a pipe, video embeds, custom-sampler parts, or a `model` link together
|
||||
with conditioning, neg_conditioning, samples or latent) is `unsupported-node`
|
||||
naming it, here and in a detailer's image ancestry; a latent that is not a link or
|
||||
@@ -1898,36 +1834,8 @@ browser fields, then the host fields (`POST /prompt_manager/settings` with
|
||||
highlights their controls; `settings-changed` is a conflict and keeps the draft. System
|
||||
reads the host values on mount and again after a conflict; until a read returned a
|
||||
revision, a host save fails without a request, so it never sends a blind
|
||||
`expected_revision: null` (which S1 merges into the file on disk). Save stays disabled
|
||||
until a section has an unsaved change.
|
||||
|
||||
Appearance (`system-appearance.mjs`, S3) is the concept's "Browsing & display" surface
|
||||
on real values. Its browser rows are this browser's display defaults in the legacy
|
||||
gallery's own `gallerySettings` object (`web/js/workspace/preferences.mjs`, Q3): Default
|
||||
layout and Thumbnail size (`workspaceLayout` grid/list/masonry, `workspaceDensity`
|
||||
comfortable/large/compact, new keys), Load metadata on selection (`autoLoadMetadata`),
|
||||
Cache metadata in this browser (`cacheMetadata`), Image loading quality (`imageQuality`:
|
||||
Optimized→`medium`, Original→`high`, Fast→`low`; the thumbnail dialog's preselected quality
|
||||
once S5 lands), the three video rows (`videoAutoplay`, `videoMute`, `videoLoop`) and Show
|
||||
file paths (`showFilePaths`). A write re-reads the stored object and writes back only the
|
||||
changed keys, every other key (legacy paging, thumbnail state, unknown keys) as stored;
|
||||
the legacy gallery's Save also keeps the keys it does not edit, so the two pages stay in
|
||||
sync. The workspace never reads the legacy paging keys (`defaultLimit`, `gridColumns`,
|
||||
`infiniteScroll`; README continuous-browsing ruling). A browser that refuses storage
|
||||
(denied, or a full quota that leaves the stored bytes as they were) keeps the change for
|
||||
this tab only and the status says "Saved for this tab only; browser storage is
|
||||
unavailable.", never "Saved in this browser.". Another tab's change (a `storage` event)
|
||||
repaints the clean controls; a draft it overtook stays, highlighted, with the conflict
|
||||
status. Open from ComfyUI (New tab→`newtab`, Popup window→`popup`) and Node search
|
||||
auto-hide (seconds) (a whole number 0–300; anything else is highlighted and never sent)
|
||||
are the server's `webui_display_mode` and `result_timeout`, saved through
|
||||
`POST /prompt_manager/settings` with the revision. The consumers read the defaults on
|
||||
their next visit, never the current view: a new Images visit starts from the layout and
|
||||
size (the last visit keeps its own, a saved view opens at Grid and Comfortable as
|
||||
before); with metadata off a selection reads nothing until "Load metadata"; with the
|
||||
cache off selecting the shown file again re-reads it (on, the one current record is
|
||||
reused); paths off leaves out only the Path row of File & provenance; the viewer reads
|
||||
the video defaults.
|
||||
`expected_revision: null` (which S1 merges into the file on disk). No delivered section
|
||||
edits a setting yet, so Save stays disabled until Appearance (S3).
|
||||
|
||||
Overview (`system-overview.mjs`) reads `GET /prompt_manager/system/overview` once per
|
||||
visit through `latestOnly()` on the section scope; a late answer after the section left
|
||||
@@ -1956,7 +1864,7 @@ the connection's `total_changes` is 0 at close and the data reads back unchanged
|
||||
`-shm` bytes are not compared: every WAL reader rewrites its read marks there, so no
|
||||
reader can keep it byte-identical (`test_health_check_never_writes`).
|
||||
|
||||
Recorded deviations (plan D60–D65 renumbered D92–D97; D121, D122, D123, D124, D125 and D126 assigned):
|
||||
Recorded deviations (plan D60–D64 renumbered D92–D96; D121, D122 and D123 assigned):
|
||||
|
||||
- **D92** (shell): no "Sample configuration" chip. The Save status is truthful: "No
|
||||
unsaved changes.", "Unsaved changes.", "Saved in this browser." / "Saved to the server
|
||||
@@ -2014,51 +1922,6 @@ Recorded deviations (plan D60–D65 renumbered D92–D97; D121, D122, D123, D124
|
||||
the rest of Maintenance, above its D94 hand-off, as the native place that runs the
|
||||
health check. A press shows "Checking…" (`aria-disabled`) and opens the report; a
|
||||
failed check toasts "The health check could not finish. Nothing was changed."
|
||||
- **D97** (Appearance): added rows for the legacy gallery settings the concept leaves
|
||||
out, after its own rows: "Show image details on cards" (`showImageInfo`; off hides a
|
||||
card's title and file line, Info and Tag stay at the end of the row), "Collapse viewer
|
||||
details by default" (`sidebarCollapsedByDefault`; the viewer opens with
|
||||
`#viewer-details` hidden and a "Show details"/"Hide details" toggle with
|
||||
`aria-expanded`, the stage taking the whole width), and the operation "Reset display
|
||||
defaults" ("Restore this browser’s display defaults, then save. Other settings are
|
||||
kept.", button "Reset"), which drafts the gallery's defaults for the browser rows only.
|
||||
The node search timeout shows the server's real value, not the sample 30. CSS under D97.
|
||||
- **D124** (inspector): with "Load metadata on selection" off, the Images panel and
|
||||
the viewer's details show "Metadata loads when you ask for it." and a "Load metadata"
|
||||
button instead of reading the file. The concept has no such state, so no concept
|
||||
comparison renders it: `metadata off shows an explicit load and loads once on request`
|
||||
(workspace-system.spec) pins it instead.
|
||||
- **D125** (prompt blocks, maintainer option A): a resolved prompt whose source is
|
||||
`runtime-parts-omitted` (a PromptManager `prepend_text` or `append_text` fed directly
|
||||
by a listed run-time node, such as LoraManager's TriggerWord Toggle, was left out)
|
||||
shows the muted line "Text added at run time, such as trigger words, is not
|
||||
included." under its text. The evidence badge stays "Workflow inferred"; the line is
|
||||
never sent or copied. The concept has no such state (`a prompt that left out run-time
|
||||
trigger words says so under its text (D125)` in `tests/js/prompt-blocks.test.js`).
|
||||
- **D126** (prompt blocks, maintainer ruling on the added-conditioning floor): a
|
||||
resolved positive whose source is `added-conditioning` (a later stage combined more
|
||||
conditioning with the base prompt, as UltimateSDUpscale's ConditioningCombine of the
|
||||
PromptManager and a style trigger) shows "Conditioning added at a later stage is not
|
||||
included." the same way. A prompt with both shows both lines, D125 first (source
|
||||
`runtime-parts-omitted-and-added-conditioning`). Always labelled, with no length or
|
||||
"short trigger" heuristic (`a prompt a later stage added conditioning to says so, and
|
||||
both lines show together (D126)`).
|
||||
|
||||
Appearance scope and states. The Default layout and Thumbnail size defaults start the
|
||||
Images page only (a new visit, not the current view or a saved view); the embedded
|
||||
galleries (a prompt history's Shared gallery and its All images / Repeat run dialogs)
|
||||
have no toolbar, so they keep Grid/Comfortable, while the metadata, cache and card
|
||||
defaults apply there too. A host row whose `/settings` read failed stays disabled with
|
||||
"Server settings could not be read."; a rejected timeout says "Some changes were not
|
||||
saved. Review the highlighted settings." A display default another page saved under one
|
||||
of this tab's drafts flags that draft with the conflict status, whether it arrived while
|
||||
System was mounted (a `storage` event) or while it was not (checked again on mount, and
|
||||
before a write); the draft is never written over the other page's value without that
|
||||
status first. A change kept for this tab only is stored by the next write storage accepts.
|
||||
|
||||
Open question (saved-views follow-up, M8): a saved view stores no layout or size and
|
||||
always opens at Grid/Comfortable, even when the Default layout is Masonry. Should saved
|
||||
views store a layout, or follow the default?
|
||||
|
||||
`tests/browser/system-concept.spec.mjs` compares the shell and Overview with the concept
|
||||
(dark and light, 1600×1100 and 390×844), mapping only the deviations above; unmapped,
|
||||
@@ -2071,34 +1934,20 @@ source when there are several, replaces only the chips with the fixture's expect
|
||||
results, and asserts the full unmapped path list; it maps D96 and compares the
|
||||
Maintenance notice unchanged. `system comparison detects mutations` removes a row,
|
||||
renames a metric label, swaps the `--line` token, recolours a chip, renames a check,
|
||||
removes the totals table and renames a caption. `appearance matches the concept` maps
|
||||
only D97 and the real timeout (unmapped, exactly those paths differ), and `appearance
|
||||
comparison detects mutations` removes a row, renames a label and an option and swaps
|
||||
the `--line` token. Paired captures (`{concept,production}-system-{dark,light}-{1600,390}.png`,
|
||||
`{concept,production}-{statistics,health}-{dark,light}.png`,
|
||||
`{concept,production}-appearance-{dark,light}-{1600,390}.png`) are test artifacts; there
|
||||
removes the totals table and renames a caption. Paired captures (`{concept,production}-system-{dark,light}-{1600,390}.png`,
|
||||
`{concept,production}-{statistics,health}-{dark,light}.png`) are test artifacts; there
|
||||
are no PNG baselines. `tests/browser/workspace-system.spec.mjs` covers the real counts,
|
||||
the health check and metric 4, a failed check, the thumbnail row against the status
|
||||
endpoint (painted after the rest when its read is held, unavailable when it fails), the review row (Q6), the section links, the Statistics dialog against the
|
||||
route, a load failure with Retry and late answers after leaving, and Appearance: new
|
||||
visits versus the current view and a saved view, the embedded gallery keeping Grid,
|
||||
metadata off and on request, the cache (counted up to a request barrier), hidden paths,
|
||||
the video defaults, the host fields with their revision, a stale one and a failed read,
|
||||
reload and a second tab, a second tab's save while System was closed, storage refused,
|
||||
the D97 rows and reset. Its hooks are
|
||||
route, a load failure with Retry and late answers after leaving. Its hooks are
|
||||
`tests/browser/workspace_system_fixture.py` (`/__test__/system/*`: reset, injected
|
||||
failures, an absent source, a deleted linked file, unthumbnailed files) on the
|
||||
concept-shaped fixture, with delays through the shared request holds; reset also restores
|
||||
the server's host settings and its `config.json`, which must be the one inside the server's
|
||||
temp root (`tests/test_workspace_system_fixture.py`).
|
||||
concept-shaped fixture, with delays through the shared request holds.
|
||||
|
||||
```sh
|
||||
python3 -m unittest tests.test_database_health tests.test_system_api tests.test_library \
|
||||
tests.test_workspace_system_fixture -v
|
||||
python3 -m unittest tests.test_database_health tests.test_system_api tests.test_library -v
|
||||
node --test tests/js/workspace-system-state.test.js tests/js/workspace-system-controls.test.js \
|
||||
tests/js/workspace-system-overview.test.js tests/js/workspace-state.test.js \
|
||||
tests/js/workspace-system-appearance.test.js tests/js/workspace-preferences.test.js \
|
||||
tests/js/workspace-inspection-state.test.js tests/js/workspace-images-state.test.js
|
||||
tests/js/workspace-system-overview.test.js tests/js/workspace-state.test.js
|
||||
npm run --prefix tools/vendor test:workflows -- system-concept.spec.mjs workspace-system.spec.mjs \
|
||||
--project chromium --project touch --project firefox --project webkit
|
||||
```
|
||||
@@ -2144,24 +1993,8 @@ and skip the parse.
|
||||
integers past 2**53 as their exact digits, so the card and the inspector read
|
||||
the same digits. The parse runs after the file handle closes. A record over
|
||||
2 MiB (UTF-8), a parser error, a recursion error, a placeholder, or a
|
||||
non-integral, boolean or non-finite seed gives null. When the prompt graph's
|
||||
sampler stages read different seed inputs and the inspector's resolution names
|
||||
one sampler for a resolved pair (a detailer or pass-through chain, whose facts
|
||||
are the named stage's, D19), the card compares that stage's seed (read by the
|
||||
same parser, the other samplers hidden from it) with its own and shows no seed
|
||||
only when they differ. A seed that agrees, or one the named stage does not make
|
||||
known, is kept, as on main (a saved sampler beside a preview-only one keeps its
|
||||
seed; an ambiguous chain, whose facts merge and fill from the parser, keeps it).
|
||||
Only such graphs are resolved for the card, with `INSPECTION_PROFILE`
|
||||
(`test_a_chain_the_inspector_names_one_stage_of_shows_no_differing_seed`,
|
||||
`test_a_seed_that_agrees_with_the_named_stage_is_kept`,
|
||||
`test_a_named_ambiguous_chain_keeps_the_parser_seed`; a literal named seed needs no
|
||||
second parse, and stages sharing one linked seed skip the check. Measured on a
|
||||
200-card page of about 54 KB graphs, half multi-stage with per-stage seeds: 618 ms
|
||||
cold against main's 387 ms, about 1.2 ms more per such card, paid once per file
|
||||
through the header-facts cache below;
|
||||
before this rule a KSampler then FaceDetailer chain with two seeds broke the
|
||||
contract below). A card never shows a seed that differs from the inspector's
|
||||
non-integral, boolean or non-finite seed gives null. A card never shows a seed
|
||||
that differs from the inspector's
|
||||
(`test_card_seed_equals_inspector_seed_when_both_present`, which `JSON.parse`s
|
||||
the listing in Node and compares `String(seed)` with the inspector's seed,
|
||||
including one past 2**53). The card shows no seed for A1111 `parameters` files
|
||||
|
||||
@@ -4,7 +4,6 @@ Database operations for KikoTextEncode prompt storage and retrieval.
|
||||
|
||||
import sqlite3
|
||||
import json
|
||||
import math
|
||||
import datetime
|
||||
import os
|
||||
from typing import Optional, List, Dict, Any, Set, Tuple
|
||||
@@ -27,7 +26,6 @@ from .health import HealthOperations, iter_consistency_issues
|
||||
try:
|
||||
from ..utils.logging_config import get_logger
|
||||
from ..utils.hashing import generate_prompt_hash
|
||||
from ..utils.json_strict import dumps_strict
|
||||
except ImportError:
|
||||
import sys
|
||||
|
||||
@@ -35,7 +33,6 @@ except ImportError:
|
||||
sys.path.insert(0, current_dir)
|
||||
from utils.logging_config import get_logger
|
||||
from utils.hashing import generate_prompt_hash
|
||||
from utils.json_strict import dumps_strict
|
||||
|
||||
|
||||
# Subquery to fetch tags from junction table, embedded in SELECT statements
|
||||
@@ -1700,9 +1697,9 @@ class PromptDatabase(
|
||||
width,
|
||||
height,
|
||||
image_format,
|
||||
dumps_strict(metadata.get("workflow", {})),
|
||||
dumps_strict(metadata.get("prompt", {})),
|
||||
dumps_strict(metadata.get("parameters", {})),
|
||||
json.dumps(metadata.get("workflow", {})),
|
||||
json.dumps(metadata.get("prompt", {})),
|
||||
json.dumps(metadata.get("parameters", {})),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
@@ -1907,13 +1904,13 @@ class PromptDatabase(
|
||||
obj: The object to clean (dict, list, or scalar)
|
||||
|
||||
Returns:
|
||||
Cleaned object with NaN and Infinity values replaced by None
|
||||
Cleaned object with NaN values replaced by None
|
||||
"""
|
||||
if isinstance(obj, dict):
|
||||
return {key: self._clean_nan_values(value) for key, value in obj.items()}
|
||||
elif isinstance(obj, list):
|
||||
return [self._clean_nan_values(item) for item in obj]
|
||||
elif isinstance(obj, float) and not math.isfinite(obj):
|
||||
elif isinstance(obj, float) and str(obj) == "nan":
|
||||
return None
|
||||
else:
|
||||
return obj
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Atomic metadata enrichment for a single known generation context."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Dict
|
||||
|
||||
@@ -8,10 +9,8 @@ from .operations import _coerce_file_info
|
||||
|
||||
try:
|
||||
from ..utils.hashing import generate_prompt_hash
|
||||
from ..utils.json_strict import dumps_strict
|
||||
except ImportError:
|
||||
from utils.hashing import generate_prompt_hash
|
||||
from utils.json_strict import dumps_strict
|
||||
|
||||
|
||||
def ingest_resolved_pair(
|
||||
@@ -25,7 +24,9 @@ def ingest_resolved_pair(
|
||||
negative_text = negative.get("storage_text", negative["text"])
|
||||
normalized = normalize_image_path(image_path)
|
||||
provenance = {"resolver_version": 1, "positive": positive, "negative": negative}
|
||||
encoded = dumps_strict(provenance, sort_keys=True, separators=(",", ":"))
|
||||
encoded = json.dumps(
|
||||
provenance, sort_keys=True, separators=(",", ":"), allow_nan=False
|
||||
)
|
||||
with db._pair_transaction() as conn:
|
||||
images = conn.execute(
|
||||
"SELECT gi.*,p.hash AS positive_hash FROM generated_images gi "
|
||||
@@ -66,9 +67,8 @@ def ingest_resolved_pair(
|
||||
).fetchone()
|
||||
pairs_added = int(prior_pair is None)
|
||||
file_size, width, height, image_format = _coerce_file_info(metadata)
|
||||
# ComfyUI's NaN/Infinity are stored as null: the snapshot is strict JSON.
|
||||
snapshots = {
|
||||
column: dumps_strict(metadata[key], sort_keys=True)
|
||||
column: json.dumps(metadata[key], sort_keys=True, allow_nan=False)
|
||||
for column, key in (
|
||||
("workflow_data", "workflow"),
|
||||
("prompt_metadata", "prompt"),
|
||||
|
||||
+2
-3
@@ -16,7 +16,6 @@ import datetime
|
||||
import functools
|
||||
import gzip as gzip_module
|
||||
import json
|
||||
import math
|
||||
import ntpath
|
||||
import os
|
||||
import re
|
||||
@@ -610,12 +609,12 @@ class PromptManagerAPI(
|
||||
return prompts
|
||||
|
||||
def _clean_nan_recursive(self, obj):
|
||||
"""Recursively replace NaN and Infinity in nested data with None."""
|
||||
"""Recursively clean NaN values from nested data structures."""
|
||||
if isinstance(obj, dict):
|
||||
return {key: self._clean_nan_recursive(value) for key, value in obj.items()}
|
||||
elif isinstance(obj, list):
|
||||
return [self._clean_nan_recursive(item) for item in obj]
|
||||
elif isinstance(obj, float) and not math.isfinite(obj):
|
||||
elif isinstance(obj, float) and str(obj) == "nan":
|
||||
return None
|
||||
else:
|
||||
return obj
|
||||
|
||||
+21
-9
@@ -3,7 +3,6 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import binascii
|
||||
import functools
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
@@ -38,7 +37,6 @@ try:
|
||||
from ...database.models import normalize_image_path
|
||||
from ...utils.card_seed import card_seed, header_params, parsed_positive
|
||||
from ...utils.parallel import map_parallel
|
||||
from ...utils.json_strict import dumps_strict
|
||||
from ...utils.inspection_response import (
|
||||
encode_inspection_response,
|
||||
library_metadata,
|
||||
@@ -58,7 +56,6 @@ except ImportError:
|
||||
from database.models import normalize_image_path
|
||||
from utils.card_seed import card_seed, header_params, parsed_positive
|
||||
from utils.parallel import map_parallel
|
||||
from utils.json_strict import dumps_strict
|
||||
from utils.inspection_response import (
|
||||
encode_inspection_response,
|
||||
library_metadata,
|
||||
@@ -985,12 +982,27 @@ class ImageRoutesMixin:
|
||||
# Add url and thumbnail_url so the frontend can serve them
|
||||
cleaned_images = self._present_images(cleaned_images)
|
||||
|
||||
# Rows an older version stored may still hold NaN/Infinity: answer
|
||||
# strict JSON, and leave prompt text that spells "NaN" untouched.
|
||||
return web.json_response(
|
||||
{"success": True, "images": cleaned_images},
|
||||
dumps=functools.partial(dumps_strict, default=str),
|
||||
)
|
||||
# Additional fallback: convert to JSON string and clean NaN values manually
|
||||
try:
|
||||
response_data = {"success": True, "images": cleaned_images}
|
||||
# Convert to JSON string
|
||||
json_str = json.dumps(response_data, default=str)
|
||||
|
||||
# Clean any remaining NaN values with regex
|
||||
json_str = re.sub(r":\s*NaN", ": null", json_str)
|
||||
json_str = re.sub(r"\[\s*NaN\s*\]", "[null]", json_str)
|
||||
json_str = re.sub(r",\s*NaN\s*,", ", null,", json_str)
|
||||
json_str = re.sub(r",\s*NaN\s*\]", ", null]", json_str)
|
||||
json_str = re.sub(r"\[\s*NaN\s*,", "[null,", json_str)
|
||||
|
||||
# Parse back to verify it's valid JSON
|
||||
cleaned_data = json.loads(json_str)
|
||||
|
||||
return web.json_response(cleaned_data)
|
||||
except Exception as json_error:
|
||||
self.logger.error(f"JSON cleaning error: {json_error}")
|
||||
# Fallback to original response
|
||||
return web.json_response({"success": True, "images": cleaned_images})
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Get prompt images error: {e}")
|
||||
|
||||
+2
-15
@@ -40,19 +40,6 @@ _HEALTH_STATUS_LINES = {
|
||||
_StoredReport = namedtuple("_StoredReport", "generation report")
|
||||
|
||||
|
||||
async def _gather_settled(*reads):
|
||||
"""Await every read, then raise the first failure.
|
||||
|
||||
Unlike a plain gather, a failure never answers while the other executor
|
||||
reads still run against the database.
|
||||
"""
|
||||
results = await asyncio.gather(*reads, return_exceptions=True)
|
||||
for result in results:
|
||||
if isinstance(result, BaseException):
|
||||
raise result
|
||||
return results
|
||||
|
||||
|
||||
def _failure(message, status=500, code=None):
|
||||
return web.json_response(
|
||||
{"success": False, "code": code, "error": message}, status=status
|
||||
@@ -167,7 +154,7 @@ class SystemRoutesMixin:
|
||||
from ..config import GalleryConfig
|
||||
|
||||
try:
|
||||
(output_dirs, auto_detected), counts = await _gather_settled(
|
||||
(output_dirs, auto_detected), counts = await asyncio.gather(
|
||||
self._run_in_executor(self._output_dirs_with_origin),
|
||||
self._run_in_executor(self.db.library_counts),
|
||||
)
|
||||
@@ -201,7 +188,7 @@ class SystemRoutesMixin:
|
||||
|
||||
async def get_system_statistics(self, request):
|
||||
try:
|
||||
by_category, totals, counts = await _gather_settled(
|
||||
by_category, totals, counts = await asyncio.gather(
|
||||
self._run_in_executor(self.db.usage_by_category),
|
||||
self._run_in_executor(self.db.get_statistics),
|
||||
self._run_in_executor(self.db.library_counts),
|
||||
|
||||
@@ -408,128 +408,3 @@ for (const theme of THEMES) test(`statistics and health dialogs match the concep
|
||||
await page.evaluate(() => { document.querySelector('#modal-body caption').textContent = 'Runs per category'; });
|
||||
expect(diffInventory(expectedStatistics, await page.evaluate(measureDialog)).map(item => item.path)).toEqual(['tables.0.caption']);
|
||||
});
|
||||
|
||||
/** Runs in the page: Appearance's surface, every row's text and control, and the concept's row geometry and tokens. */
|
||||
function measureAppearance(mainSelector) {
|
||||
const layout = document.querySelector(`${mainSelector} .settings-layout`);
|
||||
const css = (node, ...keys) => node ? Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]])) : null;
|
||||
const name = node => [node.localName, ...[...node.classList].sort()].join('.');
|
||||
const text = node => node?.textContent.trim() ?? null;
|
||||
const surface = layout.querySelector(':scope > section.surface');
|
||||
const control = row => {
|
||||
const select = row.querySelector(':scope > select');
|
||||
if (select) return { kind: 'select', id: select.id, options: [...select.options].map(option => option.textContent),
|
||||
selected: select.selectedOptions[0]?.textContent ?? null };
|
||||
const input = row.querySelector(':scope > input');
|
||||
if (input) return { kind: `input.${input.type}`, id: input.id, value: input.value };
|
||||
const toggle = row.querySelector(':scope > * input[type="checkbox"]');
|
||||
if (toggle) return { kind: 'switch', id: toggle.id, wrapper: name(toggle.parentElement), checked: toggle.checked,
|
||||
...css(toggle.parentElement, 'width', 'height') };
|
||||
const chip = row.querySelector(':scope > .chip');
|
||||
return chip ? { kind: name(chip), text: text(chip), ...css(chip, 'color', 'backgroundColor', 'fontSize') } : null;
|
||||
};
|
||||
const rows = [...surface.querySelectorAll(':scope > .setting-row')];
|
||||
const operations = [...surface.querySelectorAll(':scope > .operation')];
|
||||
const firstRow = rows[0];
|
||||
return {
|
||||
layout: [...layout.children].map(name),
|
||||
surface: { tree: [...surface.children].map(name), heading: text(surface.querySelector(':scope > h2')),
|
||||
intro: text(surface.querySelector(':scope > p')),
|
||||
...css(surface, 'paddingTop', 'paddingLeft', 'backgroundColor', 'borderTopWidth', 'borderTopColor', 'borderTopLeftRadius') },
|
||||
rows: rows.map(row => ({ tree: [...row.children].map(name), text: [...row.firstElementChild.children].map(node => [node.localName, text(node),
|
||||
node.localName === 'label' ? node.htmlFor : null]), control: control(row) })),
|
||||
operations: operations.map(row => ({ tree: [...row.children].map(name), title: text(row.querySelector('h3')),
|
||||
description: text(row.querySelector('p')), action: text(row.querySelector('.row-actions > button')) })),
|
||||
row: css(firstRow, 'display', 'alignItems', 'justifyContent', 'columnGap', 'paddingTop', 'paddingBottom', 'borderTopWidth',
|
||||
'borderTopColor', 'marginTop'),
|
||||
label: css(firstRow.querySelector('label'), 'fontSize', 'fontWeight', 'color'),
|
||||
description: css(firstRow.querySelector('p'), 'fontSize', 'color', 'marginTop', 'maxWidth'),
|
||||
select: css(firstRow.querySelector('select'), 'fontSize', 'minWidth', 'maxWidth', 'backgroundColor', 'color', 'borderTopColor'),
|
||||
heading3: css(surface.querySelector('.setting-row h3'), 'fontSize', 'fontWeight', 'color'),
|
||||
number: css(surface.querySelector('.setting-row input[type="number"]'), 'fontSize', 'minWidth', 'maxWidth', 'backgroundColor'),
|
||||
pageScroll: document.documentElement.scrollWidth > document.documentElement.clientWidth,
|
||||
};
|
||||
}
|
||||
|
||||
// D97: the rows Appearance adds for the legacy gallery settings, after the concept's own, as production renders them.
|
||||
const ADDED_APPEARANCE_ROWS = [
|
||||
['card-details', 'Show image details on cards', 'Show each card’s title and file details under the preview.', true],
|
||||
['viewer-details', 'Collapse viewer details by default', 'Open the image viewer with its details panel hidden.', false],
|
||||
];
|
||||
|
||||
/** The concept's Appearance with the recorded deviations applied, given the server's real settings. */
|
||||
function mapAppearanceConcept(reference, settings) {
|
||||
const toggleRow = reference.rows.find(row => row.control?.kind === 'switch');
|
||||
const added = ADDED_APPEARANCE_ROWS.map(([id, label, description, checked]) => ({
|
||||
tree: toggleRow.tree, text: [['label', label, id], ['p', description, null]],
|
||||
control: { ...toggleRow.control, id, checked } }));
|
||||
return {
|
||||
...reference,
|
||||
// D97: two added toggles and the "Reset display defaults" operation close the surface.
|
||||
surface: { ...reference.surface, tree: [...reference.surface.tree, 'div.setting-row', 'div.setting-row', 'div.operation'] },
|
||||
rows: [...reference.rows.map(row => row.control?.id === 'timeout'
|
||||
// The node search timeout is the server's real `result_timeout`, not the concept's sample 30.
|
||||
? { ...row, control: { ...row.control, value: String(settings.result_timeout) } } : row), ...added],
|
||||
operations: [{ tree: ['div', 'div.row-actions'], title: 'Reset display defaults',
|
||||
description: 'Restore this browser’s display defaults, then save. Other settings are kept.', action: 'Reset' }],
|
||||
};
|
||||
}
|
||||
|
||||
async function conceptAppearance(page, theme) {
|
||||
await openConcept(page, 'system', theme);
|
||||
await page.locator('#main .section-nav').getByRole('button', { name: 'Appearance', exact: true }).click();
|
||||
await expect(page.locator('#main .settings-layout > section.surface > h2')).toHaveText('Browsing & display');
|
||||
await page.mouse.move(0, 0);
|
||||
return page.evaluate(measureAppearance, '#main');
|
||||
}
|
||||
|
||||
async function productionAppearance(page, theme) {
|
||||
await openProduction(page, 'system/appearance', theme);
|
||||
await expect(page.locator('#workspace-main #timeout')).toBeEnabled();
|
||||
await page.mouse.move(0, 0);
|
||||
return page.evaluate(measureAppearance, '#workspace-main');
|
||||
}
|
||||
|
||||
for (const theme of THEMES) for (const [width, height] of SIZES) {
|
||||
test(`appearance matches the concept ${theme} ${width}x${height}`, async ({ page, request }, testInfo) => {
|
||||
await page.setViewportSize({ width, height });
|
||||
const { settings } = await serverJson(request, '/prompt_manager/settings');
|
||||
const concept = await conceptAppearance(page, theme);
|
||||
await page.screenshot({ path: testInfo.outputPath(`concept-appearance-${theme}-${width}.png`), fullPage: true });
|
||||
const production = await productionAppearance(page, theme);
|
||||
await page.screenshot({ path: testInfo.outputPath(`production-appearance-${theme}-${width}.png`), fullPage: true });
|
||||
expect(diffInventory(mapAppearanceConcept(concept, settings), production)).toEqual([]);
|
||||
expect(production.pageScroll).toBe(false);
|
||||
});
|
||||
}
|
||||
|
||||
test('appearance comparison detects mutations', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
const { settings } = await serverJson(request, '/prompt_manager/settings');
|
||||
const concept = await conceptAppearance(page, 'dark');
|
||||
const expected = mapAppearanceConcept(concept, settings);
|
||||
const production = await productionAppearance(page, 'dark');
|
||||
expect(diffInventory(expected, production)).toEqual([]);
|
||||
// Unmapped, the concept differs only where D97 and the real timeout are recorded.
|
||||
expect(diffInventory(concept, production).map(item => item.path.split('.').slice(0, 2).join('.'))
|
||||
.filter((path, index, all) => all.indexOf(path) === index).sort())
|
||||
.toEqual(['operations.0', 'operations.length', 'rows.11', 'rows.12', 'rows.13', 'rows.length', 'surface.tree'].sort());
|
||||
const measure = () => page.evaluate(measureAppearance, '#workspace-main');
|
||||
const paths = async () => diffInventory(expected, await measure()).map(item => item.path);
|
||||
// A removed row.
|
||||
const undoRow = await mutate(page, { remove: ['#workspace-main .setting-row:nth-of-type(3)'] });
|
||||
expect(await paths()).toEqual(expect.arrayContaining(['rows.length', 'surface.tree.length']));
|
||||
await undoRow();
|
||||
// A changed label and a changed option.
|
||||
await page.evaluate(() => { document.querySelector('#workspace-main label[for="density"]').textContent = 'Thumbnail sizes'; });
|
||||
expect(await paths()).toEqual(['rows.1.text.0.1']);
|
||||
await page.evaluate(() => { document.querySelector('#workspace-main label[for="density"]').textContent = 'Thumbnail size'; });
|
||||
await page.evaluate(() => { document.querySelector('#workspace-main #image-quality option').textContent = 'Medium'; });
|
||||
expect(await paths()).toEqual(expect.arrayContaining(['rows.5.control.options.0']));
|
||||
await page.evaluate(() => { document.querySelector('#workspace-main #image-quality option').textContent = 'Optimized'; });
|
||||
// A swapped token.
|
||||
const undoToken = await mutate(page, { css: '#workspace-main .settings-layout { --line: rgb(255, 0, 0); }' });
|
||||
expect(await paths()).toEqual(expect.arrayContaining(['row.borderTopColor']));
|
||||
await undoToken();
|
||||
expect(diffInventory(expected, await measure())).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -284,19 +284,6 @@ test('reload during a scan reattaches to the running job', async ({ page, reques
|
||||
await expect(pill(page)).toBeHidden();
|
||||
});
|
||||
|
||||
test('an empty Activity says no job has run, and a list that cannot be read says so', async ({ page }) => {
|
||||
// The empty and failed states were only reached in passing by other tests, so their coverage depended on timing.
|
||||
const empty = main(page).locator('section.surface > p.empty-state');
|
||||
await page.goto(`${workspace}#/activity`);
|
||||
await expect(empty).toHaveText('No jobs have run since ComfyUI started. Scans and other long operations appear here.');
|
||||
await expect(empty).not.toHaveAttribute('role');
|
||||
await expect(items(page)).toHaveCount(0);
|
||||
await page.route(url => url.pathname === '/prompt_manager/jobs', route => route.abort());
|
||||
await page.reload();
|
||||
await expect(empty).toHaveText('Activity could not be read. It is tried again shortly.');
|
||||
await expect(empty).toHaveAttribute('role', 'alert');
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -737,42 +724,6 @@ test('a held legacy job shows no Cancel in the list or its view', async ({ page,
|
||||
await expect(heading(page).locator('.heading-actions > button')).toHaveText(['Download report']);
|
||||
});
|
||||
|
||||
// Since #289 an item dropped its Cancel job only when it was already in the page, so the first render of jobs the
|
||||
// store already knew (Activity opened from another page) kept a Cancel job on a job that had ended, and moved a
|
||||
// running job's progress after its actions. The read Activity starts on opening is held, so that first render is
|
||||
// what the page shows (the read's own render would otherwise repair it a moment later).
|
||||
test('jobs that ended before Activity opened show no Cancel job, a running one keeps its progress first', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
const finished = await startJob(request, { title: 'Finished scan' });
|
||||
await finish(request, finished.id);
|
||||
const cancelled = await startJob(request, { title: 'Cancelled scan' });
|
||||
await finish(request, cancelled.id, 'cancelled');
|
||||
await startJob(request, { title: 'Running scan' });
|
||||
// The page's first list read has all three jobs once the running one shows in the pill.
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(pill(page)).toBeVisible();
|
||||
let hold;
|
||||
const held = new Promise(resolve => { hold = resolve; });
|
||||
// Only the first read is held; any later one goes through.
|
||||
await page.route(url => url.pathname === '/prompt_manager/jobs', route => {
|
||||
if (!hold) return route.continue();
|
||||
hold(route);
|
||||
hold = null;
|
||||
});
|
||||
await activityButton(page).click();
|
||||
const route = await held;
|
||||
await expect(items(page)).toHaveCount(3);
|
||||
const item = title => items(page).filter({ has: page.getByRole('link', { name: new RegExp(`${title}$`) }) });
|
||||
for (const ended of ['Finished scan', 'Cancelled scan']) {
|
||||
await expect(item(ended).locator('progress, .row-actions')).toHaveCount(0);
|
||||
await expect(item(ended).getByRole('button', { name: 'Cancel job', exact: true })).toHaveCount(0);
|
||||
}
|
||||
await expect(item('Running scan').getByRole('button', { name: 'Cancel job', exact: true })).toHaveCount(1);
|
||||
expect(await item('Running scan').evaluate(node => [...node.children].map(child => child.localName + (child.className ? `.${child.className}` : ''))))
|
||||
.toEqual(['div.panel-heading', 'p', 'progress', 'div.row-actions']);
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
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.
|
||||
|
||||
@@ -463,50 +463,6 @@ test('collection viewer ignores delayed media callbacks and keeps inspection ret
|
||||
await page.evaluate(()=>{location.hash='/overview';});await expect(page.locator('.viewer-container,#viewer-dialog')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('collection viewer leaving an image Viewer.js has not built yet releases it at once',async({page})=>{
|
||||
await page.goto(workspace);await expect(cards(page)).toHaveCount(2);
|
||||
let release,held;const waiting=new Promise(resolve=>{held=resolve;});const pending=new Promise(resolve=>{release=resolve;});
|
||||
await page.route('**/images/serve/shared/same.png?*',async route=>{
|
||||
const url=new URL(route.request().url());
|
||||
if(url.searchParams.get('root')==='0'&&url.searchParams.has('_pm_viewer')) {held();await pending;}
|
||||
await route.continue().catch(()=>{});
|
||||
});
|
||||
await cards(page).first().getByRole('button',{name:'Open same.png',exact:true}).click();await waiting;
|
||||
const preview=page.locator('dialog#viewer-dialog');
|
||||
await page.evaluate(()=>{window.unbuiltImage=document.querySelector('#viewer-stage > img');});
|
||||
await preview.getByRole('button',{name:'Next media',exact:true}).click();
|
||||
await expect(preview.getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');
|
||||
// The left image is off the stage. Viewer.js has not built it, so the viewer is destroyed and the src released at
|
||||
// once: its late load must never build Viewer.js into an image without a parent (getComputedStyle(null)).
|
||||
release();
|
||||
await expect.poll(()=>page.evaluate(()=>[window.unbuiltImage.isConnected,window.unbuiltImage.hasAttribute('src'),!!window.unbuiltImage.viewer])).toEqual([false,false,false]);
|
||||
await page.evaluate(()=>{location.hash='/overview';});await expect(page.locator('.viewer-container,#viewer-dialog')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('collection viewer closed while its image finishes loading never builds Viewer.js afterwards',async({page})=>{
|
||||
await page.goto(workspace);await expect(cards(page)).toHaveCount(2);
|
||||
let held;const waiting=new Promise(resolve=>{held=resolve;});
|
||||
// The image is held for the whole test: its late load is delivered below, at the moment Viewer.js cannot cancel.
|
||||
await page.route('**/images/serve/shared/same.png?*',async route=>{
|
||||
const url=new URL(route.request().url());
|
||||
if(url.searchParams.get('root')==='0'&&url.searchParams.has('_pm_viewer')) {held();return;}
|
||||
await route.continue().catch(()=>{});
|
||||
});
|
||||
await cards(page).first().getByRole('button',{name:'Open same.png',exact:true}).click();await waiting;
|
||||
const preview=page.locator('dialog#viewer-dialog');
|
||||
// Viewer.js's own cancel removes its image listeners only while `image.complete` is false. An image that has
|
||||
// finished downloading but whose load event is still queued is already complete: close in that state, then deliver it.
|
||||
await page.evaluate(()=>{window.finishingImage=document.querySelector('#viewer-stage > img');Object.defineProperty(window.finishingImage,'complete',{get:()=>true});});
|
||||
await page.keyboard.press('Escape');await expect(preview).toHaveCount(0);
|
||||
const after=await page.evaluate(async()=>{
|
||||
const image=window.finishingImage;image.dispatchEvent(new Event('load'));
|
||||
// Viewer.js builds an inline viewer in a zero-delay timeout after its image loads; this runs after it.
|
||||
await new Promise(resolve=>setTimeout(resolve));
|
||||
return {src:image.hasAttribute('src'),built:!!image.parentNode?.querySelector('.viewer-container')};
|
||||
});
|
||||
expect(after).toEqual({src:false,built:false});
|
||||
});
|
||||
|
||||
test('collection viewer describes video transforms and releases native playback on navigation',async({page,request})=>{
|
||||
await expect(await request.post('/__test__/reset',{data:{include_video:true}})).toBeOK();await page.goto(workspace);
|
||||
await page.locator('article[data-image-key="0:clip.webm"] .image-open').click();
|
||||
|
||||
@@ -11,7 +11,7 @@ const number = value => value.toLocaleString('en-US');
|
||||
|
||||
test.beforeEach(async ({ page, request, browserName }) => {
|
||||
await startCoverage(page, browserName);
|
||||
page.manifest = await seedConcept(request);
|
||||
await seedConcept(request);
|
||||
await expect(await request.post('/__test__/system/reset')).toBeOK();
|
||||
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
|
||||
page.systemErrors = [];
|
||||
@@ -250,368 +250,3 @@ test('out-of-order overview responses never repaint a newer section', async ({ p
|
||||
await expect(layout.locator('.summary-metrics')).toHaveCount(1);
|
||||
await expect(layout.locator('.two-col')).toHaveCount(1);
|
||||
});
|
||||
|
||||
// System › Appearance (#249 System S3 Task 8): the browser display defaults (`gallerySettings`, preferences.mjs) and the
|
||||
// two host fields behind the one Save row, and the consumers that read them on their next visit.
|
||||
const SETTINGS_URL = '/prompt_manager/settings';
|
||||
const INSPECT_PATH = '/prompt_manager/images/inspect/';
|
||||
const statusOf = page => page.locator('#workspace-main #settings-status');
|
||||
const saveOf = page => page.locator('#workspace-main .save-row > button');
|
||||
const storedSettings = page => page.evaluate(() => JSON.parse(localStorage.getItem('gallerySettings') || 'null'));
|
||||
const imageCards = page => page.locator('#workspace-main article.media-card');
|
||||
const panel = page => page.getByRole('complementary', { name: 'Selected image details', exact: true });
|
||||
|
||||
async function openAppearance(page) {
|
||||
await page.goto(`${workspace}#/system/appearance`);
|
||||
await expect(page.locator('#workspace-main .settings-layout > section.surface > h2')).toHaveText('Browsing & display');
|
||||
// The host rows are enabled once the server settings answered.
|
||||
await expect(page.locator('#timeout')).toBeEnabled();
|
||||
}
|
||||
|
||||
async function saveAppearance(page, text) {
|
||||
await expect(saveOf(page)).not.toHaveAttribute('aria-disabled', 'true');
|
||||
await saveOf(page).click();
|
||||
await expect(statusOf(page)).toHaveText(text);
|
||||
}
|
||||
|
||||
/** Set toggles (by row id) through their Toolkit switches. */
|
||||
async function toggle(page, values) {
|
||||
for (const [id, checked] of Object.entries(values)) await page.locator(`#${id}`).setChecked(checked, { force: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* A request barrier: one request issued and answered after everything the page has asked for so far, so a read the
|
||||
* last action started has been seen (Playwright reports a page's requests in order).
|
||||
*/
|
||||
async function requestBarrier(page) {
|
||||
expect(await page.evaluate(url => fetch(url, { cache: 'no-store' }).then(response => response.ok), SETTINGS_URL)).toBe(true);
|
||||
}
|
||||
|
||||
async function openImages(page) {
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(imageCards(page).first()).toBeVisible();
|
||||
}
|
||||
|
||||
test('defaults apply to a new visit but not to the current view or a saved view', async ({ page, request }) => {
|
||||
const created = await request.post('/prompt_manager/saved-views', { data: { page: 'images', name: 'Everything',
|
||||
state: { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false } } });
|
||||
expect(created.status()).toBe(201);
|
||||
const view = (await created.json()).view;
|
||||
try {
|
||||
await openImages(page);
|
||||
// The current view: the toolbar sets List.
|
||||
await page.getByRole('group', { name: 'Image layout' }).getByRole('button', { name: 'List', exact: true }).click();
|
||||
await expect(page.locator('.image-grid')).toHaveAttribute('data-layout', 'list');
|
||||
await openAppearance(page);
|
||||
await expect(page.locator('#default-view')).toHaveValue('grid');
|
||||
await page.locator('#default-view').selectOption('masonry');
|
||||
await page.locator('#density').selectOption('compact');
|
||||
await expect(statusOf(page)).toHaveText('Unsaved changes.');
|
||||
await saveAppearance(page, 'Saved in this browser.');
|
||||
const stored = await storedSettings(page);
|
||||
expect([stored.workspaceLayout, stored.workspaceDensity]).toEqual(['masonry', 'compact']);
|
||||
// Back in Images (the page session's last visit): the current view keeps its own layout and size.
|
||||
await page.locator('#primary-nav a[data-route="images"]').click();
|
||||
await expect(page.locator('.image-grid')).toHaveAttribute('data-layout', 'list');
|
||||
await expect(page.locator('.image-grid')).toHaveAttribute('data-density', 'comfortable');
|
||||
// The saved view opens as before.
|
||||
await page.goto(`${workspace}#/saved/${view.id}`);
|
||||
await expect(imageCards(page).first()).toBeVisible();
|
||||
await expect(page.locator('.image-grid')).toHaveAttribute('data-layout', 'grid');
|
||||
await expect(page.locator('.image-grid')).toHaveAttribute('data-density', 'comfortable');
|
||||
// A new visit starts from the defaults.
|
||||
await openImages(page);
|
||||
await page.reload();
|
||||
await expect(imageCards(page).first()).toBeVisible();
|
||||
await expect(page.locator('.image-grid')).toHaveAttribute('data-layout', 'masonry');
|
||||
await expect(page.locator('.image-grid')).toHaveAttribute('data-density', 'compact');
|
||||
await expect(page.getByRole('group', { name: 'Image layout' }).getByRole('button', { name: 'Masonry', exact: true }))
|
||||
.toHaveAttribute('aria-pressed', 'true');
|
||||
} finally {
|
||||
await request.delete(`/prompt_manager/saved-views/${view.id}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('the defaults reach the Images page only; embedded galleries keep their own presentation', async ({ page }) => {
|
||||
await openAppearance(page);
|
||||
await page.locator('#default-view').selectOption('list');
|
||||
await page.locator('#density').selectOption('compact');
|
||||
await saveAppearance(page, 'Saved in this browser.');
|
||||
// The prompt history's shared gallery has no toolbar to change them, so it opens as it always did.
|
||||
await page.goto(`${workspace}#/library/${page.manifest.prompts[0].positive_id}`);
|
||||
await expect(imageCards(page).first()).toBeVisible();
|
||||
await expect(page.locator('#workspace-main .image-grid')).toHaveAttribute('data-layout', 'grid');
|
||||
await expect(page.locator('#workspace-main .image-grid')).toHaveAttribute('data-density', 'comfortable');
|
||||
// The Images page starts from them.
|
||||
await page.locator('#primary-nav a[data-route="images"]').click();
|
||||
await expect(imageCards(page).first()).toBeVisible();
|
||||
await expect(page.locator('.image-grid')).toHaveAttribute('data-layout', 'list');
|
||||
await expect(page.locator('.image-grid')).toHaveAttribute('data-density', 'compact');
|
||||
});
|
||||
|
||||
test('metadata off shows an explicit load and loads once on request', async ({ page }) => {
|
||||
await openAppearance(page);
|
||||
await toggle(page, { 'metadata-auto': false });
|
||||
await saveAppearance(page, 'Saved in this browser.');
|
||||
const reads = [];
|
||||
page.on('request', item => { if (new URL(item.url()).pathname.startsWith(INSPECT_PATH)) reads.push(item.url()); });
|
||||
await openImages(page);
|
||||
const first = imageCards(page).first();
|
||||
await first.locator('.image-card-actions button').first().click();
|
||||
await expect(panel(page).getByRole('status')).toHaveText('Metadata loads when you ask for it.');
|
||||
const load = panel(page).getByRole('button', { name: 'Load metadata', exact: true });
|
||||
await expect(load).toBeVisible();
|
||||
expect(reads).toEqual([]);
|
||||
await load.click();
|
||||
await expect(panel(page).locator('.panel-heading h2')).toBeVisible();
|
||||
expect(reads).toHaveLength(1);
|
||||
// The same file again reads nothing more (the cache is on).
|
||||
await first.locator('.image-card-actions button').first().click();
|
||||
await expect(panel(page).locator('.panel-heading h2')).toBeVisible();
|
||||
await requestBarrier(page);
|
||||
expect(reads).toHaveLength(1);
|
||||
// The viewer's details use the same inspector: another file waits there too, and loads on request.
|
||||
await imageCards(page).nth(1).locator('.image-open').click();
|
||||
const viewerDetails = page.locator('#viewer-details');
|
||||
await expect(viewerDetails.getByRole('status')).toHaveText('Metadata loads when you ask for it.');
|
||||
expect(reads).toHaveLength(1);
|
||||
await viewerDetails.getByRole('button', { name: 'Load metadata', exact: true }).click();
|
||||
await expect(viewerDetails.locator('.panel-heading h2')).toBeVisible();
|
||||
expect(reads).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('cache off rereads the current selection; on reuses the one current record', async ({ page }) => {
|
||||
const reads = [];
|
||||
page.on('request', item => { if (new URL(item.url()).pathname.startsWith(INSPECT_PATH)) reads.push(item.url()); });
|
||||
for (const cache of [true, false]) {
|
||||
if (!cache) {
|
||||
await openAppearance(page);
|
||||
await toggle(page, { 'metadata-cache': false });
|
||||
await saveAppearance(page, 'Saved in this browser.');
|
||||
await page.reload();
|
||||
}
|
||||
await openImages(page);
|
||||
const info = imageCards(page).first().locator('.image-card-actions button').first();
|
||||
await info.click();
|
||||
await expect(panel(page).locator('.panel-heading h2')).toBeVisible();
|
||||
await expect.poll(() => reads.length).toBeGreaterThan(0);
|
||||
await requestBarrier(page);
|
||||
reads.length = 0;
|
||||
// Inspect the selected file twice more.
|
||||
for (const _ of [1, 2]) {
|
||||
const answered = cache ? null : page.waitForResponse(response => new URL(response.url()).pathname.startsWith(INSPECT_PATH));
|
||||
await info.click();
|
||||
if (answered) await answered;
|
||||
await expect(panel(page).locator('.panel-heading h2')).toBeVisible();
|
||||
}
|
||||
await requestBarrier(page);
|
||||
expect(reads, `cache ${cache}`).toHaveLength(cache ? 0 : 2);
|
||||
}
|
||||
});
|
||||
|
||||
test('paths hidden only changes display', async ({ page }) => {
|
||||
const rows = async () => page.locator('#viewer-details details').filter({ hasText: 'File & provenance' }).locator('dt')
|
||||
.evaluateAll(terms => terms.map(term => term.textContent));
|
||||
const openViewer = async () => {
|
||||
await openImages(page);
|
||||
await imageCards(page).first().locator('.image-open').click();
|
||||
const provenance = page.locator('#viewer-details details').filter({ hasText: 'File & provenance' });
|
||||
await provenance.locator('summary').click();
|
||||
await expect(provenance.locator('dl')).toBeVisible();
|
||||
return provenance;
|
||||
};
|
||||
let provenance = await openViewer();
|
||||
expect(await rows()).toContain('Path');
|
||||
const before = { show: await provenance.getByRole('button', { name: /^Show .* in folder$/ }).count(),
|
||||
download: await provenance.getByRole('link', { name: 'Download original', exact: true }).getAttribute('href') };
|
||||
await page.keyboard.press('Escape');
|
||||
await openAppearance(page);
|
||||
await toggle(page, { paths: false });
|
||||
await saveAppearance(page, 'Saved in this browser.');
|
||||
provenance = await openViewer();
|
||||
const shown = await rows();
|
||||
expect(shown).not.toContain('Path');
|
||||
expect(shown).toEqual(expect.arrayContaining(['Source', 'Size']));
|
||||
// Everything else is as it was: the folder action and the original download stay.
|
||||
expect(await provenance.getByRole('button', { name: /^Show .* in folder$/ }).count()).toBe(before.show);
|
||||
await expect(provenance.getByRole('link', { name: 'Download original', exact: true })).toHaveAttribute('href', before.download);
|
||||
});
|
||||
|
||||
test('video defaults reach the viewer', async ({ page }) => {
|
||||
await openAppearance(page);
|
||||
await toggle(page, { 'video-auto': true, 'video-muted': false, 'video-loop': false });
|
||||
await saveAppearance(page, 'Saved in this browser.');
|
||||
expect(await page.evaluate(async () => (await import('/prompt_manager/js/workspace/media-viewer.mjs')).readViewerPreferences()))
|
||||
.toEqual({ videoAutoplay: true, videoMute: false, videoLoop: false });
|
||||
const stored = await storedSettings(page);
|
||||
expect([stored.videoAutoplay, stored.videoMute, stored.videoLoop]).toEqual([true, false, false]);
|
||||
});
|
||||
|
||||
test('host fields round-trip through /settings with revision', async ({ page, request }) => {
|
||||
const posts = [];
|
||||
page.on('request', item => { if (item.method() === 'POST' && new URL(item.url()).pathname === SETTINGS_URL) posts.push(item.postDataJSON()); });
|
||||
const before = await serverJson(request, SETTINGS_URL);
|
||||
await openAppearance(page);
|
||||
await expect(page.locator('#open-mode')).toHaveValue(before.settings.webui_display_mode);
|
||||
await expect(page.locator('#timeout')).toHaveValue(String(before.settings.result_timeout));
|
||||
// Only whole seconds from 0 to 300: anything else is highlighted and never sent.
|
||||
for (const bad of ['301', '-1', '2.5']) {
|
||||
await page.locator('#timeout').fill(bad);
|
||||
await page.locator('#timeout').press('Tab');
|
||||
await expect(page.locator('#timeout')).toHaveAttribute('aria-invalid', 'true');
|
||||
await expect(statusOf(page)).toHaveText('Some changes were not saved. Review the highlighted settings.');
|
||||
await expect(saveOf(page)).toHaveAttribute('aria-disabled', 'true');
|
||||
}
|
||||
await page.locator('#timeout').fill('0');
|
||||
await page.locator('#timeout').press('Tab');
|
||||
await expect(page.locator('#timeout')).not.toHaveAttribute('aria-invalid');
|
||||
await page.locator('#open-mode').selectOption('popup');
|
||||
await saveAppearance(page, 'Saved to the server configuration.');
|
||||
expect(posts).toEqual([{ webui_display_mode: 'popup', result_timeout: 0, expected_revision: before.revision }]);
|
||||
const after = await serverJson(request, SETTINGS_URL);
|
||||
expect([after.settings.webui_display_mode, after.settings.result_timeout]).toEqual(['popup', 0]);
|
||||
expect(after.revision).not.toBe(before.revision);
|
||||
// No browser key was written for a host field.
|
||||
expect(await storedSettings(page)).toBeNull();
|
||||
});
|
||||
|
||||
test('server settings that cannot be read say so in their rows', async ({ page }) => {
|
||||
await page.route(url => url.pathname === SETTINGS_URL, route => (route.request().method() === 'GET'
|
||||
? route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ success: false, error: 'Injected failure' }) })
|
||||
: route.continue()));
|
||||
await page.goto(`${workspace}#/system/appearance`);
|
||||
for (const id of ['open-mode', 'timeout']) {
|
||||
const row = page.locator('#workspace-main .setting-row').filter({ has: page.locator(`#${id}`) });
|
||||
await expect(row.locator('p.small')).toHaveText('Server settings could not be read.');
|
||||
await expect(page.locator(`#${id}`)).toBeDisabled();
|
||||
}
|
||||
// The browser rows are this browser's, and work as before.
|
||||
await expect(page.locator('#default-view')).toBeEnabled();
|
||||
await expect(page.locator('#workspace-main .setting-row p.small')).toHaveCount(2);
|
||||
});
|
||||
|
||||
test('stale revision shows the conflict status', async ({ page, request }) => {
|
||||
await openAppearance(page);
|
||||
// Another page saves after this one read the settings.
|
||||
const current = await serverJson(request, SETTINGS_URL);
|
||||
await expect(await request.post(SETTINGS_URL, { data: { result_timeout: 42, expected_revision: current.revision } })).toBeOK();
|
||||
await page.locator('#open-mode').selectOption('popup');
|
||||
await saveAppearance(page, 'Settings changed elsewhere. Reload this section to see them.');
|
||||
await expect(page.locator('#open-mode')).toHaveAttribute('aria-invalid', 'true');
|
||||
// The draft is kept and the section now shows the other page's value.
|
||||
await expect(page.locator('#open-mode')).toHaveValue('popup');
|
||||
await expect(page.locator('#timeout')).toHaveValue('42');
|
||||
expect((await serverJson(request, SETTINGS_URL)).settings.webui_display_mode).toBe('newtab');
|
||||
// With the reloaded revision, Save succeeds.
|
||||
await saveAppearance(page, 'Saved to the server configuration.');
|
||||
expect((await serverJson(request, SETTINGS_URL)).settings.webui_display_mode).toBe('popup');
|
||||
});
|
||||
|
||||
test('reload keeps saved values; a new tab reads them', async ({ page, context }) => {
|
||||
await openAppearance(page);
|
||||
await page.locator('#image-quality').selectOption('high');
|
||||
await toggle(page, { 'card-details': false });
|
||||
await page.locator('#open-mode').selectOption('popup');
|
||||
await saveAppearance(page, 'Saved in this browser. Saved to the server configuration.');
|
||||
await page.reload();
|
||||
await expect(page.locator('#timeout')).toBeEnabled();
|
||||
await expect(page.locator('#image-quality')).toHaveValue('high');
|
||||
await expect(page.locator('#card-details')).not.toBeChecked();
|
||||
await expect(page.locator('#open-mode')).toHaveValue('popup');
|
||||
const other = await context.newPage();
|
||||
await openAppearance(other);
|
||||
await expect(other.locator('#image-quality')).toHaveValue('high');
|
||||
await expect(other.locator('#card-details')).not.toBeChecked();
|
||||
// A change saved in one tab reaches the other's clean controls; a draft it overtakes is flagged and kept.
|
||||
await other.locator('#density').selectOption('large');
|
||||
await page.locator('#density').selectOption('compact');
|
||||
await saveAppearance(page, 'Saved in this browser.');
|
||||
await expect(other.locator('#density')).toHaveValue('large');
|
||||
await expect(other.locator('#settings-status')).toHaveText('Settings changed elsewhere. Reload this section to see them.');
|
||||
await expect(other.locator('#density')).toHaveAttribute('aria-invalid', 'true');
|
||||
await page.locator('#image-quality').selectOption('low');
|
||||
await saveAppearance(page, 'Saved in this browser.');
|
||||
await expect(other.locator('#image-quality')).toHaveValue('low');
|
||||
await other.close();
|
||||
});
|
||||
|
||||
test('a change another tab saved while System was closed is a conflict on return, never saved over silently', async ({ page, context }) => {
|
||||
await openAppearance(page);
|
||||
await page.locator('#density').selectOption('compact');
|
||||
await expect(statusOf(page)).toHaveText('Unsaved changes.');
|
||||
// Leave System for Images: the draft stays for the page session, but System no longer listens.
|
||||
await page.locator('#primary-nav a[data-route="images"]').click();
|
||||
await expect(imageCards(page).first()).toBeVisible();
|
||||
const other = await context.newPage();
|
||||
await openAppearance(other);
|
||||
await other.locator('#density').selectOption('large');
|
||||
await saveAppearance(other, 'Saved in this browser.');
|
||||
await other.close();
|
||||
// Back in System: the draft is kept and flagged, and nothing was written over the other tab's value.
|
||||
await page.evaluate(() => { location.hash = '#/system/appearance'; });
|
||||
await expect(page.locator('#workspace-main .settings-layout > section.surface > h2')).toHaveText('Browsing & display');
|
||||
await expect(statusOf(page)).toHaveText('Settings changed elsewhere. Reload this section to see them.');
|
||||
await expect(page.locator('#density')).toHaveAttribute('aria-invalid', 'true');
|
||||
await expect(page.locator('#density')).toHaveValue('compact');
|
||||
expect((await storedSettings(page)).workspaceDensity).toBe('large');
|
||||
// Saving after the conflict was shown is an explicit choice: it wins.
|
||||
await saveAppearance(page, 'Saved in this browser.');
|
||||
expect((await storedSettings(page)).workspaceDensity).toBe('compact');
|
||||
await expect(page.locator('#density')).not.toHaveAttribute('aria-invalid');
|
||||
});
|
||||
|
||||
test('a browser that refuses storage is saved for this tab only, never "Saved"', async ({ page }) => {
|
||||
await openAppearance(page);
|
||||
await page.evaluate(() => {
|
||||
Storage.prototype.setItem = function setItem() { throw new DOMException('Quota exceeded', 'QuotaExceededError'); };
|
||||
});
|
||||
await page.locator('#default-view').selectOption('list');
|
||||
await saveAppearance(page, 'Saved for this tab only; browser storage is unavailable.');
|
||||
expect(await storedSettings(page)).toBeNull();
|
||||
// This tab's next Images visit still uses it.
|
||||
await page.locator('#primary-nav a[data-route="images"]').click();
|
||||
await expect(imageCards(page).first()).toBeVisible();
|
||||
await expect(page.locator('.image-grid')).toHaveAttribute('data-layout', 'list');
|
||||
});
|
||||
|
||||
test('added rows reach their consumers', async ({ page }) => {
|
||||
await openImages(page);
|
||||
await expect(imageCards(page).first().locator('.media-info h3')).toBeVisible();
|
||||
await openAppearance(page);
|
||||
await toggle(page, { 'card-details': false, 'viewer-details': true });
|
||||
await saveAppearance(page, 'Saved in this browser.');
|
||||
await page.reload();
|
||||
await openImages(page);
|
||||
// Show image details on cards off: no title or file line; Info and Tag stay.
|
||||
const card = imageCards(page).first();
|
||||
await expect(card.locator('.media-info h3')).toBeHidden();
|
||||
await expect(card.locator('.image-card-actions button')).toHaveCount(2);
|
||||
// Collapse viewer details by default: the viewer opens with its details hidden behind one toggle.
|
||||
await card.locator('.image-open').click();
|
||||
const details = page.locator('#viewer-details');
|
||||
await expect(page.locator('#viewer-dialog')).toBeVisible();
|
||||
await expect(details).toBeHidden();
|
||||
const show = page.locator('#viewer-toolbar').getByRole('button', { name: 'Show details', exact: true });
|
||||
await expect(show).toHaveAttribute('aria-expanded', 'false');
|
||||
await show.click();
|
||||
await expect(details).toBeVisible();
|
||||
await expect(page.locator('#viewer-toolbar').getByRole('button', { name: 'Hide details', exact: true })).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
|
||||
test('reset restores defaults without touching other keys', async ({ page }) => {
|
||||
await page.goto(`${workspace}#/system/appearance`);
|
||||
await page.evaluate(() => localStorage.setItem('gallerySettings', JSON.stringify({ gridColumns: 5, defaultLimit: 200,
|
||||
thumbnailPromptDismissed: 'kept', workspaceLayout: 'list', videoMute: false, showImageInfo: false })));
|
||||
await page.reload();
|
||||
await expect(page.locator('#timeout')).toBeEnabled();
|
||||
await expect(page.locator('#default-view')).toHaveValue('list');
|
||||
const reset = page.locator('#workspace-main .operation').filter({ hasText: 'Reset display defaults' }).getByRole('button', { name: 'Reset', exact: true });
|
||||
await reset.click();
|
||||
await expect(page.locator('#default-view')).toHaveValue('grid');
|
||||
await expect(page.locator('#video-muted')).toBeChecked();
|
||||
await expect(statusOf(page)).toHaveText('Unsaved changes.');
|
||||
await saveAppearance(page, 'Saved in this browser.');
|
||||
const stored = await storedSettings(page);
|
||||
expect(stored).toEqual({ gridColumns: 5, defaultLimit: 200, thumbnailPromptDismissed: 'kept', workspaceLayout: 'grid',
|
||||
videoMute: true, showImageInfo: true });
|
||||
});
|
||||
|
||||
@@ -425,7 +425,7 @@ for (const width of [1600, 390]) test(`overview and handoff render with concept
|
||||
const SYSTEM_SECTION_LABELS = ['Overview', 'Appearance', 'Sources & scans', 'Integrations', 'Auto-tagging', 'Maintenance',
|
||||
'Backup & export', 'Diagnostics & logs'];
|
||||
const SYSTEM_HASHES = ['overview', 'appearance', 'sources', 'integrations', 'tagging', 'maintenance', 'backups', 'diagnostics'];
|
||||
const SYSTEM_HANDOFFS = { sources: ['/prompt_manager/admin', '/prompt_manager/gallery'],
|
||||
const SYSTEM_HANDOFFS = { appearance: ['/prompt_manager/gallery', '/prompt_manager/admin'], sources: ['/prompt_manager/admin', '/prompt_manager/gallery'],
|
||||
maintenance: ['/prompt_manager/admin', '/prompt_manager/gallery'] };
|
||||
|
||||
test('System is native: the concept shell, section navigation, sections, hand-offs and the Save row', async ({ page }) => {
|
||||
@@ -462,14 +462,14 @@ test('System is native: the concept shell, section navigation, sections, hand-of
|
||||
await expect(nav.locator('[aria-pressed="true"]')).toHaveCount(1);
|
||||
// The section changes in place: focus stays on the pressed section, not the page title.
|
||||
await expect(button).toBeFocused();
|
||||
// S2 Task 6: Overview is native (workspace-system.spec.mjs), and so is Appearance (S3 Task 8); Maintenance shows
|
||||
// its health-check notice above the hand-off (D123); every other section hands off.
|
||||
// S2 Task 6: Overview is native (workspace-system.spec.mjs); Maintenance shows its health-check notice above
|
||||
// the hand-off (D123); every other section hands off.
|
||||
if (hash === 'overview') await expect(layout.locator(':scope > .summary-metrics')).toBeVisible();
|
||||
expect(await layout.evaluate(node => [...node.children].map(child => `${child.localName}.${child.className}`)))
|
||||
.toEqual({ overview: ['div.summary-metrics', 'div.two-col', 'div.save-row'], appearance: ['section.surface', 'div.save-row'],
|
||||
.toEqual({ overview: ['div.summary-metrics', 'div.two-col', 'div.save-row'],
|
||||
maintenance: ['div.notice', 'section.surface handoff', 'div.save-row'] }[hash]
|
||||
?? ['section.surface handoff', 'div.save-row']);
|
||||
if (hash === 'overview' || hash === 'appearance') continue;
|
||||
if (hash === 'overview') continue;
|
||||
const handoff = layout.locator('section.handoff');
|
||||
await expect(handoff.locator('h2')).toHaveText('Your current tools are ready');
|
||||
expect(await handoff.locator('a.workspace-link').evaluateAll(links => links.map(link => link.getAttribute('href'))))
|
||||
|
||||
@@ -31,27 +31,9 @@ MAX_UNTHUMBNAILED = 50
|
||||
|
||||
|
||||
def register_system_commands(app, routes, api, root: Path):
|
||||
# System › Appearance saves the host fields to this server's config.json (S3);
|
||||
# reset restores both. The file is this server's own, inside its temp root:
|
||||
# reset rewrites or deletes it, so a server whose configuration points
|
||||
# anywhere else (a real config.json) refuses to register these hooks.
|
||||
config_path = root / "config.json"
|
||||
configured_path = os.environ.get("PROMPT_MANAGER_CONFIG_PATH")
|
||||
if (
|
||||
configured_path is None
|
||||
or Path(configured_path).resolve() != config_path.resolve()
|
||||
):
|
||||
raise RuntimeError(
|
||||
f"PROMPT_MANAGER_CONFIG_PATH must be {config_path}, not {configured_path!r}"
|
||||
)
|
||||
from py.config import GalleryConfig, PromptManagerConfig
|
||||
from py.config import GalleryConfig
|
||||
|
||||
configured = list(GalleryConfig.MONITORING_DIRECTORIES)
|
||||
config_bytes = config_path.read_bytes() if config_path.exists() else None
|
||||
host_values = (
|
||||
PromptManagerConfig.RESULT_TIMEOUT,
|
||||
PromptManagerConfig.WEBUI_DISPLAY_MODE,
|
||||
)
|
||||
failures: dict[str, int] = {}
|
||||
|
||||
@web.middleware
|
||||
@@ -70,16 +52,6 @@ def register_system_commands(app, routes, api, root: Path):
|
||||
failures.clear()
|
||||
GalleryConfig.MONITORING_DIRECTORIES = list(configured)
|
||||
api._health_report = None
|
||||
PromptManagerConfig.RESULT_TIMEOUT, PromptManagerConfig.WEBUI_DISPLAY_MODE = (
|
||||
host_values
|
||||
)
|
||||
if config_bytes is None:
|
||||
config_path.unlink(missing_ok=True)
|
||||
else:
|
||||
# Replace in one step, as the server writes it (Windows preflight).
|
||||
staged = config_path.with_name(config_path.name + ".reset")
|
||||
staged.write_bytes(config_bytes)
|
||||
os.replace(staged, config_path)
|
||||
|
||||
@routes.post("/__test__/system/reset")
|
||||
async def system_reset(request):
|
||||
|
||||
@@ -1,20 +1,11 @@
|
||||
import { expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import runtime from '../../tools/vendor/comfyui-runtime.json' with { type: 'json' };
|
||||
import { hostBaseDirectory } from '../../tools/vendor/comfyui-host.mjs';
|
||||
|
||||
export function installHostHooks(test, expectedTypes = ['PromptManager', 'PromptManagerText', 'PromptSearchList']) {
|
||||
test.beforeEach(async ({ page, context, request }, testInfo) => {
|
||||
const response = await request.get('/system_stats');
|
||||
expect(response.ok()).toBe(true);
|
||||
const stats = await response.json();
|
||||
// comfyui.config.mjs's launcher reports the host it owns. The execution suite has no
|
||||
// web server: it starts its own host per worker behind a port refusal.
|
||||
if (testInfo.config.webServer) {
|
||||
const ownedBase = process.env.PM_COMFY_HOST_BASE;
|
||||
expect(ownedBase, 'the launcher reported the host it started').toBeTruthy();
|
||||
expect(hostBaseDirectory(stats), 'the host answering the port is the one this run started').toBe(ownedBase);
|
||||
}
|
||||
const info = stats.system;
|
||||
const info = (await response.json()).system;
|
||||
expect(info.comfyui_version).toBe(runtime.serverVersion);
|
||||
expect(info.pytorch_version).toBe(`${runtime.torchVersion}+cpu`);
|
||||
expect(info.required_frontend_version).toBe(runtime.frontendVersion);
|
||||
|
||||
+4
-4
@@ -594,12 +594,12 @@
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "resolved",
|
||||
"reason": "",
|
||||
"state": "ambiguous",
|
||||
"reason": "Multiple sampler branches",
|
||||
"reason_code": null,
|
||||
"selection": null,
|
||||
"positive": "a red fox in snow, detailed fur",
|
||||
"negative": "blurry, lowres",
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"candidate_count": 0,
|
||||
"facts": {
|
||||
"seed": {
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -61,50 +61,3 @@ test("infinite scrolling is off by default and must be a boolean", () => {
|
||||
assert.equal(normalizeGallerySettings({ infiniteScroll: true }).infiniteScroll, true);
|
||||
assert.equal(normalizeGallerySettings({ infiniteScroll: "yes" }).infiniteScroll, false);
|
||||
});
|
||||
|
||||
test("the workspace keys have their own defaults and validation", () => {
|
||||
const { WORKSPACE_LAYOUTS, WORKSPACE_DENSITIES } = require("../../web/js/gallery-settings.js");
|
||||
assert.equal(DEFAULT_SETTINGS.workspaceLayout, "grid");
|
||||
assert.equal(DEFAULT_SETTINGS.workspaceDensity, "comfortable");
|
||||
assert.deepEqual(WORKSPACE_LAYOUTS, ["grid", "list", "masonry"]);
|
||||
assert.deepEqual(WORKSPACE_DENSITIES, ["comfortable", "large", "compact"]);
|
||||
assert.equal(normalizeGallerySettings({ workspaceLayout: "masonry" }).workspaceLayout, "masonry");
|
||||
assert.equal(normalizeGallerySettings({ workspaceLayout: "mosaic" }).workspaceLayout, "grid");
|
||||
assert.equal(normalizeGallerySettings({ workspaceDensity: "large" }).workspaceDensity, "large");
|
||||
assert.equal(normalizeGallerySettings({ workspaceDensity: 3 }).workspaceDensity, "comfortable");
|
||||
// The legacy gallery's own view mode is separate: it has no masonry.
|
||||
assert.equal(normalizeGallerySettings({ defaultViewMode: "masonry" }).defaultViewMode, "grid");
|
||||
});
|
||||
|
||||
test("a legacy gallery Save keeps the workspace keys and every other stored key", () => {
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const vm = require("node:vm");
|
||||
const settings = require("../../web/js/gallery-settings.js");
|
||||
const storage = new Map([["gallerySettings", JSON.stringify({ workspaceLayout: "masonry", workspaceDensity: "compact", someOtherTool: 1 })]]);
|
||||
const controls = {
|
||||
lazyLoadingToggle: { checked: true }, imageQualitySelect: { value: "high" }, defaultViewModeSelect: { value: "list" },
|
||||
defaultLimitSelect: { value: "200" }, gridColumnsSelect: { value: "6" }, showImageInfoToggle: { checked: false },
|
||||
autoLoadMetadataToggle: { checked: true }, cacheMetadataToggle: { checked: true }, showFilePathsToggle: { checked: false },
|
||||
sidebarCollapsedByDefaultToggle: { checked: true }, infiniteScrollToggle: { checked: false }, debugModeToggle: { checked: false },
|
||||
apiTimeoutInput: { value: "30" }, checkThumbnailsAtStartup: { checked: true }, videoAutoplayToggle: { checked: true },
|
||||
videoMuteToggle: { checked: false }, videoLoopToggle: { checked: true },
|
||||
};
|
||||
const context = vm.createContext({
|
||||
document: { addEventListener() {}, getElementById: id => controls[id] },
|
||||
window: {}, console: { debug() {}, warn() {}, error() {} }, GallerySettings: settings,
|
||||
localStorage: { getItem: key => storage.get(key) ?? null, setItem: (key, value) => storage.set(key, value) },
|
||||
});
|
||||
const file = path.join(__dirname, "../../web/js/gallery.js");
|
||||
const Manager = vm.runInContext(`${fs.readFileSync(file, "utf8")}\nGalleryManager;`, context, { filename: file });
|
||||
const manager = Object.create(Manager.prototype);
|
||||
Object.assign(manager, { showNotification() {}, hideSettings() {}, applySettings() {} });
|
||||
manager.saveSettings();
|
||||
const saved = JSON.parse(storage.get("gallerySettings"));
|
||||
assert.equal(saved.workspaceLayout, "masonry");
|
||||
assert.equal(saved.workspaceDensity, "compact");
|
||||
assert.equal(saved.someOtherTool, 1);
|
||||
assert.equal(saved.defaultViewMode, "list");
|
||||
assert.equal(saved.showImageInfo, false);
|
||||
assert.equal(saved.videoMute, false);
|
||||
});
|
||||
|
||||
@@ -82,32 +82,6 @@ test('a zeroed conditioning negative shows as an explicit empty negative without
|
||||
}finally{scope.dispose();f.restore();}
|
||||
});
|
||||
|
||||
test('a prompt that left out run-time trigger words says so under its text (D125)',()=>{
|
||||
const f=dom(),scope=createScope(),container=new f.Element('div'),sent=[];
|
||||
const note='Text added at run time, such as trigger words, is not included.';
|
||||
try{
|
||||
module.mountPromptBlocks(container,{parentScope:scope,sender:{open:roles=>sent.push(roles)},prompts:{positive:{status:'resolved',text:'own text',source:'runtime-parts-omitted'},negative:{status:'resolved',text:'blur',source:'graph-inferred'}}});
|
||||
const notes=f.walk(container,node=>node.textContent===note);
|
||||
assert.equal(notes.length,1,'one note, under the positive only');
|
||||
assert.equal(notes[0].parent.attributes['aria-label'],'Positive prompt');
|
||||
container.click(f.walk(container,node=>node.dataset.sendRole==='pair')[0]);
|
||||
assert.deepEqual(sent,[{positive:'own text',negative:'blur'}],'the note is never sent');
|
||||
}finally{scope.dispose();f.restore();}
|
||||
});
|
||||
|
||||
test('a prompt a later stage added conditioning to says so, and both lines show together (D126)',()=>{
|
||||
const f=dom(),scope=createScope(),container=new f.Element('div');
|
||||
const added='Conditioning added at a later stage is not included.', runtime='Text added at run time, such as trigger words, is not included.';
|
||||
try{
|
||||
const view=module.mountPromptBlocks(container,{parentScope:scope,prompts:{positive:{status:'resolved',text:'base',source:'added-conditioning'}}});
|
||||
assert.deepEqual(f.walk(container,node=>node.className==='small muted').map(node=>node.textContent),[added]);
|
||||
view.update({positive:{status:'resolved',text:'base',source:'runtime-parts-omitted-and-added-conditioning'}});
|
||||
assert.deepEqual(f.walk(container,node=>node.className==='small muted').map(node=>node.textContent),[runtime,added]);
|
||||
view.update({positive:{status:'resolved',text:'base',source:'graph-inferred'}});
|
||||
assert.equal(f.walk(container,node=>node.className==='small muted').length,0);
|
||||
}finally{scope.dispose();f.restore();}
|
||||
});
|
||||
|
||||
const unresolved=message=>({status:'unresolved',text:null,source:null,...(message===undefined?{}:{message})});
|
||||
const statusLines=(f,container)=>f.walk(container,node=>node.tagName==='p'&&node.className==='prompt-text muted').map(node=>node.textContent);
|
||||
test('unsupported node status names the class',()=>{
|
||||
|
||||
@@ -478,19 +478,3 @@ test('a refresh deferred behind a page load is dropped when the stream is dispos
|
||||
await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.equal(requests.length,2,'no refresh runs after dispose');
|
||||
});
|
||||
|
||||
test('defaults apply to a new visit but not to the current view or a saved view', async () => {
|
||||
const { initialImagesView } = await source;
|
||||
assert.equal(typeof initialImagesView, 'function', 'initialImagesView is exported');
|
||||
const preferences = { workspaceLayout: 'masonry', workspaceDensity: 'compact' };
|
||||
// A new visit starts from System › Appearance.
|
||||
assert.deepEqual(initialImagesView({ preferences }), { layout: 'masonry', density: 'compact' });
|
||||
// The current view (the last visit) keeps its own toolbar choices.
|
||||
assert.deepEqual(initialImagesView({ prior: { layout: 'list', density: 'large' }, preferences }), { layout: 'list', density: 'large' });
|
||||
assert.deepEqual(initialImagesView({ prior: {}, preferences }), { layout: 'grid', density: 'comfortable' });
|
||||
// A saved view opens as it always did.
|
||||
assert.deepEqual(initialImagesView({ saved: { q: '' }, preferences }), { layout: 'grid', density: 'comfortable' });
|
||||
// Without readable preferences nothing changes.
|
||||
assert.deepEqual(initialImagesView({}), { layout: 'grid', density: 'comfortable' });
|
||||
assert.deepEqual(initialImagesView({ preferences: {} }), { layout: 'grid', density: 'comfortable' });
|
||||
});
|
||||
|
||||
@@ -825,73 +825,3 @@ test('refresh re-reads the selected file and keeps the shown record until the ne
|
||||
assert.equal(controller.state.record.identity.path, 'folder/b.png');
|
||||
controller.dispose(); assert.equal(await controller.refresh(), undefined);
|
||||
});
|
||||
|
||||
test('metadata off shows an explicit load and loads once on request', async () => {
|
||||
const { api, requests } = pendingApi(), updates = [];
|
||||
const controller = contract('createInspectionController')({ api, extractFacts: extractGenerationFacts, autoLoad: false });
|
||||
controller.subscribe(state => updates.push(state));
|
||||
// Selecting reads nothing: the state says the metadata waits for a request.
|
||||
assert.equal(controller.select(image()), undefined);
|
||||
assert.equal(requests.length, 0);
|
||||
assert.equal(controller.state.deferred, true);
|
||||
assert.equal(controller.state.loading, false);
|
||||
assert.equal(controller.state.record, null);
|
||||
// Selecting the same file again still reads nothing.
|
||||
controller.select({ ...image() });
|
||||
assert.equal(requests.length, 0);
|
||||
const load = controller.load();
|
||||
assert.equal(requests.length, 1);
|
||||
assert.equal(controller.state.loading, true);
|
||||
assert.equal(controller.state.deferred, false);
|
||||
// A second press while it reads, and the same file selected again once it loaded, read nothing more.
|
||||
controller.load();
|
||||
assert.equal(requests.length, 1);
|
||||
requests[0].resolve(envelope()); await load;
|
||||
assert.equal(controller.state.record.generation.seed.value, '9007199254740993');
|
||||
controller.load(); controller.select(image());
|
||||
assert.equal(requests.length, 1);
|
||||
// Another file waits for its own request again.
|
||||
controller.select({ ...image(), path: 'folder/b.png', filename: 'b.png', url: image().url.replace('a%20%23%25', 'b') });
|
||||
assert.equal(controller.state.deferred, true);
|
||||
assert.equal(requests.length, 1);
|
||||
// The default controller loads on selection, as before.
|
||||
const eager = contract('createInspectionController')({ api, extractFacts: extractGenerationFacts });
|
||||
eager.select(image());
|
||||
assert.equal(requests.length, 2);
|
||||
assert.equal(eager.state.deferred, false);
|
||||
eager.dispose(); controller.dispose();
|
||||
assert.equal(controller.load(), undefined);
|
||||
});
|
||||
|
||||
test('cache off rereads the current selection; on reuses the one current record', async () => {
|
||||
for (const cache of [true, false]) {
|
||||
const { api, requests } = pendingApi();
|
||||
const controller = contract('createInspectionController')({ api, extractFacts: extractGenerationFacts, cache });
|
||||
const first = controller.select(image());
|
||||
// A read in flight is shared either way.
|
||||
controller.select(image());
|
||||
assert.equal(requests.length, 1);
|
||||
requests[0].resolve(envelope()); await first;
|
||||
const again = controller.select(image());
|
||||
assert.equal(requests.length, cache ? 1 : 2, `cache ${cache}`);
|
||||
if (!cache) { requests[1].resolve(envelope()); await again; assert.equal(controller.state.record.identity.path, 'folder/a #%.png'); }
|
||||
controller.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('cache off never rereads a selection whose source changed, and refresh never loads a deferred one', async () => {
|
||||
const { api, requests } = pendingApi();
|
||||
const controller = contract('createInspectionController')({ api, extractFacts: extractGenerationFacts, cache: false });
|
||||
const first = controller.select(image());
|
||||
requests[0].reject(new ApiError(409, refreshMessage, 'source-changed')); await first;
|
||||
assert.equal(controller.state.refreshRequired, true);
|
||||
controller.select(image());
|
||||
assert.equal(requests.length, 1);
|
||||
controller.dispose();
|
||||
const deferred = contract('createInspectionController')({ api, extractFacts: extractGenerationFacts, autoLoad: false });
|
||||
deferred.select(image());
|
||||
assert.equal(deferred.refresh(), undefined);
|
||||
assert.equal(requests.length, 1);
|
||||
assert.equal(deferred.state.deferred, true);
|
||||
deferred.dispose();
|
||||
});
|
||||
|
||||
@@ -136,17 +136,6 @@ test('an unusable semantics table is refused', () => {
|
||||
for (const doc of [null, {schema: 2}, {schema: 1, samplers: []}]) assert.throws(() => resolver.compileSemantics(doc), resolver.SemanticsError);
|
||||
});
|
||||
|
||||
test('the lineage section is compiled, and a bad one refuses the table', () => {
|
||||
const doc = JSON.parse(fs.readFileSync(new URL('../../web/js/inspection-semantics.json', import.meta.url), 'utf8'));
|
||||
assert.deepEqual(semantics.lineage.get('Image Filter\u00001'), { strategy: 'continue', input: 'latents' });
|
||||
// Lineage describes no prompt: its classes are not table classes for the stage walk.
|
||||
assert.equal(semantics.allClasses.has('VAEEncode'), false);
|
||||
const row = { class: 'VAEEncode', slot: 0, strategy: 'continue', input: 'pixels' };
|
||||
for (const lineage of [undefined, {}, [null], [{ ...row, slot: -1 }], [{ ...row, strategy: 'passthrough' }], [{ ...row, input: '' }], [row, row]]) {
|
||||
assert.throws(() => resolver.compileSemantics({ ...doc, lineage }), resolver.SemanticsError, JSON.stringify(lineage));
|
||||
}
|
||||
});
|
||||
|
||||
// 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', () => {
|
||||
|
||||
@@ -124,51 +124,18 @@ test('real inspection navigation preserves slideshow across two advances and def
|
||||
} finally {view.dispose();controller.dispose();parentScope.dispose();globalThis.setTimeout=oldSetTimeout;globalThis.clearTimeout=oldClearTimeout;f.restore();}
|
||||
});
|
||||
|
||||
test('Viewer.js is built only on a loaded image and an unbuilt viewer is released at once', async () => {
|
||||
const {reportedLocationAdapter:adapter} = await import('../../web/js/workspace/history-media.mjs');
|
||||
const f = mountedDom(), parentScope = createScope(), controller = borrowedController(), items = [occurrenceMedia('1',1,'c'),occurrenceMedia('2',0,'d')];
|
||||
const instances = [];
|
||||
// On a loaded image Viewer.js schedules its inline build; until it runs `ready` is false and destroy() cancels it.
|
||||
f.win.Viewer = class { constructor(node,options) { this.node = node; this.options = options; this.destroyed = 0; this.ready = false; this.viewed = false; instances.push(this); }
|
||||
destroy() { this.destroyed++; } resize() {} };
|
||||
const globals = {ResizeObserver:globalThis.ResizeObserver, requestAnimationFrame:globalThis.requestAnimationFrame, cancelAnimationFrame:globalThis.cancelAnimationFrame};
|
||||
Object.assign(globalThis, {ResizeObserver:class { observe() {} disconnect() {} }, requestAnimationFrame:() => 1, cancelAnimationFrame() {}});
|
||||
const view = module.createMediaViewer({container:f.container,parentScope,inspectionController:controller,adapter,sender:{open(){}}});
|
||||
try {
|
||||
const stageImage = () => f.walk(f.container,n => n.tagName === 'img')[0];
|
||||
view.open({items,selectedKey:'d'.repeat(64)});
|
||||
// Viewer.js's own cancel misses an image whose load event is still queued, so none exists before the load.
|
||||
const unloaded = stageImage(), dropped = [];unloaded.removeAttribute = key => dropped.push(key);
|
||||
assert.equal(instances.length, 0, 'no Viewer.js before the image loads');
|
||||
f.walk(f.container,n => n.attributes['aria-label'] === 'Previous media')[0].fire('click');
|
||||
assert.deepEqual(dropped, ['src'], 'an image left before it loads is released at once');
|
||||
unloaded.fire('load');
|
||||
assert.equal(instances.length, 0, 'its late load builds no Viewer.js');
|
||||
stageImage().fire('load');
|
||||
const [loading] = instances, image = loading.node, removed = [];
|
||||
image.removeAttribute = key => removed.push(key);
|
||||
// Next drops the image whose build is pending; a deferred destroy would let that build run there.
|
||||
f.walk(f.container,n => n.attributes['aria-label'] === 'Next media')[0].fire('click');
|
||||
stageImage().fire('load');assert.equal(instances.length, 2, 'the next image builds its own Viewer.js once loaded');
|
||||
assert.equal(loading.destroyed, 1, 'an unbuilt viewer is destroyed at once');
|
||||
assert.deepEqual(removed, ['src'], 'and its image is released at once');
|
||||
loading.options.viewed.call(loading);
|
||||
assert.equal(loading.destroyed, 1, 'never destroyed twice');
|
||||
} finally { view.dispose(); parentScope.dispose(); f.restore(); Object.assign(globalThis, globals); }
|
||||
});
|
||||
|
||||
test('an inline Viewer still loading its image is destroyed only after it has viewed it', async () => {
|
||||
const {reportedLocationAdapter:adapter} = await import('../../web/js/workspace/history-media.mjs');
|
||||
const f = mountedDom(), parentScope = createScope(), controller = borrowedController(), items = [occurrenceMedia('1',1,'c')];
|
||||
const instances = [];
|
||||
// A built (`ready`) inline Viewer.js's destroy() leaves an in-flight view running; its callback then needs a live instance.
|
||||
f.win.Viewer = class { constructor(node,options) { this.node = node; this.options = options; this.destroyed = 0; this.ready = true; this.viewed = false; instances.push(this); }
|
||||
// Viewer.js's inline destroy() leaves an in-flight view running; its callback then needs a live instance.
|
||||
f.win.Viewer = class { constructor(node,options) { this.node = node; this.options = options; this.destroyed = 0; this.viewed = false; instances.push(this); }
|
||||
destroy() { this.destroyed++; } resize() {} };
|
||||
const globals = {ResizeObserver:globalThis.ResizeObserver, requestAnimationFrame:globalThis.requestAnimationFrame, cancelAnimationFrame:globalThis.cancelAnimationFrame};
|
||||
Object.assign(globalThis, {ResizeObserver:class { observe() {} disconnect() {} }, requestAnimationFrame:() => 1, cancelAnimationFrame() {}});
|
||||
const view = module.createMediaViewer({container:f.container,parentScope,inspectionController:controller,adapter,sender:{open(){}}});
|
||||
try {
|
||||
view.open({items,selectedKey:'c'.repeat(64)});f.walk(f.container,n => n.tagName === 'img')[0].fire('load');
|
||||
view.open({items,selectedKey:'c'.repeat(64)});
|
||||
const [loading] = instances, image = loading.node, removed = [];
|
||||
image.removeAttribute = key => removed.push(key);
|
||||
f.walk(f.container,n => n.attributes['aria-label'] === 'Close image viewer')[0].fire('click');
|
||||
@@ -181,7 +148,7 @@ test('an inline Viewer still loading its image is destroyed only after it has vi
|
||||
loading.options.viewed.call(loading);
|
||||
assert.equal(loading.destroyed, 1, 'never destroyed twice');
|
||||
|
||||
view.open({items,selectedKey:'c'.repeat(64)});f.walk(f.container,n => n.tagName === 'img')[0].fire('load');
|
||||
view.open({items,selectedKey:'c'.repeat(64)});
|
||||
const shown = instances[1]; shown.viewed = true; shown.options.viewed.call(shown);
|
||||
f.walk(f.container,n => n.attributes['aria-label'] === 'Close image viewer')[0].fire('click');
|
||||
assert.equal(shown.destroyed, 1, 'a viewed image is destroyed at once');
|
||||
@@ -269,7 +236,7 @@ test('viewer uses adapter.mediaUrl for image and video', async () => {
|
||||
const img = f.walk(f.container,n => n.tagName === 'img')[0];
|
||||
assert.equal(img.src, image.url, 'the blob URL is used exactly');
|
||||
assert.doesNotMatch(img.src, /\?/);
|
||||
img.fire('load');assert.equal(instances[0].node, img, 'Viewer.js shows the blob image');
|
||||
assert.equal(instances[0].node, img, 'Viewer.js shows the blob image');
|
||||
const download = f.walk(f.container,n => n.attributes['aria-label'] === 'Download original')[0];
|
||||
assert.equal(download.href, image.url);assert.equal(download.download, 'a.png');
|
||||
f.walk(f.container,n => n.attributes['aria-label'] === 'Next media')[0].fire('click');
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
// Browser display defaults (#249 System S3 Task 7): the workspace reads and writes the legacy gallery's own
|
||||
// `gallerySettings` object (Q3, "One display configuration"), writes only the keys it changed, never consumes the
|
||||
// legacy paging keys, and says when this browser refused to keep a change.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import gallerySettings from '../../web/js/gallery-settings.js';
|
||||
|
||||
const module = await import('../../web/js/workspace/preferences.mjs').catch(error => {
|
||||
if (error.code !== 'ERR_MODULE_NOT_FOUND') throw error;
|
||||
return {};
|
||||
});
|
||||
globalThis.GallerySettings = gallerySettings;
|
||||
const KEY = 'gallerySettings';
|
||||
|
||||
/** A localStorage stand-in holding `text` under the settings key; `setItem` can be made to throw. */
|
||||
function storage(text = null, { quota = false } = {}) {
|
||||
const writes = [];
|
||||
const store = {
|
||||
text,
|
||||
writes,
|
||||
getItem(key) { assert.equal(key, KEY); return store.text; },
|
||||
setItem(key, value) {
|
||||
assert.equal(key, KEY);
|
||||
writes.push(value);
|
||||
if (quota) { const error = new Error('Quota exceeded'); error.name = 'QuotaExceededError'; throw error; }
|
||||
store.text = value;
|
||||
},
|
||||
removeItem() { assert.fail('Preferences never remove the shared settings'); },
|
||||
};
|
||||
return store;
|
||||
}
|
||||
const denied = () => { const error = new Error('Denied'); error.name = 'SecurityError'; throw error; };
|
||||
|
||||
test('normalizes new workspace keys and keeps legacy keys', () => {
|
||||
assert.equal(typeof module.readPreferences, 'function', 'preferences.mjs exports readPreferences');
|
||||
module.resetTabPreferences();
|
||||
const legacy = { defaultViewMode: 'list', gridColumns: 5, defaultLimit: 200, thumbnailPromptDismissed: 'sig', extra: 1 };
|
||||
const store = storage(JSON.stringify({ ...legacy, workspaceLayout: 'masonry', workspaceDensity: 'compact', showFilePaths: false }));
|
||||
const { values, persisted } = module.readPreferences(() => store);
|
||||
assert.equal(persisted, true);
|
||||
assert.equal(values.workspaceLayout, 'masonry');
|
||||
assert.equal(values.workspaceDensity, 'compact');
|
||||
assert.equal(values.showFilePaths, false);
|
||||
// Invalid workspace values fall back to the defaults, as every legacy key does.
|
||||
store.text = JSON.stringify({ workspaceLayout: 'mosaic', workspaceDensity: 7, videoMute: 'no' });
|
||||
const fallback = module.readPreferences(() => store).values;
|
||||
assert.equal(fallback.workspaceLayout, 'grid');
|
||||
assert.equal(fallback.workspaceDensity, 'comfortable');
|
||||
assert.equal(fallback.videoMute, true);
|
||||
// The legacy normalizer knows the new keys and still keeps unknown ones.
|
||||
const normalized = gallerySettings.normalizeGallerySettings({ ...legacy, workspaceLayout: 'list' });
|
||||
assert.equal(normalized.workspaceLayout, 'list');
|
||||
assert.equal(normalized.workspaceDensity, 'comfortable');
|
||||
assert.equal(normalized.extra, 1);
|
||||
assert.equal(normalized.thumbnailPromptDismissed, 'sig');
|
||||
assert.deepEqual(gallerySettings.WORKSPACE_LAYOUTS, ['grid', 'list', 'masonry']);
|
||||
assert.deepEqual(gallerySettings.WORKSPACE_DENSITIES, ['comfortable', 'large', 'compact']);
|
||||
});
|
||||
|
||||
test('write touches only dirty keys', () => {
|
||||
module.resetTabPreferences();
|
||||
const stored = { defaultViewMode: 'list', gridColumns: 5, thumbnailsGenerated: true, extra: { kept: true }, videoMute: false };
|
||||
const store = storage(JSON.stringify(stored));
|
||||
const result = module.writePreferences({ workspaceLayout: 'masonry', showImageInfo: false }, () => store);
|
||||
assert.deepEqual(result, { persisted: true, conflicts: [] });
|
||||
assert.equal(store.writes.length, 1);
|
||||
// Every other key, legacy or unknown, is written back exactly as it was stored: no defaults are filled in.
|
||||
assert.deepEqual(JSON.parse(store.text), { ...stored, workspaceLayout: 'masonry', showImageInfo: false });
|
||||
// It re-reads the stored object, so a key another page wrote after this one read is kept.
|
||||
store.text = JSON.stringify({ ...JSON.parse(store.text), gridColumns: 9 });
|
||||
module.writePreferences({ videoLoop: false }, () => store);
|
||||
assert.equal(JSON.parse(store.text).gridColumns, 9);
|
||||
assert.equal(JSON.parse(store.text).workspaceLayout, 'masonry');
|
||||
// An empty or malformed stored value starts from an empty object, never the defaults.
|
||||
for (const text of [null, '{broken', '[]', '42']) {
|
||||
const fresh = storage(text);
|
||||
assert.equal(module.writePreferences({ cacheMetadata: false }, () => fresh).persisted, true);
|
||||
assert.deepEqual(JSON.parse(fresh.text), { cacheMetadata: false });
|
||||
}
|
||||
// Only workspace keys with valid values are accepted; nothing is written for a bad change.
|
||||
for (const bad of [{ gridColumns: 4 }, { defaultLimit: 50 }, { workspaceLayout: 'mosaic' }, { videoMute: 'yes' }, { imageQuality: 'ultra' }]) {
|
||||
const untouched = storage(JSON.stringify(stored));
|
||||
assert.throws(() => module.writePreferences(bad, () => untouched), TypeError);
|
||||
assert.equal(untouched.writes.length, 0);
|
||||
}
|
||||
// A key changed elsewhere since this tab read it (its baseline) is reported, and the explicit save still wins.
|
||||
const elsewhere = storage(JSON.stringify({ videoLoop: false }));
|
||||
const outcome = module.writePreferences({ videoLoop: true, videoMute: false }, () => elsewhere, { baseline: { videoLoop: true, videoMute: true } });
|
||||
assert.deepEqual(outcome, { persisted: true, conflicts: ['videoLoop'] });
|
||||
assert.equal(JSON.parse(elsewhere.text).videoLoop, true);
|
||||
});
|
||||
|
||||
test('denied storage reports tab-only', () => {
|
||||
module.resetTabPreferences();
|
||||
const read = module.readPreferences(denied);
|
||||
assert.equal(read.persisted, false);
|
||||
assert.equal(read.values.workspaceLayout, 'grid');
|
||||
const result = module.writePreferences({ workspaceLayout: 'list', videoAutoplay: true }, denied);
|
||||
assert.deepEqual(result, { persisted: false, conflicts: [] });
|
||||
// The change is kept for this tab: every reader in it (a new Images visit, the viewer) sees it.
|
||||
const tab = module.readPreferences(denied);
|
||||
assert.equal(tab.persisted, false);
|
||||
assert.equal(tab.values.workspaceLayout, 'list');
|
||||
assert.equal(tab.values.videoAutoplay, true);
|
||||
// A getItem that throws is denied storage too.
|
||||
const throwing = { getItem: denied, setItem: denied };
|
||||
assert.equal(module.readPreferences(() => throwing).values.workspaceLayout, 'list');
|
||||
assert.equal(module.writePreferences({ workspaceDensity: 'large' }, () => throwing).persisted, false);
|
||||
// Once storage accepts a write, the tab copy of that key gives way to the stored value.
|
||||
const working = storage(JSON.stringify({ workspaceLayout: 'masonry' }));
|
||||
assert.equal(module.writePreferences({ workspaceLayout: 'masonry' }, () => working).persisted, true);
|
||||
assert.equal(module.readPreferences(() => working).values.workspaceLayout, 'masonry');
|
||||
module.resetTabPreferences();
|
||||
assert.equal(module.readPreferences(denied).values.videoAutoplay, false);
|
||||
});
|
||||
|
||||
test('quota error keeps previous bytes', () => {
|
||||
module.resetTabPreferences();
|
||||
const before = JSON.stringify({ gridColumns: 6, workspaceDensity: 'large' });
|
||||
const store = storage(before, { quota: true });
|
||||
const result = module.writePreferences({ workspaceDensity: 'compact' }, () => store);
|
||||
assert.deepEqual(result, { persisted: false, conflicts: [] });
|
||||
assert.equal(store.writes.length, 1);
|
||||
assert.equal(store.text, before);
|
||||
// This tab keeps the change it could not store.
|
||||
assert.equal(module.readPreferences(() => store).values.workspaceDensity, 'compact');
|
||||
module.resetTabPreferences();
|
||||
assert.equal(module.readPreferences(() => store).values.workspaceDensity, 'large');
|
||||
});
|
||||
|
||||
/** A window stand-in that records its storage listeners. */
|
||||
function fakeWindow() {
|
||||
const listeners = new Set();
|
||||
return {
|
||||
listeners,
|
||||
addEventListener(type, listener) { assert.equal(type, 'storage'); listeners.add(listener); },
|
||||
removeEventListener(type, listener) { assert.equal(type, 'storage'); listeners.delete(listener); },
|
||||
fire(event) { for (const listener of [...listeners]) listener(event); },
|
||||
};
|
||||
}
|
||||
|
||||
test('storage event updates clean values and flags dirty conflicts', () => {
|
||||
module.resetTabPreferences();
|
||||
const store = storage(JSON.stringify({ workspaceLayout: 'list', videoMute: false }));
|
||||
const win = fakeWindow();
|
||||
const seen = [];
|
||||
const unsubscribe = module.subscribePreferences(win, next => seen.push(next), () => store);
|
||||
assert.equal(win.listeners.size, 1);
|
||||
// Another key, or another storage area's event, is not ours.
|
||||
win.fire({ key: 'pm_update_notice_dismissed', storageArea: store });
|
||||
win.fire({ key: KEY, storageArea: { other: true } });
|
||||
assert.equal(seen.length, 0);
|
||||
win.fire({ key: KEY, storageArea: store });
|
||||
win.fire({ key: null, storageArea: store }); // localStorage.clear() elsewhere
|
||||
assert.equal(seen.length, 2);
|
||||
assert.equal(seen[0].values.workspaceLayout, 'list');
|
||||
unsubscribe();
|
||||
assert.equal(win.listeners.size, 0);
|
||||
// The reconciliation of the System drafts: clean values follow the other page; a key this tab has a draft for and
|
||||
// the other page changed is a conflict, and the draft stays.
|
||||
const baseline = { workspaceLayout: 'grid', videoMute: true, videoLoop: true };
|
||||
const next = { workspaceLayout: 'list', videoMute: true, videoLoop: false };
|
||||
assert.deepEqual(module.reconcilePreferences({ baseline, drafts: { videoMute: false, videoLoop: false }, next }), { conflicts: [] });
|
||||
assert.deepEqual(module.reconcilePreferences({ baseline, drafts: { workspaceLayout: 'masonry' }, next }), { conflicts: ['workspaceLayout'] });
|
||||
assert.deepEqual(module.reconcilePreferences({ baseline, drafts: { workspaceLayout: 'list' }, next }), { conflicts: [] });
|
||||
});
|
||||
|
||||
test('legacy paging keys are never consumed by the workspace', () => {
|
||||
module.resetTabPreferences();
|
||||
const store = storage(JSON.stringify({ defaultLimit: 500, gridColumns: 12, infiniteScroll: true, lazyLoading: false,
|
||||
defaultViewMode: 'list', debugMode: true, apiTimeout: 9, thumbnailsGenerated: true, checkThumbnailsAtStartup: false }));
|
||||
const { values } = module.readPreferences(() => store);
|
||||
assert.deepEqual(Object.keys(values).sort(), [...module.WORKSPACE_PREFERENCE_KEYS].sort());
|
||||
for (const key of ['defaultLimit', 'gridColumns', 'infiniteScroll', 'lazyLoading', 'defaultViewMode', 'debugMode', 'apiTimeout',
|
||||
'thumbnailsGenerated', 'checkThumbnailsAtStartup', 'thumbnailPromptDismissed']) assert.equal(Object.hasOwn(values, key), false, key);
|
||||
// The display defaults are the gallery's own defaults for these keys.
|
||||
const defaults = module.preferenceDefaults();
|
||||
for (const key of module.WORKSPACE_PREFERENCE_KEYS) assert.equal(defaults[key], gallerySettings.DEFAULT_SETTINGS[key], key);
|
||||
});
|
||||
|
||||
test('a later save flushes the changes kept for this tab only', () => {
|
||||
module.resetTabPreferences();
|
||||
const store = storage(JSON.stringify({ gridColumns: 4 }), { quota: true });
|
||||
assert.equal(module.writePreferences({ workspaceLayout: 'list' }, () => store).persisted, false);
|
||||
// Storage accepts writes again: saving another key stores the tab-only change with it, so "Saved" is true for both.
|
||||
const working = storage(store.text);
|
||||
assert.deepEqual(module.writePreferences({ videoLoop: false }, () => working), { persisted: true, conflicts: [] });
|
||||
assert.deepEqual(JSON.parse(working.text), { gridColumns: 4, workspaceLayout: 'list', videoLoop: false });
|
||||
// Nothing is overlaid any more: another page's later change reads through.
|
||||
working.text = JSON.stringify({ ...JSON.parse(working.text), workspaceLayout: 'masonry' });
|
||||
assert.equal(module.readPreferences(() => working).values.workspaceLayout, 'masonry');
|
||||
module.resetTabPreferences();
|
||||
});
|
||||
@@ -1,188 +0,0 @@
|
||||
// System › Appearance (#249 System S3 Task 8): the concept's "Browsing & display" rows (app.js 676–678) on the drafts
|
||||
// of the one Save row, their stored values (Q3 mappings), the host timeout's 0–300 whole seconds and the D97 rows.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const gallerySettings = require('../../web/js/gallery-settings.js');
|
||||
|
||||
const appearanceModule = import('../../web/js/workspace/system-appearance.mjs');
|
||||
const stateModule = import('../../web/js/workspace/system-state.mjs');
|
||||
const coreModule = import('../../web/js/shared/toolkit/core.js');
|
||||
globalThis.GallerySettings = gallerySettings;
|
||||
|
||||
/** A minimal DOM whose elements count their live listeners (a listener's signal aborts it, as in a browser). */
|
||||
function fakeDOM() {
|
||||
const previous = global.document;
|
||||
const elements = [];
|
||||
class Element {
|
||||
constructor(tag) {
|
||||
this.tagName = tag.toUpperCase();
|
||||
this.children = [];
|
||||
this.attributes = {};
|
||||
this.listeners = [];
|
||||
this.dataset = {};
|
||||
this.className = '';
|
||||
this.id = '';
|
||||
this.value = '';
|
||||
this._text = '';
|
||||
elements.push(this);
|
||||
}
|
||||
get textContent() { return this._text + this.children.map(child => child.textContent).join(''); }
|
||||
set textContent(text) { this.children = []; this._text = String(text); }
|
||||
append(...nodes) {
|
||||
for (const node of nodes) {
|
||||
const child = typeof node === 'string' ? Object.assign(new Element('#text'), { _text: node }) : node;
|
||||
child.parentNode = this;
|
||||
this.children.push(child);
|
||||
}
|
||||
}
|
||||
remove() { if (this.parentNode) this.parentNode.children = this.parentNode.children.filter(child => child !== this); this.parentNode = null; }
|
||||
replaceChildren(...nodes) { this.children = []; this._text = ''; this.append(...nodes); }
|
||||
setAttribute(name, value) { this.attributes[name] = String(value); }
|
||||
getAttribute(name) { return Object.hasOwn(this.attributes, name) ? this.attributes[name] : null; }
|
||||
removeAttribute(name) { delete this.attributes[name]; }
|
||||
addEventListener(type, callback, options = {}) {
|
||||
if (options.signal?.aborted) return;
|
||||
const entry = { type, callback };
|
||||
this.listeners.push(entry);
|
||||
options.signal?.addEventListener('abort', () => { this.listeners = this.listeners.filter(item => item !== entry); });
|
||||
}
|
||||
removeEventListener(type, callback) { this.listeners = this.listeners.filter(item => item.type !== type || item.callback !== callback); }
|
||||
dispatch(type) { for (const { callback } of this.listeners.filter(item => item.type === type)) callback({ type, target: this }); }
|
||||
click() { this.dispatch('click'); }
|
||||
}
|
||||
global.document = {
|
||||
createElement: tag => new Element(tag),
|
||||
createTextNode: text => Object.assign(new Element('#text'), { _text: String(text) }),
|
||||
};
|
||||
const walk = (node, found = []) => { found.push(node); node.children.forEach(child => walk(child, found)); return found; };
|
||||
return {
|
||||
listenerCount: () => elements.reduce((sum, element) => sum + element.listeners.length, 0),
|
||||
find: (root, predicate) => walk(root).find(predicate),
|
||||
tags: node => node.children.map(child => child.tagName.toLowerCase()),
|
||||
restore() { global.document = previous; },
|
||||
};
|
||||
}
|
||||
|
||||
const BROWSER = { workspaceLayout: 'grid', workspaceDensity: 'comfortable', autoLoadMetadata: true, cacheMetadata: true,
|
||||
imageQuality: 'medium', videoAutoplay: false, videoMute: true, videoLoop: true, showFilePaths: true, showImageInfo: true,
|
||||
sidebarCollapsedByDefault: false };
|
||||
|
||||
async function mounted({ host = { webui_display_mode: 'newtab', result_timeout: 5 }, browser = BROWSER, hostFails = false } = {}) {
|
||||
const { mountAppearance } = await appearanceModule;
|
||||
const { createSystemDrafts } = await stateModule;
|
||||
const { createScope } = await coreModule;
|
||||
const dom = fakeDOM();
|
||||
let hostValues = null;
|
||||
const drafts = createSystemDrafts({
|
||||
readBrowser: () => ({ ...browser }), readRuntime: () => ({}),
|
||||
readHost: async () => { if (hostFails) throw new Error('offline'); return { values: hostValues, revision: 'sha256:one' }; },
|
||||
writeBrowser: async () => ({ persisted: true }), writeHost: async () => ({ success: true, persisted: true }), writeRuntime: async () => ({}),
|
||||
});
|
||||
await drafts.load('browser');
|
||||
const scope = createScope();
|
||||
const [surface] = mountAppearance(null, { scope, drafts });
|
||||
const byId = id => dom.find(surface, node => node.dataset?.setting === id);
|
||||
return { dom, drafts, scope, surface, byId, loadHost: async () => { hostValues = host; await drafts.load('host'); },
|
||||
fixHost: () => { hostFails = false; } };
|
||||
}
|
||||
|
||||
test('the surface is the concept section with the concept rows in order, then the D97 rows', async () => {
|
||||
const { APPEARANCE_ROWS } = await appearanceModule;
|
||||
const { dom, surface, scope } = await mounted();
|
||||
try {
|
||||
assert.equal(surface.className, 'surface');
|
||||
assert.equal(surface.children[0].textContent, 'Browsing & display');
|
||||
assert.equal(surface.children[1].textContent, 'One set of defaults; toolbar changes apply to the current view.');
|
||||
const rows = surface.children.slice(2);
|
||||
const titles = rows.map(row => row.children[0].children[0].textContent);
|
||||
assert.deepEqual(titles, ['Default layout', 'Thumbnail size', 'Continuous browsing', 'Load metadata on selection',
|
||||
'Cache metadata in this browser', 'Image loading quality', 'Autoplay videos', 'Mute videos by default', 'Loop videos',
|
||||
'Show file paths', 'Open from ComfyUI', 'Node search auto-hide (seconds)', 'Show image details on cards',
|
||||
'Collapse viewer details by default', 'Reset display defaults']);
|
||||
const continuous = rows[2];
|
||||
assert.deepEqual(dom.tags(continuous.children[0]), ['h3', 'p']);
|
||||
assert.equal(continuous.children[1].className, 'chip blue');
|
||||
assert.equal(continuous.children[1].textContent, 'Infinite scroll');
|
||||
assert.equal(rows.at(-1).className, 'operation');
|
||||
assert.equal(APPEARANCE_ROWS.filter(Array.isArray).length, 13);
|
||||
} finally { scope.dispose(); assert.equal(dom.listenerCount(), 0); dom.restore(); }
|
||||
});
|
||||
|
||||
test('added rows reach their consumers: each control edits its stored key with the Q3 values', async () => {
|
||||
const { dom, drafts, scope, byId, loadHost } = await mounted();
|
||||
try {
|
||||
// Host rows stay disabled until the server settings answered.
|
||||
assert.equal(byId('open-mode').disabled, true);
|
||||
assert.equal(byId('timeout').disabled, true);
|
||||
await loadHost();
|
||||
assert.equal(byId('open-mode').disabled, false);
|
||||
assert.equal(byId('timeout').value, '5');
|
||||
assert.deepEqual(byId('image-quality').children.map(option => [option.value, option.textContent]),
|
||||
[['medium', 'Optimized'], ['high', 'Original'], ['low', 'Fast']]);
|
||||
assert.deepEqual(byId('open-mode').children.map(option => [option.value, option.textContent]),
|
||||
[['newtab', 'New tab'], ['popup', 'Popup window']]);
|
||||
const edits = [['default-view', 'masonry'], ['density', 'large'], ['image-quality', 'low'], ['open-mode', 'popup']];
|
||||
for (const [id, value] of edits) { byId(id).value = value; byId(id).dispatch('change'); }
|
||||
for (const id of ['metadata-auto', 'metadata-cache', 'video-muted', 'video-loop', 'paths', 'card-details']) {
|
||||
byId(id).checked = false; byId(id).dispatch('change');
|
||||
}
|
||||
for (const id of ['video-auto', 'viewer-details']) { byId(id).checked = true; byId(id).dispatch('change'); }
|
||||
assert.deepEqual(drafts.changes('browser'), { workspaceLayout: 'masonry', workspaceDensity: 'large', imageQuality: 'low',
|
||||
autoLoadMetadata: false, cacheMetadata: false, videoMute: false, videoLoop: false, showFilePaths: false, showImageInfo: false,
|
||||
videoAutoplay: true, sidebarCollapsedByDefault: true });
|
||||
assert.deepEqual(drafts.changes('host'), { webui_display_mode: 'popup' });
|
||||
} finally { scope.dispose(); dom.restore(); }
|
||||
});
|
||||
|
||||
test('the auto-hide timeout takes whole seconds from 0 to 300 only', async () => {
|
||||
const { resultTimeout } = await appearanceModule;
|
||||
assert.deepEqual(['0', '30', ' 300 ', '301', '-1', '2.5', '', 'abc', '1e2'].map(resultTimeout), [0, 30, 300, null, null, null, null, null, null]);
|
||||
const { dom, drafts, scope, byId, loadHost } = await mounted();
|
||||
try {
|
||||
await loadHost();
|
||||
const timeout = byId('timeout');
|
||||
timeout.value = '45'; timeout.dispatch('change');
|
||||
assert.deepEqual(drafts.changes('host'), { result_timeout: 45 });
|
||||
timeout.value = '301'; timeout.dispatch('change');
|
||||
assert.deepEqual(drafts.changes('host'), {});
|
||||
assert.deepEqual([...drafts.failed('host')], ['result_timeout']);
|
||||
timeout.value = '0'; timeout.dispatch('change');
|
||||
assert.deepEqual(drafts.changes('host'), { result_timeout: 0 });
|
||||
assert.deepEqual([...drafts.failed('host')], []);
|
||||
} finally { scope.dispose(); dom.restore(); }
|
||||
});
|
||||
|
||||
test('reset restores defaults without touching other keys', async () => {
|
||||
const { DISPLAY_KEYS } = await appearanceModule;
|
||||
const changed = { ...BROWSER, workspaceLayout: 'list', videoMute: false, showImageInfo: false, sidebarCollapsedByDefault: true };
|
||||
const { dom, drafts, scope, surface, loadHost } = await mounted({ browser: changed });
|
||||
try {
|
||||
await loadHost();
|
||||
drafts.set('host', 'result_timeout', 9);
|
||||
const reset = dom.find(surface.children.at(-1), node => node.tagName === 'BUTTON');
|
||||
assert.equal(reset.textContent, 'Reset');
|
||||
reset.click();
|
||||
assert.deepEqual(drafts.changes('browser'), { workspaceLayout: 'grid', videoMute: true, showImageInfo: true, sidebarCollapsedByDefault: false });
|
||||
// The host fields and every key outside the display defaults are left as they are.
|
||||
assert.deepEqual(drafts.changes('host'), { result_timeout: 9 });
|
||||
assert.deepEqual([...DISPLAY_KEYS].sort(), Object.keys(BROWSER).sort());
|
||||
} finally { scope.dispose(); dom.restore(); }
|
||||
});
|
||||
|
||||
test('host rows that could not be read stay disabled and say so; a later read clears it', async () => {
|
||||
const { HOST_UNAVAILABLE } = await appearanceModule;
|
||||
const { dom, drafts, scope, byId, surface, fixHost } = await mounted({ hostFails: true });
|
||||
const notes = () => surface.children.slice(2).flatMap(row => row.children[0].children.slice(2).map(note => [row.children[0].children[0].textContent, note.className, note.textContent]));
|
||||
try {
|
||||
assert.deepEqual(notes(), []);
|
||||
assert.equal(await drafts.reloadHost(), false);
|
||||
assert.deepEqual(notes(), [['Open from ComfyUI', 'small', HOST_UNAVAILABLE], ['Node search auto-hide (seconds)', 'small', HOST_UNAVAILABLE]]);
|
||||
assert.equal(byId('timeout').disabled, true);
|
||||
// Repaints keep one line per row.
|
||||
drafts.set('browser', 'videoMute', false);
|
||||
assert.equal(notes().length, 2);
|
||||
fixHost();
|
||||
assert.equal(await drafts.reloadHost(), true);
|
||||
assert.deepEqual(notes(), []);
|
||||
} finally { scope.dispose(); assert.equal(dom.listenerCount(), 0); dom.restore(); }
|
||||
});
|
||||
@@ -259,60 +259,3 @@ test('a failed read keeps the store usable and drafts survive a reload (Q10)', a
|
||||
assert.equal(failing.dirty(), false);
|
||||
assert.throws(() => createSystemDrafts({ ...domains, writeHost: null }), TypeError);
|
||||
});
|
||||
|
||||
test('a rejected value drops its draft and is highlighted; a conflict elsewhere keeps the drafts', async () => {
|
||||
const domains = adapters({ browser: { videoMute: true, videoLoop: true }, host: { result_timeout: 5 } });
|
||||
const drafts = await loaded(domains);
|
||||
assert.deepEqual(drafts.saved('browser'), { videoMute: true, videoLoop: true });
|
||||
drafts.set('host', 'result_timeout', 12);
|
||||
assert.equal(drafts.dirty('host'), true);
|
||||
drafts.reject('host', 'result_timeout');
|
||||
assert.equal(drafts.dirty('host'), false);
|
||||
assert.deepEqual([...drafts.failed('host')], ['result_timeout']);
|
||||
assert.equal(drafts.get('host', 'result_timeout'), 5);
|
||||
// A valid value clears the highlight.
|
||||
drafts.set('host', 'result_timeout', 7);
|
||||
assert.deepEqual([...drafts.failed('host')], []);
|
||||
drafts.discard();
|
||||
drafts.set('browser', 'videoMute', false);
|
||||
drafts.conflict('browser', []);
|
||||
assert.equal(drafts.statusText(), 'Unsaved changes.');
|
||||
drafts.conflict('browser', ['videoMute']);
|
||||
assert.equal(drafts.statusText(), 'Settings changed elsewhere. Reload this section to see them.');
|
||||
assert.deepEqual([...drafts.failed('browser')], ['videoMute']);
|
||||
assert.equal(drafts.get('browser', 'videoMute'), false);
|
||||
assert.throws(() => drafts.reject('nowhere', 'x'), TypeError);
|
||||
});
|
||||
|
||||
test('a rejected value says why in the status until the next edit', async () => {
|
||||
const drafts = await loaded(adapters({ host: { result_timeout: 5 } }));
|
||||
drafts.reject('host', 'result_timeout');
|
||||
assert.equal(drafts.statusText(), 'Some changes were not saved. Review the highlighted settings.');
|
||||
drafts.set('host', 'result_timeout', 9);
|
||||
assert.equal(drafts.statusText(), 'Unsaved changes.');
|
||||
});
|
||||
|
||||
test('a browser write refused as changed elsewhere keeps the draft and reads the browser values again', async () => {
|
||||
const domains = adapters({ browser: { workspaceDensity: 'comfortable' } });
|
||||
const drafts = await loaded(domains);
|
||||
drafts.set('browser', 'workspaceDensity', 'compact');
|
||||
domains.outcome.browser = () => { throw Object.assign(new Error('changed'), { code: 'settings-changed' }); };
|
||||
assert.deepEqual(await drafts.save(), { browser: 'conflict', host: 'clean', runtime: 'clean' });
|
||||
assert.deepEqual(domains.calls.map(([name]) => name), ['writeBrowser', 'readBrowser']);
|
||||
assert.equal(drafts.get('browser', 'workspaceDensity'), 'compact');
|
||||
assert.deepEqual([...drafts.failed('browser')], ['workspaceDensity']);
|
||||
assert.equal(drafts.statusText(), 'Settings changed elsewhere. Reload this section to see them.');
|
||||
});
|
||||
|
||||
test('host read state says whether the server settings could be read', async () => {
|
||||
const { createSystemDrafts } = await source;
|
||||
const domains = adapters({ host: { result_timeout: 5 } });
|
||||
let fail = true;
|
||||
const drafts = createSystemDrafts({ ...domains, readHost: async () => { if (fail) throw new Error('offline'); return { values: {}, revision: 'r' }; } });
|
||||
assert.equal(drafts.hostState(), 'pending');
|
||||
assert.equal(await drafts.reloadHost(), false);
|
||||
assert.equal(drafts.hostState(), 'failed');
|
||||
fail = false;
|
||||
assert.equal(await drafts.reloadHost(), true);
|
||||
assert.equal(drafts.hostState(), 'loaded');
|
||||
});
|
||||
|
||||
@@ -29,7 +29,6 @@ VECTORS_PATH = (
|
||||
# Committed record sets stay small; the large bound cases are built at test time.
|
||||
MAX_VECTOR_CHARS = 20_000
|
||||
COMMITTED_FUZZ = (0, 60)
|
||||
COMMITTED_PASSTHROUGH = (0, 40)
|
||||
|
||||
SPACES = ["", " ", "\t", "\n", "\x1c", "\x85", " ", "", "
"]
|
||||
WORDS = ["fox", "snow", "café", "猫", "\U0001f600", "a,", "detailed fur", ""]
|
||||
@@ -1381,7 +1380,6 @@ def committed_vectors():
|
||||
}
|
||||
cases.update(fuzz_cases(*COMMITTED_FUZZ))
|
||||
cases.update(selection.selection_fuzz_cases(*COMMITTED_FUZZ))
|
||||
cases.update(selection.passthrough_fuzz_cases(*COMMITTED_PASSTHROUGH))
|
||||
vectors = [
|
||||
{
|
||||
"name": name,
|
||||
|
||||
@@ -12,10 +12,8 @@ import json
|
||||
import random
|
||||
|
||||
try:
|
||||
from tests import passthrough_graphs as shapes
|
||||
from tests.local_resolver_cases import EXECUTION_KEY, SPACES, WORDS, _Builder
|
||||
except ImportError: # pragma: no cover - discover -s tests puts tests/ first
|
||||
import passthrough_graphs as shapes
|
||||
from local_resolver_cases import EXECUTION_KEY, SPACES, WORDS, _Builder
|
||||
|
||||
|
||||
@@ -439,213 +437,10 @@ def _parameters():
|
||||
return cases
|
||||
|
||||
|
||||
def _passthrough():
|
||||
"""Chains joined through lineage nodes, and the floor for added conditioning."""
|
||||
cases = {
|
||||
"hires-fix": shapes.hires_fix(),
|
||||
"hires-fix-differs": shapes.hires_fix(second_positive=shapes.OTHER),
|
||||
"hires-fix-unlisted": shapes.hires_fix(filter_class="MysteryImageEffect"),
|
||||
"hires-fix-generated": shapes.generated_text_chain(),
|
||||
"flux-three-stage": shapes.flux_three_stage(),
|
||||
"flux-concat": shapes.flux_three_stage(composite="ConditioningConcat"),
|
||||
"flux-average": shapes.flux_three_stage(composite="ConditioningAverage"),
|
||||
"edit-batch": shapes.edit_batch(3),
|
||||
}
|
||||
graph = shapes.flux_three_stage(combined=(("37", 0), ("31", 0)))
|
||||
graph["37"] = shapes.encode(shapes.OTHER)
|
||||
cases["flux-without-base"] = graph
|
||||
graph = shapes.flux_three_stage()
|
||||
graph["38"] = shapes.encode("watermark")
|
||||
graph["60"]["inputs"]["negative"] = ["38", 0]
|
||||
cases["flux-other-negative"] = graph
|
||||
graph = shapes.hires_fix()
|
||||
graph["20"]["inputs"]["latent_image"] = ["10", 0]
|
||||
graph["10"]["inputs"]["latent_image"] = ["20", 0]
|
||||
cases["hires-fix-cycle"] = graph
|
||||
graph = shapes.hires_fix()
|
||||
graph["17"]["inputs"]["pixels"] = ["18", 0]
|
||||
graph["18"] = {"class_type": "Reroute", "inputs": {"value": ["16", 0]}}
|
||||
cases["hires-fix-reroute"] = graph
|
||||
graph = shapes.hires_fix()
|
||||
graph["24"] = {"class_type": "SaveImage", "inputs": {"images": ["11", 0]}}
|
||||
cases["hires-fix-both-saved"] = graph
|
||||
graph = shapes.hires_fix()
|
||||
graph["20"]["inputs"]["latent_image"] = ["99", 0]
|
||||
cases["hires-fix-missing-latent"] = graph
|
||||
graph = shapes.flux_three_stage()
|
||||
graph["38"] = {"class_type": "MysteryNegative", "inputs": {"seed": 1}}
|
||||
graph["60"]["inputs"]["negative"] = ["38", 0]
|
||||
cases["flux-negative-unsupported"] = graph
|
||||
graph = shapes.flux_three_stage()
|
||||
graph["39"] = {"class_type": "Reroute", "inputs": {"input": ["35", 0]}}
|
||||
graph["60"]["inputs"]["positive"] = ["39", 0]
|
||||
cases["flux-reroute-input"] = graph
|
||||
graph = shapes.flux_three_stage()
|
||||
graph["39"] = {
|
||||
"class_type": "CLIPTextEncodeSDXL",
|
||||
"inputs": {"text_g": shapes.POSITIVE, "text_l": shapes.STYLE},
|
||||
}
|
||||
graph["60"]["inputs"]["positive"] = ["39", 0]
|
||||
cases["flux-encoder-parts"] = graph
|
||||
cases.update(_passthrough_edges())
|
||||
cases.update(_runtime_parts())
|
||||
return cases
|
||||
|
||||
|
||||
def _runtime_parts():
|
||||
"""Option A: a PromptManager part fed by a run-time node is left out."""
|
||||
cases = {"trigger-prepend": shapes.hires_fix_with_trigger_words()}
|
||||
graph = shapes.hires_fix_with_trigger_words()
|
||||
graph["2"]["inputs"].update(prepend_text="masterpiece", append_text=["8", 0])
|
||||
graph["20"]["inputs"]["latent_image"] = ["4", 0]
|
||||
cases["trigger-append-single"] = graph
|
||||
graph = shapes.hires_fix_with_trigger_words()
|
||||
graph["8"] = {"class_type": "PrimitiveString", "inputs": {"value": "trig9word"}}
|
||||
cases["traced-prepend"] = graph
|
||||
graph = shapes.hires_fix_with_trigger_words()
|
||||
graph["8"]["class_type"] = "MysteryWords"
|
||||
cases["unknown-prepend"] = graph
|
||||
graph = shapes.hires_fix_with_trigger_words()
|
||||
graph["9"] = {
|
||||
"class_type": "StringConcatenate",
|
||||
"inputs": {"string_a": "kept", "string_b": ["8", 0], "delimiter": ""},
|
||||
}
|
||||
graph["2"]["inputs"]["prepend_text"] = ["9", 0]
|
||||
cases["indirect-prepend"] = graph
|
||||
graph = shapes.hires_fix_with_trigger_words()
|
||||
graph["2"]["inputs"].update(prepend_text="", text=["8", 0])
|
||||
cases["runtime-main-text"] = graph
|
||||
graph = shapes.hires_fix_with_trigger_words()
|
||||
graph["2"]["class_type"] = "PromptManagerPair"
|
||||
graph["2"]["inputs"]["negative_text"] = shapes.NEGATIVE
|
||||
graph["10"]["inputs"]["negative"] = ["2", 1]
|
||||
graph["20"]["inputs"]["negative"] = ["2", 1]
|
||||
cases["trigger-pair"] = graph
|
||||
graph = shapes.hires_fix_with_trigger_words()
|
||||
graph["30"] = {"class_type": "PromptSearchList", "inputs": {"search": "x"}}
|
||||
graph["2"]["inputs"]["append_text"] = ["30", 0]
|
||||
cases["search-list-append"] = graph
|
||||
for shape in ("text-encode", "chained", "concat"):
|
||||
cases[f"through-{shape}"] = shapes.trigger_words_through(shape)
|
||||
cases["branches-with-and-without"] = (
|
||||
shapes.branches_with_and_without_trigger_words()
|
||||
)
|
||||
graph = shapes.hires_fix_with_trigger_words()
|
||||
graph["9"] = shapes.prompt_manager(shapes.POSITIVE)
|
||||
graph["20"]["inputs"]["positive"] = ["9", 0]
|
||||
cases["stage-with-and-without"] = graph
|
||||
graph = shapes.hires_fix()
|
||||
graph["9"] = {
|
||||
"class_type": "JoinStringMulti",
|
||||
"inputs": {
|
||||
"inputcount": 2,
|
||||
"delimiter": " ",
|
||||
"return_list": ["98", 0],
|
||||
"string_1": "static words",
|
||||
"string_2": "more",
|
||||
},
|
||||
}
|
||||
graph["98"] = {"class_type": "PrimitiveBoolean", "inputs": {"value": False}}
|
||||
graph["2"]["inputs"]["prepend_text"] = ["9", 0]
|
||||
cases["join-multi-flag"] = graph
|
||||
graph = shapes.flux_three_stage()
|
||||
graph["31"] = shapes.encode(shapes.OTHER)
|
||||
cases["flux-other-full-prompt"] = graph
|
||||
graph = shapes.flux_three_stage()
|
||||
graph["7"] = {
|
||||
"class_type": "Lora Loader (LoraManager)",
|
||||
"inputs": {"model": ["1", 0], "clip": ["2", 0], "text": "x"},
|
||||
}
|
||||
graph["8"] = shapes.trigger_toggle()
|
||||
graph["30"]["inputs"]["prepend_text"] = ["8", 0]
|
||||
cases["flux-trigger"] = graph
|
||||
graph = shapes.flux_three_stage()
|
||||
graph["60"] = {
|
||||
"class_type": "SamplerCustomAdvanced",
|
||||
"inputs": {"guider": ["63", 0], "latent_image": ["66", 0]},
|
||||
}
|
||||
graph["63"] = {
|
||||
"class_type": "CFGGuider",
|
||||
"inputs": {"positive": ["35", 0], "negative": ["33", 0]},
|
||||
}
|
||||
graph["66"] = {"class_type": "VAEEncode", "inputs": {"pixels": ["51", 0]}}
|
||||
graph["67"] = {"class_type": "VAEDecode", "inputs": {"samples": ["60", 0]}}
|
||||
graph["61"]["inputs"]["images"] = ["67", 0]
|
||||
cases["flux-custom-advanced-chained"] = graph
|
||||
return {f"runtime-{name}": graph for name, graph in cases.items()}
|
||||
|
||||
|
||||
def _passthrough_edges():
|
||||
"""Lineage and floor edges: branching refiners, other slots, cycles, guiders."""
|
||||
cases = {}
|
||||
graph = shapes.hires_fix()
|
||||
graph["30"] = shapes.ksampler(["2", 0], ["3", 0], ["17", 0], denoise=0.4)
|
||||
graph["31"] = {"class_type": "SaveImage", "inputs": {"images": ["30", 0]}}
|
||||
cases["hires-fix-two-refiners"] = graph
|
||||
graph = shapes.hires_fix()
|
||||
graph["10"]["class_type"] = "SamplerCustom"
|
||||
graph["11"]["inputs"]["samples"] = ["10", 1]
|
||||
cases["hires-fix-denoised-slot"] = graph
|
||||
graph = shapes.hires_fix()
|
||||
graph["30"] = shapes.ksampler(["2", 0], ["3", 0], ["4", 0])
|
||||
graph["31"] = {"class_type": "VAEDecode", "inputs": {"samples": ["30", 0]}}
|
||||
graph["32"] = {"class_type": "VAEEncode", "inputs": {"pixels": ["31", 0]}}
|
||||
graph["30"]["inputs"]["latent_image"] = ["32", 0]
|
||||
cases["hires-fix-self-latent"] = graph
|
||||
graph = shapes.hires_fix()
|
||||
graph["30"] = shapes.ksampler(["2", 0], ["3", 0], ["32", 0])
|
||||
graph["31"] = {"class_type": "VAEDecode", "inputs": {"samples": ["32", 0]}}
|
||||
graph["32"] = {"class_type": "VAEEncode", "inputs": {"pixels": ["31", 0]}}
|
||||
graph["33"] = {"class_type": "SaveImage", "inputs": {"images": ["30", 0]}}
|
||||
cases["hires-fix-lineage-cycle"] = graph
|
||||
graph = shapes.flux_three_stage()
|
||||
graph["39"] = shapes.encode(shapes.OTHER)
|
||||
graph["40"]["inputs"]["positive"] = ["39", 0]
|
||||
cases["flux-base-stages-differ"] = graph
|
||||
graph = shapes.flux_three_stage()
|
||||
del graph["60"]["inputs"]["negative"]
|
||||
cases["flux-no-last-negative"] = graph
|
||||
graph = shapes.flux_three_stage()
|
||||
graph["39"] = {"class_type": "MysteryPrompt", "inputs": {"seed": 1}}
|
||||
graph["40"]["inputs"]["positive"] = ["39", 0]
|
||||
graph["50"]["inputs"]["positive"] = ["39", 0]
|
||||
cases["flux-unreadable-base"] = graph
|
||||
graph = shapes.flux_three_stage()
|
||||
graph["39"] = {
|
||||
"class_type": "MysteryUpscaler",
|
||||
"inputs": {"image": ["51", 0], "model": ["1", 0], "positive": ["34", 0]},
|
||||
}
|
||||
graph["52"]["inputs"]["images"] = ["39", 0]
|
||||
cases["flux-unsupported-ancestry"] = graph
|
||||
graph = shapes.flux_three_stage()
|
||||
graph["60"] = {
|
||||
"class_type": "SamplerCustomAdvanced",
|
||||
"inputs": {
|
||||
"noise": ["62", 0],
|
||||
"guider": ["63", 0],
|
||||
"sampler": ["64", 0],
|
||||
"sigmas": ["65", 0],
|
||||
"latent_image": ["66", 0],
|
||||
},
|
||||
}
|
||||
graph["62"] = {"class_type": "RandomNoise", "inputs": {"noise_seed": 1}}
|
||||
graph["63"] = {
|
||||
"class_type": "CFGGuider",
|
||||
"inputs": {"model": ["1", 0], "positive": ["35", 0], "negative": ["33", 0]},
|
||||
}
|
||||
graph["64"] = {"class_type": "KSamplerSelect", "inputs": {"sampler_name": "euler"}}
|
||||
graph["65"] = {"class_type": "BasicScheduler", "inputs": {"steps": 4}}
|
||||
graph["66"] = {"class_type": "VAEEncode", "inputs": {"pixels": ["52", 0]}}
|
||||
graph["67"] = {"class_type": "VAEDecode", "inputs": {"samples": ["60", 0]}}
|
||||
graph["61"]["inputs"]["images"] = ["67", 0]
|
||||
cases["flux-custom-advanced-last"] = graph
|
||||
return {f"edge-{name}": graph for name, graph in cases.items()}
|
||||
|
||||
|
||||
def selection_cases():
|
||||
"""Named record sets for every Task 9 path, each a ``read_inspection_file`` dict."""
|
||||
cases = {}
|
||||
for group in (_samplers(), _previews(), _detailers(), _passthrough()):
|
||||
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())
|
||||
@@ -777,101 +572,3 @@ def selection_fuzz_cases(seed, count):
|
||||
records.update(builder.records)
|
||||
cases[f"select-fuzz-{seed}-{index}"] = records
|
||||
return cases
|
||||
|
||||
|
||||
_LINEAGE_HOPS = (
|
||||
("VAEDecode", "samples"),
|
||||
("VAEDecodeTiled", "samples"),
|
||||
("Image Filter", "images"),
|
||||
("ImageUpscaleWithModel", "image"),
|
||||
("ImageScale", "image"),
|
||||
("ImageScaleBy", "image"),
|
||||
("VAEEncode", "pixels"),
|
||||
("LatentUpscaleBy", "samples"),
|
||||
("Reroute", "value"),
|
||||
("MysteryImageEffect", "image"),
|
||||
)
|
||||
|
||||
|
||||
def passthrough_fuzz_cases(seed, count):
|
||||
"""Seeded sampler chains joined by random lineage (and unlisted) hops.
|
||||
|
||||
Each later stage reads the earlier one's output through zero to four hops,
|
||||
with a shared, a different, an added-to (combine, concat, average) or a
|
||||
run-time positive, and a shared or different negative, under a saver or a
|
||||
preview. The server is the oracle; nothing states the expected answer.
|
||||
"""
|
||||
rng = random.Random(seed)
|
||||
cases = {}
|
||||
for index in range(count):
|
||||
graph = {
|
||||
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "m"}},
|
||||
"2": shapes.prompt_manager(rng.choice([shapes.POSITIVE, "fox"])),
|
||||
"3": shapes.encode(shapes.NEGATIVE),
|
||||
"4": {"class_type": "EmptyLatentBatch", "inputs": {"batch_size": 2}},
|
||||
"5": shapes.encode(shapes.STYLE),
|
||||
"6": shapes.encode(shapes.OTHER),
|
||||
}
|
||||
previous, next_id = ["4", 0], 10
|
||||
for stage in range(rng.randint(2, 3)):
|
||||
positive = ["2", 0]
|
||||
choice = rng.randrange(8)
|
||||
if stage and choice == 1:
|
||||
positive = ["6", 0]
|
||||
elif stage and choice in (2, 3, 4):
|
||||
name = (
|
||||
"ConditioningCombine",
|
||||
"ConditioningConcat",
|
||||
"ConditioningAverage",
|
||||
)[choice - 2]
|
||||
names = shapes._COMPOSITE_INPUTS[name]
|
||||
parts = [["2", 0], rng.choice([["5", 0], ["6", 0]])]
|
||||
rng.shuffle(parts)
|
||||
graph[str(next_id)] = {
|
||||
"class_type": name,
|
||||
"inputs": dict(zip(names, parts)),
|
||||
}
|
||||
positive, next_id = [str(next_id), 0], next_id + 1
|
||||
elif stage and choice == 5:
|
||||
graph[str(next_id)] = {
|
||||
"class_type": "FluxGuidance",
|
||||
"inputs": {"guidance": 3.5, "conditioning": ["2", 0]},
|
||||
}
|
||||
positive, next_id = [str(next_id), 0], next_id + 1
|
||||
negative = ["6", 0] if stage and rng.random() < 0.15 else ["3", 0]
|
||||
latent = previous
|
||||
if stage:
|
||||
image = previous
|
||||
for _ in range(rng.randrange(5)):
|
||||
name, key = rng.choice(_LINEAGE_HOPS)
|
||||
graph[str(next_id)] = {"class_type": name, "inputs": {key: image}}
|
||||
image, next_id = [str(next_id), 0], next_id + 1
|
||||
latent = image
|
||||
sampler = shapes.ksampler(positive, negative, latent)
|
||||
if stage and rng.random() < 0.2:
|
||||
sampler = {
|
||||
"class_type": "UltimateSDUpscale",
|
||||
"inputs": {
|
||||
"image": latent,
|
||||
"model": ["1", 0],
|
||||
"positive": positive,
|
||||
"negative": negative,
|
||||
},
|
||||
}
|
||||
graph[str(next_id)] = sampler
|
||||
previous, next_id = [str(next_id), 0], next_id + 1
|
||||
if rng.random() < 0.3:
|
||||
saver = rng.choice(["SaveImage", "PreviewImage"])
|
||||
graph[str(next_id)] = {
|
||||
"class_type": saver,
|
||||
"inputs": {"images": previous},
|
||||
}
|
||||
next_id += 1
|
||||
graph[str(next_id)] = {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {"images": previous},
|
||||
}
|
||||
cases[f"select-passthrough-fuzz-{seed}-{index}"] = {
|
||||
"prompt": json.dumps(graph, ensure_ascii=False)
|
||||
}
|
||||
return cases
|
||||
|
||||
@@ -1,332 +0,0 @@
|
||||
"""Synthetic multi-stage graphs for the pass-through lineage rule.
|
||||
|
||||
Each builder reproduces one graph shape with neutral texts and generic names:
|
||||
a hires-fix or refiner chain (sampler, decode, image filter, model upscale,
|
||||
scale, encode, sampler), a three-stage Flux run whose last stage combines the
|
||||
base prompt with a style trigger, an image-edit batch of samplers with distinct
|
||||
prompts, and a chain whose prompt is generated at run time. They are shared by
|
||||
the resolver tests and the browser-port parity cases; no user file is involved.
|
||||
"""
|
||||
|
||||
import copy
|
||||
|
||||
POSITIVE = "a lighthouse on a cliff, golden hour"
|
||||
NEGATIVE = "blurry, lowres"
|
||||
OTHER = "a red bicycle in the rain"
|
||||
STYLE = "film grain, analog photo"
|
||||
|
||||
|
||||
def encode(text):
|
||||
return {"class_type": "CLIPTextEncode", "inputs": {"text": text, "clip": ["1", 1]}}
|
||||
|
||||
|
||||
def prompt_manager(text, prepend=""):
|
||||
return {
|
||||
"class_type": "PromptManager",
|
||||
"inputs": {
|
||||
"text": text,
|
||||
"category": "",
|
||||
"tags": "",
|
||||
"search_text": "",
|
||||
"prepend_text": prepend,
|
||||
"append_text": "",
|
||||
"clip": ["1", 1],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def ksampler(positive, negative, latent, denoise=1.0):
|
||||
return {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"seed": 7,
|
||||
"steps": 20,
|
||||
"cfg": 7.0,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "normal",
|
||||
"denoise": denoise,
|
||||
"model": ["1", 0],
|
||||
"positive": positive,
|
||||
"negative": negative,
|
||||
"latent_image": latent,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def hires_fix(second_positive=None, filter_class="Image Filter"):
|
||||
"""Two KSamplers joined in image space, like a hires-fix or refiner run.
|
||||
|
||||
Sampler 10 runs on a custom batch latent; its output goes through a tiled
|
||||
decode, an interactive image filter, a model upscale and a scale (sized by a
|
||||
resolution node reading the filtered image), then is encoded again for
|
||||
sampler 20 (denoise 0.25), which is decoded and saved.
|
||||
"""
|
||||
graph = {
|
||||
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "m"}},
|
||||
"2": prompt_manager(POSITIVE),
|
||||
"3": encode(NEGATIVE),
|
||||
"4": {
|
||||
"class_type": "EmptyLatentBatch",
|
||||
"inputs": {"width": 1024, "height": 512, "batch_size": 4},
|
||||
},
|
||||
"10": ksampler(["2", 0], ["3", 0], ["4", 0]),
|
||||
"11": {
|
||||
"class_type": "VAEDecodeTiled",
|
||||
"inputs": {"tile_size": 512, "samples": ["10", 0], "vae": ["1", 2]},
|
||||
},
|
||||
"12": {
|
||||
"class_type": filter_class,
|
||||
"inputs": {"timeout": 600, "ontimeout": "send none", "images": ["11", 0]},
|
||||
},
|
||||
"13": {"class_type": "UpscaleModelLoader", "inputs": {"model_name": "x4"}},
|
||||
"14": {
|
||||
"class_type": "ImageUpscaleWithModel",
|
||||
"inputs": {"upscale_model": ["13", 0], "image": ["12", 0]},
|
||||
},
|
||||
"15": {
|
||||
"class_type": "ResolutionCalculator",
|
||||
"inputs": {"scale_factor": 1.5, "image": ["12", 0]},
|
||||
},
|
||||
"16": {
|
||||
"class_type": "ImageScale",
|
||||
"inputs": {
|
||||
"upscale_method": "nearest-exact",
|
||||
"width": ["15", 0],
|
||||
"height": ["15", 1],
|
||||
"crop": "disabled",
|
||||
"image": ["14", 0],
|
||||
},
|
||||
},
|
||||
"17": {
|
||||
"class_type": "VAEEncode",
|
||||
"inputs": {"pixels": ["16", 0], "vae": ["1", 2]},
|
||||
},
|
||||
"20": ksampler(["2", 0], ["3", 0], ["17", 0], denoise=0.25),
|
||||
"21": {
|
||||
"class_type": "VAEDecodeTiled",
|
||||
"inputs": {"tile_size": 512, "samples": ["20", 0], "vae": ["1", 2]},
|
||||
},
|
||||
"22": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {"filename_prefix": "hires", "images": ["21", 0]},
|
||||
},
|
||||
"23": {"class_type": "PlaySound|pysssss", "inputs": {"any": ["21", 0]}},
|
||||
}
|
||||
if second_positive is not None:
|
||||
graph["5"] = encode(second_positive)
|
||||
graph["20"]["inputs"]["positive"] = ["5", 0]
|
||||
return graph
|
||||
|
||||
|
||||
_COMPOSITE_INPUTS = {
|
||||
"ConditioningCombine": ("conditioning_1", "conditioning_2"),
|
||||
"ConditioningConcat": ("conditioning_to", "conditioning_from"),
|
||||
"ConditioningAverage": ("conditioning_to", "conditioning_from"),
|
||||
}
|
||||
|
||||
|
||||
def flux_three_stage(combined=(("30", 0), ("31", 0)), composite="ConditioningCombine"):
|
||||
"""FluxGuidance base, a refiner through an image filter, then a tiled upscale.
|
||||
|
||||
Samplers 40 and 50 share FluxGuidance(PromptManager 30) and a zeroed empty
|
||||
negative. Sampler 50 refines 40's filtered image (denoise 0.25). The final
|
||||
UltimateSDUpscale 60 reads 50's image, scaled down, and a positive that is
|
||||
``composite`` of ``combined`` (by default the base PromptManager and a
|
||||
style-trigger encoder 31).
|
||||
"""
|
||||
names = _COMPOSITE_INPUTS[composite]
|
||||
graph = {
|
||||
"1": {"class_type": "UNETLoader", "inputs": {"unet_name": "flux"}},
|
||||
"2": {"class_type": "DualCLIPLoader", "inputs": {"type": "flux"}},
|
||||
"3": {"class_type": "VAELoader", "inputs": {"vae_name": "ae"}},
|
||||
"30": prompt_manager(POSITIVE),
|
||||
"31": encode(STYLE),
|
||||
"32": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": "", "clip": ["2", 0]},
|
||||
},
|
||||
"33": {
|
||||
"class_type": "ConditioningZeroOut",
|
||||
"inputs": {"conditioning": ["32", 0]},
|
||||
},
|
||||
"34": {
|
||||
"class_type": "FluxGuidance",
|
||||
"inputs": {"guidance": 3.5, "conditioning": ["30", 0]},
|
||||
},
|
||||
"35": {
|
||||
"class_type": composite,
|
||||
"inputs": dict(zip(names, map(list, combined))),
|
||||
},
|
||||
"36": {
|
||||
"class_type": "EmptyLatentBatch",
|
||||
"inputs": {"width": 1080, "height": 1920, "batch_size": 4},
|
||||
},
|
||||
"40": ksampler(["34", 0], ["33", 0], ["36", 0]),
|
||||
"41": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {"samples": ["40", 0], "vae": ["3", 0]},
|
||||
},
|
||||
"42": {
|
||||
"class_type": "Image Filter",
|
||||
"inputs": {"timeout": 600, "images": ["41", 0]},
|
||||
},
|
||||
"43": {"class_type": "PreviewImage", "inputs": {"images": ["42", 0]}},
|
||||
"44": {
|
||||
"class_type": "VAEEncode",
|
||||
"inputs": {"pixels": ["42", 0], "vae": ["3", 0]},
|
||||
},
|
||||
"50": ksampler(["34", 0], ["33", 0], ["44", 0], denoise=0.25),
|
||||
"51": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {"samples": ["50", 0], "vae": ["3", 0]},
|
||||
},
|
||||
"52": {
|
||||
"class_type": "ImageScaleDownBy",
|
||||
"inputs": {"scale_by": 0.75, "images": ["51", 0]},
|
||||
},
|
||||
"53": {"class_type": "PreviewImage", "inputs": {"images": ["51", 0]}},
|
||||
"54": {
|
||||
"class_type": "Image Comparer (rgthree)",
|
||||
"inputs": {"image_a": ["51", 0], "image_b": ["42", 0]},
|
||||
},
|
||||
"60": {
|
||||
"class_type": "UltimateSDUpscale",
|
||||
"inputs": {
|
||||
"upscale_by": 2.0,
|
||||
"seed": 7,
|
||||
"steps": 4,
|
||||
"denoise": 0.35,
|
||||
"image": ["52", 0],
|
||||
"model": ["1", 0],
|
||||
"positive": ["35", 0],
|
||||
"negative": ["33", 0],
|
||||
"vae": ["3", 0],
|
||||
},
|
||||
},
|
||||
"61": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {"filename_prefix": "flux", "images": ["60", 0]},
|
||||
},
|
||||
}
|
||||
return graph
|
||||
|
||||
|
||||
def edit_batch(count=3):
|
||||
"""Image-edit samplers with distinct prompts, all saved with one prefix."""
|
||||
graph = {
|
||||
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "m"}},
|
||||
"2": {"class_type": "LoadImage", "inputs": {"image": "ref.png"}},
|
||||
"3": {
|
||||
"class_type": "VAEEncode",
|
||||
"inputs": {"pixels": ["2", 0], "vae": ["1", 2]},
|
||||
},
|
||||
"4": {"class_type": "PrimitiveString", "inputs": {"value": "edit"}},
|
||||
}
|
||||
for index in range(count):
|
||||
base = 100 + index * 10
|
||||
graph[str(base)] = {
|
||||
"class_type": "TextEncodeQwenImageEdit",
|
||||
"inputs": {
|
||||
"prompt": f"edit number {index}: change the pose",
|
||||
"clip": ["1", 1],
|
||||
},
|
||||
}
|
||||
graph[str(base + 1)] = ksampler([str(base), 0], [str(base), 0], ["3", 0])
|
||||
graph[str(base + 2)] = {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {"samples": [str(base + 1), 0], "vae": ["1", 2]},
|
||||
}
|
||||
graph[str(base + 3)] = {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {"filename_prefix": ["4", 0], "images": [str(base + 2), 0]},
|
||||
}
|
||||
return graph
|
||||
|
||||
|
||||
def trigger_toggle(words="trig9word"):
|
||||
"""LoraManager's TriggerWord Toggle: its output is decided at run time."""
|
||||
return {
|
||||
"class_type": "TriggerWord Toggle (LoraManager)",
|
||||
"inputs": {
|
||||
"group_mode": True,
|
||||
"default_active": True,
|
||||
"toggle_trigger_words": {"__value__": [{"text": words, "active": True}]},
|
||||
"orinalMessage": words,
|
||||
"trigger_words": ["7", 2],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def hires_fix_with_trigger_words():
|
||||
"""``hires_fix`` whose PromptManager prepends LoRA trigger words (node 8)."""
|
||||
graph = hires_fix()
|
||||
graph["7"] = {
|
||||
"class_type": "Lora Loader (LoraManager)",
|
||||
"inputs": {"model": ["1", 0], "clip": ["1", 1], "text": "<lora:x:1>"},
|
||||
}
|
||||
graph["8"] = trigger_toggle()
|
||||
graph["2"]["inputs"]["prepend_text"] = ["8", 0]
|
||||
return graph
|
||||
|
||||
|
||||
def prompt_manager_text(text, prepend=""):
|
||||
"""PromptManagerText: a STRING PromptManager (prepend/main/append)."""
|
||||
return {
|
||||
"class_type": "PromptManagerText",
|
||||
"inputs": {
|
||||
"text": text,
|
||||
"category": "",
|
||||
"tags": "",
|
||||
"search_text": "",
|
||||
"prepend_text": prepend,
|
||||
"append_text": "",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def trigger_words_through(shape):
|
||||
"""``hires_fix_with_trigger_words`` with nodes between the toggle and sampler.
|
||||
|
||||
``text-encode``: PromptManagerText (prepend <- toggle) -> CLIPTextEncode.
|
||||
``chained``: a PromptManager whose ``text`` reads that PromptManagerText.
|
||||
``concat``: PromptManagerText -> StringConcatenate(..., "extra") -> encoder.
|
||||
"""
|
||||
graph = hires_fix_with_trigger_words()
|
||||
graph["9"] = prompt_manager_text(POSITIVE, ["8", 0])
|
||||
if shape == "chained":
|
||||
graph["2"]["inputs"].update(prepend_text="", text=["9", 0])
|
||||
elif shape == "text-encode":
|
||||
graph["2"] = {"class_type": "CLIPTextEncode", "inputs": {"text": ["9", 0]}}
|
||||
else:
|
||||
graph["19"] = {
|
||||
"class_type": "StringConcatenate",
|
||||
"inputs": {"string_a": ["9", 0], "string_b": "extra", "delimiter": ", "},
|
||||
}
|
||||
graph["2"] = {"class_type": "CLIPTextEncode", "inputs": {"text": ["19", 0]}}
|
||||
return graph
|
||||
|
||||
|
||||
def branches_with_and_without_trigger_words():
|
||||
"""Two saved branches: one PromptManager prepends trigger words, one does not."""
|
||||
graph = hires_fix_with_trigger_words()
|
||||
graph["9"] = prompt_manager(POSITIVE)
|
||||
graph["20"]["inputs"].update(positive=["9", 0], latent_image=["4", 0])
|
||||
return graph
|
||||
|
||||
|
||||
def generated_text_chain():
|
||||
"""``hires_fix`` whose positive text comes from a run-time text generator."""
|
||||
graph = hires_fix()
|
||||
graph["2"] = {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": ["6", 0], "clip": ["1", 1]},
|
||||
}
|
||||
graph["6"] = {
|
||||
"class_type": "TextGenerate",
|
||||
"inputs": {"prompt": "write a prompt", "model": ["1", 0]},
|
||||
}
|
||||
return graph
|
||||
|
||||
|
||||
def copied(graph):
|
||||
return copy.deepcopy(graph)
|
||||
@@ -68,55 +68,6 @@ class CardSeed(unittest.TestCase):
|
||||
self.assertIn(expected, (111, 222))
|
||||
self.assertEqual(info_seed(info), str(expected))
|
||||
|
||||
def test_a_chain_the_inspector_names_one_stage_of_shows_no_differing_seed(self):
|
||||
# The inspector shows the named final stage's seed (D19, pass-through
|
||||
# chains); the parser shows another stage's. Never two seeds: none.
|
||||
extra = {"2": {"class_type": "EmptyLatentImage", "inputs": {}}}
|
||||
extra["20"] = {"class_type": "VAEDecode", "inputs": {"samples": ["10", 0]}}
|
||||
extra["21"] = {
|
||||
"class_type": "FaceDetailer",
|
||||
"inputs": {
|
||||
"seed": 333,
|
||||
"image": ["20", 0],
|
||||
"model": ["4", 0],
|
||||
"positive": ["6", 0],
|
||||
"negative": ["7", 0],
|
||||
},
|
||||
}
|
||||
info = graph(seeded(111), extra=extra)
|
||||
self.assertEqual(extract_from_records(info["prompt"], None)["seed"], 111)
|
||||
self.assertIsNone(info_seed(info))
|
||||
# The same chain on one seed keeps it; so does a refiner over a decode.
|
||||
extra["21"]["inputs"]["seed"] = 111
|
||||
self.assertEqual(info_seed(graph(seeded(111), extra=extra)), "111")
|
||||
refiner = seeded(222, latent_image=["22", 0])
|
||||
extra["22"] = {"class_type": "VAEEncode", "inputs": {"pixels": ["20", 0]}}
|
||||
del extra["21"]
|
||||
self.assertIsNone(info_seed(graph(seeded(111), refiner, extra=extra)))
|
||||
refiner["inputs"]["seed"] = 111
|
||||
self.assertEqual(info_seed(graph(seeded(111), refiner, extra=extra)), "111")
|
||||
|
||||
def test_a_seed_that_agrees_with_the_named_stage_is_kept(self):
|
||||
# Review I3: a saved sampler (seed 7) beside a preview-only sampler
|
||||
# (seed 5): the parser and the inspector both show 7, as on main.
|
||||
extra = {"2": {"class_type": "EmptyLatentImage", "inputs": {}}}
|
||||
extra["20"] = {"class_type": "VAEDecode", "inputs": {"samples": ["10", 0]}}
|
||||
extra["21"] = {"class_type": "SaveImage", "inputs": {"images": ["20", 0]}}
|
||||
extra["30"] = {"class_type": "VAEDecode", "inputs": {"samples": ["11", 0]}}
|
||||
extra["31"] = {"class_type": "PreviewImage", "inputs": {"images": ["30", 0]}}
|
||||
info = graph(seeded(7), seeded(5), extra=extra)
|
||||
self.assertEqual(info_seed(info), "7")
|
||||
|
||||
def test_a_named_ambiguous_chain_keeps_the_parser_seed(self):
|
||||
# Review M2: multistage-differs names a sampler, but its facts merge,
|
||||
# and the inspector fills the parser's seed: the card keeps it.
|
||||
extra = {"2": {"class_type": "EmptyLatentImage", "inputs": {}}}
|
||||
extra["8"] = encoder("another scene")
|
||||
extra["20"] = {"class_type": "VAEDecode", "inputs": {"samples": ["10", 0]}}
|
||||
extra["22"] = {"class_type": "VAEEncode", "inputs": {"pixels": ["20", 0]}}
|
||||
refiner = ksampler(("8", 0), ("7", 0), seed=222, latent_image=["22", 0])
|
||||
self.assertEqual(info_seed(graph(seeded(111), refiner, extra=extra)), "111")
|
||||
|
||||
def test_card_seed_linked_input_follows_shared_parser(self):
|
||||
primitive = {"class_type": "PrimitiveNode", "inputs": {"value": 555}}
|
||||
info = graph(seeded(["20", 0]), extra={"20": primitive})
|
||||
@@ -256,7 +207,6 @@ class CardSeed(unittest.TestCase):
|
||||
self.assertIsNone(parsed_positive(unparsable))
|
||||
|
||||
def test_every_shared_case_reads_the_projected_graphs(self):
|
||||
withheld = []
|
||||
for name, case in CASES.items():
|
||||
with self.subTest(case=name):
|
||||
info = {k: v for k, v in case.items() if v is not None}
|
||||
@@ -275,18 +225,11 @@ class CardSeed(unittest.TestCase):
|
||||
except GenerationParamsError:
|
||||
self.assertIsNone(params)
|
||||
continue
|
||||
if name == "shared-prompt-pipeline":
|
||||
# The inspector names the detailer (seed 333); the parser
|
||||
# reads the base sampler (111): the card shows none.
|
||||
self.assertEqual(expected["seed"], 111)
|
||||
expected = {**expected, "seed": None}
|
||||
withheld.append(name)
|
||||
self.assertEqual(params, expected)
|
||||
# Prompt text is the same as from a plain parse of the records.
|
||||
if len(values) == len(info):
|
||||
raw = extract_from_records(case["prompt"], case["workflow"])
|
||||
self.assertEqual(params["positivePrompt"], raw["positivePrompt"])
|
||||
self.assertEqual(withheld, ["shared-prompt-pipeline"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -24,9 +24,6 @@ from database.operations import PromptDatabase
|
||||
from tests.open_handles import assert_closed
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
# A bound on thread hand-offs that only a hung test reaches (never a pacing timer).
|
||||
THREAD_WAIT_SECONDS = 120
|
||||
|
||||
|
||||
def _count_prompts(path):
|
||||
"""Row count read through a brand-new connection (closed before return)."""
|
||||
@@ -353,13 +350,7 @@ class TestRestoreFromFile(BackupTestCase):
|
||||
def worker():
|
||||
self.db.search_prompts(text="old") # opens this thread's connection
|
||||
ready.set()
|
||||
# Wait for the restore itself, not for a timer: a stalled Windows
|
||||
# runner once took longer than 5 s to restore, and the worker then
|
||||
# read the old database before the restore had run.
|
||||
if not proceed.wait(THREAD_WAIT_SECONDS):
|
||||
results["error"] = "the restore did not finish"
|
||||
self.db.close()
|
||||
return
|
||||
proceed.wait(5)
|
||||
try:
|
||||
results["texts"] = [p["text"] for p in self.db.search_prompts()]
|
||||
except Exception as exc: # noqa: BLE001 - reported through results
|
||||
@@ -369,15 +360,12 @@ class TestRestoreFromFile(BackupTestCase):
|
||||
|
||||
thread = threading.Thread(target=worker)
|
||||
thread.start()
|
||||
self.assertTrue(ready.wait(THREAD_WAIT_SECONDS))
|
||||
ready.wait(5)
|
||||
|
||||
try:
|
||||
self.db.model.restore_from_file(src)
|
||||
finally:
|
||||
proceed.set()
|
||||
thread.join(THREAD_WAIT_SECONDS)
|
||||
self.db.model.restore_from_file(src)
|
||||
proceed.set()
|
||||
thread.join(5)
|
||||
|
||||
self.assertFalse(thread.is_alive())
|
||||
self.assertNotIn("error", results)
|
||||
self.assertEqual(results["texts"], ["restored"])
|
||||
self.assertEqual([p["text"] for p in self.db.search_prompts()], ["restored"])
|
||||
|
||||
@@ -800,15 +800,10 @@ class TestUpstreamVerifiedNodeSemantics(unittest.TestCase):
|
||||
prompt["31"] = dict(prompt["3"], inputs=dict(prompt["3"]["inputs"]))
|
||||
prompt["31"]["inputs"]["latent_image"] = ["3", 0]
|
||||
prompt["8"]["inputs"]["samples"] = ["31", 0]
|
||||
# 31 continues 3's latent: the stages are compared, as for KSampler.
|
||||
for anchor in (None, "9"):
|
||||
result = _inspect({"prompt": prompt}, output_node_id=anchor)
|
||||
self.assertEqual(
|
||||
(result["state"], result["sampler_id"]), ("resolved", "31")
|
||||
)
|
||||
prompt["31"]["inputs"]["positive"] = ["7", 0]
|
||||
# Unanchored, two listed samplers are two branches, as for KSampler.
|
||||
self.assertEqual(_inspect({"prompt": prompt})["state"], "ambiguous")
|
||||
result = _inspect({"prompt": prompt}, output_node_id="9")
|
||||
self.assertEqual(result["reason_code"], "multistage-differs")
|
||||
self.assertEqual(result["reason"], resolver.MULTISTAGE_UNATTRIBUTED_REASON)
|
||||
|
||||
def test_seed_variance_enhancer_passes_its_conditioning(self):
|
||||
prompt = _f02(
|
||||
|
||||
@@ -55,9 +55,6 @@ def _minimal():
|
||||
"runtime": [],
|
||||
"savers": {},
|
||||
"non_file_outputs": [],
|
||||
"lineage": [
|
||||
{"class": "VAEEncode", "slot": 0, "strategy": "continue", "input": "pixels"}
|
||||
],
|
||||
"stage_inputs": {"shaped": ["positive"], "with_model": ["samples"]},
|
||||
"facts": {
|
||||
"configuration_outputs": {},
|
||||
@@ -202,26 +199,16 @@ class SpecEntriesTest(unittest.TestCase):
|
||||
},
|
||||
)
|
||||
|
||||
# Combine and concat keep every part in effect (additive); an average
|
||||
# blends its parts, so a part may be weighted away.
|
||||
self.assertEqual(
|
||||
table.composites["ConditioningCombine"],
|
||||
{
|
||||
"strategy": "combine",
|
||||
"inputs": ("conditioning_1", "conditioning_2"),
|
||||
"additive": True,
|
||||
},
|
||||
{"strategy": "combine", "inputs": ("conditioning_1", "conditioning_2")},
|
||||
)
|
||||
for name, additive in (
|
||||
("ConditioningConcat", {"additive": True}),
|
||||
("ConditioningAverage", {}),
|
||||
):
|
||||
for name in ("ConditioningConcat", "ConditioningAverage"):
|
||||
self.assertEqual(
|
||||
table.composites[name],
|
||||
{
|
||||
"strategy": "combine",
|
||||
"inputs": ("conditioning_to", "conditioning_from"),
|
||||
**additive,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -270,12 +257,7 @@ class SpecEntriesTest(unittest.TestCase):
|
||||
)
|
||||
|
||||
# D18: decided at run time.
|
||||
# LoraManager's TriggerWord Toggle outputs the LoRA's trigger words as
|
||||
# filtered at run time (option A leaves them out of a PromptManager).
|
||||
self.assertEqual(
|
||||
table.runtime,
|
||||
frozenset({"PromptSearchList", "TriggerWord Toggle (LoraManager)"}),
|
||||
)
|
||||
self.assertEqual(table.runtime, frozenset({"PromptSearchList"}))
|
||||
|
||||
comfy = "{name}_{counter:05}_.{ext}"
|
||||
for name, image in (
|
||||
@@ -482,89 +464,6 @@ class SpecEntriesTest(unittest.TestCase):
|
||||
self.assertIsInstance(table.composites["ConditioningCombine"]["inputs"], tuple)
|
||||
|
||||
|
||||
class LineageTableTest(_TableCase):
|
||||
"""``lineage``: outputs whose image or latent continues one input's."""
|
||||
|
||||
def test_repository_lineage_lists_the_pass_through_nodes(self):
|
||||
table = load_semantics()
|
||||
self.assertEqual(
|
||||
{key: dict(entry) for key, entry in table.lineage.items()},
|
||||
{
|
||||
("VAEDecode", 0): {"strategy": "continue", "input": "samples"},
|
||||
("VAEDecodeTiled", 0): {"strategy": "continue", "input": "samples"},
|
||||
("VAEEncode", 0): {"strategy": "continue", "input": "pixels"},
|
||||
("VAEEncodeTiled", 0): {"strategy": "continue", "input": "pixels"},
|
||||
("ImageUpscaleWithModel", 0): {
|
||||
"strategy": "continue",
|
||||
"input": "image",
|
||||
},
|
||||
("ImageScale", 0): {"strategy": "continue", "input": "image"},
|
||||
("ImageScaleBy", 0): {"strategy": "continue", "input": "image"},
|
||||
("ImageScaleToTotalPixels", 0): {
|
||||
"strategy": "continue",
|
||||
"input": "image",
|
||||
},
|
||||
("LatentUpscale", 0): {"strategy": "continue", "input": "samples"},
|
||||
("LatentUpscaleBy", 0): {"strategy": "continue", "input": "samples"},
|
||||
("Image Filter", 0): {"strategy": "continue", "input": "images"},
|
||||
("Image Filter", 1): {"strategy": "continue", "input": "latents"},
|
||||
},
|
||||
)
|
||||
# Lineage describes no prompt: its classes are not prompt categories.
|
||||
self.assertFalse({name for name, _ in table.lineage} & table.all_classes)
|
||||
|
||||
def test_lineage_rows_are_validated(self):
|
||||
def row(**changes):
|
||||
entry = {
|
||||
"class": "VAEEncode",
|
||||
"slot": 0,
|
||||
"strategy": "continue",
|
||||
"input": "pixels",
|
||||
}
|
||||
entry.update(changes)
|
||||
return entry
|
||||
|
||||
cases = (
|
||||
({"lineage": {}}, "must be a list"),
|
||||
({"lineage": ["VAEEncode"]}, "must be an object"),
|
||||
({"lineage": [row(strategy="passthrough")]}, "unknown strategy"),
|
||||
({"lineage": [row(slot=-1)]}, "slot"),
|
||||
({"lineage": [row(slot=True)]}, "slot"),
|
||||
({"lineage": [row(input="")]}, "input name"),
|
||||
({"lineage": [row(note="x")]}, "field"),
|
||||
({"lineage": [row(), row()]}, "duplicate"),
|
||||
({"lineage": [row(**{"class": ""})]}, "class"),
|
||||
)
|
||||
for index, (change, fragment) in enumerate(cases):
|
||||
with self.subTest(case=index):
|
||||
doc = _minimal()
|
||||
doc.update(change)
|
||||
self.assertRejected(doc, fragment)
|
||||
doc = _minimal()
|
||||
doc["lineage"].append(row(slot=1, input="latents"))
|
||||
self.assertEqual(len(self.load_doc(doc).lineage), 2)
|
||||
|
||||
def test_a_lineage_class_may_also_carry_conditioning(self):
|
||||
# InpaintModelConditioning-like: conditioning slots, a latent slot.
|
||||
doc = _minimal()
|
||||
doc["lineage"].append(
|
||||
{"class": "FluxGuidance", "slot": 1, "strategy": "continue", "input": "x"}
|
||||
)
|
||||
self.assertIn(("FluxGuidance", 1), self.load_doc(doc).lineage)
|
||||
|
||||
def test_composite_additive_must_be_a_boolean(self):
|
||||
doc = _minimal()
|
||||
doc["composites"]["ConditioningCombine"] = {
|
||||
"strategy": "combine",
|
||||
"inputs": ["conditioning_1", "conditioning_2"],
|
||||
"additive": "yes",
|
||||
}
|
||||
self.assertRejected(doc, "additive must be a boolean")
|
||||
doc["composites"]["ConditioningCombine"]["additive"] = True
|
||||
table = self.load_doc(doc)
|
||||
self.assertIs(table.composites["ConditioningCombine"]["additive"], True)
|
||||
|
||||
|
||||
class ValidationTest(_TableCase):
|
||||
def test_minimal_table_loads(self):
|
||||
table = self.load_doc(_minimal())
|
||||
@@ -602,7 +501,6 @@ class ValidationTest(_TableCase):
|
||||
{"literal", "strip-newlines", "join-multi", "concat"}
|
||||
),
|
||||
"savers": frozenset({"comfy-counter", "vhs-counter"}),
|
||||
"lineage": frozenset({"continue"}),
|
||||
},
|
||||
)
|
||||
with self.assertRaises(TypeError):
|
||||
|
||||
@@ -52,7 +52,7 @@ from utils.prompt_inspector import (
|
||||
)
|
||||
|
||||
from utils.prompt_inspector import inspect_prompt_evidence, unreadable_evidence
|
||||
from utils.prompt_pair_resolver import ADDED_CONDITIONING_REASON, INSPECTION_PROFILE
|
||||
from utils.prompt_pair_resolver import INSPECTION_PROFILE
|
||||
|
||||
try:
|
||||
from tests import inspection_parity as parity
|
||||
@@ -1293,33 +1293,6 @@ class LocalResolverParityTests(unittest.TestCase):
|
||||
self.assertEqual(
|
||||
answers["select-preview-dropped"]["resolution"]["sampler_id"], "3"
|
||||
)
|
||||
# Pass-through lineage: the chain shapes reach every new outcome.
|
||||
self.assertEqual(
|
||||
{
|
||||
name: answers[f"select-{name}"]["resolution"]["reason"]
|
||||
for name in ("hires-fix", "flux-three-stage", "hires-fix-differs")
|
||||
},
|
||||
{
|
||||
"hires-fix": "",
|
||||
"flux-three-stage": ADDED_CONDITIONING_REASON,
|
||||
"hires-fix-differs": "Sampler stages have different prompts",
|
||||
},
|
||||
)
|
||||
|
||||
def test_local_roles_match_fuzzed_pass_through_chains(self):
|
||||
cases = {}
|
||||
for seed in range(4):
|
||||
cases.update(selection_cases.passthrough_fuzz_cases(seed, 120))
|
||||
answers = self._compare(cases)
|
||||
reasons = [answer["resolution"]["reason"] for answer in answers.values()]
|
||||
for reason in (
|
||||
"",
|
||||
"Sampler stages have different prompts",
|
||||
"A later stage adds conditioning to the base prompt",
|
||||
"Combined conditioning has different texts",
|
||||
"Multiple sampler branches",
|
||||
):
|
||||
self.assertGreater(reasons.count(reason), 5, reason)
|
||||
|
||||
def test_local_roles_match_fuzzed_selection_sets(self):
|
||||
cases = {}
|
||||
|
||||
@@ -1,360 +0,0 @@
|
||||
"""ComfyUI's ``NaN``/``Infinity`` metadata ingests, and is stored as strict JSON.
|
||||
|
||||
ComfyUI serializes the prompt with Python's ``json.dumps``, so a node whose
|
||||
``IS_CHANGED`` returns ``float("nan")`` (the "always re-run" idiom) writes
|
||||
``"is_changed": [NaN]`` into every saved PNG; widgets can hold ``Infinity``.
|
||||
The legacy pages replaced ``NaN`` with ``null`` before ``JSON.parse``, so such
|
||||
files must ingest. Whatever is stored or answered as JSON stays strict (the
|
||||
non-finite numbers become ``null``) and the prompt text and hash never change.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from PIL import Image, PngImagePlugin
|
||||
|
||||
from database.operations import PromptDatabase
|
||||
from tests.history_waits import drain_writer
|
||||
from tests.test_api_admin import AdminAPITestCase
|
||||
from tests.test_api_images import ImageAPITestCase
|
||||
from utils.hashing import generate_prompt_hash
|
||||
from utils.image_monitor import ImageGenerationHandler
|
||||
|
||||
try:
|
||||
from tests.open_handles import assert_closed
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from open_handles import assert_closed
|
||||
|
||||
# The words "NaN" and "Infinity" inside the prompt are text, never numbers.
|
||||
POSITIVE = " A cat named NaN beside an Infinity pool, ratio: NaN, [NaN] "
|
||||
NEGATIVE = "blurry"
|
||||
NAN, INF = float("nan"), float("inf")
|
||||
|
||||
|
||||
def graph(positive=POSITIVE, negative=NEGATIVE):
|
||||
"""An API prompt graph with non-finite numbers where ComfyUI writes them."""
|
||||
g = {
|
||||
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": positive}},
|
||||
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": negative}},
|
||||
"3": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {"positive": ["1", 0], "negative": ["2", 0], "model": ["4", 0]},
|
||||
},
|
||||
"4": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "m"}},
|
||||
"5": {
|
||||
"class_type": "Image Filter",
|
||||
"inputs": {"strength": INF, "floor": -INF},
|
||||
"_meta": {"title": "Image Filter"},
|
||||
"is_changed": [NAN],
|
||||
},
|
||||
}
|
||||
return g
|
||||
|
||||
|
||||
WORKFLOW = {
|
||||
"nodes": [
|
||||
{"id": 5, "type": "Image Filter", "widgets_values": [NAN, INF, -INF]},
|
||||
],
|
||||
"links": [],
|
||||
}
|
||||
|
||||
|
||||
def comfy_text(value):
|
||||
"""The record text ComfyUI writes: Python's default, non-strict JSON."""
|
||||
text = json.dumps(value)
|
||||
assert "NaN" in text and "Infinity" in text
|
||||
return text
|
||||
|
||||
|
||||
def write_png(path, prompt_graph=None, workflow=WORKFLOW):
|
||||
info = PngImagePlugin.PngInfo()
|
||||
info.add_text("prompt", comfy_text(prompt_graph or graph()))
|
||||
if workflow is not None:
|
||||
info.add_text("workflow", comfy_text(workflow))
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with Image.new("RGB", (4, 4)) as image:
|
||||
image.save(path, pnginfo=info)
|
||||
return path
|
||||
|
||||
|
||||
def _reject(name):
|
||||
raise ValueError(f"non-strict JSON constant {name}")
|
||||
|
||||
|
||||
def strict_loads(text):
|
||||
return json.loads(text, parse_constant=_reject)
|
||||
|
||||
|
||||
class StrictRows:
|
||||
"""Assertions over the rows a write left behind."""
|
||||
|
||||
def image_rows(self, db):
|
||||
with db.model.get_connection() as conn:
|
||||
return [
|
||||
dict(row)
|
||||
for row in conn.execute(
|
||||
"SELECT prompt_id, workflow_data, prompt_metadata, parameters "
|
||||
"FROM generated_images ORDER BY id"
|
||||
)
|
||||
]
|
||||
|
||||
def assert_strict_snapshot(self, row, *, workflow=True):
|
||||
prompt = strict_loads(row["prompt_metadata"])
|
||||
self.assertEqual(prompt["5"]["is_changed"], [None])
|
||||
self.assertEqual(prompt["5"]["inputs"], {"strength": None, "floor": None})
|
||||
self.assertEqual(prompt["1"]["inputs"]["text"], POSITIVE)
|
||||
stored_workflow = strict_loads(row["workflow_data"])
|
||||
if workflow:
|
||||
self.assertEqual(
|
||||
stored_workflow["nodes"][0]["widgets_values"], [None, None, None]
|
||||
)
|
||||
if row["parameters"] is not None:
|
||||
strict_loads(row["parameters"])
|
||||
|
||||
def assert_prompt_text(self, db, prompt_id):
|
||||
with db.model.get_connection() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT text, hash FROM prompts WHERE id=?", (prompt_id,)
|
||||
).fetchone()
|
||||
self.assertEqual(row["text"], POSITIVE.strip())
|
||||
self.assertEqual(row["hash"], generate_prompt_hash(POSITIVE))
|
||||
|
||||
|
||||
def dumps_strict(value, **kwargs):
|
||||
from utils.json_strict import dumps_strict as dumps
|
||||
|
||||
return dumps(value, **kwargs)
|
||||
|
||||
|
||||
class TestDumpsStrict(unittest.TestCase):
|
||||
def test_finite_values_serialize_byte_identically(self):
|
||||
value = {"b": [1, 2.5, "NaN", None, True], "a": {"x": "Infinity"}}
|
||||
for kwargs in ({}, {"sort_keys": True, "separators": (",", ":")}):
|
||||
with self.subTest(kwargs=kwargs):
|
||||
self.assertEqual(
|
||||
dumps_strict(value, **kwargs),
|
||||
json.dumps(value, allow_nan=False, **kwargs),
|
||||
)
|
||||
|
||||
def test_non_finite_numbers_become_null_and_strings_stay(self):
|
||||
value = {"n": NAN, "i": [INF, -INF], "s": "NaN Infinity", "t": (NAN, 1)}
|
||||
encoded = dumps_strict(value, sort_keys=True)
|
||||
self.assertEqual(
|
||||
strict_loads(encoded),
|
||||
{"n": None, "i": [None, None], "s": "NaN Infinity", "t": [None, 1]},
|
||||
)
|
||||
|
||||
def test_other_errors_still_raise(self):
|
||||
cycle = []
|
||||
cycle.append(cycle)
|
||||
for value in (cycle, [NAN, cycle]):
|
||||
with self.assertRaisesRegex(ValueError, "Circular"):
|
||||
dumps_strict(value)
|
||||
with self.assertRaises(TypeError):
|
||||
dumps_strict({"x": object()})
|
||||
self.assertEqual(
|
||||
dumps_strict({"x": object()}, default=lambda _: NAN), '{"x": null}'
|
||||
)
|
||||
|
||||
|
||||
class TestScanIngestsNonFiniteMetadata(StrictRows, AdminAPITestCase):
|
||||
def ingest(self, name, prompt_graph=None):
|
||||
path = write_png(self.output_dir / name, prompt_graph)
|
||||
extracted = self.api._extract_comfyui_metadata(str(path))
|
||||
return self.api._ingest_scan_batch_sync([(path, extracted)])
|
||||
|
||||
def test_resolved_pair_with_nan_is_added_with_strict_snapshots(self):
|
||||
counts = self.ingest("nan.png")
|
||||
self.assertEqual(counts["failed"], 0)
|
||||
self.assertEqual(counts["added"], 1)
|
||||
rows = self.image_rows(self.api.db)
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assert_strict_snapshot(rows[0])
|
||||
self.assert_prompt_text(self.api.db, rows[0]["prompt_id"])
|
||||
pairs = self.api.db.list_prompt_pairs()["pairs"]
|
||||
self.assertEqual(
|
||||
[(p["positive_text"], p["negative_text"]) for p in pairs],
|
||||
[(POSITIVE.strip(), NEGATIVE)],
|
||||
)
|
||||
# A repeated scan compares the same strict snapshot: unchanged.
|
||||
self.assertEqual(self.ingest("nan.png")["unchanged"], 1)
|
||||
|
||||
async def test_scan_route_reports_no_failures(self):
|
||||
from py.config import GalleryConfig
|
||||
|
||||
write_png(self.output_dir / "nan.png")
|
||||
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.output_dir)]
|
||||
resp = await self.client.request("POST", "/prompt_manager/scan")
|
||||
body = await resp.text()
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertIn('"type": "complete"', body)
|
||||
self.assertIn('"added": 1', body)
|
||||
self.assertIn('"failed": 0', body)
|
||||
|
||||
|
||||
class TestMonitorIngestsNonFiniteMetadata(StrictRows, unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.db = PromptDatabase(str(Path(self.temp.name) / "db.sqlite"))
|
||||
self.pid = self.db.save_prompt(
|
||||
POSITIVE.strip(), prompt_hash=generate_prompt_hash(POSITIVE)
|
||||
)
|
||||
self.tracker = SimpleNamespace(
|
||||
pop_next_prompt=lambda: None,
|
||||
get_current_prompt=lambda: None,
|
||||
extend_prompt_timeout=lambda *args: None,
|
||||
)
|
||||
self.handler = ImageGenerationHandler(self.db, self.tracker)
|
||||
self.handler.logger = logging.getLogger("test.nonfinite.monitor")
|
||||
self.handler.logger.disabled = True
|
||||
|
||||
def tearDown(self):
|
||||
self.db.close_all()
|
||||
assert_closed(self, self.temp.name)
|
||||
self.temp.cleanup()
|
||||
|
||||
def test_new_image_with_nan_links_its_pair(self):
|
||||
# The monitor links only images a PromptManager node produced.
|
||||
g = graph()
|
||||
g["1"]["class_type"] = "PromptManager"
|
||||
path = write_png(Path(self.temp.name) / "out" / "nan.png", g)
|
||||
self.handler.process_new_image(str(path))
|
||||
rows = self.image_rows(self.db)
|
||||
self.assertEqual([row["prompt_id"] for row in rows], [self.pid])
|
||||
self.assert_strict_snapshot(rows[0])
|
||||
self.assert_prompt_text(self.db, self.pid)
|
||||
pairs = self.db.list_prompt_pairs()["pairs"]
|
||||
self.assertEqual(len(pairs), 1)
|
||||
self.assertEqual(pairs[0]["image_count"], 1)
|
||||
|
||||
def test_legacy_link_stores_strict_json(self):
|
||||
# The plain prompt link (no exact pair) must not write NaN either.
|
||||
path = str(Path(self.temp.name) / "plain.png")
|
||||
metadata = {"prompt": graph(), "workflow": WORKFLOW, "parameters": {"x": NAN}}
|
||||
self.assertTrue(self.db.link_image_to_prompt(self.pid, path, metadata))
|
||||
row = self.image_rows(self.db)[0]
|
||||
self.assert_strict_snapshot(row)
|
||||
self.assertEqual(strict_loads(row["parameters"]), {"x": None})
|
||||
|
||||
def test_stored_images_read_back_without_non_finite_numbers(self):
|
||||
path = str(Path(self.temp.name) / "legacy.png")
|
||||
self.db.link_image_to_prompt(self.pid, path, {"prompt": graph()})
|
||||
with self.db.model.get_connection() as conn:
|
||||
# A row an older version wrote with Python's default json.dumps.
|
||||
conn.execute(
|
||||
"UPDATE generated_images SET prompt_metadata=?", (comfy_text(graph()),)
|
||||
)
|
||||
conn.commit()
|
||||
images = self.db.get_prompt_images(str(self.pid))
|
||||
strict_loads(json.dumps(images, default=str))
|
||||
|
||||
|
||||
class TestInspectorAnswersStrictJSON(ImageAPITestCase):
|
||||
async def test_inspect_route_resolves_and_answers_strict_json(self):
|
||||
write_png(self.output_dir / "nan.png")
|
||||
response = await self.client.get(
|
||||
"/prompt_manager/images/inspect/nan.png", params={"root": "0"}
|
||||
)
|
||||
self.assertEqual(response.status, 200)
|
||||
result = strict_loads(await response.text())
|
||||
self.assertEqual(result["resolution"]["state"], "resolved")
|
||||
self.assertEqual(
|
||||
result["prompts"]["positive"],
|
||||
{"status": "resolved", "text": POSITIVE, "source": "graph-inferred"},
|
||||
)
|
||||
|
||||
async def test_prompt_images_route_answers_strict_json(self):
|
||||
pid = self.api.db.save_prompt(
|
||||
POSITIVE.strip(), prompt_hash=generate_prompt_hash(POSITIVE)
|
||||
)
|
||||
path = str(write_png(self.output_dir / "linked.png"))
|
||||
self.api.db.link_image_to_prompt(pid, path, {"prompt": graph()})
|
||||
with self.api.db.model.get_connection() as conn:
|
||||
conn.execute(
|
||||
"UPDATE generated_images SET prompt_metadata=?", (comfy_text(graph()),)
|
||||
)
|
||||
conn.commit()
|
||||
response = await self.client.get(f"/prompt_manager/prompts/{pid}/images")
|
||||
self.assertEqual(response.status, 200)
|
||||
result = strict_loads(await response.text())
|
||||
self.assertEqual(len(result["images"]), 1)
|
||||
stored = result["images"][0]["prompt_metadata"]
|
||||
self.assertEqual(stored["1"]["inputs"]["text"], POSITIVE)
|
||||
self.assertEqual(stored["5"]["is_changed"], [None])
|
||||
|
||||
|
||||
class TestHistoryCapturesNonFiniteGraphs(unittest.TestCase):
|
||||
"""The queued graph ComfyUI hands the hook may carry float('nan')."""
|
||||
|
||||
JOB = "a0c26da3-eb26-405c-b143-c662a84c3112"
|
||||
|
||||
def setUp(self):
|
||||
self.directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.directory.cleanup)
|
||||
self.db = PromptDatabase(os.path.join(self.directory.name, "history.db"))
|
||||
self.addCleanup(self.db.model.reset_schema_cache)
|
||||
self.addCleanup(self.db.model.close_all)
|
||||
|
||||
def test_queued_graph_with_nan_records_its_run_and_contexts(self):
|
||||
from tests.test_execution_history_hooks import HostQueue, status
|
||||
from utils.execution_context_capture import record_runtime_context
|
||||
from utils.execution_history import register_history_hook
|
||||
|
||||
queue = HostQueue()
|
||||
registration = register_history_hook(
|
||||
SimpleNamespace(prompt_queue=queue), lambda: self.db
|
||||
)
|
||||
self.addCleanup(lambda: registration.dispose(3))
|
||||
self.addCleanup(registration.writer.invalidate)
|
||||
deadline = time.monotonic() + 3
|
||||
while not registration.writer.status().accepting:
|
||||
self.assertLess(time.monotonic(), deadline, "writer never accepted")
|
||||
time.sleep(0.005)
|
||||
g = {
|
||||
"p": {
|
||||
"class_type": "PromptManagerPair",
|
||||
"inputs": {"text": POSITIVE, "negative_text": NEGATIVE},
|
||||
},
|
||||
"s": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {"positive": ["p", 0], "negative": ["p", 1], "cfg": INF},
|
||||
"is_changed": [NAN],
|
||||
},
|
||||
"5": graph()["5"],
|
||||
}
|
||||
extra = {"extra_pnginfo": {"workflow": WORKFLOW}}
|
||||
queue.results.append(((0, self.JOB, g, extra, ["s"]), 1))
|
||||
queue.get()
|
||||
host = types.ModuleType("comfy_execution.utils")
|
||||
host.get_executing_context = lambda: SimpleNamespace(
|
||||
prompt_id=self.JOB, node_id="p", list_index=0
|
||||
)
|
||||
with patch.dict(sys.modules, {"comfy_execution.utils": host}):
|
||||
for role, text in (("positive", POSITIVE), ("negative", NEGATIVE)):
|
||||
record_runtime_context(
|
||||
"PromptManagerPair", text, "", "", text, role=role
|
||||
)
|
||||
queue.task_done(1, {}, status("execution_success", job=self.JOB))
|
||||
drain_writer(registration.writer, clock=time.monotonic)
|
||||
with self.db.model.get_connection() as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
runs = [dict(r) for r in conn.execute("SELECT * FROM execution_runs")]
|
||||
self.assertEqual([run["outcome"] for run in runs], ["succeeded"])
|
||||
contexts = self.db.get_execution_contexts(runs[0]["run_id"])
|
||||
self.assertEqual(contexts["state"], "complete")
|
||||
self.assertEqual(contexts["contexts"][0]["positive"]["raw"], POSITIVE)
|
||||
strict_loads(json.dumps(contexts))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1245,7 +1245,7 @@ class TestGuiderSamplers(unittest.TestCase):
|
||||
source["3"]["inputs"]["guider"] = [previous, 0]
|
||||
self.assertEqual(_inspect(source)["reason_code"], "depth-limit")
|
||||
|
||||
def test_custom_advanced_upstream_of_a_sampler_is_a_compared_stage(self):
|
||||
def test_custom_advanced_upstream_of_a_sampler_is_multistage(self):
|
||||
source = _guided()
|
||||
source["41"] = {
|
||||
"class_type": "KSampler",
|
||||
@@ -1256,18 +1256,15 @@ class TestGuiderSamplers(unittest.TestCase):
|
||||
},
|
||||
}
|
||||
source["8"]["inputs"]["samples"] = ["41", 0]
|
||||
# 41 continues 3's latent and reads the same texts as 3's guider.
|
||||
for anchor in ("9", None):
|
||||
result = _inspect(source, output_node_id=anchor)
|
||||
self.assertEqual(
|
||||
(result["state"], result["sampler_id"]), ("resolved", "41")
|
||||
)
|
||||
source["41"]["inputs"]["negative"] = ["6", 0]
|
||||
result = _inspect(source, output_node_id="9")
|
||||
self.assertEqual(result["state"], "ambiguous")
|
||||
self.assertEqual(result["reason_code"], "multistage-differs")
|
||||
self.assertEqual(result["sampler_id"], "41")
|
||||
self.assertEqual(result["state"], "unresolved")
|
||||
self.assertEqual(result["reason"], "Multistage sampler ancestry is ambiguous")
|
||||
unanchored = _inspect(source)
|
||||
self.assertEqual(unanchored["state"], "ambiguous")
|
||||
self.assertEqual(
|
||||
sorted(item["sampler_id"] for item in result["candidates"]), ["3", "41"]
|
||||
sorted(item["sampler_id"] for item in unanchored["candidates"]),
|
||||
["3", "41"],
|
||||
)
|
||||
|
||||
|
||||
@@ -2635,19 +2632,17 @@ class TestLatentAncestry(unittest.TestCase):
|
||||
self.assertEqual(_sides(result), (parity.POS, parity.NEG))
|
||||
self.assertEqual(_inspect(source, output_node_id="9"), result)
|
||||
|
||||
def test_upstream_sampler_through_vaedecode_vaeencode_is_compared(self):
|
||||
# The latent continues the first stage's through lineage nodes: the
|
||||
# stages are compared (spec 9.3), equal prompts resolve, others differ.
|
||||
result = _inspect(_image_to_image(parity.POS), output_node_id="63")
|
||||
self.assertEqual((result["state"], result["sampler_id"]), ("resolved", "61"))
|
||||
self.assertEqual(_sides(result), (parity.POS, parity.NEG))
|
||||
result = _inspect(_image_to_image(OTHER), output_node_id="63")
|
||||
self.assertEqual(result["state"], "ambiguous")
|
||||
self.assertEqual(result["sampler_id"], "61")
|
||||
self.assertEqual(result["reason_code"], "multistage-differs")
|
||||
self.assertEqual(
|
||||
[item["sampler_id"] for item in result["candidates"]], ["61", "3"]
|
||||
)
|
||||
def test_upstream_sampler_through_vaedecode_vaeencode_is_multistage(self):
|
||||
for positive in (OTHER, parity.POS):
|
||||
with self.subTest(positive=positive):
|
||||
result = _inspect(_image_to_image(positive), output_node_id="63")
|
||||
# Until PR3 compares stage texts, never one stage's prompt.
|
||||
self.assertEqual(result["state"], "unresolved")
|
||||
self.assertEqual(result["sampler_id"], "61")
|
||||
self.assertEqual(result["reason"], MULTISTAGE)
|
||||
self.assertIsNone(result["reason_code"])
|
||||
self.assertIsNone(result["positive"])
|
||||
self.assertIsNone(result["negative"])
|
||||
# An upstream sampler reached through a non-latent input is a stage too.
|
||||
source = _latent_from(
|
||||
{
|
||||
@@ -2964,15 +2959,9 @@ class TestLatentAncestry(unittest.TestCase):
|
||||
result = _inspect(source, output_node_id="9")
|
||||
self.assertEqual(result["reason_code"], "unsupported-node")
|
||||
self.assertEqual(result["node"], {"id": "60", "class_type": "MysteryUpscaler"})
|
||||
# The listed stage is now an earlier sampler stage, continued through
|
||||
# VAEEncode: its prompt equals this stage's, so the chain resolves.
|
||||
# The listed stage is now an earlier sampler stage in the ancestry.
|
||||
source["60"]["class_type"] = "UltimateSDUpscale"
|
||||
result = _inspect(source, output_node_id="9")
|
||||
self.assertEqual((result["state"], result["sampler_id"]), ("resolved", "3"))
|
||||
# Reached only through an unlisted image node, it stays unattributed.
|
||||
source["61"] = {"class_type": "MysteryFilter", "inputs": {"image": ["60", 0]}}
|
||||
source["5"]["inputs"]["pixels"] = ["61", 0]
|
||||
result = _inspect(source, output_node_id="9")
|
||||
self.assertEqual(result["reason"], resolver.MULTISTAGE_UNATTRIBUTED_REASON)
|
||||
|
||||
def test_latent_walk_budget_exhaustion_is_work_limit(self):
|
||||
|
||||
@@ -1,419 +0,0 @@
|
||||
"""Multi-stage chains joined through image-space pass-through nodes.
|
||||
|
||||
A hires-fix or refiner run decodes sampler A's latent, filters, upscales and
|
||||
scales the image, then encodes it again for sampler B. The semantics table's
|
||||
``lineage`` section lists the nodes through which the latent or image carries
|
||||
on unchanged in kind. When B's latent descends from A only through those nodes
|
||||
and the stages share one pair, the chain resolves to it. A last stage that adds
|
||||
conditioning to the base prompt (ConditioningCombine of the base and a style
|
||||
trigger) keeps the base pair: the floor legacy linked. Everything else stays
|
||||
ambiguous or unresolved. Ingestion (the default profile) is unchanged (D1).
|
||||
"""
|
||||
|
||||
import json
|
||||
import unittest
|
||||
|
||||
from utils import prompt_pair_resolver as resolver
|
||||
from utils.prompt_inspector import inspect_prompt_evidence
|
||||
from utils.prompt_pair_resolver import resolve_prompt_pair
|
||||
|
||||
try:
|
||||
from tests import passthrough_graphs as shapes
|
||||
except ImportError: # discovered with ``-s tests``
|
||||
import passthrough_graphs as shapes
|
||||
|
||||
|
||||
def _inspect(graph, **options):
|
||||
return resolve_prompt_pair(
|
||||
{"prompt": graph}, profile=resolver.INSPECTION_PROFILE, **options
|
||||
)
|
||||
|
||||
|
||||
def _texts(result):
|
||||
return tuple(
|
||||
result[role]["text"] if isinstance(result[role], dict) else None
|
||||
for role in ("positive", "negative")
|
||||
)
|
||||
|
||||
|
||||
class TestHiresFixChain(unittest.TestCase):
|
||||
def test_shared_pair_resolves_through_image_space_pass_through(self):
|
||||
graph = shapes.hires_fix()
|
||||
for anchor in (None, "22"):
|
||||
with self.subTest(anchor=anchor):
|
||||
result = _inspect(graph, output_node_id=anchor)
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertEqual(result["sampler_id"], "20")
|
||||
self.assertEqual(_texts(result), (shapes.POSITIVE, shapes.NEGATIVE))
|
||||
self.assertIsNone(result["reason_code"])
|
||||
|
||||
def test_the_earlier_stage_is_judged_in_its_chain(self):
|
||||
# Sampler 10 only feeds sampler 20, so the file is 20's: one candidate.
|
||||
result = _inspect(shapes.hires_fix())
|
||||
self.assertEqual(result["candidates"], [])
|
||||
|
||||
def test_inspector_shows_the_shared_prompt(self):
|
||||
evidence = inspect_prompt_evidence({"prompt": json.dumps(shapes.hires_fix())})
|
||||
self.assertEqual(evidence["resolution"]["state"], "resolved")
|
||||
self.assertEqual(
|
||||
[evidence["prompts"][role]["text"] for role in ("positive", "negative")],
|
||||
[shapes.POSITIVE, shapes.NEGATIVE],
|
||||
)
|
||||
|
||||
def test_different_stage_prompts_are_multistage_ambiguous(self):
|
||||
result = _inspect(shapes.hires_fix(second_positive=shapes.OTHER))
|
||||
self.assertEqual(result["state"], "ambiguous")
|
||||
self.assertEqual(result["reason_code"], "multistage-differs")
|
||||
self.assertEqual(
|
||||
sorted(_texts(item)[0] for item in result["candidates"]),
|
||||
sorted([shapes.POSITIVE, shapes.OTHER]),
|
||||
)
|
||||
|
||||
def test_an_unlisted_image_node_breaks_the_lineage(self):
|
||||
# The walk still finds the earlier sampler, but not through listed
|
||||
# pass-through nodes: the stages are never compared.
|
||||
result = _inspect(shapes.hires_fix(filter_class="MysteryImageEffect"))
|
||||
self.assertEqual(result["state"], "ambiguous")
|
||||
later = next(
|
||||
item for item in result["candidates"] if item["sampler_id"] == "20"
|
||||
)
|
||||
self.assertEqual(later["reason"], resolver.MULTISTAGE_UNATTRIBUTED_REASON)
|
||||
|
||||
def test_a_second_earlier_sampler_in_the_ancestry_stays_unattributed(self):
|
||||
graph = shapes.hires_fix()
|
||||
graph["30"] = shapes.ksampler(["2", 0], ["3", 0], ["4", 0])
|
||||
graph["31"] = {"class_type": "VAEDecode", "inputs": {"samples": ["30", 0]}}
|
||||
graph["32"] = {
|
||||
"class_type": "ImageBlend",
|
||||
"inputs": {"image1": ["14", 0], "image2": ["31", 0], "blend_factor": 0.5},
|
||||
}
|
||||
graph["16"]["inputs"]["image"] = ["32", 0]
|
||||
result = _inspect(graph, output_node_id="22")
|
||||
self.assertEqual(result["state"], "unresolved")
|
||||
self.assertEqual(result["reason"], resolver.MULTISTAGE_UNATTRIBUTED_REASON)
|
||||
|
||||
def test_a_direct_latent_link_is_a_chain_too(self):
|
||||
graph = shapes.hires_fix()
|
||||
graph["20"]["inputs"]["latent_image"] = ["10", 0]
|
||||
result = _inspect(graph, output_node_id="22")
|
||||
self.assertEqual((result["state"], result["sampler_id"]), ("resolved", "20"))
|
||||
|
||||
def test_an_unreadable_earlier_stage_blocks_the_chain(self):
|
||||
graph = shapes.hires_fix(second_positive=shapes.POSITIVE)
|
||||
graph["10"]["inputs"]["positive"] = ["6", 0]
|
||||
graph["6"] = {"class_type": "MysteryPrompt", "inputs": {"seed": 1}}
|
||||
result = _inspect(graph, output_node_id="22")
|
||||
self.assertEqual(result["state"], "unresolved")
|
||||
self.assertEqual(result["reason_code"], "unsupported-node")
|
||||
self.assertEqual(result["node"]["class_type"], "MysteryPrompt")
|
||||
|
||||
def test_generated_text_stays_untraceable(self):
|
||||
result = _inspect(shapes.generated_text_chain())
|
||||
self.assertEqual(result["state"], "unresolved")
|
||||
self.assertEqual(result["reason_code"], "unsupported-node")
|
||||
self.assertEqual(result["node"]["class_type"], "TextGenerate")
|
||||
|
||||
def test_ingestion_is_unchanged(self):
|
||||
# D1: the default profile keeps its historical latent rule.
|
||||
result = resolve_prompt_pair({"prompt": shapes.hires_fix()})
|
||||
self.assertEqual(result["state"], "ambiguous")
|
||||
self.assertEqual(
|
||||
{item["reason"] for item in result["candidates"]},
|
||||
{"Unsupported latent ancestry"},
|
||||
)
|
||||
|
||||
|
||||
class TestFluxThreeStage(unittest.TestCase):
|
||||
def test_added_style_conditioning_keeps_the_base_pair(self):
|
||||
graph = shapes.flux_three_stage()
|
||||
for anchor in (None, "61"):
|
||||
with self.subTest(anchor=anchor):
|
||||
result = _inspect(graph, output_node_id=anchor)
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertEqual(_texts(result), (shapes.POSITIVE, ""))
|
||||
self.assertEqual(result["reason"], resolver.ADDED_CONDITIONING_REASON)
|
||||
self.assertEqual(result["sampler_id"], "50")
|
||||
# D126: the positive says a later stage added conditioning.
|
||||
self.assertEqual(
|
||||
result["positive"]["provenance"]["text_source"],
|
||||
resolver.ADDED_CONDITIONING_SOURCE,
|
||||
)
|
||||
self.assertEqual(
|
||||
result["negative"]["provenance"]["text_source"],
|
||||
"zeroed-conditioning",
|
||||
)
|
||||
|
||||
def test_any_added_conditioning_is_labelled_never_measured(self):
|
||||
# Ruling I2: a whole other prompt combined in is labelled the same way;
|
||||
# no length or "short trigger" heuristic.
|
||||
graph = shapes.flux_three_stage()
|
||||
graph["31"] = shapes.encode(shapes.OTHER)
|
||||
result = _inspect(graph)
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertEqual(_texts(result)[0], shapes.POSITIVE)
|
||||
self.assertEqual(
|
||||
result["positive"]["provenance"]["text_source"],
|
||||
resolver.ADDED_CONDITIONING_SOURCE,
|
||||
)
|
||||
|
||||
def test_trigger_words_and_added_conditioning_carry_both_labels(self):
|
||||
graph = shapes.flux_three_stage()
|
||||
graph["7"] = {
|
||||
"class_type": "Lora Loader (LoraManager)",
|
||||
"inputs": {"model": ["1", 0], "clip": ["2", 0], "text": "x"},
|
||||
}
|
||||
graph["8"] = shapes.trigger_toggle()
|
||||
graph["30"]["inputs"]["prepend_text"] = ["8", 0]
|
||||
result = _inspect(graph)
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertEqual(
|
||||
result["positive"]["provenance"]["text_source"],
|
||||
resolver.RUNTIME_AND_ADDED_SOURCE,
|
||||
)
|
||||
|
||||
def test_a_custom_advanced_last_stage_keeps_its_failure(self):
|
||||
# Review M2: a guider's combined positive is not floored.
|
||||
graph = shapes.flux_three_stage()
|
||||
graph["60"] = {
|
||||
"class_type": "SamplerCustomAdvanced",
|
||||
"inputs": {"guider": ["63", 0], "latent_image": ["66", 0]},
|
||||
}
|
||||
graph["63"] = {
|
||||
"class_type": "CFGGuider",
|
||||
"inputs": {"positive": ["35", 0], "negative": ["33", 0]},
|
||||
}
|
||||
graph["66"] = {"class_type": "VAEEncode", "inputs": {"pixels": ["51", 0]}}
|
||||
graph["67"] = {"class_type": "VAEDecode", "inputs": {"samples": ["60", 0]}}
|
||||
graph["61"]["inputs"]["images"] = ["67", 0]
|
||||
result = _inspect(graph, output_node_id="61")
|
||||
self.assertEqual(
|
||||
(result["state"], result["reason_code"]), ("unresolved", "composite")
|
||||
)
|
||||
|
||||
def test_concat_also_adds_to_the_base_prompt(self):
|
||||
result = _inspect(shapes.flux_three_stage(composite="ConditioningConcat"))
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertEqual(_texts(result), (shapes.POSITIVE, ""))
|
||||
|
||||
def test_an_average_blends_the_base_away(self):
|
||||
result = _inspect(shapes.flux_three_stage(composite="ConditioningAverage"))
|
||||
self.assertEqual(
|
||||
(result["state"], result["reason_code"]), ("unresolved", "composite")
|
||||
)
|
||||
|
||||
def test_a_combine_without_the_base_prompt_is_not_the_base_pair(self):
|
||||
graph = shapes.flux_three_stage(combined=(("37", 0), ("31", 0)))
|
||||
graph["37"] = shapes.encode(shapes.OTHER)
|
||||
result = _inspect(graph)
|
||||
self.assertEqual(
|
||||
(result["state"], result["reason_code"]), ("unresolved", "composite")
|
||||
)
|
||||
|
||||
def test_a_different_last_negative_is_not_the_base_pair(self):
|
||||
graph = shapes.flux_three_stage()
|
||||
graph["38"] = shapes.encode("watermark")
|
||||
graph["60"]["inputs"]["negative"] = ["38", 0]
|
||||
result = _inspect(graph)
|
||||
self.assertEqual(
|
||||
(result["state"], result["reason_code"]), ("unresolved", "composite")
|
||||
)
|
||||
|
||||
def test_different_base_stages_are_never_floored(self):
|
||||
graph = shapes.flux_three_stage()
|
||||
graph["39"] = shapes.encode(shapes.OTHER)
|
||||
graph["40"]["inputs"]["positive"] = ["39", 0]
|
||||
result = _inspect(graph)
|
||||
self.assertNotEqual(result["state"], "resolved")
|
||||
|
||||
def test_a_rerouted_combine_is_followed(self):
|
||||
graph = shapes.flux_three_stage()
|
||||
graph["39"] = {"class_type": "Reroute", "inputs": {"input": ["35", 0]}}
|
||||
graph["60"]["inputs"]["positive"] = ["39", 0]
|
||||
result = _inspect(graph)
|
||||
self.assertEqual(result["reason"], resolver.ADDED_CONDITIONING_REASON)
|
||||
|
||||
def test_an_unreadable_last_negative_or_encoder_parts_keep_the_failure(self):
|
||||
graph = shapes.flux_three_stage()
|
||||
graph["38"] = {"class_type": "MysteryNegative", "inputs": {"seed": 1}}
|
||||
graph["60"]["inputs"]["negative"] = ["38", 0]
|
||||
self.assertEqual(_inspect(graph)["reason_code"], "composite")
|
||||
# Differing encoder parts are a composite too, but not an added stage.
|
||||
graph = shapes.flux_three_stage()
|
||||
graph["39"] = {
|
||||
"class_type": "CLIPTextEncodeSDXL",
|
||||
"inputs": {"text_g": shapes.POSITIVE, "text_l": shapes.STYLE},
|
||||
}
|
||||
graph["60"]["inputs"]["positive"] = ["39", 0]
|
||||
result = _inspect(graph)
|
||||
self.assertEqual(
|
||||
(result["state"], result["reason_code"]), ("unresolved", "composite")
|
||||
)
|
||||
self.assertEqual(result["node"]["class_type"], "CLIPTextEncodeSDXL")
|
||||
|
||||
def test_inspector_shows_the_base_prompt(self):
|
||||
evidence = inspect_prompt_evidence(
|
||||
{"prompt": json.dumps(shapes.flux_three_stage())}
|
||||
)
|
||||
self.assertEqual(evidence["resolution"]["state"], "resolved")
|
||||
self.assertEqual(evidence["prompts"]["positive"]["text"], shapes.POSITIVE)
|
||||
|
||||
|
||||
class TestRunTimePromptManagerParts(unittest.TestCase):
|
||||
"""Maintainer option A: a run-time prepend or append is left out, labelled."""
|
||||
|
||||
def test_trigger_words_prepend_resolves_to_the_prompt_manager_text(self):
|
||||
for anchor in (None, "22"):
|
||||
with self.subTest(anchor=anchor):
|
||||
result = _inspect(
|
||||
shapes.hires_fix_with_trigger_words(), output_node_id=anchor
|
||||
)
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertEqual(_texts(result), (shapes.POSITIVE, shapes.NEGATIVE))
|
||||
self.assertEqual(result["reason"], resolver.RUNTIME_PARTS_REASON)
|
||||
provenance = result["positive"]["provenance"]
|
||||
self.assertEqual(
|
||||
provenance["text_source"], resolver.RUNTIME_PARTS_SOURCE
|
||||
)
|
||||
self.assertEqual(result["positive"]["storage_text"], shapes.POSITIVE)
|
||||
# The negative is untouched by the positive's run-time part.
|
||||
self.assertEqual(
|
||||
result["negative"]["provenance"]["text_source"], "graph-inferred"
|
||||
)
|
||||
|
||||
def test_append_and_a_single_sampler_are_labelled_too(self):
|
||||
graph = shapes.hires_fix_with_trigger_words()
|
||||
graph["2"]["inputs"]["prepend_text"] = "masterpiece"
|
||||
graph["2"]["inputs"]["append_text"] = ["8", 0]
|
||||
graph["20"]["inputs"]["latent_image"] = ["4", 0]
|
||||
result = _inspect(graph, output_node_id="22")
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
# The literal prepend is still combined exactly as before.
|
||||
self.assertEqual(_texts(result)[0], "masterpiece " + shapes.POSITIVE)
|
||||
self.assertEqual(result["reason"], resolver.RUNTIME_PARTS_REASON)
|
||||
|
||||
def test_the_inspector_shows_the_prompt_and_its_source(self):
|
||||
evidence = inspect_prompt_evidence(
|
||||
{"prompt": json.dumps(shapes.hires_fix_with_trigger_words())}
|
||||
)
|
||||
self.assertEqual(evidence["resolution"]["state"], "resolved")
|
||||
self.assertEqual(
|
||||
evidence["prompts"]["positive"],
|
||||
{
|
||||
"status": "resolved",
|
||||
"text": shapes.POSITIVE,
|
||||
"source": resolver.RUNTIME_PARTS_SOURCE,
|
||||
},
|
||||
)
|
||||
|
||||
def test_literal_and_traceable_parts_are_combined_as_before(self):
|
||||
graph = shapes.hires_fix_with_trigger_words()
|
||||
graph["8"] = {"class_type": "PrimitiveString", "inputs": {"value": "trig9word"}}
|
||||
result = _inspect(graph)
|
||||
self.assertEqual(_texts(result)[0], "trig9word " + shapes.POSITIVE)
|
||||
self.assertEqual(result["reason"], "")
|
||||
self.assertEqual(
|
||||
result["positive"]["provenance"]["text_source"], "graph-inferred"
|
||||
)
|
||||
|
||||
def test_only_a_run_time_feeder_is_left_out(self):
|
||||
# An unknown feeder stays unsupported, naming it.
|
||||
graph = shapes.hires_fix_with_trigger_words()
|
||||
graph["8"]["class_type"] = "MysteryWords"
|
||||
result = _inspect(graph)
|
||||
self.assertEqual(result["reason_code"], "unsupported-node")
|
||||
self.assertEqual(result["node"]["class_type"], "MysteryWords")
|
||||
# A run-time node deeper inside the part makes the whole part run time,
|
||||
# but it is not a direct feeder: never leave out a traced literal.
|
||||
graph = shapes.hires_fix_with_trigger_words()
|
||||
graph["9"] = {
|
||||
"class_type": "StringConcatenate",
|
||||
"inputs": {"string_a": "kept", "string_b": ["8", 0], "delimiter": ""},
|
||||
}
|
||||
graph["2"]["inputs"]["prepend_text"] = ["9", 0]
|
||||
result = _inspect(graph)
|
||||
self.assertEqual(result["reason_code"], "runtime-value")
|
||||
|
||||
def test_the_main_text_from_a_run_time_node_is_never_left_out(self):
|
||||
graph = shapes.hires_fix_with_trigger_words()
|
||||
graph["2"]["inputs"]["prepend_text"] = ""
|
||||
graph["2"]["inputs"]["text"] = ["8", 0]
|
||||
result = _inspect(graph)
|
||||
self.assertEqual(result["reason_code"], "runtime-value")
|
||||
|
||||
def test_the_label_survives_nodes_between_the_prompt_manager_and_sampler(self):
|
||||
# Review C1: encoders, chained PromptManagers and concatenations.
|
||||
expected = {
|
||||
"text-encode": shapes.POSITIVE,
|
||||
"chained": shapes.POSITIVE,
|
||||
"concat": shapes.POSITIVE + ", extra",
|
||||
}
|
||||
for shape, text in expected.items():
|
||||
with self.subTest(shape=shape):
|
||||
result = _inspect(shapes.trigger_words_through(shape))
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertEqual(_texts(result)[0], text)
|
||||
self.assertEqual(result["reason"], resolver.RUNTIME_PARTS_REASON)
|
||||
self.assertEqual(
|
||||
result["positive"]["provenance"]["text_source"],
|
||||
resolver.RUNTIME_PARTS_SOURCE,
|
||||
)
|
||||
|
||||
def test_a_partial_prompt_never_equals_a_complete_one(self):
|
||||
# Review C2: never collapse a branch that left out trigger words with
|
||||
# one that did not, nor compare such stages as equal.
|
||||
result = _inspect(shapes.branches_with_and_without_trigger_words())
|
||||
self.assertEqual(
|
||||
(result["state"], result["reason"]),
|
||||
("ambiguous", "Multiple sampler branches"),
|
||||
)
|
||||
graph = shapes.hires_fix_with_trigger_words()
|
||||
graph["9"] = shapes.prompt_manager(shapes.POSITIVE)
|
||||
graph["20"]["inputs"]["positive"] = ["9", 0]
|
||||
result = _inspect(graph, output_node_id="22")
|
||||
self.assertEqual(
|
||||
(result["state"], result["reason_code"]),
|
||||
("ambiguous", "multistage-differs"),
|
||||
)
|
||||
# Two PromptManagers leaving out two different toggles differ too.
|
||||
graph["8b"] = shapes.trigger_toggle("otherword")
|
||||
graph["9"]["inputs"]["prepend_text"] = ["8b", 0]
|
||||
result = _inspect(graph, output_node_id="22")
|
||||
self.assertEqual(result["reason_code"], "multistage-differs")
|
||||
|
||||
def test_only_listed_run_time_classes_are_left_out(self):
|
||||
# Review M1: a JoinStringMulti with a linked list flag is run time
|
||||
# too, but it is not a listed run-time node: never left out.
|
||||
graph = shapes.hires_fix()
|
||||
graph["9"] = {
|
||||
"class_type": "JoinStringMulti",
|
||||
"inputs": {
|
||||
"inputcount": 2,
|
||||
"delimiter": " ",
|
||||
"return_list": ["98", 0],
|
||||
"string_1": "static words",
|
||||
"string_2": "more",
|
||||
},
|
||||
}
|
||||
graph["98"] = {"class_type": "PrimitiveBoolean", "inputs": {"value": False}}
|
||||
graph["2"]["inputs"]["prepend_text"] = ["9", 0]
|
||||
result = _inspect(graph)
|
||||
self.assertEqual(
|
||||
(result["state"], result["reason_code"]), ("unresolved", "runtime-value")
|
||||
)
|
||||
self.assertEqual(result["node"]["class_type"], "JoinStringMulti")
|
||||
|
||||
def test_ingestion_is_unchanged(self):
|
||||
result = resolve_prompt_pair({"prompt": shapes.hires_fix_with_trigger_words()})
|
||||
self.assertEqual(result["state"], "ambiguous")
|
||||
|
||||
|
||||
class TestStillAmbiguous(unittest.TestCase):
|
||||
def test_edit_batch_with_distinct_prompts_stays_ambiguous(self):
|
||||
result = _inspect(shapes.edit_batch(4))
|
||||
self.assertEqual(result["state"], "ambiguous")
|
||||
self.assertEqual(result["reason"], "Multiple sampler branches")
|
||||
self.assertEqual(len({_texts(item)[0] for item in result["candidates"]}), 4)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -416,63 +416,6 @@ class TestStatisticsRoute(SystemAPITestCase):
|
||||
)
|
||||
|
||||
|
||||
class TestFailedReadsSettleFirst(SystemAPITestCase):
|
||||
"""A failed read answers only after the route's other reads finished.
|
||||
|
||||
Answering on the first failure (``asyncio.gather`` without
|
||||
``return_exceptions``) left the other executor reads running against a
|
||||
database the caller may already be closing; on Python 3.10 CI that
|
||||
crashed the test process inside SQLite.
|
||||
"""
|
||||
|
||||
async def assert_answers_after_reads(self, url, failing, slow, error):
|
||||
raised, release, finished = (threading.Event() for _ in range(3))
|
||||
real = getattr(*slow)
|
||||
|
||||
def fail(*args, **kwargs):
|
||||
raised.set()
|
||||
raise RuntimeError("secret")
|
||||
|
||||
def held(*args, **kwargs):
|
||||
release.wait()
|
||||
try:
|
||||
return real(*args, **kwargs)
|
||||
finally:
|
||||
finished.set()
|
||||
|
||||
with (
|
||||
patch.object(*failing, side_effect=fail),
|
||||
patch.object(*slow, side_effect=held),
|
||||
self.assertLogs("prompt_manager.api", "ERROR"),
|
||||
):
|
||||
answer = asyncio.ensure_future(self.json("GET", url, 500))
|
||||
try:
|
||||
await asyncio.get_running_loop().run_in_executor(None, raised.wait)
|
||||
done, _ = await asyncio.wait({answer}, timeout=0.5)
|
||||
self.assertEqual(done, set(), "answered while a read still ran")
|
||||
finally:
|
||||
release.set()
|
||||
body = await answer
|
||||
self.assertTrue(finished.is_set())
|
||||
self.assertEqual(body, {"success": False, "code": None, "error": error})
|
||||
|
||||
async def test_statistics(self):
|
||||
await self.assert_answers_after_reads(
|
||||
"/prompt_manager/system/statistics",
|
||||
(self.api.db, "usage_by_category"),
|
||||
(self.api.db, "get_statistics"),
|
||||
"Failed to read statistics",
|
||||
)
|
||||
|
||||
async def test_overview(self):
|
||||
await self.assert_answers_after_reads(
|
||||
"/prompt_manager/system/overview",
|
||||
(self.api.db, "library_counts"),
|
||||
(self.api, "_output_dirs_with_origin"),
|
||||
"Failed to read the overview",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import unittest
|
||||
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
"""The System browser hooks only ever reset their own server's config.json (#249 S3)."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from tests.browser.workspace_system_fixture import register_system_commands
|
||||
|
||||
|
||||
class TestWorkspaceSystemFixture(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.root = Path(self.tmp.name) / "server"
|
||||
self.root.mkdir()
|
||||
|
||||
def test_refuses_a_config_path_outside_its_root(self):
|
||||
elsewhere = Path(self.tmp.name) / "config.json"
|
||||
elsewhere.write_bytes(b'{"real": true}')
|
||||
for value in (str(elsewhere), None):
|
||||
env = {
|
||||
k: v for k, v in os.environ.items() if k != "PROMPT_MANAGER_CONFIG_PATH"
|
||||
}
|
||||
if value is not None:
|
||||
env["PROMPT_MANAGER_CONFIG_PATH"] = value
|
||||
with (
|
||||
self.subTest(value=value),
|
||||
mock.patch.dict(os.environ, env, clear=True),
|
||||
):
|
||||
with self.assertRaisesRegex(RuntimeError, "PROMPT_MANAGER_CONFIG_PATH"):
|
||||
register_system_commands(None, None, None, self.root)
|
||||
self.assertEqual(elsewhere.read_bytes(), b'{"real": true}')
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Vendored
-14
@@ -51,17 +51,3 @@ test('CI retains stock, execution and pack suites in order with uploaded executi
|
||||
assert.deepEqual(commands, ['test:comfyui', 'test:executions', 'test:compat']);
|
||||
assert.match(workflow, /path: tools\/vendor\/test-results\//);
|
||||
});
|
||||
|
||||
test('real-host config waits for its own launcher, never for whichever server holds the port', async t => {
|
||||
const previous = process.env.PM_COMFY_ROOT;
|
||||
process.env.PM_COMFY_ROOT = '/unused-pinned-checkout';
|
||||
t.after(() => { if (previous === undefined) delete process.env.PM_COMFY_ROOT; else process.env.PM_COMFY_ROOT = previous; });
|
||||
const { default: config } = await import('../../tools/vendor/comfyui.config.mjs');
|
||||
const [server] = config.webServer;
|
||||
assert.equal(server.url, undefined);
|
||||
assert.equal(server.port, undefined);
|
||||
assert.equal(server.reuseExistingServer, false);
|
||||
const ready = server.wait.stdout.exec('Starting server\nPM_COMFY_HOST_READY base-directory=/tmp/comfyassets-host-a b\r\nlater');
|
||||
assert.equal(ready.groups.pm_comfy_host_base, '/tmp/comfyassets-host-a b');
|
||||
assert.equal(server.wait.stdout.exec('To see the GUI go to: http://127.0.0.1:8189'), null);
|
||||
});
|
||||
|
||||
Vendored
+1
-50
@@ -6,8 +6,7 @@ import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { once } from 'node:events';
|
||||
import { createServer } from 'node:net';
|
||||
import { createServer as createHttpServer } from 'node:http';
|
||||
import { launchComfyHost, assertPortFree, waitForOwnHost, hostBaseDirectory } from '../../tools/vendor/comfyui-host.mjs';
|
||||
import { launchComfyHost } from '../../tools/vendor/comfyui-host.mjs';
|
||||
|
||||
async function fixture(t, program) {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'pm-host-test-'));
|
||||
@@ -233,51 +232,3 @@ test('execution launcher refuses an occupied port without touching an existing s
|
||||
await new Promise(resolve => server.close(resolve));
|
||||
}
|
||||
});
|
||||
|
||||
// A stand-in ComfyUI that answers /system_stats with its own argv, as the real host does.
|
||||
const answersOwnArgv = `const args = process.argv.slice(1);
|
||||
require('node:http').createServer((request, response) => {
|
||||
response.setHeader('content-type', 'application/json');
|
||||
response.end(JSON.stringify({ system: { argv: args } }));
|
||||
}).listen(Number(args[args.indexOf('--port') + 1]), '127.0.0.1');`;
|
||||
|
||||
async function listen(t, port, handler = (request, response) => response.end()) {
|
||||
const server = createHttpServer(handler);
|
||||
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, '127.0.0.1', resolve); });
|
||||
t.after(() => new Promise(resolve => server.close(resolve)));
|
||||
return server;
|
||||
}
|
||||
|
||||
test('real-host launcher refuses a port another run already holds and leaves that server running', async t => {
|
||||
const server = await listen(t, 8199);
|
||||
await assert.rejects(assertPortFree(8199), /8199 is already in use.*never shared/s);
|
||||
assert.equal(server.listening, true);
|
||||
});
|
||||
|
||||
test('real-host launcher is ready only when its own ComfyUI answers the port', async t => {
|
||||
const options = await fixture(t, answersOwnArgv);
|
||||
await assertPortFree(options.port);
|
||||
const host = await launchComfyHost(options);
|
||||
t.after(() => host.stop());
|
||||
await waitForOwnHost(host, options.port);
|
||||
const stats = await (await fetch(`http://127.0.0.1:${options.port}/system_stats`)).json();
|
||||
assert.equal(hostBaseDirectory(stats), host.stateDirectory);
|
||||
});
|
||||
|
||||
test('real-host launcher refuses a host it did not start that answers before its own binds', async t => {
|
||||
await listen(t, 8199, (request, response) => {
|
||||
response.setHeader('content-type', 'application/json');
|
||||
response.end(JSON.stringify({ system: { argv: ['main.py', '--base-directory', '/other-run-state'] } }));
|
||||
});
|
||||
const options = await fixture(t, idle);
|
||||
const host = await launchComfyHost(options);
|
||||
t.after(() => host.stop());
|
||||
await assert.rejects(waitForOwnHost(host, options.port), /did not start \(base directory \/other-run-state\)/);
|
||||
});
|
||||
|
||||
test('real-host launcher reports a host that exits before it answers', async t => {
|
||||
const options = await fixture(t, 'process.exit(3);');
|
||||
const host = await launchComfyHost(options);
|
||||
t.after(() => host.stop());
|
||||
await assert.rejects(waitForOwnHost(host, options.port), /exited before it answered on port 8199 \(exit=3/);
|
||||
});
|
||||
|
||||
Vendored
+3
-58
@@ -1,5 +1,4 @@
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { createServer } from 'node:net';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { launchComfyHost as launchSharedHost } from '@comfyassets/toolkit/testing/host';
|
||||
@@ -8,9 +7,6 @@ import runtime from './comfyui-runtime.json' with { type: 'json' };
|
||||
export { launchSharedHost };
|
||||
|
||||
const repositoryRoot = fileURLToPath(new URL('../../', import.meta.url));
|
||||
// comfyui.config.mjs waits for this line; its named group becomes PM_COMFY_HOST_BASE in the test workers.
|
||||
export const HOST_READY_MARKER = 'PM_COMFY_HOST_READY';
|
||||
const READY_POLL_MS = 250;
|
||||
|
||||
export async function preparePromptManagerPlugin({ installed, stateDirectory }) {
|
||||
const configPath = join(installed, 'config.json');
|
||||
@@ -31,68 +27,17 @@ export function launchComfyHost({ checkout, repository = repositoryRoot,
|
||||
});
|
||||
}
|
||||
|
||||
export async function assertPortFree(port) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const probe = createServer();
|
||||
probe.once('error', error => reject(new Error(`ComfyUI host port ${port} is already in use, often by another `
|
||||
+ 'real-host run. A managed host is never shared: stop that run or set PM_COMFY_URL to a free '
|
||||
+ 'http://127.0.0.1:PORT/ address.', { cause: error })));
|
||||
probe.listen({ host: '127.0.0.1', port, exclusive: true }, () => probe.close(error => error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
|
||||
export function hostBaseDirectory(stats) {
|
||||
const argv = stats?.system?.argv;
|
||||
if (!Array.isArray(argv)) return null;
|
||||
const index = argv.indexOf('--base-directory');
|
||||
return index >= 0 ? argv[index + 1] ?? null : null;
|
||||
}
|
||||
|
||||
// Ready means this launcher's own ComfyUI answers: /system_stats reports its base directory.
|
||||
// Any other answer on the port comes from a host this run did not start, so the run must not use it.
|
||||
export async function waitForOwnHost(host, port, { fetchImpl = fetch } = {}) {
|
||||
let exited = null;
|
||||
host.finished.then(result => { exited = result; });
|
||||
const url = `http://127.0.0.1:${port}/system_stats`;
|
||||
for (;;) {
|
||||
let stats = null;
|
||||
try { stats = await (await fetchImpl(url)).json(); }
|
||||
catch { stats = null; }
|
||||
if (stats) {
|
||||
const base = hostBaseDirectory(stats);
|
||||
if (base === host.stateDirectory) return;
|
||||
throw new Error(`Port ${port} is answered by a ComfyUI host this run did not start `
|
||||
+ `(base directory ${base ?? 'unknown'}); another run is using the port.`);
|
||||
}
|
||||
if (exited) {
|
||||
throw new Error(`ComfyUI host exited before it answered on port ${port} `
|
||||
+ `(exit=${exited.code}, signal=${exited.signal || 'none'}).`);
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, READY_POLL_MS));
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
||||
try {
|
||||
const port = Number(process.env.PM_COMFY_PORT || 8189);
|
||||
await assertPortFree(port);
|
||||
const host = await launchComfyHost({ checkout: process.env.PM_COMFY_ROOT, port });
|
||||
const host = await launchComfyHost({ checkout: process.env.PM_COMFY_ROOT,
|
||||
port: Number(process.env.PM_COMFY_PORT || 8189) });
|
||||
const stop = () => { host.stop().catch(error => { console.error(error.message); process.exitCode = 1; }); };
|
||||
process.once('SIGINT', stop);
|
||||
process.once('SIGTERM', stop);
|
||||
let refused = false;
|
||||
try {
|
||||
await waitForOwnHost(host, port);
|
||||
console.log(`${HOST_READY_MARKER} base-directory=${host.stateDirectory}`);
|
||||
} catch (error) {
|
||||
refused = true;
|
||||
console.error(error.message);
|
||||
await host.stop();
|
||||
}
|
||||
const result = await host.finished;
|
||||
process.removeListener('SIGINT', stop);
|
||||
process.removeListener('SIGTERM', stop);
|
||||
if (result.code) console.error(`ComfyUI host exited with status ${result.code}; see tools/vendor/test-results/comfyui-host.log`);
|
||||
process.exitCode = refused ? 1 : result.code;
|
||||
process.exitCode = result.code;
|
||||
} catch (error) { console.error(error.message); process.exitCode = 1; }
|
||||
}
|
||||
|
||||
Vendored
+1
-6
@@ -1,6 +1,5 @@
|
||||
import { defineConfig } from './node_modules/@playwright/test/index.mjs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { HOST_READY_MARKER } from './comfyui-host.mjs';
|
||||
|
||||
if (!process.env.PM_COMFY_ROOT) throw new Error('Set PM_COMFY_ROOT to a pinned disposable ComfyUI checkout');
|
||||
const host = new URL(process.env.PM_COMFY_URL || 'http://127.0.0.1:8189/');
|
||||
@@ -8,11 +7,7 @@ if (host.protocol !== 'http:' || host.hostname !== '127.0.0.1' || !host.port) th
|
||||
const servers = [{
|
||||
command: 'node tools/vendor/comfyui-host.mjs',
|
||||
cwd: fileURLToPath(new URL('../../', import.meta.url)),
|
||||
// No URL wait: any server on the port would satisfy it, including another run's host that
|
||||
// then stops mid-test. The launcher refuses a used port and prints this line only once its
|
||||
// own ComfyUI answers; the group reaches the tests as PM_COMFY_HOST_BASE.
|
||||
wait: { stdout: new RegExp(`^${HOST_READY_MARKER} base-directory=(?<pm_comfy_host_base>.+?)\r?$`, 'm') },
|
||||
timeout: 120000, reuseExistingServer: false,
|
||||
url: new URL('system_stats', host).href, timeout: 120000, reuseExistingServer: false,
|
||||
env: { PM_COMFY_PORT: host.port },
|
||||
gracefulShutdown: { signal: 'SIGTERM', timeout: 10000 },
|
||||
}];
|
||||
|
||||
+9
-21
@@ -37,15 +37,15 @@
|
||||
},
|
||||
"gallery-settings.js": {
|
||||
"lines": 98,
|
||||
"branches": 94,
|
||||
"branches": 90,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"gallery.js": {
|
||||
"lines": 41,
|
||||
"lines": 40,
|
||||
"branches": 63,
|
||||
"functions": 44,
|
||||
"statements": 41
|
||||
"statements": 40
|
||||
},
|
||||
"html-escape.js": {
|
||||
"lines": 98,
|
||||
@@ -401,12 +401,6 @@
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/preferences.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 96,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/prompt-blocks.mjs": {
|
||||
"lines": 90,
|
||||
"branches": 92,
|
||||
@@ -449,12 +443,6 @@
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/system-appearance.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 93,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/system-controls.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 87,
|
||||
@@ -469,15 +457,15 @@
|
||||
},
|
||||
"workspace/system-state.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 94,
|
||||
"functions": 98,
|
||||
"branches": 92,
|
||||
"functions": 92,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/system.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 92,
|
||||
"functions": 85,
|
||||
"statements": 98
|
||||
"lines": 93,
|
||||
"branches": 85,
|
||||
"functions": 69,
|
||||
"statements": 93
|
||||
},
|
||||
"workspace/theme.mjs": {
|
||||
"lines": 98,
|
||||
|
||||
+3
-95
@@ -6,28 +6,19 @@ the inspection projection (``project_graph``: strict JSON, the depth and node
|
||||
bounds, big integers and decimals as text), and only graphs it parsed, through
|
||||
the shared parser (``extract_generation_params``, the Python twin of the
|
||||
browser parser the inspector fills from since #251). No separate seed
|
||||
heuristic and no extra file reads. One parse feeds both the seed and the
|
||||
positive that confirms a first-line card title. One exception keeps the card
|
||||
from ever showing a seed the inspector does not: when sampler stages read
|
||||
different seed inputs and the inspector's resolution names one of them for a
|
||||
resolved pair (whose facts are that stage's), the card shows no seed when the
|
||||
named stage's seed differs from the parser's. Only such graphs are resolved,
|
||||
with the inspector's own profile.
|
||||
heuristic, no graph resolution and no extra file reads. One parse feeds both
|
||||
the seed and the positive that confirms a first-line card title.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
|
||||
from .generation_params import (
|
||||
PLACEHOLDERS,
|
||||
GenerationParamsError,
|
||||
_is_sampler,
|
||||
extract_generation_params,
|
||||
)
|
||||
from .inspection_response import project_graph
|
||||
from .inspection_semantics import SemanticsError, get_semantics
|
||||
from .prompt_pair_resolver import INSPECTION_PROFILE, resolve_prompt_pair
|
||||
|
||||
__all__ = [
|
||||
"MAX_RECORD_BYTES",
|
||||
@@ -82,93 +73,10 @@ def header_params(info) -> dict | None:
|
||||
if not graphs:
|
||||
return None
|
||||
try:
|
||||
params = extract_generation_params(graphs)
|
||||
return extract_generation_params(graphs)
|
||||
except (GenerationParamsError, RecursionError):
|
||||
# Defence in depth: a parser failure gives no card facts, never a 500.
|
||||
return None
|
||||
if isinstance(params, dict) and _inspector_names_another_seed(
|
||||
graphs, card_seed(params)
|
||||
):
|
||||
return {**params, "seed": None}
|
||||
return params
|
||||
|
||||
|
||||
_SEED_INPUTS = ("seed", "noise_seed", "noise")
|
||||
|
||||
|
||||
def _stage_seed(graph: Mapping, node_id: str) -> str | None:
|
||||
"""The seed the parser reads for one sampler node, or None when unknown.
|
||||
|
||||
A literal seed input is that seed. A linked one is read by the parser
|
||||
itself when it treats the node as a sampler, with every other such node
|
||||
hidden from it (no ``positive`` input), so linked seeds resolve as they do
|
||||
on the card; any other linked seed is unknown.
|
||||
"""
|
||||
node = graph.get(node_id)
|
||||
if not isinstance(node, Mapping) or not isinstance(node.get("inputs"), Mapping):
|
||||
return None
|
||||
inputs = node["inputs"]
|
||||
literal = next((inputs[k] for k in _SEED_INPUTS if k in inputs), None)
|
||||
if not isinstance(literal, (list, tuple)):
|
||||
# A literal needs no second parse (the cost is measured in TESTING.md).
|
||||
return card_seed({"seed": literal})
|
||||
if _is_sampler(node):
|
||||
only = {
|
||||
key: (
|
||||
{**other, "inputs": {**other["inputs"], "positive": None}}
|
||||
if key != node_id and _is_sampler(other)
|
||||
else other
|
||||
)
|
||||
for key, other in graph.items()
|
||||
}
|
||||
try:
|
||||
return card_seed(extract_generation_params({"prompt": only}))
|
||||
except (GenerationParamsError, RecursionError):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _inspector_names_another_seed(graphs: dict, parsed: str | None) -> bool:
|
||||
"""Whether the inspector shows another stage's seed than the parser's.
|
||||
|
||||
True when the prompt graph's sampler stages read different seed inputs, the
|
||||
inspection resolution names one sampler for a resolved pair (the
|
||||
inspector's facts are then that stage's, D19), and that stage's seed is
|
||||
known and differs from ``parsed``. A seed that agrees is kept.
|
||||
"""
|
||||
if parsed is None:
|
||||
return False
|
||||
graph = graphs.get("prompt")
|
||||
if not isinstance(graph, Mapping):
|
||||
return False
|
||||
try:
|
||||
samplers = get_semantics().samplers
|
||||
except SemanticsError:
|
||||
return False
|
||||
seeds = set()
|
||||
for node in graph.values():
|
||||
if not isinstance(node, Mapping) or not isinstance(node.get("inputs"), Mapping):
|
||||
continue
|
||||
if not isinstance(node.get("class_type"), str):
|
||||
continue
|
||||
if node["class_type"] not in samplers:
|
||||
continue
|
||||
inputs = node["inputs"]
|
||||
value = next((inputs[key] for key in _SEED_INPUTS if key in inputs), None)
|
||||
seeds.add(json.dumps(value, sort_keys=True, default=str))
|
||||
if len(seeds) < 2:
|
||||
return False
|
||||
try:
|
||||
resolution = resolve_prompt_pair(
|
||||
{"prompt": dict(graph)}, profile=INSPECTION_PROFILE
|
||||
)
|
||||
except RecursionError:
|
||||
return True
|
||||
named = resolution["sampler_id"]
|
||||
if resolution["state"] != "resolved" or named is None:
|
||||
return False
|
||||
seed = _stage_seed(graph, named)
|
||||
return seed is not None and seed != parsed
|
||||
|
||||
|
||||
def card_seed(params: dict | None) -> str | None:
|
||||
|
||||
@@ -12,10 +12,8 @@ valid result. Importing this module reads nothing, and the default (ingestion)
|
||||
profile never imports it, so a missing or bad table can never break ingestion.
|
||||
An invalid table raises ``SemanticsError`` so a bad edit fails loudly instead of
|
||||
silently changing what the inspector resolves. The file is bounded in size and
|
||||
nesting before it is validated. Every class appears in exactly one prompt
|
||||
category; classes that are not listed stay ``unsupported-node``. ``lineage``
|
||||
(image and latent pass-through outputs for multi-stage chains) describes no
|
||||
prompt, so its classes are not claimed by a category.
|
||||
nesting before it is validated. Every class appears in exactly one category;
|
||||
classes that are not listed stay ``unsupported-node``.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -46,7 +44,6 @@ STRATEGIES: Mapping[str, FrozenSet[str]] = MappingProxyType(
|
||||
"encoders": frozenset({"single", "parts-equal", "pm-compose", "flag-parts"}),
|
||||
"strings": frozenset({"literal", "strip-newlines", "join-multi", "concat"}),
|
||||
"savers": frozenset({"comfy-counter", "vhs-counter"}),
|
||||
"lineage": frozenset({"continue"}),
|
||||
}
|
||||
)
|
||||
# Shapes of the facts each sampler class provides (consumed by comfy-metadata.js).
|
||||
@@ -75,7 +72,7 @@ _CATEGORIES = frozenset(
|
||||
+ _CLASS_CATEGORIES
|
||||
+ _SLOT_CATEGORIES
|
||||
+ _SET_CATEGORIES
|
||||
+ ("lineage", "stage_inputs", "facts")
|
||||
+ ("stage_inputs", "facts")
|
||||
)
|
||||
# Input names that make an unlisted node a possible sampler stage in an ancestry
|
||||
# walk (``shaped``: pipes, video embeds and custom-sampler parts carry a prompt
|
||||
@@ -114,7 +111,6 @@ class Semantics:
|
||||
runtime: FrozenSet[str]
|
||||
savers: Mapping[str, Mapping[str, Any]]
|
||||
non_file_outputs: FrozenSet[str]
|
||||
lineage: Mapping[Tuple[str, int], Mapping[str, Any]]
|
||||
stage_inputs: Mapping[str, Tuple[str, ...]]
|
||||
facts: Mapping[str, Any]
|
||||
all_classes: FrozenSet[str]
|
||||
@@ -229,12 +225,8 @@ def _check_selector(entry: dict, strategy: str, where: str) -> None:
|
||||
|
||||
|
||||
def _check_composite(entry: dict, strategy: str, where: str) -> None:
|
||||
# ``additive``: every part stays in effect (combine, concat), so a later
|
||||
# stage that adds parts to an earlier stage's prompt keeps that prompt.
|
||||
_fields(entry, where, frozenset({"strategy", "inputs"}), frozenset({"additive"}))
|
||||
_fields(entry, where, frozenset({"strategy", "inputs"}), frozenset())
|
||||
_names(entry["inputs"], where, minimum=2)
|
||||
if "additive" in entry and not isinstance(entry["additive"], bool):
|
||||
raise SemanticsError(f"{where}: additive must be a boolean")
|
||||
|
||||
|
||||
def _check_saver(entry: dict, strategy: str, where: str) -> None:
|
||||
@@ -364,35 +356,6 @@ def _slot_category(doc: dict, category: str, owners: _Owners) -> Mapping:
|
||||
return MappingProxyType(result)
|
||||
|
||||
|
||||
def _lineage(doc: dict) -> Mapping:
|
||||
"""``(class, slot)`` outputs whose image or latent continues one input's.
|
||||
|
||||
These describe an output's image or latent, never its prompt, so they are
|
||||
not prompt categories: a class listed here is not claimed, and stays
|
||||
unsupported (or listed elsewhere) for text. Each output is listed once.
|
||||
"""
|
||||
rows = doc["lineage"]
|
||||
if not isinstance(rows, list):
|
||||
raise SemanticsError("category lineage must be a list")
|
||||
result = {}
|
||||
for index, row in enumerate(rows):
|
||||
where = f"lineage[{index}]"
|
||||
if not isinstance(row, dict):
|
||||
raise SemanticsError(f"{where}: entry must be an object")
|
||||
name = _name(row.get("class"), where, "class")
|
||||
slot = _slot(row, where)
|
||||
entry = {
|
||||
key: value for key, value in row.items() if key not in ("class", "slot")
|
||||
}
|
||||
_strategy(entry, "lineage", where)
|
||||
_fields(entry, where, frozenset({"strategy", "input"}), frozenset())
|
||||
_name(entry["input"], where)
|
||||
if (name, slot) in result:
|
||||
raise SemanticsError(f"lineage: duplicate output key {(name, slot)!r}")
|
||||
result[(name, slot)] = _freeze(entry)
|
||||
return MappingProxyType(result)
|
||||
|
||||
|
||||
def _set_category(doc: dict, category: str, owners: _Owners) -> FrozenSet[str]:
|
||||
rows = doc[category]
|
||||
if not isinstance(rows, list):
|
||||
@@ -489,7 +452,6 @@ def _validate(doc: Any) -> Semantics:
|
||||
for category in _SET_CATEGORIES:
|
||||
tables[category] = _set_category(doc, category, owners)
|
||||
return Semantics(
|
||||
lineage=_lineage(doc),
|
||||
stage_inputs=_stage_inputs(doc),
|
||||
facts=_facts(doc),
|
||||
all_classes=owners.classes(),
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
"""Strict JSON output for values that may carry ComfyUI's non-finite numbers.
|
||||
|
||||
ComfyUI writes its metadata with Python's default ``json.dumps``, so a graph can
|
||||
hold ``NaN``, ``Infinity`` and ``-Infinity`` (an ``IS_CHANGED`` that returns
|
||||
``float("nan")`` puts ``"is_changed": [NaN]`` into every saved prompt). Those
|
||||
literals are not JSON: browsers' ``JSON.parse`` and ``allow_nan=False`` reject
|
||||
them. Reading follows the legacy pages (``parse_page_json`` where the browser
|
||||
parser is mirrored; Python's ``json.loads`` elsewhere). Whatever PromptManager
|
||||
stores or answers as JSON goes through ``dumps_strict``, which writes each
|
||||
non-finite number as ``null`` (the legacy pages' own replacement for NaN) and
|
||||
leaves strings, including the words ``NaN`` or ``Infinity`` inside prompt
|
||||
text, untouched.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
__all__ = ["dumps_strict"]
|
||||
|
||||
|
||||
def _null(_constant: str) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def dumps_strict(value: Any, **kwargs: Any) -> str:
|
||||
"""``json.dumps(value, allow_nan=False, **kwargs)`` with non-finite numbers null.
|
||||
|
||||
A value without non-finite numbers serializes byte for byte as the plain
|
||||
call does. Any other error (a cycle, an unserializable object) still raises.
|
||||
"""
|
||||
kwargs.pop("allow_nan", None)
|
||||
try:
|
||||
return json.dumps(value, allow_nan=False, **kwargs)
|
||||
except ValueError as error:
|
||||
if "Out of range float" not in str(error):
|
||||
raise
|
||||
# The non-strict text spells each non-finite number as a bare literal, never
|
||||
# inside a string, so parsing it back maps exactly those numbers to null.
|
||||
lenient = json.dumps(value, allow_nan=True, default=kwargs.get("default"))
|
||||
return json.dumps(
|
||||
json.loads(lenient, parse_constant=_null), allow_nan=False, **kwargs
|
||||
)
|
||||
+23
-282
@@ -191,18 +191,6 @@ EMPTY_POSITIVE_REASON = "Positive prompt is empty"
|
||||
# Several sampler branches, every one resolved to the same pair (#249).
|
||||
IDENTICAL_BRANCHES_REASON = "Every sampler branch uses the same prompt pair"
|
||||
MULTISTAGE_UNATTRIBUTED_REASON = "Multistage sampler ancestry is ambiguous"
|
||||
# A later stage's positive combines the earlier stages' shared prompt with more
|
||||
# conditioning (a style trigger): that shared pair is the run's prompt (floor).
|
||||
ADDED_CONDITIONING_REASON = "A later stage adds conditioning to the base prompt"
|
||||
# Maintainer option A (2026-10-23): a PromptManager prepend or append fed
|
||||
# directly by a run-time node (LoRA trigger words) is left out, never guessed;
|
||||
# the text is the node's own, labelled by this source and reason (D125). A side
|
||||
# is labelled when any node on its traced path left a part out.
|
||||
RUNTIME_PARTS_SOURCE = "runtime-parts-omitted"
|
||||
RUNTIME_PARTS_REASON = "Text added at run time, such as trigger words, is not included"
|
||||
# The added-conditioning floor's positive (D126), and both labels at once.
|
||||
ADDED_CONDITIONING_SOURCE = "added-conditioning"
|
||||
RUNTIME_AND_ADDED_SOURCE = "runtime-parts-omitted-and-added-conditioning"
|
||||
_INCOMPATIBLE_SLOT = "Output slot has an incompatible type"
|
||||
# Before 3.2.3 a save patch overwrote every PromptManager node's saved ``text``
|
||||
# with the last-run node's prompt (comfy-metadata.js ``createContext``).
|
||||
@@ -322,51 +310,15 @@ def _role_text(side: Any) -> Optional[str]:
|
||||
return side["text"] if isinstance(side, dict) else None
|
||||
|
||||
|
||||
def _left_out(side: Any) -> tuple:
|
||||
"""What a side's text leaves out: the PromptManager nodes that omitted a
|
||||
run-time part, and the composite whose added conditioning is not included.
|
||||
|
||||
Empty for a complete text, so a partial prompt never equals a complete one.
|
||||
"""
|
||||
provenance = side.get("provenance") if isinstance(side, dict) else None
|
||||
if not isinstance(provenance, dict):
|
||||
return ()
|
||||
return (
|
||||
tuple(provenance.get("omitted_parts", ())),
|
||||
provenance.get("added_by"),
|
||||
)
|
||||
|
||||
|
||||
def _outcome_key(result: Dict[str, Any]) -> tuple:
|
||||
"""What a reader sees of a result: state, the two role texts, and what
|
||||
each text leaves out."""
|
||||
"""What a reader sees of a result: state and the two role texts."""
|
||||
return (
|
||||
result["state"],
|
||||
_role_text(result["positive"]),
|
||||
_role_text(result["negative"]),
|
||||
_left_out(result["positive"]),
|
||||
_left_out(result["negative"]),
|
||||
)
|
||||
|
||||
|
||||
def _reroute_value(inputs: dict) -> Any:
|
||||
"""A Reroute's one input (``value``, ``input`` or ``in``), else ``None``."""
|
||||
keys = [key for key in ("value", "input", "in") if key in inputs]
|
||||
return inputs[keys[0]] if len(keys) == 1 else None
|
||||
|
||||
|
||||
def _parts_reason(*sides: dict) -> str:
|
||||
"""The run-time parts reason when a side left out a PromptManager part."""
|
||||
if any(side["provenance"]["text_source"] == RUNTIME_PARTS_SOURCE for side in sides):
|
||||
return RUNTIME_PARTS_REASON
|
||||
return ""
|
||||
|
||||
|
||||
def _combined_positive(result: Dict[str, Any]) -> bool:
|
||||
"""Whether a stage failed only because its positive combines texts."""
|
||||
return result["reason_code"] == "composite" and result["positive"] is None
|
||||
|
||||
|
||||
def _legacy_result(result: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""The default profile's wire: today's keys plus ``reason_code: None``."""
|
||||
legacy = {key: value for key, value in result.items() if key != "node"}
|
||||
@@ -422,8 +374,6 @@ class _GraphResolver:
|
||||
self.active: set = set()
|
||||
self.metadata = metadata or {}
|
||||
self.text_sources: dict = {}
|
||||
# PromptManager outputs that left out a run-time part (option A).
|
||||
self.omitting: set = set()
|
||||
# Forced runtime selector choices (inspection); a result traced through
|
||||
# one is "tainted" and never memoized, since it depends on the choice.
|
||||
self.choices: dict = {}
|
||||
@@ -573,12 +523,11 @@ class _GraphResolver:
|
||||
if adapter.compose or adapter.storage_input:
|
||||
storage_text = text
|
||||
if adapter.compose:
|
||||
omitted: list = []
|
||||
before, before_path = self.part(
|
||||
inputs.get("prepend_text", ""), depth, omitted
|
||||
before, before_path, _ = self.text(
|
||||
inputs.get("prepend_text", ""), "string", depth + 1
|
||||
)
|
||||
after, after_path = self.part(
|
||||
inputs.get("append_text", ""), depth, omitted
|
||||
after, after_path, _ = self.text(
|
||||
inputs.get("append_text", ""), "string", depth + 1
|
||||
)
|
||||
self.scan(
|
||||
len(before)
|
||||
@@ -597,8 +546,6 @@ class _GraphResolver:
|
||||
)
|
||||
)
|
||||
path = before_path + path + after_path
|
||||
if omitted:
|
||||
self.omitting.add((node_id, slot))
|
||||
elif (
|
||||
name in ("StringConcatenate", "Text Concatenate")
|
||||
and slot == 0
|
||||
@@ -668,30 +615,6 @@ class _GraphResolver:
|
||||
self.tainted = outer_tainted or self.tainted
|
||||
self.active.discard(key)
|
||||
|
||||
def part(self, value: Any, depth: int, omitted: list) -> Tuple[str, list]:
|
||||
"""A PromptManager prepend or append: traced, or left out when run time.
|
||||
|
||||
Only with the semantics table (inspection), and only when the input is
|
||||
a link to a node the table lists as ``runtime`` (such as LoraManager's
|
||||
TriggerWord Toggle), which raises ``runtime-value`` at itself: that part
|
||||
is not in the file, so it is left out and recorded in ``omitted``.
|
||||
Anything else, including a run-time source further upstream or another
|
||||
``runtime-value`` node (a JoinStringMulti list), keeps its failure.
|
||||
"""
|
||||
try:
|
||||
text, path, _ = self.text(value, "string", depth + 1)
|
||||
except _Unresolved as error:
|
||||
if (
|
||||
self.semantics is None
|
||||
or error.code != "runtime-value"
|
||||
or not _link(value)
|
||||
or self.node(str(value[0]))[0] not in self.semantics.runtime
|
||||
):
|
||||
raise
|
||||
omitted.append(str(value[0]))
|
||||
return "", []
|
||||
return text, path
|
||||
|
||||
def historical(
|
||||
self, adapter: OutputAdapter, inputs: dict, depth: int
|
||||
) -> Tuple[str, list, str]:
|
||||
@@ -1062,15 +985,6 @@ class _GraphResolver:
|
||||
if guider_id is not None:
|
||||
# The conditioning path starts below the guider the sampler reads.
|
||||
provenance["guider_id"] = guider_id
|
||||
omitted: list = []
|
||||
for step in path:
|
||||
if (step["node_id"], step["output_slot"]) in self.omitting:
|
||||
if step["node_id"] not in omitted:
|
||||
omitted.append(step["node_id"])
|
||||
if omitted:
|
||||
# Through encoders, concatenations and chained PromptManagers (D125).
|
||||
provenance["text_source"] = RUNTIME_PARTS_SOURCE
|
||||
provenance["omitted_parts"] = omitted
|
||||
return {"text": text, "storage_text": storage_text, "provenance": provenance}
|
||||
|
||||
def guider(self, value: Any) -> Tuple[str, dict, Mapping, str, int]:
|
||||
@@ -1147,18 +1061,19 @@ class _GraphResolver:
|
||||
reason_code="depth-limit",
|
||||
)
|
||||
own = self.sides(sampler_id)
|
||||
# An unresolved stage is final, unless its positive combines texts: it
|
||||
# may then add conditioning to its earlier stages' prompt (the floor).
|
||||
combined = own["state"] == "unresolved" and _combined_positive(own)
|
||||
if own["state"] == "unresolved" and not combined:
|
||||
if own["state"] == "unresolved":
|
||||
return own
|
||||
name, inputs = self.node(sampler_id)
|
||||
entry = self.semantics.samplers.get(name)
|
||||
if entry is None or entry["strategy"] != _DETAILER:
|
||||
return own
|
||||
try:
|
||||
upstream = self.earlier_stages(sampler_id)
|
||||
upstream = self.upstream_stages(
|
||||
sampler_id, inputs.get(entry["image_stage"])
|
||||
)
|
||||
if any(node_id in later for node_id in upstream):
|
||||
raise _Unresolved("Cyclic sampler stage ancestry", "malformed")
|
||||
except _Unresolved as error:
|
||||
if combined:
|
||||
return own
|
||||
return _result(
|
||||
"unresolved",
|
||||
sampler_id=sampler_id,
|
||||
@@ -1171,10 +1086,6 @@ class _GraphResolver:
|
||||
later = later | {sampler_id}
|
||||
stages = [self.stage(node_id, later) for node_id in upstream]
|
||||
blocked = next((item for item in stages if item["state"] == "unresolved"), None)
|
||||
if combined:
|
||||
if blocked is not None:
|
||||
return own
|
||||
return self.added_conditioning(sampler_id, stages) or own
|
||||
if blocked is not None:
|
||||
# A stage that cannot be read cannot be compared: never pick one.
|
||||
return _result(
|
||||
@@ -1197,128 +1108,6 @@ class _GraphResolver:
|
||||
reason_code="multistage-differs",
|
||||
)
|
||||
|
||||
def earlier_stages(self, sampler_id: str) -> list:
|
||||
"""The stages a sampler continues: a detailer's image ancestry, or the
|
||||
one sampler its latent descends from through listed lineage nodes.
|
||||
|
||||
The latent walk already ran in ``sides`` (its outcome is cached); a
|
||||
sampler without a latent input, or whose walk found no earlier sampler,
|
||||
has none.
|
||||
"""
|
||||
name, inputs = self.node(sampler_id)
|
||||
entry = self.semantics.samplers[name]
|
||||
if entry["strategy"] == _DETAILER:
|
||||
return self.upstream_stages(sampler_id, inputs.get(entry["image_stage"]))
|
||||
walked = self.latent_walks.get(sampler_id)
|
||||
if walked is None or walked[2] is None:
|
||||
return []
|
||||
return [walked[2]]
|
||||
|
||||
def lineage_predecessor(self, value: Any) -> Optional[str]:
|
||||
"""The sampler whose output a latent input continues, or ``None``.
|
||||
|
||||
Follows only the table's ``lineage`` outputs (decode, image filter,
|
||||
upscale, scale, encode) and Reroutes, from the input back to a listed
|
||||
sampler's output slot 0. Any other node ends the lineage: its output is
|
||||
not known to continue one image or latent. At most ``max_depth`` hops;
|
||||
each node is read through ``node`` (one scan charge per node).
|
||||
"""
|
||||
seen: set = set()
|
||||
while _link(value) and len(seen) < self.max_depth:
|
||||
node_id, slot = str(value[0]), value[1]
|
||||
if node_id in seen:
|
||||
return None
|
||||
seen.add(node_id)
|
||||
name, inputs = self.node(node_id)
|
||||
if name in self.stage_classes:
|
||||
return node_id if slot == 0 else None
|
||||
if name == "Reroute" and slot == 0:
|
||||
value = _reroute_value(inputs)
|
||||
continue
|
||||
entry = self.semantics.lineage.get((name, slot))
|
||||
if entry is None:
|
||||
return None
|
||||
value = inputs.get(entry["input"])
|
||||
return None
|
||||
|
||||
def added_conditioning(self, sampler_id: str, stages: list) -> Optional[dict]:
|
||||
"""The earlier stages' shared pair when this stage only adds to it.
|
||||
|
||||
This stage's positive reaches (through Reroutes and conditioning
|
||||
pass-throughs) an ``additive`` composite, one of whose parts is exactly
|
||||
the earlier stages' positive text, and its negative is exactly theirs:
|
||||
every stage is conditioned on that prompt, the last one with more. Any
|
||||
other shape, or a part that cannot be traced, is ``None``.
|
||||
"""
|
||||
flat: list = []
|
||||
for item in stages:
|
||||
flat.extend(item["candidates"] if item["state"] == "ambiguous" else [item])
|
||||
base = flat[0]
|
||||
if base["state"] != "resolved" or len({_outcome_key(i) for i in flat}) != 1:
|
||||
return None
|
||||
name, inputs = self.node(sampler_id)
|
||||
entry = self.semantics.samplers[name]
|
||||
if entry["strategy"] == "custom-advanced":
|
||||
return None
|
||||
conditioning = entry["conditioning"]
|
||||
try:
|
||||
found = self.composite_of(inputs.get(conditioning["positive"]))
|
||||
if found is None:
|
||||
return None
|
||||
parts, depth, composite = found
|
||||
texts = [self.text(part, "conditioning", depth)[0] for part in parts]
|
||||
if base["positive"]["text"] not in texts:
|
||||
return None
|
||||
if conditioning["negative"] not in inputs:
|
||||
return None
|
||||
negative = self.side(inputs[conditioning["negative"]], sampler_id)
|
||||
except _Unresolved:
|
||||
return None
|
||||
if negative["text"] != base["negative"]["text"]:
|
||||
return None
|
||||
# D126: the positive does not include what the later stage added.
|
||||
provenance = dict(base["positive"]["provenance"])
|
||||
provenance["text_source"] = (
|
||||
RUNTIME_AND_ADDED_SOURCE
|
||||
if provenance["text_source"] == RUNTIME_PARTS_SOURCE
|
||||
else ADDED_CONDITIONING_SOURCE
|
||||
)
|
||||
provenance["added_by"] = composite
|
||||
positive = {**base["positive"], "provenance": provenance}
|
||||
return _result(
|
||||
"resolved",
|
||||
positive,
|
||||
base["negative"],
|
||||
base["sampler_id"],
|
||||
ADDED_CONDITIONING_REASON,
|
||||
)
|
||||
|
||||
def composite_of(self, value: Any) -> Optional[Tuple[list, int]]:
|
||||
"""An ``additive`` composite a conditioning input reaches, or ``None``.
|
||||
|
||||
Reroutes and conditioning pass-throughs (FluxGuidance and the like) are
|
||||
followed; returns the composite's part values, the depth reached and
|
||||
its node ID. ``text`` follows a composite only at output slot 0, so a
|
||||
stage whose positive failed as ``composite`` reached it there.
|
||||
"""
|
||||
depth = 0
|
||||
while _link(value) and depth < self.max_depth:
|
||||
node_id, slot = str(value[0]), value[1]
|
||||
name, inputs = self.node(node_id)
|
||||
depth += 1
|
||||
if name == "Reroute" and slot == 0:
|
||||
value = _reroute_value(inputs)
|
||||
continue
|
||||
forward = self.semantics.conditioning.get((name, slot))
|
||||
if forward is not None and forward["strategy"] == "passthrough":
|
||||
value = inputs.get(forward["input"])
|
||||
continue
|
||||
composite = self.semantics.composites.get(name)
|
||||
if composite is None or composite.get("additive") is not True:
|
||||
return None
|
||||
return [inputs.get(part) for part in composite["inputs"]], depth, node_id
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def walk_from(value: list) -> Tuple[deque, set]:
|
||||
"""A breadth-first queue holding one start link, and its queued IDs."""
|
||||
@@ -1394,10 +1183,7 @@ class _GraphResolver:
|
||||
if self.semantics.samplers[self.graph[node_id]["class_type"]]["strategy"]
|
||||
== _DETAILER
|
||||
]
|
||||
# A sampler whose latent continues another's (hires-fix, refiner) is
|
||||
# judged with it in ``stage``, like a detailer's upstream samplers.
|
||||
continued = self.continued_stages(sampler_ids) if len(sampler_ids) > 1 else []
|
||||
if not detailers and not continued:
|
||||
if not detailers:
|
||||
return sampler_ids
|
||||
try:
|
||||
consumers = self.consumers()
|
||||
@@ -1414,36 +1200,9 @@ class _GraphResolver:
|
||||
absorbed.add(node_id)
|
||||
except _Unresolved:
|
||||
continue
|
||||
for node_id in continued:
|
||||
self.budget.reset(self.per_candidate_visits)
|
||||
try:
|
||||
if not self.reaches_output(node_id, consumers):
|
||||
absorbed.add(node_id)
|
||||
except _Unresolved:
|
||||
continue
|
||||
kept = [node_id for node_id in sampler_ids if node_id not in absorbed]
|
||||
return kept or sampler_ids
|
||||
|
||||
def continued_stages(self, sampler_ids: list) -> list:
|
||||
"""Lineage predecessors of the non-detailer candidates, in order, once.
|
||||
|
||||
A predecessor equal to its own sampler (a cycle) is left out; a walk
|
||||
that cannot read a node names none.
|
||||
"""
|
||||
found: list = []
|
||||
for node_id in sampler_ids:
|
||||
entry = self.semantics.samplers[self.graph[node_id]["class_type"]]
|
||||
if entry["strategy"] == _DETAILER or entry.get("latent") is None:
|
||||
continue
|
||||
try:
|
||||
_, inputs = self.node(node_id)
|
||||
earlier = self.lineage_predecessor(inputs.get(entry["latent"]))
|
||||
except _Unresolved:
|
||||
continue
|
||||
if earlier is not None and earlier != node_id and earlier not in found:
|
||||
found.append(earlier)
|
||||
return found
|
||||
|
||||
def file_stages(self, sampler_ids: list) -> list:
|
||||
"""Stages not proven preview-only; all of them when every one is.
|
||||
|
||||
@@ -1622,13 +1381,7 @@ class _GraphResolver:
|
||||
reason=absent_reason,
|
||||
)
|
||||
negative = self.side(inputs[negative_name], sampler_id, guider_id, depth)
|
||||
return _result(
|
||||
"resolved",
|
||||
positive,
|
||||
negative,
|
||||
sampler_id,
|
||||
_parts_reason(positive, negative),
|
||||
)
|
||||
return _result("resolved", positive, negative, sampler_id)
|
||||
except _Unresolved as error:
|
||||
return _result(
|
||||
"unresolved",
|
||||
@@ -1679,7 +1432,7 @@ class _GraphResolver:
|
||||
return
|
||||
raise self.unsupported("Unsupported latent ancestry", node_id)
|
||||
|
||||
def latent_checked(self, sampler_id: str, value: Any) -> Optional[str]:
|
||||
def latent_checked(self, sampler_id: str, value: Any) -> None:
|
||||
"""``latent_stages`` once per sampler per resolution.
|
||||
|
||||
A candidate reusing another candidate's walk is charged its visit cost
|
||||
@@ -1688,29 +1441,26 @@ class _GraphResolver:
|
||||
out of work is never reused, since it depends on the remaining budget.
|
||||
"""
|
||||
if sampler_id in self.latent_walks:
|
||||
error, cost, earlier = self.latent_walks[sampler_id]
|
||||
error, cost = self.latent_walks[sampler_id]
|
||||
if sampler_id not in self.latent_charged:
|
||||
self.budget.charge(cost)
|
||||
self.latent_charged.add(sampler_id)
|
||||
else:
|
||||
before = self.budget.allowance
|
||||
earlier = None
|
||||
try:
|
||||
earlier = self.latent_stages(sampler_id, value)
|
||||
self.latent_stages(sampler_id, value)
|
||||
error = None
|
||||
except _Unresolved as raised:
|
||||
if raised.code == "work-limit":
|
||||
raise
|
||||
error = raised
|
||||
cost = before - self.budget.allowance
|
||||
self.latent_walks[sampler_id] = (error, cost, earlier)
|
||||
self.latent_walks[sampler_id] = (error, before - self.budget.allowance)
|
||||
self.latent_charged.add(sampler_id)
|
||||
if error is not None:
|
||||
node = dict(error.node) if error.node is not None else None
|
||||
raise _Unresolved(str(error), error.code, node)
|
||||
return earlier
|
||||
|
||||
def latent_stages(self, origin: str, value: Any) -> Optional[str]:
|
||||
def latent_stages(self, origin: str, value: Any) -> None:
|
||||
"""Semantics table: is there an earlier sampler in the latent ancestry?
|
||||
|
||||
Every link input of non-sampler nodes is walked breadth-first (encoders,
|
||||
@@ -1719,14 +1469,10 @@ class _GraphResolver:
|
||||
(VAEEncode, EmptyLatentBatch, WanImageToVideo slot 2, ...). An unlisted
|
||||
node with sampler-shaped inputs could itself be a stage, so it is
|
||||
``unsupported-node``. Budget or depth exhaustion is never single stage.
|
||||
One earlier sampler that the latent continues through ``lineage``
|
||||
nodes (``lineage_predecessor``) is returned, to be compared in
|
||||
``stage``; any other sampler found leaves the stages unattributed.
|
||||
"""
|
||||
if not _link(value):
|
||||
raise _Unresolved("Unknown latent ancestry", "malformed")
|
||||
queue, queued = self.walk_from(value)
|
||||
earlier, lineage, traced = None, None, False
|
||||
while queue:
|
||||
node_id, depth = queue.popleft()
|
||||
if node_id == origin:
|
||||
@@ -1736,17 +1482,12 @@ class _GraphResolver:
|
||||
self.budget.spend()
|
||||
name, inputs = self.node(node_id)
|
||||
if name in self.stage_classes:
|
||||
if not traced:
|
||||
lineage, traced = self.lineage_predecessor(value), True
|
||||
if node_id != lineage:
|
||||
# Not the one continued stage: no stage's prompt is chosen.
|
||||
raise _Unresolved(MULTISTAGE_UNATTRIBUTED_REASON)
|
||||
earlier = node_id
|
||||
continue
|
||||
# ``multistage-differs`` is reserved for when stage texts are
|
||||
# compared (PR3); until then no stage's prompt is chosen.
|
||||
raise _Unresolved(MULTISTAGE_UNATTRIBUTED_REASON)
|
||||
if self.stage_shaped(name, inputs):
|
||||
raise self.unsupported("Unsupported latent ancestry", node_id)
|
||||
self.enqueue(queue, queued, inputs, depth + 1)
|
||||
return earlier
|
||||
|
||||
def anchor(self, node_id: str) -> str:
|
||||
seen = set()
|
||||
|
||||
@@ -462,7 +462,7 @@
|
||||
"ControlNetApplyAdvanced", "ControlNetApplySD3", "InpaintModelConditioning", "WanImageToVideo", "ConditioningZeroOut",
|
||||
"CLIPTextEncode", "CLIPTextEncodeSDXL", "CLIPTextEncodeFlux", "CLIPTextEncodeSD3", "TextEncodeQwenImageEdit",
|
||||
"TextEncodeQwenImageEditPlus", "TextEncodeQwenImage21", "KikoTextEncode", "StringConstant", "StringConstantMultiline",
|
||||
"JoinStringMulti", "PromptSearchList", "TriggerWord Toggle (LoraManager)", "PreviewImage", "PreviewAny", "SaveImageWebsocket",
|
||||
"JoinStringMulti", "PromptSearchList", "PreviewImage", "PreviewAny", "SaveImageWebsocket",
|
||||
"Image Comparer (rgthree)", "DetailerForEach", "UltimateSDUpscale", "UltimateSDUpscaleNoUpscale",
|
||||
"ControlNetInpaintingAliMamaApply", "LTXVConditioning", "FluxKontextMultiReferenceLatentMethod", "Text to Conditioning",
|
||||
"TextInput", "DisplayText", "ClownsharKSampler_Beta", "KSampler (Efficient)", "ttN KSampler_v2",
|
||||
|
||||
@@ -4,9 +4,6 @@
|
||||
*
|
||||
* Settings are per browser (localStorage key "gallerySettings"). Anything
|
||||
* stored can be stale or hand-edited, so every value is checked before use.
|
||||
* The workspace shares this object ("One display configuration", #249 System
|
||||
* S3): it adds workspaceLayout and workspaceDensity, and every key either page
|
||||
* does not know is kept.
|
||||
*
|
||||
* Loadable in the browser (window.GallerySettings) and in Node for tests.
|
||||
*/
|
||||
@@ -14,9 +11,6 @@
|
||||
"use strict";
|
||||
|
||||
const VIEW_MODES = ["grid", "list"];
|
||||
// The workspace Images defaults; the legacy gallery's defaultViewMode has no masonry.
|
||||
const WORKSPACE_LAYOUTS = ["grid", "list", "masonry"];
|
||||
const WORKSPACE_DENSITIES = ["comfortable", "large", "compact"];
|
||||
const LIMIT_OPTIONS = [50, 100, 200, 500];
|
||||
const MIN_COLUMNS = 2;
|
||||
const MAX_COLUMNS = 12;
|
||||
@@ -40,8 +34,6 @@
|
||||
videoAutoplay: false,
|
||||
videoMute: true,
|
||||
videoLoop: true,
|
||||
workspaceLayout: "grid",
|
||||
workspaceDensity: "comfortable",
|
||||
});
|
||||
|
||||
function toInt(value) {
|
||||
@@ -80,8 +72,6 @@
|
||||
videoAutoplay: bool(merged.videoAutoplay, DEFAULT_SETTINGS.videoAutoplay),
|
||||
videoMute: bool(merged.videoMute, DEFAULT_SETTINGS.videoMute),
|
||||
videoLoop: bool(merged.videoLoop, DEFAULT_SETTINGS.videoLoop),
|
||||
workspaceLayout: WORKSPACE_LAYOUTS.includes(merged.workspaceLayout) ? merged.workspaceLayout : DEFAULT_SETTINGS.workspaceLayout,
|
||||
workspaceDensity: WORKSPACE_DENSITIES.includes(merged.workspaceDensity) ? merged.workspaceDensity : DEFAULT_SETTINGS.workspaceDensity,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -91,7 +81,7 @@
|
||||
return `repeat(${n}, minmax(0, 1fr))`;
|
||||
}
|
||||
|
||||
const api = { DEFAULT_SETTINGS, LIMIT_OPTIONS, VIEW_MODES, WORKSPACE_LAYOUTS, WORKSPACE_DENSITIES, normalizeGallerySettings, gridColumnsStyle };
|
||||
const api = { DEFAULT_SETTINGS, LIMIT_OPTIONS, VIEW_MODES, normalizeGallerySettings, gridColumnsStyle };
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
||||
else root.GallerySettings = api;
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
||||
+2
-3
@@ -1768,10 +1768,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
}
|
||||
|
||||
saveSettings() {
|
||||
// The workspace shares this object (its layout and density, #249 System S3), so a Save starts from what is
|
||||
// stored and keeps every key this dialog does not edit.
|
||||
const settings = {
|
||||
...this.getSettings(),
|
||||
lazyLoading: document.getElementById('lazyLoadingToggle').checked,
|
||||
imageQuality: document.getElementById('imageQualitySelect').value,
|
||||
defaultViewMode: document.getElementById('defaultViewModeSelect').value,
|
||||
@@ -1785,6 +1782,8 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
infiniteScroll: document.getElementById('infiniteScrollToggle').checked,
|
||||
debugMode: document.getElementById('debugModeToggle').checked,
|
||||
apiTimeout: parseInt(document.getElementById('apiTimeoutInput').value),
|
||||
thumbnailsGenerated: this.getSettings().thumbnailsGenerated, // Preserve this
|
||||
thumbnailPromptDismissed: this.getSettings().thumbnailPromptDismissed,
|
||||
checkThumbnailsAtStartup: document.getElementById('checkThumbnailsAtStartup').checked,
|
||||
// Video settings
|
||||
videoAutoplay: document.getElementById('videoAutoplayToggle').checked,
|
||||
|
||||
@@ -124,8 +124,8 @@
|
||||
}
|
||||
},
|
||||
"composites": {
|
||||
"ConditioningCombine": { "strategy": "combine", "inputs": ["conditioning_1", "conditioning_2"], "additive": true },
|
||||
"ConditioningConcat": { "strategy": "combine", "inputs": ["conditioning_to", "conditioning_from"], "additive": true },
|
||||
"ConditioningCombine": { "strategy": "combine", "inputs": ["conditioning_1", "conditioning_2"] },
|
||||
"ConditioningConcat": { "strategy": "combine", "inputs": ["conditioning_to", "conditioning_from"] },
|
||||
"ConditioningAverage": { "strategy": "combine", "inputs": ["conditioning_to", "conditioning_from"] }
|
||||
},
|
||||
"encoders": [
|
||||
@@ -175,7 +175,7 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"runtime": ["PromptSearchList", "TriggerWord Toggle (LoraManager)"],
|
||||
"runtime": ["PromptSearchList"],
|
||||
"savers": {
|
||||
"SaveImage": {
|
||||
"strategy": "comfy-counter",
|
||||
@@ -215,20 +215,6 @@
|
||||
}
|
||||
},
|
||||
"non_file_outputs": ["PreviewImage", "PreviewAny", "SaveImageWebsocket", "Image Comparer (rgthree)"],
|
||||
"lineage": [
|
||||
{ "class": "VAEDecode", "slot": 0, "strategy": "continue", "input": "samples" },
|
||||
{ "class": "VAEDecodeTiled", "slot": 0, "strategy": "continue", "input": "samples" },
|
||||
{ "class": "VAEEncode", "slot": 0, "strategy": "continue", "input": "pixels" },
|
||||
{ "class": "VAEEncodeTiled", "slot": 0, "strategy": "continue", "input": "pixels" },
|
||||
{ "class": "ImageUpscaleWithModel", "slot": 0, "strategy": "continue", "input": "image" },
|
||||
{ "class": "ImageScale", "slot": 0, "strategy": "continue", "input": "image" },
|
||||
{ "class": "ImageScaleBy", "slot": 0, "strategy": "continue", "input": "image" },
|
||||
{ "class": "ImageScaleToTotalPixels", "slot": 0, "strategy": "continue", "input": "image" },
|
||||
{ "class": "LatentUpscale", "slot": 0, "strategy": "continue", "input": "samples" },
|
||||
{ "class": "LatentUpscaleBy", "slot": 0, "strategy": "continue", "input": "samples" },
|
||||
{ "class": "Image Filter", "slot": 0, "strategy": "continue", "input": "images" },
|
||||
{ "class": "Image Filter", "slot": 1, "strategy": "continue", "input": "latents" }
|
||||
],
|
||||
"stage_inputs": {
|
||||
"shaped": [
|
||||
"latent_image",
|
||||
|
||||
@@ -148,20 +148,18 @@ function itemFor(job, scope) {
|
||||
chip.textContent = state;
|
||||
detail.textContent = jobDetail(next);
|
||||
const running = next.state === 'running';
|
||||
// parentNode, not isConnected: on the first render the item is not in the page yet, so a job that had already
|
||||
// ended when Activity opened kept its Cancel job (and a running one's progress moved after its actions).
|
||||
if (running) {
|
||||
progress.value = next.progress;
|
||||
progress.textContent = `${next.progress}%`;
|
||||
progress.setAttribute('aria-label', `${next.title} progress`);
|
||||
if (!progress.parentNode) item.insertBefore(progress, actions.parentNode ? actions : null);
|
||||
if (!progress.isConnected) item.insertBefore(progress, actions.isConnected ? actions : null);
|
||||
} 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.parentNode) item.append(actions);
|
||||
} else if (actions.parentNode) {
|
||||
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();
|
||||
actions.remove();
|
||||
|
||||
@@ -6,7 +6,6 @@ import { createWorkspaceIcon } from './icons.mjs';
|
||||
import { navigateTo } from './state.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
import { openCopyFallback } from './workspace-dialogs.mjs';
|
||||
import { displayPreferences } from './preferences.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.';
|
||||
@@ -156,10 +155,7 @@ function generationFacts(record, listClass, noteClass) {
|
||||
return [facts, explanation];
|
||||
}
|
||||
// D21: the panel badge names the strongest resolved prompt source; any other source shows no badge.
|
||||
// A prompt that left out a run-time PromptManager part (D125) or a later stage's added conditioning (D126) is still
|
||||
// inferred from the workflow.
|
||||
const EVIDENCE_BADGES = [['execution', 'Execution recorded'], ['graph-inferred', 'Workflow inferred'], ['runtime-parts-omitted', 'Workflow inferred'],
|
||||
['added-conditioning', 'Workflow inferred'], ['runtime-parts-omitted-and-added-conditioning', 'Workflow inferred'], ['embedded-parameters', 'Embedded parameters']];
|
||||
const EVIDENCE_BADGES = [['execution', 'Execution recorded'], ['graph-inferred', 'Workflow inferred'], ['embedded-parameters', 'Embedded parameters']];
|
||||
function evidenceBadge(prompts) {
|
||||
const sources = new Set(Object.values(prompts).filter(role => role?.status === 'resolved').map(role => role.source));
|
||||
return EVIDENCE_BADGES.find(([source]) => sources.has(source))?.[1] ?? null;
|
||||
@@ -200,10 +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`.
|
||||
*/
|
||||
/** D124: the inspector's line while "Load metadata on selection" is off. */
|
||||
export const DEFERRED_TEXT = 'Metadata loads when you ask for it.';
|
||||
|
||||
export function mountFileInspector(container, {api, sender, parentScope, variant, inline = false, promptContext = '', onTagFilter, onRetry, onRefresh, onLoad, onInteraction = () => {}, copyFallback = openCopyFallback}) {
|
||||
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;
|
||||
@@ -242,13 +235,6 @@ export function mountFileInspector(container, {api, sender, parentScope, variant
|
||||
function render(state) {
|
||||
const inner = el('div', undefined, full ? undefined : 'detail-inner');
|
||||
if (state.loading || state.error) {container.append(inner); statusView(state, inner); return;}
|
||||
// D124: "Load metadata on selection" is off, so the file's details wait for an explicit request.
|
||||
if (state.deferred) {
|
||||
container.append(inner);
|
||||
const status = el('p', DEFERRED_TEXT, 'small'); status.setAttribute('role', 'status'); inner.append(status);
|
||||
if (onLoad) {const load = createButton('Load metadata'); contentScope.listen(load, 'click', onLoad); inner.append(load);}
|
||||
return;
|
||||
}
|
||||
const record = state.record;
|
||||
if (!record) return;
|
||||
const {file, library} = record;
|
||||
@@ -340,10 +326,8 @@ export function mountFileInspector(container, {api, sender, parentScope, variant
|
||||
section.append(el('p', reported ? 'Current file at the reported location · identity unverified' : local ? 'Selected local file · read in this browser' :
|
||||
'Current file at this source; historical bytes are not verified.', 'small'));
|
||||
const path = el('dd', record.identity.path ?? 'Not available');
|
||||
// System › Appearance "Show file paths" off: the Path row is left out; nothing else changes.
|
||||
const showPath = !local && displayPreferences()?.showFilePaths !== false;
|
||||
const facts = file && definitionList([
|
||||
['Source', file.source_label], ...(showPath ? [['Path', path]] : []), ['Size', formatBytes(file.size)],
|
||||
['Source', file.source_label], ...(local ? [] : [['Path', path]]), ['Size', formatBytes(file.size)],
|
||||
['Dimensions', file.width && file.height ? `${file.width} × ${file.height}` : null],
|
||||
['Format', file.format || `${extensionOf(record)} extension`], ['Media type', file.media_type],
|
||||
['Modified', file.modified_time !== null ? new Date(file.modified_time * 1000).toLocaleString() : null],
|
||||
@@ -351,9 +335,8 @@ export function mountFileInspector(container, {api, sender, parentScope, variant
|
||||
if (facts) {
|
||||
// D38: "Show in folder" follows the Path value.
|
||||
const target = api ? revealTarget(record.identity) : null;
|
||||
if (target) path.append(' ', revealButton(target));
|
||||
section.append(facts);
|
||||
if (target && showPath) path.append(' ', revealButton(target));
|
||||
else if (target) section.append(revealButton(target));
|
||||
} else section.append(el('p', 'Current file observations are unavailable for this inspection response.', 'small'));
|
||||
// The concept's "Prompt evidence: execution recorded" line, in the D21 badge's words; no badge, no line.
|
||||
if (badge) section.append(el('p', `Prompt evidence: ${badge.toLowerCase()}`, 'small'));
|
||||
|
||||
@@ -1,17 +1,6 @@
|
||||
import { noun } from './plural.mjs';
|
||||
|
||||
/** Identity is the source and relative path; content hashes may be shared by files. */
|
||||
/**
|
||||
* The layout and thumbnail size an Images visit starts with (#249 System S3). The last visit (the current view) keeps
|
||||
* its own; a saved view starts as before, at Grid and Comfortable; any other new visit starts from System ›
|
||||
* Appearance's defaults (`preferences`, preferences.mjs values, or null when unavailable).
|
||||
*/
|
||||
export function initialImagesView({ prior = null, saved = null, preferences = null } = {}) {
|
||||
if (prior) return { layout: prior.layout || 'grid', density: prior.density || 'comfortable' };
|
||||
if (saved || !preferences) return { layout: 'grid', density: 'comfortable' };
|
||||
return { layout: preferences.workspaceLayout || 'grid', density: preferences.workspaceDensity || 'comfortable' };
|
||||
}
|
||||
|
||||
export function imageKey(image) {
|
||||
return `${image.root_index}:${image.path}`;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createScope, createButton } from '../shared/toolkit/core.js';
|
||||
import { createImageStream, withItem, imageKey, countLine, captionDate, scopeLabel, activeRefinements, cardTitle, cardSubtitle, mediaRatio, initialImagesView } from './images-state.mjs';
|
||||
import { createImageStream, withItem, imageKey, countLine, captionDate, scopeLabel, activeRefinements, cardTitle, cardSubtitle, mediaRatio } from './images-state.mjs';
|
||||
import { layoutImages, visibleImages, gridMetrics, stacksCardInfo } from './image-layout.mjs';
|
||||
import { mountFileInspector } from './file-inspector.mjs';
|
||||
import { createInspectionController } from './inspection-state.mjs';
|
||||
@@ -15,7 +15,6 @@ import { openImportDialog, openRefineDialog } from './workspace-dialogs.mjs';
|
||||
import { openMetadataScan } from './activity-scan.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
import { imagesFilters, imagesViewState } from './saved-views.mjs';
|
||||
import { displayPreferences } from './preferences.mjs';
|
||||
import { createTagButton, onImageTagsSaved } from './image-tagging.mjs';
|
||||
import { followImageTagSaves, openCardTagging } from './images-tagging.mjs';
|
||||
import { favoriteBusy, paintStar, receiveFavorites, setFavoriteVisitTarget, toggleFavorite } from './images-favorites.mjs';
|
||||
@@ -112,9 +111,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
const { resolveUrl } = window.ApiClient;
|
||||
const sender = createPromptSender({scope});
|
||||
let detailInspector;
|
||||
// System › Appearance: the display defaults this visit starts from (preferences.mjs); null without gallery-settings.js.
|
||||
const preferences = displayPreferences();
|
||||
const inspection = createInspectionController({api, autoLoad:preferences?.autoLoadMetadata !== false, cache:preferences?.cacheMetadata !== false});
|
||||
const inspection = createInspectionController({api});
|
||||
scope.cleanup(inspection.subscribe(next => detailInspector?.setState(next)));
|
||||
scope.cleanup(() => inspection.dispose());
|
||||
scope.cleanup(() => detailInspector?.dispose());
|
||||
@@ -122,12 +119,9 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
const prior = embedded || saved || favorites ? null : lastVisit;
|
||||
// A stale visit keeps its filters, layout and size, and lists again instead of restoring (M6).
|
||||
const visit = prior?.stale ? null : prior;
|
||||
// The layout and size defaults are the Images page's: an embedded gallery (a prompt history's, its dialogs) has no
|
||||
// toolbar to change them, so it keeps its fixed Grid/Comfortable. The metadata and card defaults apply everywhere.
|
||||
const start = initialImagesView({prior, saved, preferences:embedded ? null : preferences});
|
||||
let density = start.density;
|
||||
let density = prior?.density || 'comfortable';
|
||||
let metrics = {...INITIAL_METRICS};
|
||||
let layout = start.layout, selection = visit?.selection || null, frame = 0, loadFrame = 0, restoreFrame = 0, observer;
|
||||
let layout = prior?.layout || 'grid', selection = visit?.selection || null, frame = 0, loadFrame = 0, restoreFrame = 0, observer;
|
||||
let state, geometry = { positions: [], columns: 1, height: 0, maxHeight: 0 }, galleryWidth = 0, optionsRequest, categoryRequest, recovering = false, rescanning = false;
|
||||
// Discovered sources (each with its own folders) and every folder, for "Refine this view" and the scope chip.
|
||||
let roots = [], folders = [];
|
||||
@@ -293,7 +287,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
selection = imageKey(image);
|
||||
for (const video of details.querySelectorAll('video')) releaseVideo(video);
|
||||
detailInspector = mountFileInspector(details, {sender, parentScope:scope, variant:'panel',
|
||||
onTagFilter:embedded ? undefined : filterByTag, onRetry:() => inspection.retry(), onLoad:() => inspection.load(), onRefresh:refreshInspectionCollection});
|
||||
onTagFilter:embedded ? undefined : filterByTag, onRetry:() => inspection.retry(), onRefresh:refreshInspectionCollection});
|
||||
detailInspector.setState(inspection.state);
|
||||
inspection.select(image);
|
||||
for (const [key, card] of rendered) markSelected(card, key === selection);
|
||||
@@ -337,8 +331,6 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
const text = el('div');
|
||||
const title = el('h3', cardTitle(image)); title.title = image.path;
|
||||
text.append(title, el('p', cardSubtitle(image)));
|
||||
// D97: "Show image details on cards" off hides the title and file line; Info and Tag stay.
|
||||
if (preferences?.showImageInfo === false) text.hidden = true;
|
||||
const actions = el('div', undefined, 'image-card-actions');
|
||||
const inspectButton = createButton('', 'icon-btn');
|
||||
inspectButton.append(createWorkspaceIcon('info'));
|
||||
|
||||
@@ -344,16 +344,9 @@ export function createInspectionLoader(api, onChange, { extractFacts, adapter =
|
||||
};
|
||||
}
|
||||
|
||||
const emptyState = () => ({ loading: false, error: false, deferred: false, record: null, prompts: undefined, message: '', refreshRequired: false, code: null, recovery: null });
|
||||
const emptyState = () => ({ loading: false, error: false, record: null, prompts: undefined, message: '', refreshRequired: false, code: null, recovery: null });
|
||||
|
||||
/**
|
||||
* One selection's inspection. #249 System S3 (Appearance): `autoLoad: false` ("Load metadata on selection" off) makes a
|
||||
* selection publish `deferred` instead of reading, and `load()` reads it once on request; `cache: false` ("Cache
|
||||
* metadata in this browser" off) re-reads the selected file when it is selected again, where the default reuses the one
|
||||
* current record. A read in flight is shared either way, and a file whose source changed is never re-read by selecting
|
||||
* it again (as `retry()` and `refresh()` refuse).
|
||||
*/
|
||||
export function createInspectionController({ api, extractFacts, adapter = rootedFileAdapter, autoLoad = true, cache = true }) {
|
||||
export function createInspectionController({ api, extractFacts, adapter = rootedFileAdapter }) {
|
||||
let state = Object.freeze(emptyState()), current = null, pending = null, disposed = false;
|
||||
let attempted, hasAttempt = false;
|
||||
const listeners = new Set();
|
||||
@@ -376,19 +369,13 @@ export function createInspectionController({ api, extractFacts, adapter = rooted
|
||||
try {
|
||||
adapter.validate(image);
|
||||
const scope = adapter.scope(image), key = adapter.key(image), path = adapter.inspectionPath(image);
|
||||
if (current?.key === key && current.scope === scope && current.path === path && (cache || state.loading || state.deferred || state.refreshRequired)) return pending;
|
||||
if (current?.key === key && current.scope === scope && current.path === path) return pending;
|
||||
selected = { key, scope, path };
|
||||
} catch {
|
||||
// The loader publishes validation errors after invalidating the previous source.
|
||||
}
|
||||
loader.dispose(); loader = makeLoader(); current = selected;
|
||||
attempted = object(image) ? { ...image, ...(adapter !== rootedFileAdapter ? {occurrence:{...image.occurrence}, reference:{...image.reference}} : {}) } : image; hasAttempt = true;
|
||||
if (!autoLoad && selected) { pending = null; publish({ deferred: true }); return undefined; }
|
||||
return read(attempted);
|
||||
},
|
||||
/** Read a deferred selection (the inspector's "Load metadata"); a read already made or running is reused. */
|
||||
load() {
|
||||
if (disposed || !hasAttempt || !state.deferred) return pending ?? undefined;
|
||||
return read(attempted);
|
||||
},
|
||||
retry() {
|
||||
@@ -397,8 +384,7 @@ export function createInspectionController({ api, extractFacts, adapter = rooted
|
||||
},
|
||||
/** Re-read the selected file keeping the shown record until the answer (a retry when none is shown). */
|
||||
refresh() {
|
||||
// A deferred selection loads only through `load()` (the user's "Load metadata").
|
||||
if (disposed || !hasAttempt || state.refreshRequired || state.deferred) return;
|
||||
if (disposed || !hasAttempt || state.refreshRequired) return;
|
||||
return read(attempted, { quiet: Boolean(state.record) && !state.loading && !state.error });
|
||||
},
|
||||
clear() {
|
||||
|
||||
@@ -1,30 +1,17 @@
|
||||
// Candidate selection for local-resolver.mjs (#249 local Inspect, PR L3 Task 9): ports of
|
||||
// utils/prompt_pair_resolver.py `_GraphResolver` `stage`, `earlier_stages`, `added_conditioning`, `composite_of`,
|
||||
// `upstream_stages`, `final_stages`, `continued_stages`, `file_stages`, `non_file_outputs`, `producers_of`,
|
||||
// `consumers`, `reaches_output`, `lineage_predecessor`, `latent_stages` and `part`, plus `_left_out`, `_outcome_key`,
|
||||
// `_recipe_key`, `_combined_positive` and `_shared_pair` (#270). Pass-through lineage: a sampler whose latent continues
|
||||
// another's through `lineage` nodes is compared with it. 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.
|
||||
// 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 {
|
||||
ADDED_CONDITIONING_REASON, ADDED_CONDITIONING_SOURCE, DETAILER, IDENTICAL_BRANCHES_REASON,
|
||||
MULTISTAGE_UNATTRIBUTED_REASON, RUNTIME_AND_ADDED_SOURCE, RUNTIME_PARTS_SOURCE, Unresolved, isLink, rerouteValue, result,
|
||||
slotKey,
|
||||
} from './local-resolver-support.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;
|
||||
/** `_left_out`: the omitting PromptManagers and the added-by composite, so a partial text never equals a complete one. */
|
||||
function leftOut(side) {
|
||||
const provenance = side !== null && typeof side === 'object' ? side.provenance : null;
|
||||
if (provenance === null || typeof provenance !== 'object') return [];
|
||||
return [provenance.omitted_parts ?? [], provenance.added_by ?? null];
|
||||
}
|
||||
/** `_outcome_key`: what a reader sees of a result, the state, the two role texts and what each leaves out. */
|
||||
const outcomeParts = item => [item.state, roleText(item.positive), roleText(item.negative), leftOut(item.positive), leftOut(item.negative)];
|
||||
export const outcomeKey = item => JSON.stringify(outcomeParts(item));
|
||||
/** `_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([...outcomeParts(item), storageText(item.positive), storageText(item.negative)]);
|
||||
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) {
|
||||
@@ -34,29 +21,26 @@ export function sharedPair(candidates) {
|
||||
return result('resolved', { positive: first.positive, negative: first.negative, reason: IDENTICAL_BRANCHES_REASON, candidates });
|
||||
}
|
||||
|
||||
/** `_combined_positive`: whether a stage failed only because its positive combines texts. */
|
||||
const combinedPositive = item => item.reason_code === 'composite' && item.positive === null;
|
||||
|
||||
/** `stage`: one sampler's result, compared with the stages it continues (detailer image, latent lineage). */
|
||||
/** `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);
|
||||
const combined = own.state === 'unresolved' && combinedPositive(own);
|
||||
if (own.state === 'unresolved' && !combined) return own;
|
||||
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 = earlierStages(resolver, samplerId);
|
||||
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;
|
||||
if (combined) return own;
|
||||
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');
|
||||
if (combined) return blocked !== undefined ? own : addedConditioning(resolver, samplerId, stages) ?? own;
|
||||
// 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 = [];
|
||||
@@ -65,67 +49,6 @@ export function stage(resolver, samplerId, later) {
|
||||
return result('ambiguous', { samplerId, reason: 'Sampler stages have different prompts', candidates: flat, reasonCode: 'multistage-differs' });
|
||||
}
|
||||
|
||||
/** `earlier_stages`: a detailer's upstream samplers, or the one sampler a latent continues (cached walk). */
|
||||
function earlierStages(resolver, samplerId) {
|
||||
const [name, inputs] = resolver.node(samplerId);
|
||||
const entry = resolver.semantics.samplers.get(name);
|
||||
if (entry.strategy === DETAILER) return upstreamStages(resolver, samplerId, pyGet(inputs, entry.image_stage));
|
||||
const walked = resolver.latentWalks.get(samplerId);
|
||||
if (walked === undefined || walked[2] === null) return [];
|
||||
return [walked[2]];
|
||||
}
|
||||
|
||||
/** `added_conditioning`: the earlier stages' shared pair when this stage's positive only adds to it. */
|
||||
function addedConditioning(resolver, samplerId, stages) {
|
||||
const flat = [];
|
||||
for (const item of stages) flat.push(...(item.state === 'ambiguous' ? item.candidates : [item]));
|
||||
const base = flat[0];
|
||||
if (base.state !== 'resolved' || new Set(flat.map(outcomeKey)).size !== 1) return null;
|
||||
const [name, inputs] = resolver.node(samplerId);
|
||||
const entry = resolver.semantics.samplers.get(name);
|
||||
if (entry.strategy === 'custom-advanced') return null;
|
||||
const conditioning = entry.conditioning;
|
||||
let negative, composite;
|
||||
try {
|
||||
const found = compositeOf(resolver, pyGet(inputs, conditioning.positive));
|
||||
if (found === null) return null;
|
||||
let parts, depth;
|
||||
[parts, depth, composite] = found;
|
||||
const texts = parts.map(part => resolver.text(part, 'conditioning', depth)[0]);
|
||||
if (!texts.includes(base.positive.text)) return null;
|
||||
if (!inputs.has(conditioning.negative)) return null;
|
||||
negative = resolver.side(inputs.get(conditioning.negative), samplerId);
|
||||
} catch (error) {
|
||||
if (!(error instanceof Unresolved)) throw error;
|
||||
return null;
|
||||
}
|
||||
if (negative.text !== base.negative.text) return null;
|
||||
// D126: the positive does not include what the later stage added.
|
||||
const provenance = { ...base.positive.provenance };
|
||||
provenance.text_source = provenance.text_source === RUNTIME_PARTS_SOURCE ? RUNTIME_AND_ADDED_SOURCE : ADDED_CONDITIONING_SOURCE;
|
||||
provenance.added_by = composite;
|
||||
const positive = { ...base.positive, provenance };
|
||||
return result('resolved', { positive, negative: base.negative, samplerId: base.sampler_id, reason: ADDED_CONDITIONING_REASON });
|
||||
}
|
||||
|
||||
/** `composite_of`: [part values, depth, node ID] of the `additive` composite a conditioning input reaches, or null. */
|
||||
function compositeOf(resolver, value) {
|
||||
let depth = 0;
|
||||
while (isLink(value) && depth < resolver.maxDepth) {
|
||||
const nodeId = String(value[0]), slot = value[1];
|
||||
const [name, inputs] = resolver.node(nodeId);
|
||||
depth += 1;
|
||||
if (name === 'Reroute' && slot === 0) { value = rerouteValue(inputs); continue; }
|
||||
const forward = resolver.semantics.conditioning.get(slotKey(name, slot));
|
||||
if (forward !== undefined && forward.strategy === 'passthrough') { value = pyGet(inputs, forward.input); continue; }
|
||||
// `text` follows a composite only at output slot 0, so a failed `composite` positive reached it there.
|
||||
const composite = resolver.semantics.composites.get(name);
|
||||
if (composite === undefined || composite.additive !== true) return null;
|
||||
return [composite.inputs.map(part => pyGet(inputs, part)), depth, nodeId];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** `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');
|
||||
@@ -149,8 +72,7 @@ const strategyOf = (resolver, nodeId) => resolver.semantics.samplers.get(resolve
|
||||
export function finalStages(resolver, samplerIds) {
|
||||
samplerIds = fileStages(resolver, samplerIds);
|
||||
const detailers = samplerIds.filter(nodeId => strategyOf(resolver, nodeId) === DETAILER);
|
||||
const continued = samplerIds.length > 1 ? continuedStages(resolver, samplerIds) : [];
|
||||
if (!detailers.length && !continued.length) return samplerIds;
|
||||
if (!detailers.length) return samplerIds;
|
||||
let reverse;
|
||||
try { reverse = consumers(resolver); } catch (error) {
|
||||
if (!(error instanceof Unresolved)) throw error;
|
||||
@@ -169,37 +91,10 @@ export function finalStages(resolver, samplerIds) {
|
||||
if (!(error instanceof Unresolved)) throw error;
|
||||
}
|
||||
}
|
||||
for (const nodeId of continued) {
|
||||
resolver.budget.reset(resolver.perCandidateVisits);
|
||||
try {
|
||||
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;
|
||||
}
|
||||
|
||||
/** `continued_stages`: lineage predecessors of the non-detailer candidates, in order, once. */
|
||||
function continuedStages(resolver, samplerIds) {
|
||||
const found = [];
|
||||
for (const nodeId of samplerIds) {
|
||||
const entry = resolver.semantics.samplers.get(resolver.graph.get(nodeId).get('class_type'));
|
||||
if (entry.strategy === DETAILER || entry.latent === undefined || entry.latent === null) continue;
|
||||
let earlier;
|
||||
try {
|
||||
const [, inputs] = resolver.node(nodeId);
|
||||
earlier = lineagePredecessor(resolver, pyGet(inputs, entry.latent));
|
||||
} catch (error) {
|
||||
if (!(error instanceof Unresolved)) throw error;
|
||||
continue;
|
||||
}
|
||||
if (earlier !== null && earlier !== nodeId && !found.includes(earlier)) found.push(earlier);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/** `file_stages`: stages not proven preview-only; all of them when every one is. */
|
||||
function fileStages(resolver, samplerIds) {
|
||||
if (samplerIds.length < 2) return samplerIds;
|
||||
@@ -277,57 +172,3 @@ function reachesOutput(resolver, nodeId, reverse) {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** `lineage_predecessor`: the sampler whose output a latent input continues through `lineage` nodes, or null. */
|
||||
function lineagePredecessor(resolver, value) {
|
||||
const seen = new Set();
|
||||
while (isLink(value) && seen.size < resolver.maxDepth) {
|
||||
const nodeId = String(value[0]), slot = value[1];
|
||||
if (seen.has(nodeId)) return null;
|
||||
seen.add(nodeId);
|
||||
const [name, inputs] = resolver.node(nodeId);
|
||||
if (resolver.stageClasses.has(name)) return slot === 0 ? nodeId : null;
|
||||
if (name === 'Reroute' && slot === 0) { value = rerouteValue(inputs); continue; }
|
||||
const entry = resolver.semantics.lineage.get(slotKey(name, slot));
|
||||
if (entry === undefined) return null;
|
||||
value = pyGet(inputs, entry.input);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** `latent_stages`: the one earlier sampler a latent continues, null for a single stage; else unattributed. */
|
||||
export function latentStages(resolver, origin, value) {
|
||||
if (!isLink(value)) throw new Unresolved('Unknown latent ancestry', 'malformed');
|
||||
const start = String(value[0]), queue = [[start, 0]], queued = new Set([start]);
|
||||
let earlier = null, lineage = null, traced = false;
|
||||
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 >= 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)) {
|
||||
if (!traced) { lineage = lineagePredecessor(resolver, value); traced = true; }
|
||||
if (nodeId !== lineage) throw new Unresolved(MULTISTAGE_UNATTRIBUTED_REASON);
|
||||
earlier = nodeId;
|
||||
continue;
|
||||
}
|
||||
if (resolver.stageShaped(name, inputs)) throw resolver.unsupported('Unsupported latent ancestry', nodeId);
|
||||
resolver.enqueue(queue, queued, inputs, depth + 1);
|
||||
}
|
||||
return earlier;
|
||||
}
|
||||
|
||||
/** `part`: a PromptManager prepend or append, traced, or left out when a listed run-time node feeds it directly. */
|
||||
export function promptPart(resolver, value, depth, omitted) {
|
||||
try {
|
||||
const [text, path] = resolver.text(value, 'string', depth + 1);
|
||||
return [text, path];
|
||||
} catch (error) {
|
||||
// A listed run-time node raises at itself, so a deeper run-time source never matches here.
|
||||
if (!(error instanceof Unresolved) || error.code !== 'runtime-value' || !isLink(value) ||
|
||||
!resolver.semantics.runtime.has(resolver.node(String(value[0]))[0])) throw error;
|
||||
omitted.push(String(value[0]));
|
||||
return ['', []];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,11 +31,6 @@ 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 ADDED_CONDITIONING_REASON = 'A later stage adds conditioning to the base prompt';
|
||||
export const RUNTIME_PARTS_SOURCE = 'runtime-parts-omitted';
|
||||
export const RUNTIME_PARTS_REASON = 'Text added at run time, such as trigger words, is not included';
|
||||
export const ADDED_CONDITIONING_SOURCE = 'added-conditioning';
|
||||
export const RUNTIME_AND_ADDED_SOURCE = 'runtime-parts-omitted-and-added-conditioning';
|
||||
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';
|
||||
@@ -124,11 +119,6 @@ export function isLink(value) {
|
||||
const [source, slot] = value;
|
||||
return (typeof source === 'string' || isInt(source)) && isInt(slot) && (typeof slot === 'bigint' ? slot >= 0n : slot >= 0);
|
||||
}
|
||||
/** `_reroute_value`: a Reroute's one input (`value`, `input` or `in`), else null. */
|
||||
export function rerouteValue(inputs) {
|
||||
const keys = ['value', 'input', 'in'].filter(key => inputs.has(key));
|
||||
return keys.length === 1 ? inputs.get(keys[0]) : null;
|
||||
}
|
||||
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 } = {}) {
|
||||
@@ -235,20 +225,10 @@ export function compileSemantics(doc) {
|
||||
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');
|
||||
// `lineage` describes an output's image or latent, not its prompt: its classes are not claimed.
|
||||
if (!Array.isArray(doc.lineage)) fail('category lineage must be a list');
|
||||
const lineage = new Map();
|
||||
for (const row of doc.lineage) {
|
||||
if (!plain(row) || typeof row.class !== 'string' || !Number.isInteger(row.slot) || row.slot < 0 || row.slot > 255) fail('lineage row');
|
||||
if (row.strategy !== 'continue' || typeof row.input !== 'string' || !row.input) fail('lineage row');
|
||||
const key = slotKey(row.class, row.slot);
|
||||
if (lineage.has(key)) fail('lineage: duplicate output key');
|
||||
lineage.set(key, { strategy: row.strategy, input: row.input });
|
||||
}
|
||||
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, lineage,
|
||||
samplers, guiders, selectors, composites, savers, conditioning, encoders, strings, runtime, nonFileOutputs,
|
||||
stageInputs: { shaped: [...stageInputs.shaped], withModel: [...stageInputs.with_model] }, allClasses: new Set(owners.keys()),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
MAX_JOIN_INPUTS,
|
||||
MAX_SUBGRAPH_DEPTH,
|
||||
MAX_WORKFLOW_NODES,
|
||||
MULTISTAGE_UNATTRIBUTED_REASON,
|
||||
NO_SAMPLER_NAMES,
|
||||
OVERWRITTEN_PROMPT_REASON,
|
||||
PROFILE,
|
||||
@@ -38,14 +39,12 @@ import {
|
||||
explained,
|
||||
isLink,
|
||||
nodeRef,
|
||||
RUNTIME_PARTS_REASON,
|
||||
RUNTIME_PARTS_SOURCE,
|
||||
readPairExecution,
|
||||
result,
|
||||
sideOf,
|
||||
slotKey,
|
||||
} from './local-resolver-support.mjs';
|
||||
import { finalStages, latentStages, outcomeKey, promptPart, sharedPair, stage } from './local-resolver-stages.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';
|
||||
@@ -70,7 +69,6 @@ class GraphResolver {
|
||||
this.active = new Set();
|
||||
this.metadata = metadata ?? new Map();
|
||||
this.textSources = new Map();
|
||||
this.omitting = new Set();
|
||||
this.choices = new Map();
|
||||
this.tainted = false;
|
||||
this.latentWalks = new Map();
|
||||
@@ -169,13 +167,11 @@ class GraphResolver {
|
||||
[text, path, storageText] = this.text(pyGet(inputs, adapted.inputName), 'string', depth + 1);
|
||||
if (adapted.compose || adapted.storageInput) storageText = text;
|
||||
if (adapted.compose) {
|
||||
const omitted = [];
|
||||
const [before, beforePath] = promptPart(this, pyGet(inputs, 'prepend_text', ''), depth, omitted);
|
||||
const [after, afterPath] = promptPart(this, pyGet(inputs, 'append_text', ''), depth, omitted);
|
||||
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];
|
||||
if (omitted.length) this.omitting.add(slotKey(nodeId, slot));
|
||||
}
|
||||
}
|
||||
} else if ((name === 'StringConcatenate' || name === 'Text Concatenate') && slot === 0 && kind === 'string') {
|
||||
@@ -451,10 +447,6 @@ class GraphResolver {
|
||||
text_source: this.textSources.get(slotKey(origin.node_id, origin.output_slot)) ?? 'graph-inferred',
|
||||
};
|
||||
if (guiderId !== null) provenance.guider_id = guiderId;
|
||||
const omitted = [];
|
||||
for (const step of path) if (this.omitting.has(slotKey(step.node_id, step.output_slot)) && !omitted.includes(step.node_id)) omitted.push(step.node_id);
|
||||
// Through encoders, concatenations and chained PromptManagers (D125).
|
||||
if (omitted.length) { provenance.text_source = RUNTIME_PARTS_SOURCE; provenance.omitted_parts = omitted; }
|
||||
return { text, storage_text: storageText, provenance };
|
||||
}
|
||||
|
||||
@@ -535,8 +527,7 @@ class GraphResolver {
|
||||
return result('absent', { positive, samplerId, reason: absentReason });
|
||||
}
|
||||
negative = this.side(inputs.get(negativeName), samplerId, guiderId, depth);
|
||||
const omitted = [positive, negative].some(side => side.provenance.text_source === RUNTIME_PARTS_SOURCE);
|
||||
return result('resolved', { positive, negative, samplerId, reason: omitted ? RUNTIME_PARTS_REASON : '' });
|
||||
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 });
|
||||
@@ -552,13 +543,11 @@ class GraphResolver {
|
||||
}
|
||||
}
|
||||
|
||||
/** `latent_checked`: `latent_stages` once per sampler; returns the continued earlier stage, or null. */
|
||||
latentChecked(samplerId, value) {
|
||||
let error, earlier = null;
|
||||
let error;
|
||||
if (this.latentWalks.has(samplerId)) {
|
||||
const [walked, cost, found] = this.latentWalks.get(samplerId);
|
||||
const [walked, cost] = this.latentWalks.get(samplerId);
|
||||
error = walked;
|
||||
earlier = found;
|
||||
if (!this.latentCharged.has(samplerId)) {
|
||||
this.budget.charge(cost);
|
||||
this.latentCharged.add(samplerId);
|
||||
@@ -566,17 +555,16 @@ class GraphResolver {
|
||||
} else {
|
||||
const before = this.budget.allowance;
|
||||
try {
|
||||
earlier = latentStages(this, samplerId, value);
|
||||
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, earlier]);
|
||||
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 });
|
||||
return earlier;
|
||||
}
|
||||
|
||||
enqueue(queue, queued, inputs, depth) {
|
||||
@@ -594,6 +582,21 @@ class GraphResolver {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -2,14 +2,14 @@ import {createScope, createButton} from '../shared/toolkit/core.js';
|
||||
import {createViewerSession} from './viewer-state.mjs';
|
||||
import {rootedFileAdapter} from './inspection-state.mjs';
|
||||
import {mountFileInspector} from './file-inspector.mjs';
|
||||
import {displayPreferences, readPreferences} from './preferences.mjs';
|
||||
|
||||
let viewerId = 0;
|
||||
// Upper bound on waiting for a closed Viewer's in-flight image view before destroying it.
|
||||
const PENDING_VIEW_DESTROY_MS = 10000;
|
||||
/** The video defaults, read through the shared display defaults (preferences.mjs, #249 System S3). */
|
||||
export function readViewerPreferences(getStorage = () => window.localStorage, normalize = globalThis.GallerySettings.normalizeGallerySettings) {
|
||||
const {videoAutoplay,videoMute,videoLoop} = readPreferences(getStorage,normalize).values;
|
||||
let stored;
|
||||
try { stored = JSON.parse(getStorage().getItem('gallerySettings')); } catch { stored = null; }
|
||||
const {videoAutoplay,videoMute,videoLoop} = normalize(stored);
|
||||
return {videoAutoplay,videoMute,videoLoop};
|
||||
}
|
||||
function element(tag, className, text) {
|
||||
@@ -35,7 +35,7 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle
|
||||
if (scope.signal.aborted) return;
|
||||
active?.close(false);
|
||||
const {items,selectedKey,trigger = document.activeElement} = options;
|
||||
let mounted = false, initial, entry, currentKey, viewer = null, withoutViewer = false, mediaScope = null, closed = false;
|
||||
let mounted = false, initial, entry, currentKey, viewer = null, mediaScope = null, closed = false;
|
||||
let originalDownload = null, inspectionState = inspectionController.state;
|
||||
const session = createViewerSession({adapter,items,initialKey:selectedKey,onChange:(state,selected) => {
|
||||
if (!mounted) { initial = [state,selected]; return; }
|
||||
@@ -74,31 +74,15 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle
|
||||
local.listen(download,'click',event => {session.stop();if (download.getAttribute('aria-disabled') === 'true') event.preventDefault();});
|
||||
controls.append(download);
|
||||
const next = button('Next media','Next →',() => session.navigate(1));
|
||||
// D97: "Collapse viewer details by default" opens the viewer with its details hidden behind one toggle.
|
||||
const collapsed = displayPreferences()?.sidebarCollapsedByDefault === true;
|
||||
let detailsToggle = null;
|
||||
if (collapsed) {
|
||||
detailsToggle = createButton('Show details');detailsToggle.setAttribute('aria-expanded','false');
|
||||
detailsToggle.setAttribute('aria-controls','viewer-details');controls.append(detailsToggle);
|
||||
}
|
||||
const position = element('p','media-position muted');position.id = `media-viewer-position-${++viewerId}`;
|
||||
position.setAttribute('role','status');
|
||||
const reason = element('p','media-reason muted');reason.id = `media-viewer-reason-${viewerId}`;
|
||||
const slideshowReason = element('p','media-slideshow-reason muted');slideshowReason.id = `media-viewer-slideshow-${viewerId}`;
|
||||
controls.append(position,reason,slideshowReason);left.append(stage,controls);
|
||||
const details = element('aside');details.id = 'viewer-details';details.setAttribute('aria-label','Image metadata');
|
||||
if (detailsToggle) {
|
||||
details.hidden = true;
|
||||
local.listen(detailsToggle,'click',() => {
|
||||
details.hidden = !details.hidden;
|
||||
detailsToggle.textContent = details.hidden ? 'Show details' : 'Hide details';
|
||||
detailsToggle.setAttribute('aria-expanded',String(!details.hidden));
|
||||
});
|
||||
}
|
||||
body.append(left,details);dialog.append(header,body);container.append(dialog);
|
||||
const localSender = {open(roles,trigger) {session.stop();sender.open(roles,trigger);}};
|
||||
const inspector = mountFileInspector(details,{api,sender:localSender,parentScope:local,variant:'full',onInteraction:() => session.stop(),onRetry:() => inspectionController.retry(),
|
||||
onLoad:() => inspectionController.load?.(),
|
||||
onRefresh:onRefresh ? () => { closeViewer();onRefresh(); } : undefined});
|
||||
function closeViewer(restore = true) {
|
||||
if (closed) return;
|
||||
@@ -131,9 +115,7 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle
|
||||
mediaScope?.dispose();mediaScope = createScope();viewer = null;
|
||||
for (const child of [...stage.children]) if (child !== failure) child.remove();
|
||||
failure.hidden = true;
|
||||
const mediaLocal = mediaScope, key = session.state.key, ViewerJs = window.Viewer;
|
||||
// Transforms follow the Viewer.js this media was built with, which builds once the image has loaded.
|
||||
withoutViewer = !ViewerJs;
|
||||
const mediaLocal = mediaScope, key = session.state.key;
|
||||
const current = () => !local.signal.aborted && !mediaLocal.signal.aborted && session.state.key === key;
|
||||
// An adapter's own media URL (a session-owned blob:) is used exactly, so a retry reuses it; a server
|
||||
// original gets a fresh cache-buster on every build.
|
||||
@@ -160,23 +142,15 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle
|
||||
},{once:true});
|
||||
} else {
|
||||
node.alt = selected.filename;node.decoding = 'async';
|
||||
node.hidden = !!ViewerJs;
|
||||
node.hidden = !!window.Viewer;
|
||||
// With Viewer.js the src is released after the viewer (see release below).
|
||||
if (!ViewerJs) {
|
||||
if (!window.Viewer) {
|
||||
mediaLocal.listen(node,'load',() => {if (current()) session.ready(key);});
|
||||
mediaLocal.cleanup(() => node.removeAttribute('src'));
|
||||
}
|
||||
}
|
||||
node.src = src;stage.append(node);
|
||||
if (!selected.is_video && ViewerJs) {
|
||||
// Viewer.js is built on an image that has loaded. Its own cancel removes its image listeners only
|
||||
// while `image.complete` is false, so an image left with its load event still queued would build a
|
||||
// viewer into the released image after destroy() (#293). Until then no viewer exists to cancel.
|
||||
let mountedViewer = false;
|
||||
mediaLocal.listen(node,'load',() => { if (current()) { mountedViewer = true;mountViewer(); } },{once:true});
|
||||
mediaLocal.cleanup(() => { if (!mountedViewer) node.removeAttribute('src'); });
|
||||
}
|
||||
function mountViewer() {
|
||||
if (!selected.is_video && window.Viewer) {
|
||||
let fitFrame = 0, fitWidth, fitHeight, viewSettled = false, released = false, fallback = 0;
|
||||
const release = () => {
|
||||
if (released) return;
|
||||
@@ -192,7 +166,7 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle
|
||||
fitWidth = width;fitHeight = height;instance.resize();
|
||||
});
|
||||
};
|
||||
const instance = new ViewerJs(node,{inline:true,toolbar:false,navbar:false,title:false,button:false,
|
||||
const instance = new window.Viewer(node,{inline:true,toolbar:false,navbar:false,title:false,button:false,
|
||||
keyboard:false,fullscreen:false,transition:false,viewed() {
|
||||
viewSettled = true;
|
||||
if (mediaLocal.signal.aborted) { release(); return; }
|
||||
@@ -202,12 +176,10 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle
|
||||
const observer = new ResizeObserver(scheduleFit);observer.observe(stage);
|
||||
viewer = instance;mediaLocal.cleanup(() => {
|
||||
observer.disconnect();cancelAnimationFrame(fitFrame);
|
||||
// Before Viewer.js has built (its zero-delay build is pending), destroy() cancels that build;
|
||||
// deferring it would let the build run into the image the stage has just dropped.
|
||||
// Once built, inline Viewer.js keeps viewing a still-loading image after destroy(), and that
|
||||
// view rebuilds its list from images that still have a src; keep both until the view settles,
|
||||
// fails or times out.
|
||||
if (viewSettled || !instance.ready) { release(); return; }
|
||||
// Inline Viewer.js keeps viewing a still-loading image after destroy(), and that view
|
||||
// rebuilds its list from images that still have a src; keep both until the view
|
||||
// settles, fails or times out.
|
||||
if (viewSettled) { release(); return; }
|
||||
node.addEventListener('error',release,{once:true});
|
||||
fallback = setTimeout(release,PENDING_VIEW_DESTROY_MS);
|
||||
});
|
||||
@@ -216,7 +188,7 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle
|
||||
}
|
||||
function updateControls(state) {
|
||||
const unavailable = inspectionState.refreshRequired ? inspectionState.message : state.phase === 'error' ? 'Media could not be loaded. Retry this file.' : entry.is_video ?
|
||||
'Image transforms are unavailable for video.' : withoutViewer ?
|
||||
'Image transforms are unavailable for video.' : !viewer ?
|
||||
'Image transforms are unavailable because Viewer.js could not load.' : !state.mediaReady ? 'Image transforms are available when the image is ready.' : '';
|
||||
reason.textContent = unavailable;reason.hidden = !unavailable;
|
||||
for (const node of transforms) {
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
/**
|
||||
* Browser display defaults (#249 System S3 Task 7). The workspace keeps them in the legacy gallery's own
|
||||
* `gallerySettings` object (Q3, "One display configuration"), so the gallery, the workspace and the viewer read one
|
||||
* configuration: the new `workspaceLayout` and `workspaceDensity` beside the legacy keys they share (metadata, paths,
|
||||
* card details, viewer details, image quality and the video defaults). The legacy paging keys (`defaultLimit`,
|
||||
* `gridColumns`, `infiniteScroll`, …) belong to the legacy gallery and are never read here (README continuous-browsing
|
||||
* ruling).
|
||||
*
|
||||
* - `readPreferences(getStorage, normalize)` → `{values, persisted}`: the workspace keys, normalized by `gallery-settings.js`;
|
||||
* `persisted` is false when this browser refused its storage.
|
||||
* - `writePreferences(dirty, getStorage, {baseline})` → `{persisted, conflicts}`: re-reads the stored object and writes
|
||||
* back only the dirty keys, every other key byte-for-byte as stored. A write the browser refused (denied storage, a
|
||||
* full quota) leaves the stored bytes as they were and keeps the change for this tab only, so every reader in this
|
||||
* tab still sees it; the next write storage accepts stores those changes too. `conflicts` lists the dirty keys another page changed since `baseline` was read; the explicit
|
||||
* save still wins.
|
||||
* - `subscribePreferences(win, onChange, getStorage)` → unsubscribe: `onChange(readPreferences())` after another page
|
||||
* changed the settings (a `storage` event).
|
||||
* - `reconcilePreferences({baseline, drafts, next})` → `{conflicts}`: which drafts such a change overtook.
|
||||
*
|
||||
* Every storage access is in try/catch: a private window, blocked site data or a malformed value never breaks a page.
|
||||
*/
|
||||
export const PREFERENCES_KEY = 'gallerySettings';
|
||||
export const WORKSPACE_PREFERENCE_KEYS = Object.freeze(['workspaceLayout', 'workspaceDensity', 'autoLoadMetadata', 'cacheMetadata',
|
||||
'imageQuality', 'videoAutoplay', 'videoMute', 'videoLoop', 'showFilePaths', 'showImageInfo', 'sidebarCollapsedByDefault']);
|
||||
|
||||
// The changes this tab could not store, overlaid on every read until storage accepts them.
|
||||
const tabOnly = new Map();
|
||||
const defaultStorage = () => globalThis.localStorage;
|
||||
const normalizer = () => globalThis.GallerySettings.normalizeGallerySettings;
|
||||
const isObject = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
/** Forget what this tab kept without storage (tests, and nothing else). */
|
||||
export function resetTabPreferences() {
|
||||
tabOnly.clear();
|
||||
}
|
||||
|
||||
const pick = settings => Object.fromEntries(WORKSPACE_PREFERENCE_KEYS.map(key => [key, settings[key]]));
|
||||
/** The gallery's defaults for the workspace keys. */
|
||||
export function preferenceDefaults() {
|
||||
return pick(normalizer()(null));
|
||||
}
|
||||
|
||||
/** The stored object (`{}` when absent or malformed), or null when storage is unavailable. */
|
||||
function storedObject(storage) {
|
||||
let text;
|
||||
try { text = storage.getItem(PREFERENCES_KEY); } catch { return null; }
|
||||
try {
|
||||
const parsed = text ? JSON.parse(text) : null;
|
||||
return isObject(parsed) ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function storageOf(getStorage) {
|
||||
try { return getStorage() ?? null; } catch { return null; }
|
||||
}
|
||||
|
||||
/** `normalize` is gallery-settings.js's `normalizeGallerySettings` (the page's `window.GallerySettings` by default). */
|
||||
export function readPreferences(getStorage = defaultStorage, normalize = normalizer()) {
|
||||
const storage = storageOf(getStorage);
|
||||
const stored = storage ? storedObject(storage) : null;
|
||||
const values = pick(normalize({ ...(stored ?? {}), ...Object.fromEntries(tabOnly) }));
|
||||
return { values, persisted: stored !== null };
|
||||
}
|
||||
|
||||
/** A change is only a workspace key with a value the gallery's normalizer keeps as given. */
|
||||
function validate(dirty) {
|
||||
if (!isObject(dirty)) throw new TypeError('Preferences need an object of changes');
|
||||
for (const [key, value] of Object.entries(dirty)) {
|
||||
if (!WORKSPACE_PREFERENCE_KEYS.includes(key)) throw new TypeError(`Not a workspace display preference: ${key}`);
|
||||
if (normalizer()({ [key]: value })[key] !== value) throw new TypeError(`Invalid value for ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function writePreferences(dirty, getStorage = defaultStorage, { baseline = null } = {}) {
|
||||
validate(dirty);
|
||||
const keep = () => { for (const [key, value] of Object.entries(dirty)) tabOnly.set(key, value); };
|
||||
const storage = storageOf(getStorage);
|
||||
const stored = storage ? storedObject(storage) : null;
|
||||
if (stored === null) { keep(); return { persisted: false, conflicts: [] }; }
|
||||
const current = pick(normalizer()(stored));
|
||||
const conflicts = baseline ? Object.keys(dirty).filter(key => Object.hasOwn(baseline, key) && current[key] !== baseline[key]) : [];
|
||||
try {
|
||||
// The changes this tab could not store before are stored with this one, so "Saved" is true for all of them.
|
||||
storage.setItem(PREFERENCES_KEY, JSON.stringify({ ...stored, ...Object.fromEntries(tabOnly), ...dirty }));
|
||||
} catch {
|
||||
keep();
|
||||
return { persisted: false, conflicts };
|
||||
}
|
||||
tabOnly.clear();
|
||||
return { persisted: true, conflicts };
|
||||
}
|
||||
|
||||
/** The current values for a display consumer, or null on a page without gallery-settings.js (a unit test). */
|
||||
export function displayPreferences(getStorage = defaultStorage) {
|
||||
try { return readPreferences(getStorage).values; } catch { return null; }
|
||||
}
|
||||
|
||||
export function subscribePreferences(win, onChange, getStorage = defaultStorage) {
|
||||
const listener = event => {
|
||||
if (event.key !== null && event.key !== PREFERENCES_KEY) return;
|
||||
if (event.storageArea && event.storageArea !== storageOf(getStorage)) return;
|
||||
onChange(readPreferences(getStorage));
|
||||
};
|
||||
win.addEventListener('storage', listener);
|
||||
return () => win.removeEventListener('storage', listener);
|
||||
}
|
||||
|
||||
export function reconcilePreferences({ baseline, drafts, next }) {
|
||||
return { conflicts: Object.keys(drafts).filter(key => next[key] !== baseline[key] && next[key] !== drafts[key]) };
|
||||
}
|
||||
@@ -15,16 +15,6 @@ const statuses = {
|
||||
ambiguous: 'Multiple possible prompts. Source could not be determined.',
|
||||
unresolved: 'Prompt could not be resolved.',
|
||||
};
|
||||
// D125 (maintainer option A): a PromptManager prepend or append that a run-time node decided (LoRA trigger words) is
|
||||
// not in the file, so the shown text leaves it out and says so. D126: the conditioning a later stage combined with
|
||||
// the base prompt is not in the shown text either. Each source shows its muted line under the text.
|
||||
export const RUNTIME_PARTS_NOTE = 'Text added at run time, such as trigger words, is not included.';
|
||||
export const ADDED_CONDITIONING_NOTE = 'Conditioning added at a later stage is not included.';
|
||||
const SOURCE_NOTES = Object.freeze({
|
||||
'runtime-parts-omitted': [RUNTIME_PARTS_NOTE],
|
||||
'added-conditioning': [ADDED_CONDITIONING_NOTE],
|
||||
'runtime-parts-omitted-and-added-conditioning': [RUNTIME_PARTS_NOTE, ADDED_CONDITIONING_NOTE],
|
||||
});
|
||||
const ROLE_LABELS = Object.freeze({positive: '+ Positive prompt', negative: '\u2212 Negative prompt'});
|
||||
function el(tag, text, className) {
|
||||
const node = document.createElement(tag);
|
||||
@@ -124,7 +114,6 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role
|
||||
// Production keeps a confirmed prompt keyboard-reachable for reading and selecting long text.
|
||||
text.tabIndex = 0;
|
||||
block.append(text);
|
||||
for (const note of Object.hasOwn(SOURCE_NOTES, record?.source) ? SOURCE_NOTES[record.source] : []) block.append(el('p', note, 'small muted'));
|
||||
} else block.append(el('p', typeof record?.message === 'string' && record.message ? record.message : statuses[record?.status] || statuses.unresolved, 'prompt-text muted'));
|
||||
container.append(block);
|
||||
}
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
/**
|
||||
* System › Appearance (#249 System S3 Task 8): the concept's `renderSystem` "appearance" body (app.js 676–678), one
|
||||
* `section.surface` "Browsing & display" of `.setting-row`s whose values are the drafts of the one Save row.
|
||||
*
|
||||
* - The browser rows are the display defaults this browser keeps in the shared `gallerySettings` object
|
||||
* (preferences.mjs, Q3): Default layout and Thumbnail size (`workspaceLayout`, `workspaceDensity`), the metadata,
|
||||
* quality (Optimized→`medium`, Original→`high`, Fast→`low`), video and path rows. Images, the inspector and the
|
||||
* viewer read them on their next visit; a toolbar change still applies to the current view only.
|
||||
* - "Open from ComfyUI" (New tab→`newtab`, Popup window→`popup`) and "Node search auto-hide (seconds)" (a whole number
|
||||
* 0–300) are the server's `webui_display_mode` and `result_timeout`, saved with the settings revision (#220).
|
||||
* - "Continuous browsing" is the concept's fixed row: there is no paging or column control (README ruling).
|
||||
* - D97: added rows for the legacy gallery settings the concept leaves out: "Show image details on cards"
|
||||
* (`showImageInfo`), "Collapse viewer details by default" (`sidebarCollapsedByDefault`) and the operation "Reset
|
||||
* display defaults", which drafts the gallery's defaults for these browser rows and touches no other key.
|
||||
*
|
||||
* Controls follow the store: a value read later (the host settings, another tab's change) repaints them. A host row
|
||||
* whose value could not be read stays disabled and says so ("Server settings could not be read.").
|
||||
*/
|
||||
import { settingRow, operationRow } from './system-controls.mjs';
|
||||
import { preferenceDefaults } from './preferences.mjs';
|
||||
|
||||
export const RESULT_TIMEOUT_MAX = 300;
|
||||
/** A host row's line while the server settings could not be read (its control stays disabled). */
|
||||
export const HOST_UNAVAILABLE = 'Server settings could not be read.';
|
||||
const BOOLEAN = Object.freeze({ type: 'toggle' });
|
||||
|
||||
/**
|
||||
* The concept's rows in its order, then the D97 rows: `[id, label, description, domain, key, control]`. A select's
|
||||
* options are `[stored value, concept label]`.
|
||||
*/
|
||||
export const APPEARANCE_ROWS = Object.freeze([
|
||||
['default-view', 'Default layout', 'Choose your starting image layout.', 'browser', 'workspaceLayout',
|
||||
{ type: 'select', options: [['grid', 'Grid'], ['list', 'List'], ['masonry', 'Masonry']] }],
|
||||
['density', 'Thumbnail size', 'Readable previews by default; no fixed column-count setting.', 'browser', 'workspaceDensity',
|
||||
{ type: 'select', options: [['comfortable', 'Comfortable'], ['large', 'Large'], ['compact', 'Compact']] }],
|
||||
'continuous',
|
||||
['metadata-auto', 'Load metadata on selection', 'Use the same inspector in the gallery and image viewer.', 'browser', 'autoLoadMetadata', BOOLEAN],
|
||||
['metadata-cache', 'Cache metadata in this browser', 'Reuse fetched metadata until it changes or you clear this cache.', 'browser', 'cacheMetadata', BOOLEAN],
|
||||
['image-quality', 'Image loading quality', 'Preserve original / optimized / fast image-loading preferences.', 'browser', 'imageQuality',
|
||||
{ type: 'select', options: [['medium', 'Optimized'], ['high', 'Original'], ['low', 'Fast']] }],
|
||||
['video-auto', 'Autoplay videos', 'Videos stay within the same gallery and use native playback controls.', 'browser', 'videoAutoplay', BOOLEAN],
|
||||
['video-muted', 'Mute videos by default', 'Keep playback quiet while browsing.', 'browser', 'videoMute', BOOLEAN],
|
||||
['video-loop', 'Loop videos', 'Repeat a selected clip until you navigate away.', 'browser', 'videoLoop', BOOLEAN],
|
||||
['paths', 'Show file paths', 'Paths appear under File & provenance, not across every card.', 'browser', 'showFilePaths', BOOLEAN],
|
||||
['open-mode', 'Open from ComfyUI', 'Keep the node pack’s popup / new-tab preference.', 'host', 'webui_display_mode',
|
||||
{ type: 'select', options: [['newtab', 'New tab'], ['popup', 'Popup window']] }],
|
||||
['timeout', 'Node search auto-hide (seconds)', 'Node search behavior only. Use 0 to keep results visible.', 'host', 'result_timeout',
|
||||
{ type: 'number' }],
|
||||
// D97: the legacy gallery's card and viewer settings.
|
||||
['card-details', 'Show image details on cards', 'Show each card’s title and file details under the preview.', 'browser', 'showImageInfo', BOOLEAN],
|
||||
['viewer-details', 'Collapse viewer details by default', 'Open the image viewer with its details panel hidden.', 'browser',
|
||||
'sidebarCollapsedByDefault', BOOLEAN],
|
||||
]);
|
||||
|
||||
/** The browser keys "Reset display defaults" restores (D97). */
|
||||
export const DISPLAY_KEYS = Object.freeze(APPEARANCE_ROWS.filter(row => Array.isArray(row) && row[3] === 'browser').map(row => row[4]));
|
||||
|
||||
/** A whole number of seconds from 0 to 300 (the server's `validate_result_timeout`), else null. */
|
||||
export function resultTimeout(text) {
|
||||
const value = String(text).trim();
|
||||
if (!/^\d+$/.test(value)) return null;
|
||||
const seconds = Number(value);
|
||||
return seconds <= RESULT_TIMEOUT_MAX ? seconds : null;
|
||||
}
|
||||
|
||||
function el(tag, text, className) {
|
||||
const node = document.createElement(tag);
|
||||
if (text !== undefined) node.textContent = text;
|
||||
if (className) node.className = className;
|
||||
return node;
|
||||
}
|
||||
|
||||
/** The concept's fixed "Continuous browsing" row: `div > h3 + p` and the "Infinite scroll" chip. */
|
||||
function continuousRow() {
|
||||
const row = el('div', undefined, 'setting-row');
|
||||
const text = el('div');
|
||||
text.append(el('h3', 'Continuous browsing'),
|
||||
el('p', 'Images load automatically as you scroll in Grid, List and Masonry. Batch sizing is handled internally.'));
|
||||
row.append(text, el('span', 'Infinite scroll', 'chip blue'));
|
||||
return row;
|
||||
}
|
||||
|
||||
/** The row's control (`data-setting`), wherever the Toolkit switch nests it. */
|
||||
function controlOf(node, id) {
|
||||
if (node.dataset?.setting === id) return node;
|
||||
for (const child of node.children ?? []) {
|
||||
const found = controlOf(child, id);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Show a stored value in its control; a value not read yet leaves the control disabled. */
|
||||
function paint(input, control, value) {
|
||||
input.disabled = value === undefined;
|
||||
if (value === undefined) return;
|
||||
if (control.type === 'toggle') input.checked = value === true;
|
||||
else if (document.activeElement !== input || control.type === 'select') input.value = String(value);
|
||||
}
|
||||
|
||||
/** Mount Appearance in `container` (System's `.settings-layout`); returns the section's nodes. */
|
||||
export function mountAppearance(container, { scope, drafts }) {
|
||||
const surface = el('section', undefined, 'surface');
|
||||
surface.append(el('h2', 'Browsing & display'), el('p', 'One set of defaults; toolbar changes apply to the current view.'));
|
||||
const controls = [];
|
||||
for (const row of APPEARANCE_ROWS) {
|
||||
if (row === 'continuous') { surface.append(continuousRow()); continue; }
|
||||
const [id, label, description, domain, key, control] = row;
|
||||
const onChange = value => {
|
||||
if (key === 'result_timeout') {
|
||||
const seconds = resultTimeout(input.value);
|
||||
if (seconds === null) { drafts.reject(domain, key); return; }
|
||||
value = seconds;
|
||||
}
|
||||
drafts.set(domain, key, value);
|
||||
};
|
||||
const node = settingRow({ id, label, description, control, scope, onChange });
|
||||
surface.append(node);
|
||||
const input = controlOf(node, id);
|
||||
input.dataset.key = key;
|
||||
controls.push({ input, control, domain, key, text: node.children[0], note: null });
|
||||
}
|
||||
surface.append(operationRow({ title: 'Reset display defaults',
|
||||
description: 'Restore this browser’s display defaults, then save. Other settings are kept.', label: 'Reset', scope,
|
||||
onClick: () => {
|
||||
const defaults = preferenceDefaults();
|
||||
for (const key of DISPLAY_KEYS) drafts.set('browser', key, defaults[key]);
|
||||
} }));
|
||||
const repaint = () => {
|
||||
const unavailable = drafts.hostState?.() === 'failed';
|
||||
for (const row of controls) {
|
||||
const value = drafts.get(row.domain, row.key);
|
||||
paint(row.input, row.control, value);
|
||||
if (row.domain !== 'host') continue;
|
||||
if (unavailable && value === undefined) {
|
||||
if (!row.note) { row.note = el('p', HOST_UNAVAILABLE, 'small'); row.text.append(row.note); }
|
||||
} else if (row.note) { row.note.remove(); row.note = null; }
|
||||
}
|
||||
};
|
||||
scope.cleanup(drafts.subscribe(repaint));
|
||||
repaint();
|
||||
return [surface];
|
||||
}
|
||||
@@ -15,9 +15,8 @@
|
||||
* the page session (Q10a): a section left and opened again shows them, and a reload of its values keeps them.
|
||||
*
|
||||
* Adapters: `readBrowser()` and `readRuntime()` return the values; `readHost()` returns `{values, revision}`.
|
||||
* `writeBrowser(changes)` returns `{persisted}` (false: kept for this tab only) and throws an error with `code:
|
||||
* 'settings-changed'` when another page changed a drafted value (a conflict: the browser values are read again),
|
||||
* `writeHost(changes, {expected_revision})` the settings response (`{success, persisted, revision}`), `writeRuntime(changes)` anything;
|
||||
* `writeBrowser(changes)` returns `{persisted}` (false: kept for this tab only), `writeHost(changes,
|
||||
* {expected_revision})` the settings response (`{success, persisted, revision}`), `writeRuntime(changes)` anything;
|
||||
* a write that throws failed.
|
||||
*/
|
||||
export { SYSTEM_SECTIONS, isSystemSection } from './state.mjs';
|
||||
@@ -65,7 +64,6 @@ export function createSystemDrafts(adapters) {
|
||||
const failedKeys = { browser: new Set(), host: new Set(), runtime: new Set() };
|
||||
const listeners = new Set();
|
||||
let revision = null;
|
||||
let hostRead = 'pending';
|
||||
let outcome = null;
|
||||
let saving = null;
|
||||
|
||||
@@ -80,7 +78,6 @@ export function createSystemDrafts(adapters) {
|
||||
const read = await adapters.readHost();
|
||||
values = read?.values ?? {};
|
||||
revision = read?.revision ?? null;
|
||||
hostRead = 'loaded';
|
||||
} else {
|
||||
values = await (domain === 'browser' ? adapters.readBrowser() : adapters.readRuntime());
|
||||
}
|
||||
@@ -162,11 +159,8 @@ export function createSystemDrafts(adapters) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the host values again; a failed read leaves host saves refused until one succeeds, and `hostState()` says
|
||||
* so (System S3: the host rows show that the server settings could not be read).
|
||||
*/
|
||||
const reloadHost = () => load('host').then(() => true, () => { hostRead = 'failed'; notify(); return false; });
|
||||
/** Read the host values again; a failed read leaves host saves refused until one succeeds. */
|
||||
const reloadHost = () => load('host').then(() => true, () => false);
|
||||
|
||||
/** Save the dirty domains in order; a Save pressed while one runs joins it. */
|
||||
function save() {
|
||||
@@ -176,7 +170,6 @@ export function createSystemDrafts(adapters) {
|
||||
for (const domain of DRAFT_DOMAINS) result[domain] = await saveDomain(domain);
|
||||
// Settings changed elsewhere: read them (and their revision), keeping the drafts (Q10).
|
||||
if (result.host === 'conflict') await reloadHost();
|
||||
if (result.browser === 'conflict') await load('browser').catch(() => {});
|
||||
return result;
|
||||
})().then(result => {
|
||||
saving = null;
|
||||
@@ -187,39 +180,9 @@ export function createSystemDrafts(adapters) {
|
||||
return saving;
|
||||
}
|
||||
|
||||
/**
|
||||
* A value the control could not take (System S3: a timeout outside 0–300): no draft, the control is highlighted and
|
||||
* the status says a change was not saved.
|
||||
*/
|
||||
function reject(domain, key) {
|
||||
requireDomain(domain);
|
||||
drafts[domain].delete(key);
|
||||
failedKeys[domain].add(key);
|
||||
outcome = { [domain]: 'failed' };
|
||||
notify();
|
||||
}
|
||||
|
||||
/**
|
||||
* Another page changed saved values this tab has drafts for (a browser `storage` event, System S3): the drafts stay
|
||||
* and are highlighted, and the status says the settings changed elsewhere.
|
||||
*/
|
||||
function conflict(domain, keys) {
|
||||
requireDomain(domain);
|
||||
if (!keys.length) return;
|
||||
for (const key of keys) failedKeys[domain].add(key);
|
||||
outcome = { [domain]: 'conflict' };
|
||||
notify();
|
||||
}
|
||||
|
||||
return {
|
||||
load,
|
||||
reloadHost,
|
||||
/** Whether the host values were read: `pending`, `loaded`, or `failed` (the last read failed). */
|
||||
hostState: () => hostRead,
|
||||
reject,
|
||||
conflict,
|
||||
/** The saved values of a domain (its last read or save), not the drafts. */
|
||||
saved: domain => { requireDomain(domain); return { ...baseline[domain] }; },
|
||||
get,
|
||||
set,
|
||||
dirty,
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
* `aria-pressed`; a press navigates to its hash), then `.settings-layout` with the section's body and the Save row
|
||||
* (`span.small#settings-status` + "Save preferences"). D92: no "Sample configuration" chip, the status copy is
|
||||
* truthful and "Save preferences" is aria-disabled while nothing is unsaved. A section not delivered yet shows the
|
||||
* per-section hand-off to its legacy tool (D94), never mock placeholders. Overview (system-overview.mjs) and
|
||||
* Appearance (system-appearance.mjs) are native.
|
||||
* per-section hand-off to its legacy tool (D94), never mock placeholders. Overview is native (system-overview.mjs).
|
||||
*
|
||||
* Switching sections keeps the shell (and focus on the pressed button) and disposes the old section's scope, so its
|
||||
* listeners and timers never outlive it. The drafts are module-level (Q10): they live for the page session.
|
||||
@@ -15,8 +14,6 @@ import { SYSTEM_SECTIONS, navigateTo } from './state.mjs';
|
||||
import { createSystemDrafts, DRAFT_DOMAINS } from './system-state.mjs';
|
||||
import { sectionNav } from './system-controls.mjs';
|
||||
import { healthNotice, mountOverview } from './system-overview.mjs';
|
||||
import { mountAppearance } from './system-appearance.mjs';
|
||||
import { displayPreferences, readPreferences, reconcilePreferences, subscribePreferences, writePreferences } from './preferences.mjs';
|
||||
|
||||
const SETTINGS_URL = '/prompt_manager/settings';
|
||||
const ADMIN = Object.freeze({ href: '/prompt_manager/admin', label: 'Open existing administration' });
|
||||
@@ -27,6 +24,7 @@ const GALLERY = Object.freeze({ href: '/prompt_manager/gallery', label: 'Open ex
|
||||
* settings, thumbnails, duplicates and cache tools.
|
||||
*/
|
||||
export const SECTION_HANDOFFS = Object.freeze({
|
||||
appearance: [GALLERY, ADMIN],
|
||||
sources: [ADMIN, GALLERY],
|
||||
integrations: [ADMIN],
|
||||
tagging: [ADMIN],
|
||||
@@ -48,23 +46,12 @@ function el(tag, text, className) {
|
||||
/** A domain whose settings no delivered section edits yet: nothing can be drafted, and a write would say it failed. */
|
||||
const undelivered = async () => { throw new Error(NOT_DELIVERED); };
|
||||
|
||||
/**
|
||||
* The page session's drafts (Q10). The browser domain is this browser's display defaults (preferences.mjs): a write
|
||||
* says whether storage kept it, so the status never claims "Saved in this browser." for a change only this tab holds.
|
||||
* The host domain reads and saves the server settings with their revision (#220).
|
||||
*/
|
||||
/** The page session's drafts (Q10); the host domain reads and saves the server settings with their revision (#220). */
|
||||
export function systemDrafts(api) {
|
||||
drafts ??= createSystemDrafts({
|
||||
readBrowser: () => readPreferences().values,
|
||||
readBrowser: () => ({}),
|
||||
readRuntime: () => ({}),
|
||||
writeBrowser: changes => {
|
||||
// Another page changed a drafted value since this tab read it, and no storage event told it yet: refuse,
|
||||
// so the store keeps the draft, flags it and reads the values again; the next Save is an explicit choice.
|
||||
const baseline = drafts.saved('browser');
|
||||
const { conflicts } = reconcilePreferences({ baseline, drafts: changes, next: readPreferences().values });
|
||||
if (conflicts.length) throw Object.assign(new Error('Settings changed elsewhere'), { code: 'settings-changed' });
|
||||
return { persisted: writePreferences(changes, undefined, { baseline }).persisted };
|
||||
},
|
||||
writeBrowser: undelivered,
|
||||
writeRuntime: undelivered,
|
||||
async readHost() {
|
||||
const result = await api.get(SETTINGS_URL, { cache: 'no-store' });
|
||||
@@ -93,19 +80,17 @@ function handoff(section) {
|
||||
function markFailed(layout, store) {
|
||||
const failed = new Set(DRAFT_DOMAINS.flatMap(domain => [...store.failed(domain)]));
|
||||
for (const control of layout.querySelectorAll('[data-setting]')) {
|
||||
// A row's control names its stored key when that differs from the row id (Appearance's `data-key`).
|
||||
if (failed.has(control.dataset.key ?? control.dataset.setting)) control.setAttribute('aria-invalid', 'true');
|
||||
if (failed.has(control.dataset.setting)) control.setAttribute('aria-invalid', 'true');
|
||||
else control.removeAttribute('aria-invalid');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The sections delivered so far: Overview (S2 Task 6), Appearance (S3 Task 8), and Maintenance's notice with "Run health checks" above the
|
||||
* The sections delivered so far: Overview (S2 Task 6), and Maintenance's notice with "Run health checks" above the
|
||||
* hand-off to the maintenance tools that are not native yet (D123).
|
||||
*/
|
||||
export const DELIVERED_SECTIONS = Object.freeze({
|
||||
overview: mountOverview,
|
||||
appearance: mountAppearance,
|
||||
maintenance: (container, { api, activity, scope }) => [healthNotice({ api, activity, scope }), ...handoff('maintenance')],
|
||||
});
|
||||
|
||||
@@ -116,25 +101,11 @@ export const DELIVERED_SECTIONS = Object.freeze({
|
||||
* Activity store, which follows the jobs a section starts.
|
||||
*
|
||||
* The host values are read on mount (and by the store again after a conflict), so a save carries the current revision;
|
||||
* until a read returned one, a host save fails without a request (system-state.mjs). The browser values are read on
|
||||
* mount and again whenever another page changes them (a `storage` event). Either way a value another page changed
|
||||
* under one of this tab's drafts flags that draft (D92's conflict status), including a change made while System was
|
||||
* not mounted (the drafts outlive it, Q10).
|
||||
* until a read returned one, a host save fails without a request (system-state.mjs).
|
||||
*/
|
||||
export function mountSystem(main, api, scope, { section, activity, sections = DELIVERED_SECTIONS, navigate = navigateTo }) {
|
||||
const store = systemDrafts(api);
|
||||
store.reloadHost();
|
||||
// Another tab or the legacy gallery changed the display defaults: clean values follow it; a draft it overtook stays
|
||||
// and is flagged (D92's conflict status). `next` is null when the values cannot be read (no gallery-settings.js).
|
||||
const reconcile = next => {
|
||||
const baseline = store.saved('browser');
|
||||
// The first mount has read nothing yet, so nothing can have been overtaken.
|
||||
const conflicts = next && Object.keys(baseline).length
|
||||
? reconcilePreferences({ baseline, drafts: store.changes('browser'), next }).conflicts : [];
|
||||
store.load('browser').then(() => store.conflict('browser', conflicts), () => {});
|
||||
};
|
||||
reconcile(displayPreferences());
|
||||
scope.cleanup(subscribePreferences(window, next => reconcile(next.values)));
|
||||
const nav = sectionNav({ label: 'System sections', sections: SYSTEM_SECTIONS, active: section, scope,
|
||||
onSelect: id => navigate(`#/system/${id}`) });
|
||||
const layout = el('div', undefined, 'settings-layout');
|
||||
|
||||
@@ -91,10 +91,6 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
|
||||
.setting-row [aria-invalid="true"] { outline: 2px solid var(--red); outline-offset: 2px; /* D92 */ }
|
||||
/* D93: an Overview row whose action is not available yet (the review queue, Q6) keeps its button, disabled. */
|
||||
.operation .row-actions button[aria-disabled="true"] { opacity: .55; cursor: not-allowed; /* D93 */ }
|
||||
/* D97: Appearance's legacy display rows. "Show image details on cards" off keeps a card's Info and Tag at the end of
|
||||
its row; "Collapse viewer details by default" gives the viewer's stage the whole width while its details are hidden. */
|
||||
.media-info > div[hidden] + .image-card-actions { margin-inline-start: auto; /* D97 */ }
|
||||
.viewer-layout:has(> #viewer-details[hidden]) { grid-template-columns: minmax(0, 1fr); /* D97 */ }
|
||||
@media (max-width: 700px) {
|
||||
.stat-grid { gap: 8px; }
|
||||
.stat { padding: 16px; }
|
||||
|
||||
Reference in New Issue
Block a user