Compare commits

...
Author SHA1 Message Date
Vito Sansevero d92b2eabd9 test(images): cover the text budget, XMP and layout guard of the PNG text walk (#523) 2026-10-31 15:18:02 -07:00
Vito Sansevero e5868d96f2 perf(images): read PNG text without decoding pixels (#523)
Pillow's PngImageFile.text loads every pixel to reach text chunks written
after IDAT. The scan's metadata batch and the image monitor now read PNG
text through utils/png_text.py, which runs Pillow's own chunk handlers
after the first IDAT and seeks over the pixel data.

The legacy .text read is the floor: a differential test compares both
readers on every fixture image, a curated corpus and a seeded fuzz. The
only difference is a file whose pixel data is undecodable or cut short:
it now keeps the text read before its pixels instead of reading nothing.
Animated PNGs still use .text.
2026-10-31 15:01:37 -07:00
Vito e29c23de8d chore(init): print a one-line startup banner (#574)
Replace the version line, one Loaded line per node and the total with
"[ComfyUI-PromptManager] Version: <v> - <n> tools loaded".

Closes #573
2026-10-31 18:56:42 +00:00
Vito b82e68a9d0 ci: stop testing macOS in CI (#572)
macOS is not a popular ComfyUI platform. Drop the macOS legs from the
portability workflow and its native-trash proof; Windows keeps its
Python 3.12-3.14 legs. The macOS code stays, and README calls its
support best-effort.

Closes #571
2026-10-31 18:13:27 +00:00
Vito fb02066cda test(vocabulary): open the long-list check as a new page, not a reload (#567) (#570)
Firefox restores the scroll position of a reloaded page's tag window, so its
end scrolled into view and appended a second page before the 100-row check.
Scrolling the window to its top before the reload did not always stick on CI.
A fresh navigation has no position to restore.
2026-10-31 18:13:05 +00:00
Vito ab17818792 fix(system): show the Images page count in System Overview (#568)
System > Overview's Images metric read the overview's indexed
generated_images rows, while the workspace Overview card and the Images
sidebar badge show the Images page's own total, which also lists files
no row links (110 against 122 on the demo library). The concept's
System metric and sidebar badge are both `images.length`, so the metric
now reads the shell's Images count: one source for all three, a dash
until it is read or when its read failed, re-read on each visit.

The Statistics dialog's "Library totals" were the legacy /stats raw row
counts under the same nouns; they now read "Database records" with
"Prompt records", "Tags on prompts" and "Image records". /stats and the
legacy admin page are unchanged.

Closes #555
2026-10-31 18:04:45 +00:00
Vito ef97d01dbd ci: run frontend coverage in four shards (#565)
* ci: run frontend coverage in four shards

Coverage shards reached their 18-minute step limit three times on
2026-10-31 with every test passing. Split the run into four shards with
the limits unchanged; the merge job now expects four raw coverage
artifacts, and the vendor checks move to shard 4.

Closes #563

* test(tags): pin the 390px window check to the first page's last row

Reaching the last row loads the next page (D183), and last() then followed
row 200, far outside the window, so the WebKit check failed in 4-5 of 20
runs on 2 CPUs. Pinned to row 100, it passed 90 of 90 across WebKit,
Chromium and Firefox.

Closes #566
2026-10-31 16:47:50 +00:00
Vito b7a43755ae chore: release 3.4.1 (#564)
Fixes the Windows exit hang with watchdog 7 (#562), stops LoRA scan
errors from showing server details (#560), and carries the code-quality
sweep (#558).
2026-10-31 15:38:28 +00:00
Vito 2c7e84dedd fix(security): send a generic LoRA scan error to the browser (#560)
An unexpected exception's text could reach the page in the LoRA scan's
error frame, including a path or a model name. Those failures now send a
fixed message and keep the detail in the server log. Expected refusals keep
their wording, and an OSError still shows its reason and the file's basename.

Refs #552
2026-10-31 13:59:29 +00:00
Vito cc65d15d14 fix(deps): keep watchdog below 7 (#562)
watchdog 7.0.0 (released 2026-10-31) makes the package's exit tests hang
on Windows on Python 3.12 to 3.14; the same commit passed with 6.0.0.
Cap it until observer.stop() is bounded at exit.

Refs #561
2026-10-31 13:21:14 +00:00
26 changed files with 845 additions and 165 deletions
+5 -33
View File
@@ -1,9 +1,9 @@
name: Windows and macOS
name: Windows
permissions:
contents: read
# Non-blocking portability runs (#420): never on pull requests or in the merge queue, and no context
# Non-blocking Windows runs (#420; macOS dropped from CI in #571): never on pull requests or in the merge queue, and no context
# here is a required check. A red run is fixed at its cause before the workspace cutover (#334).
on:
push:
@@ -25,10 +25,7 @@ env:
# run counts (tests/vendor/ci-workflow.test.mjs recomputes it, #493).
# Measured 2026-10-29 on main runs 40935337099 (dd247e3), 40938407252 (dd247e3), 40941240624 (dd247e3):
# slowest leg: 7m24s (py3.12 windows) / 7m31s (py3.13 windows) / 7m32s (py3.14 windows) -> 15 min
# (windows 4m56s-7m32s, macos 4m39s-6m14s)
# native-trash (#499) has no green main runs yet: its probe, run 40949370943 (2026-10-29), took
# about 1 minute per macOS leg, so ceil(1.5 x 2) + 3 gives it 6 minutes (step 4). Recalibrate
# after three green runs.
# (windows 4m56s-7m32s)
# native-trash-linux (#543) has no green main runs yet: it installs the dev requirements and runs
# one test module, about 1 minute by the same estimate, so ceil(1.5 x 2) + 3 gives it 6 minutes.
# Recalibrate after three green runs.
@@ -40,7 +37,7 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [windows-latest, macos-latest]
os: [windows-latest]
python-version: ["3.12", "3.13", "3.14"]
steps:
@@ -60,7 +57,7 @@ jobs:
# The runner's TEMP is on C:, the OS disk, where a flush took about 6 ms
# and a test database's create-to-delete about 111 ms; on the D: work
# disk the same took 0.5 ms and 23 ms (macOS: 19 ms). Tests write their
# disk the same took 0.5 ms and 23 ms. Tests write their
# databases under TEMP, so it moves to a folder on D: (measured in #487,
# run 40915866444). The runner's TEMP was an 8.3 short name
# (C:\Users\RUNNER~1\...), so the new one is spelled by its short name too
@@ -83,31 +80,6 @@ jobs:
PM_TEST_SLOWEST: "40"
run: python -m tests.run_suite -v
# The real Trash on both macOS CPUs (#499 design §10.2). It runs apart from the suite, so a red
# suite never skips the native proof.
native-trash:
name: native trash on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
timeout-minutes: 6
strategy:
fail-fast: false
matrix:
os: [macos-latest, macos-15-intel]
steps:
- uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v7
with:
python-version: "3.12"
- name: Native trash proof
timeout-minutes: 4
env:
PM_NATIVE_TRASH: macos
run: python -m unittest tests.test_trash_macos_native tests.test_trash_macos -v
# The same tests as the fake mount table, on a real ext4 mount used as the output root (#543). A
# runner can mount with sudo; the tests run as the normal user, so owner and mode checks are real.
native-trash-linux:
+2 -2
View File
@@ -28,7 +28,7 @@ env:
# py matrix, slowest py3.10 on windows-latest: 12m43s / 15m54s / 14m53s -> 27 min
# (ubuntu 5m27s-6m27s, macos 6m14s-8m30s, windows 11m42s-15m54s)
# 3.10 and 3.11 left the matrix and 3.14 joined it in #356; re-measure after three green main runs.
# Windows and macOS moved to portability.yml (#420), which has its own measured limit (#493);
# Windows moved to portability.yml (#420; macOS left CI in #571), which has its own measured limit (#493);
# this Linux-only matrix keeps 27 minutes until it is re-measured.
# real ComfyUI compatibility: 13m32s / 8m55s / 11m46s -> 24 min
# coverage gate (85%): 7m26s / 7m22s / 7m54s -> 15 min
@@ -39,7 +39,7 @@ env:
# Pull requests run a fast subset (#420): Python 3.12 on ubuntu-latest, coverage, lint and frontend
# modules. The merge queue (merge_group), pushes to main and manual runs add Python 3.13 and 3.14 on
# Linux and the real ComfyUI job. "all tests" is the required gate that holds the queue to that set.
# Windows and macOS run in portability.yml (push to main, nightly, on demand) and never gate a merge.
# Windows runs in portability.yml (push to main, nightly, on demand) and never gate a merge.
jobs:
test:
name: py${{ matrix.python-version }} on ${{ matrix.os }}
+12 -8
View File
@@ -41,9 +41,13 @@ concurrency:
# 7.3m-12.2m), because --shard splits by test count and the slow concept specs sort into shard 1. The page
# workflows now run in three shards per browser with the limits unchanged; the per-shard times are
# re-measured after three green main runs.
# #563: coverage shards reached their 18-minute step limit three times on 2026-10-31 with no failing
# test (PR #560 shard 2/3: 814 passed in 17.9m; queue run 41206495451 shard 1/3: 827 passed; PR #564
# shard 2/3 twice), against recent green shards of 11m-17m. Coverage now runs in four shards with the
# limits unchanged; the per-shard times are re-measured after three green main runs.
jobs:
frontend-coverage:
name: frontend coverage (shard ${{ matrix.shard }}/3)
name: frontend coverage (shard ${{ matrix.shard }}/4)
runs-on: ubuntu-latest
# Layout comparisons depend on the fonts each browser falls back to, so browser jobs run in the
# same pinned Playwright image as local runs (tag = @playwright/test in tools/vendor/package-lock.json;
@@ -59,7 +63,7 @@ jobs:
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3]
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v7
# The image's own Python 3.12 and Node 24: setup-python/setup-node install the runner's toolcache
@@ -88,13 +92,13 @@ jobs:
- run: npm ci --prefix tools/vendor --ignore-scripts
# Shard 1 also runs the node units and the viewer suite, so the vendor pins and the tooling tests
# run on the lightest shard instead.
- if: matrix.shard == 3
- if: matrix.shard == 4
run: npm run --prefix tools/vendor check
- if: matrix.shard == 3
- if: matrix.shard == 4
run: npm run --prefix tools/vendor test
- name: Page workflows with coverage, shard ${{ matrix.shard }}/3
- name: Page workflows with coverage, shard ${{ matrix.shard }}/4
timeout-minutes: 18
run: npm run --prefix tools/vendor test:coverage:shard -- ${{ matrix.shard }}/3
run: npm run --prefix tools/vendor test:coverage:shard -- ${{ matrix.shard }}/4
- name: Upload raw coverage
uses: actions/upload-artifact@v7
with:
@@ -136,13 +140,13 @@ jobs:
with:
pattern: coverage-raw-*
path: ${{ runner.temp }}/coverage-raw
# coverage:merge only checks that raw files exist; merging two of three shards would report
# coverage:merge only checks that raw files exist; merging three of four shards would report
# partial coverage, so a missing or empty shard artifact fails here.
- name: Verify every shard's raw coverage arrived
shell: bash
env:
STAGED_RAW: ${{ runner.temp }}/coverage-raw
EXPECTED_SHARDS: 3
EXPECTED_SHARDS: 4
run: |
found=0
for shard in $(seq 1 "$EXPECTED_SHARDS"); do
+2 -2
View File
@@ -236,12 +236,12 @@ The documentation for those pages is in [README-legacy.md](README-legacy.md).
| Layer | What runs |
| --- | --- |
| Python | Over 200 test files on Linux, Windows and macOS with Python 3.12 to 3.14, with an 85% coverage gate |
| Python | Over 200 test files on Linux and Windows with Python 3.12 to 3.14, with an 85% coverage gate |
| Frontend | About 100 unit test files, with coverage floors per file |
| Browser | Over 60 Playwright spec files in Chromium, Firefox, WebKit and touch emulation |
| Real ComfyUI | A pinned CPU ComfyUI, started in Classic and Nodes 2.0, running real workflows |
Linux runs on every pull request and in the merge queue. Windows and macOS run on every push to main and nightly. To run the tests yourself, see [TESTING.md](TESTING.md).
Linux runs on every pull request and in the merge queue. Windows runs on every push to main and nightly. macOS is not tested; it should work, but support is best-effort. To run the tests yourself, see [TESTING.md](TESTING.md).
## License
+44 -23
View File
@@ -74,14 +74,14 @@ V8 files it writes under `coverage/js/raw`:
```sh
PM_TEST_WORKERS=8 npm run --prefix tools/vendor test:coverage # whole run, one report
npm run --prefix tools/vendor test:coverage:shard -- 1/3 # node units, viewer suite, workflows 1/3
npm run --prefix tools/vendor test:coverage:shard -- 2/3 # workflows 2/3 (likewise 3/3)
npm run --prefix tools/vendor test:coverage:shard -- 1/4 # node units, viewer suite, workflows 1/4
npm run --prefix tools/vendor test:coverage:shard -- 2/4 # workflows 2/4 (likewise 3/4, 4/4)
npm run --prefix tools/vendor coverage:merge # report and baseline check of every raw file
npm run --prefix tools/vendor test:workflows -- --project webkit --shard 2/2
```
`coverage:merge` refuses an empty raw directory; it does not count shards, so run
every shard first (CI's merge job checks all three arrived). Measured on a 32-CPU
every shard first (CI's merge job checks all four arrived). Measured on a 32-CPU
machine in the pinned image (2026-10-21): `test:coverage` takes about 5 minutes
with 8 workers and 24 minutes with 1; all four projects, 8 workers, each test 3
times (6132 tests), 24 minutes. A CI-sized run (`docker run --cpus=4`,
@@ -189,14 +189,14 @@ check, so run it before pushing a new or deleted frontend file instead of
waiting for the `frontend coverage` jobs.
The `viewerjs` check runs on every PR, including backend-only changes. It is an
aggregate job. It requires the three `frontend coverage (shard N/3)` jobs and
aggregate job. It requires the four `frontend coverage (shard N/4)` jobs and
`frontend coverage (merge)` to succeed on every event. The four
`page workflows (firefox|webkit, shard N/2)` jobs run only in the merge queue,
on `main` and on `workflow_dispatch` (see "Pull requests and the merge queue").
`viewerjs` requires them to be skipped on a pull request and to succeed
everywhere else. Any other failed, cancelled or skipped job fails it. Every browser job runs with `PM_TEST_WORKERS: 3` (the public
runners have 4 vCPUs). Each coverage shard runs `test:coverage:shard -- N/3`
(shard 1 also runs the node units and the viewer suite, shard 3 the vendor pins
runners have 4 vCPUs). Each coverage shard runs `test:coverage:shard -- N/4`
(shard 1 also runs the node units and the viewer suite, shard 4 the vendor pins
and tooling tests) and uploads its raw V8 files as `coverage-raw-N` (kept 3
days). The merge job downloads them, fails unless all three arrived with raw
files and without clashing names, then runs `coverage:merge` (report plus
@@ -261,7 +261,7 @@ the process exits non-zero.
job fails anyway and the dump is what matters.
- `PM_TEST_SLOWEST=N` (off by default) lists the N slowest tests after the
summary, each timed from its `start:` line to the next one like the hang
limit. The Windows and macOS jobs (`portability.yml`) set it to 40, so every
limit. The Windows jobs (`portability.yml`) set it to 40, so every
run shows where its time went (#487). The list prints only when the run
ends: a job GitHub cancels or a watchdog exit prints none, so read the
timestamps between `start:` lines instead.
@@ -327,7 +327,7 @@ Python 3.13/3.14, real ComfyUI and Firefox/WebKit failures first show up in the
merge queue, on the merge-group commit rather than the
pushed one. Find that run with `gh run list --event merge_group --workflow test.yml`
(or `viewerjs-check.yml`) and match its head branch
(`gh-readonly-queue/main/pr-<N>-...`). Windows and macOS failures show up only
(`gh-readonly-queue/main/pr-<N>-...`). Windows failures show up only
after the merge, in `gh run list --workflow portability.yml`.
Start fixing as soon as a failed log shows the cause, but push the fix only after
@@ -445,12 +445,25 @@ A hot SQLite read that returns many rows aggregates them into one step
(`json_group_array`) instead of stepping once per row, so it does not wait for
the GIL once per row while ComfyUI generates (#354, `get_image_link_matches`).
A metadata read never decodes pixels (#523). Pillow's `PngImageFile.text` loads
every pixel to reach text chunks after IDAT; the scan (`_extract_comfyui_metadata`)
and the image monitor (`ComfyUIMetadataExtractor.extract_metadata`) read PNG text
through `utils/png_text.py`, which runs Pillow's own chunk handlers after the
first IDAT and seeks over pixel data. The legacy `.text` read is the floor:
`tests/test_png_text.py` compares both readers on every image under
`tests/fixtures`, a curated corpus (text after IDAT, zTXt, iTXt, split IDAT, bad
checksums, truncations, oversized text, APNG) and a seeded fuzz, and asserts that
neither reader decodes pixels by patching `ImageFile.load`. The only reads that
differ are files whose pixel data is undecodable or cut short: legacy found
nothing, the walk keeps the text before the pixels. Animated PNGs still use
`.text`.
The `perf budget` job in `.github/workflows/test.yml` is the reference
measurement: `ubuntu-latest`, one Python, no coverage, only the wall-clock tests.
It runs on every push to `main` and on `workflow_dispatch`, never on pull
requests, because shared-runner timing must not block a merge. Dispatch it on a
branch to measure before merging.
Shared Windows and macOS runners do not run them; the structural tests cover
Shared Windows runners do not run them; the structural tests cover
portability. Run it locally with
`PROMPTMANAGER_PERF_BUDGET=1 python -m unittest tests.test_api_workspace_images.TestWorkspaceImages.test_filter_budget`
and quote the printed p95 in the PR body.
@@ -646,10 +659,10 @@ Before changing paths, SQLite, streams, restore or cleanup:
ordinary busy timeout; cross-thread handoffs wait on events with generous
ceilings, never a short wall clock (#242).
- Run native Windows CI early for these changes. Pull requests and the merge
queue run only Linux, so start Windows and macOS on the branch with
queue run only Linux, so start Windows on the branch with
`gh workflow run portability.yml --ref <branch>`.
Include a Windows impact and evidence note in the PR. All six Windows/macOS
combinations in that run, every Linux leg and every applicable
Include a Windows impact and evidence note in the PR. All three Windows
legs in that run, every Linux leg and every applicable
exact-head check must pass before merge. Portable `ntpath`/`PureWindowsPath`
controls do not establish native filesystem, locking or cleanup success.
@@ -664,11 +677,11 @@ what runs:
| `py3.13 on ubuntu-latest`, `py3.14 on ubuntu-latest` | not created | run |
| `real ComfyUI compatibility` | skipped | runs |
| `coverage gate (85%)`, `black and flake8`, `frontend modules`, `bandit (high severity)` | run | run |
| `frontend coverage (shard N/3)`, `frontend coverage (merge)` (vendor pins and tooling tests on shard 3) | run | run |
| `frontend coverage (shard N/4)`, `frontend coverage (merge)` (vendor pins and tooling tests on shard 4) | run | run |
| `page workflows (firefox\|webkit, shard N/2)` | skipped | run |
| `all tests`, `viewerjs` (gates) | run | run |
| `perf budget` | skipped | push and dispatch only (not in the queue) |
| `py3.12-3.14 on windows-latest/macos-latest` (`portability.yml`) | not run | push to `main`, nightly, `workflow_dispatch` only; never required |
| `py3.12-3.14 on windows-latest` (`portability.yml`) | not run | push to `main`, nightly, `workflow_dispatch` only; never required |
GitHub has one required set for pull requests and the queue. A job skipped by
its `if:` reports Success. A dropped matrix leg never reports, so it would stay
@@ -681,8 +694,9 @@ guarantee:
So a merge group cannot turn green unless all three Linux Python legs, real
ComfyUI and the four browser shards passed.
Windows and macOS (Python 3.12-3.14) are out of the blocking path for now
(maintainer, 2026-10-28). They run in `portability.yml` on every push to `main`,
Windows (Python 3.12-3.14) is out of the blocking path for now
(maintainer, 2026-10-28). macOS is not tested in CI (maintainer, 2026-10-31, #571);
its code stays and support is best-effort. They run in `portability.yml` on every push to `main`,
nightly and on demand, and no context there is required. A red portability run
is still a failure: fix it at its cause, not by rerunning, and have every one
green before the workspace cutover (#334). `tests/vendor/ci-workflow.test.mjs` pins the
@@ -1407,6 +1421,8 @@ is shorter than the row it reserves.
Each Overview card is the count of the page it names: the shell's Library, Images and Tags sidebar counts, one source,
so a card and its badge cannot differ. `/prompt_manager/stats` is for the legacy page and counts raw rows. A visit re-reads
the three unless a read is in flight. An unread or failed card shows a dash; Retry re-reads only the failed counts.
System › Overview's Images metric reads the same shell count (#555), so it, the Overview card and the Images badge show
one number; its Statistics dialog labels the legacy row counts as "Database records".
Tests: `tests/js/workspace-state.test.js`, `tests/browser/workspace.spec.mjs`, `tests/comfyui/workspace.spec.mjs`.
### Images page controls and recorded deviations (#249 PR C)
@@ -2549,8 +2565,8 @@ files to the system trash. **No code path unlinks or overwrites a media file.**
("Moving files to the Recycle Bin isn't available on Windows: files larger than the bin's
capacity would be deleted permanently."). Library-only removal works everywhere. Tests use `tests/fake_trash.py`; the
browser test server installs it, so no test touches a real trash. The native proof,
`tests/test_trash_macos_native.py`, runs only with `PM_NATIVE_TRASH=macos` in
portability.yml's `native-trash` job (`macos-latest` and `macos-15-intel`): the startup
`tests/test_trash_macos_native.py`, runs only with `PM_NATIVE_TRASH=macos` on a Mac, by hand
(CI dropped its `native-trash` macOS job in #571; it last passed on `macos-latest` and `macos-15-intel`): the startup
volume, a name clash, fresh APFS and FAT32 disk images, an image used as an output root,
a read-only image and an unwritable folder (design and probe:
`docs/egm/499-macos-trash/`, local).
@@ -4006,7 +4022,11 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
"Save preferences" is `aria-disabled` (opacity .55, `not-allowed`) while clean or
saving; a setting the last save could not save gets a red outline. CSS under D92.
- **D93** (Overview): real values. The metrics are the Library's eligible positives
and active saved negatives and the indexed `generated_images` rows (Q2). "Database
and active saved negatives and the Images count (#555, amending Q2's indexed
`generated_images` rows): the shell's Images sidebar count, the Images page's own
total, which also lists files no row links, as the concept's `images.length` is
both. A visit re-reads it unless a read is in flight; "–" until read or after a
failed read. `images.indexed` stays in the API and is not shown. "Database
integrity" is "Not checked" until a check ran this session, then "N issues" (checks
that found issues, even beside a check that could not run), "Passed" or "Incomplete".
The review row is "N ambiguous image links" (the Needs review queue's pending count,
@@ -4044,10 +4064,11 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
- **D96** (Statistics; amended by D153): "Usage statistics" without the sample note: the
concept's "Runs by category" table without its Avg. rating column (Category / Runs)
with the real runs of eligible parents per category ("Uncategorized"), then a second
table "Library totals" (Total / Count: Prompts, Tags, Images from the legacy
statistics; no Rated images). The API still reports `average_rating` and
table "Database records" (Total / Count: Prompt records, Tags on prompts, Image
records: the raw row counts of the legacy statistics, labelled as rows so they never
read as the Library or Images page counts, #555; no Rated images). The API still reports `average_rating` and
`rated_images`; the workspace does not show them.
The Overview row says "Runs by category, with library totals." because
The Overview row says "Runs by category, with database record counts." because
that is what the dialog shows (the concept's own dialog shows only "Runs by
category"); per-prompt run counts, prompt tags and dated runs are recorded, but no
table of them exists yet. A failed read toasts "Statistics could not
@@ -5413,7 +5434,7 @@ list stay unscoped: any row's control proves them.
| Worker threads | `admin.html:437–445` | S4 Sources | covered | `sources` · slider · "Worker threads" | worker range max is cpu_count (workspace-system-sources.spec.mjs) |
| LoraManager badge, enable, path, trigger words, CivitAI key, clear key | `admin.html:448–490`; `admin.js:1052–1156` | S10 Integrations | covered | `integrations` · switch · "LoraManager integration" | LoRA rows show detected / enabled / cache-loaded separately (workspace-system-integrations.spec.mjs) |
| LoRA data import with progress | `admin.html:492–498,517–541`; `admin.js:1157` | S10 Integrations | covered | `integrations` · button · "Review import" | Review import shows replacement, blocked items and network effects with the backup box; Start opens the job (D107) (workspace-system-integrations.spec.mjs) |
| Statistics | `admin.html:159`; `admin.js:1627` | S2 Overview | covered | `overview` · button · "Explore" | statistics dialog lists runs by category and library totals (workspace-system.spec.mjs) |
| Statistics | `admin.html:159`; `admin.js:1627` | S2 Overview | covered | `overview` · button · "Explore" | statistics dialog lists runs by category and database record counts (workspace-system.spec.mjs) |
| Export components JSON `/export` | `admin.html:163`; `admin.js:1607`; `py/api/prompts.py:238` | S9 Backup & export | covered | `backups` · button · "Export all prompts JSON" | Export JSON downloads /library/export; secondary buttons download all prompts (/export) and /pairs/export (D103) (workspace-system-backups.spec.mjs) |
| Saved pairs export | `py/api/pairs.py:84` | S9 Backup & export | covered | `backups` · button · "Export saved pairs JSON" | Export JSON downloads /library/export; secondary buttons download all prompts (/export) and /pairs/export (D103) (workspace-system-backups.spec.mjs) |
| Library export | `py/api/library.py:223` | S9 Backup & export | covered | `backups` · button · "Export JSON" | Export JSON downloads /library/export; secondary buttons download all prompts (/export) and /pairs/export (D103) (workspace-system-backups.spec.mjs) |
+6 -9
View File
@@ -435,12 +435,9 @@ except Exception as e:
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
# Print startup message with loaded tools
print()
print(f"\033[94m[ComfyUI-PromptManager] Version:\033[0m {get_version()}")
for node_key, display_name in NODE_DISPLAY_NAME_MAPPINGS.items():
# ASCII only: a Windows console using a legacy code page cannot print emoji,
# and a UnicodeEncodeError here would abort loading the whole node.
print(f" \033[94mLoaded:\033[0m {display_name}")
print(f"\033[94mTotal: {len(NODE_CLASS_MAPPINGS)} tools loaded\033[0m")
print()
# One startup line. ASCII only: a Windows console using a legacy code page
# cannot print emoji, and a UnicodeEncodeError here would abort loading the node.
print(
f"\033[94m[ComfyUI-PromptManager] Version:\033[0m {get_version()}"
f" - {len(NODE_CLASS_MAPPINGS)} tools loaded"
)
+5 -1
View File
@@ -85,6 +85,7 @@ try:
from ...database.operations import PromptDatabase
from ...utils.file_prompt import parse_comfyui_prompt
from ...utils.logging_config import get_logger
from ...utils.png_text import png_text
from ...utils.video_metadata import is_video_path, read_video_metadata
except ImportError:
import sys
@@ -95,6 +96,7 @@ except ImportError:
from database.operations import PromptDatabase
from utils.file_prompt import parse_comfyui_prompt
from utils.logging_config import get_logger
from utils.png_text import png_text
from utils.video_metadata import is_video_path, read_video_metadata
@@ -1048,6 +1050,8 @@ class PromptManagerAPI(
def _extract_comfyui_metadata(self, image_path):
"""Embedded ComfyUI metadata of an image (PNG text chunks) or a video.
PNG text is read without decoding the pixels (``utils.png_text``, #523).
Videos go through ffprobe (see utils/video_metadata). Unreadable files
are a warning, not an error: a scan over a large output tree meets
truncated and foreign files, and it carries on past them.
@@ -1056,7 +1060,7 @@ class PromptManagerAPI(
return read_video_metadata(image_path)
try:
with Image.open(image_path) as img:
return dict(getattr(img, "text", None) or {})
return png_text(img)
except Exception as e:
self.logger.warning(
f"Unreadable image {os.path.basename(str(image_path))}: {e}"
+3 -3
View File
@@ -1668,9 +1668,9 @@ class AdminRoutesMixin:
def _extract_batch_metadata_sync(self, file_batch):
"""Extract metadata from a batch of files in one executor call.
Files are read on ``PromptManagerConfig.WORKER_THREADS`` threads:
Pillow decodes the whole image to reach trailing text chunks and
releases the GIL while doing so.
Files are read on ``PromptManagerConfig.WORKER_THREADS`` threads.
PNG text is read chunk by chunk without decoding pixels (#523); file
reads and ffprobe release the GIL.
"""
from ..config import PromptManagerConfig
+14 -2
View File
@@ -44,6 +44,7 @@ _FILES_CHANGED = (
"The LoRA metadata files changed during the scan; the previous imports "
"were kept. Run the scan again."
)
_LORA_SCAN_FAILED = "The LoRA import failed. Check the server log for details."
_IMPORTS_CHANGED = (
"The imported LoRA prompts changed during the scan; the previous imports "
"were kept. Run the scan again."
@@ -407,10 +408,21 @@ class LoraIntegrationMixin:
return
if isinstance(error, PlanChanged):
message = _IMPORTS_CHANGED
elif isinstance(error, (LoraFilesChanged, LoraSettingsUnusable)):
elif isinstance(
error,
(
LoraFilesChanged,
LoraSettingsUnusable,
EmptyReplacement,
PairDependencyError,
),
):
message = str(error)
else:
elif isinstance(error, OSError):
# Reduced to its reason and the file's basename.
message = self._public_error(error)
else:
message = _LORA_SCAN_FAILED
if isinstance(
error,
(
+2 -2
View File
@@ -1,10 +1,10 @@
[project]
name = "promptmanager"
description = "A powerful ComfyUI custom node that extends the standard text encoder with persistent prompt storage, advanced search capabilities, and an automatic image gallery system using SQLite."
version = "3.4.0"
version = "3.4.1"
license = {file = "LICENSE"}
requires-python = ">=3.12"
dependencies = ["watchdog>=2.1.0", "Pillow>=8.0.0"]
dependencies = ["watchdog>=2.1.0,<7", "Pillow>=8.0.0"]
[build-system]
requires = ["setuptools>=84.2.1"]
+1 -1
View File
@@ -1,5 +1,5 @@
# Runtime dependencies
watchdog>=6.0.0
watchdog>=6.0.0,<7
Pillow>=12.3.0
aiohttp>=3.14.5
+1 -1
View File
@@ -1,4 +1,4 @@
# Runtime dependencies for PromptManager.
# aiohttp, numpy and torch come from ComfyUI itself and are not listed here.
watchdog>=2.1.0 # Output directory monitoring
watchdog>=2.1.0,<7 # Output directory monitoring
Pillow>=8.0.0 # Image metadata extraction and thumbnails
+19 -11
View File
@@ -162,13 +162,15 @@ async function serverJson(request, url) {
}
const number = value => value.toLocaleString('en-US');
// The sidebar's Images count (state.mjs), which System's Images metric shows (#555).
const IMAGES_COUNT_URL = '/prompt_manager/images/output?mode=workspace&limit=1';
/** The concept's shell and Overview with the recorded deviations applied, given the server's real overview. */
function mapOverviewConcept(reference, overview) {
function mapOverviewConcept(reference, overview, images) {
// D165: the discreet legacy-pages line follows the settings layout.
reference = { ...reference, tree: [...reference.tree, 'p.legacy-pages.small'] };
const relabel = { 'Database integrity': 'Not checked', 'Positive prompts': number(overview.library.positive_prompts),
'Negative variants': number(overview.library.negative_variants), Images: number(overview.images.indexed) };
'Negative variants': number(overview.library.negative_variants), Images: number(images) };
const rows = {
// D93: the real pending count; Review metadata opens Needs review (the concept's go-review).
'3 ambiguous image links': row => ({ ...row,
@@ -179,8 +181,9 @@ function mapOverviewConcept(reference, overview) {
'One display configuration': row => ({ ...row, description: 'Grid and density settings are shared across views.' }),
// D93: the real sources, not samples.
'2 sample output sources': row => ({ ...row, title: `${number(overview.sources.effective)} output sources` }),
// D96: the dialog shows runs by category and library totals, not prompt, tag or date usage (D153: no ratings).
Statistics: row => ({ ...row, description: 'Runs by category, with library totals.' }),
// D96: the dialog shows runs by category and database record counts, not prompt, tag or date usage (D153: no
// ratings; #555: the counts are labelled as records, not as the Library or Images page counts).
Statistics: row => ({ ...row, description: 'Runs by category, with database record counts.' }),
};
const surfaces = reference.columns.surfaces.map(surface => ({ ...surface,
rows: surface.rows.map(row => (rows[row.title] ?? (value => value))(row)) }));
@@ -218,6 +221,8 @@ async function conceptOverview(page, theme) {
async function productionOverview(page, theme) {
await openProduction(page, 'system/overview', theme);
await expect(page.locator('#workspace-main .summary-metrics')).toBeVisible();
// #555: the Images metric is the shell's Images count: compare once it was read.
await expect(page.locator('#workspace-main .summary-metrics > .metric:nth-child(3) strong')).toHaveText(/^[\d,]+$/);
// The thumbnail row is its own read (I2): compare once it answered.
await expect(page.locator('#workspace-main .operation h3').filter({ hasText: 'thumbnails' })).toHaveText('No missing thumbnails');
// So is the backup row (S9).
@@ -231,6 +236,7 @@ for (const theme of THEMES) for (const [width, height] of SIZES) {
test(`system shell and overview match the concept ${theme} ${width}x${height}`, async ({ page, request }, testInfo) => {
await page.setViewportSize({ width, height });
const overview = await serverJson(request, '/prompt_manager/system/overview');
const images = (await serverJson(request, IMAGES_COUNT_URL)).total;
// The fixture's thumbnails all exist, so the concept's "No missing thumbnails" is also the real title.
expect((await serverJson(request, '/prompt_manager/images/thumbnails/status')).pending).toBe(0);
const concept = await conceptOverview(page, theme);
@@ -239,7 +245,7 @@ for (const theme of THEMES) for (const [width, height] of SIZES) {
await page.screenshot({ path: testInfo.outputPath(`concept-system-${theme}-${width}.png`), fullPage: true });
const production = await productionOverview(page, theme);
await page.screenshot({ path: testInfo.outputPath(`production-system-${theme}-${width}.png`), fullPage: true });
expect(diffInventory(mapOverviewConcept(concept, overview), production)).toEqual([]);
expect(diffInventory(mapOverviewConcept(concept, overview, images), production)).toEqual([]);
expect(production.pageScroll).toBe(false);
});
}
@@ -265,8 +271,9 @@ const UNMAPPED_PATHS = [
test('system comparison detects mutations', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
const overview = await serverJson(request, '/prompt_manager/system/overview');
const images = (await serverJson(request, IMAGES_COUNT_URL)).total;
const concept = await conceptOverview(page, 'dark');
const expected = mapOverviewConcept(concept, overview);
const expected = mapOverviewConcept(concept, overview, images);
const production = await productionOverview(page, 'dark');
expect(diffInventory(expected, production)).toEqual([]);
// Unmapped, the concept differs exactly where a deviation is recorded, and nowhere else.
@@ -290,7 +297,7 @@ test('system comparison detects mutations', async ({ page, request }) => {
});
/**
* D96: the concept's table, its rows the real ones; then "Library totals" in the same table markup. D153 (#447): the
* D96: the concept's table, its rows the real ones; then "Database records" (#555) in the same table markup. D153 (#447): the
* concept's "Avg. rating" column and a "Rated images" total are not drawn.
*/
function mapStatisticsConcept(reference, statistics) {
@@ -302,9 +309,10 @@ function mapStatisticsConcept(reference, statistics) {
tables: [
{ ...runs, head: runs.head.filter(([, , text]) => text !== 'Avg. rating'),
rows: statistics.by_category.map(row => [row.category ?? 'Uncategorized', number(row.runs)]) },
{ caption: 'Library totals', head: [['th', 'col', 'Total'], ['th', 'col', 'Count']],
rows: [['Prompts', number(statistics.totals.total_prompts)], ['Tags', number(statistics.totals.total_tags)],
['Images', number(statistics.totals.total_images)]] },
{ caption: 'Database records', head: [['th', 'col', 'Total'], ['th', 'col', 'Count']],
rows: [['Prompt records', number(statistics.totals.total_prompts)],
['Tags on prompts', number(statistics.totals.total_tags)],
['Image records', number(statistics.totals.total_images)]] },
],
};
}
@@ -420,7 +428,7 @@ for (const theme of THEMES) test(`statistics and health dialogs match the concep
expect(diffInventory(mapHealthConcept(concept.health), await page.evaluate(measureDialog)).map(item => item.path))
.toEqual(['operations.rows.0.title']);
await page.evaluate(() => { document.querySelector('#modal .operation h3').textContent = 'Integrity check'; });
// And a removed "Library totals" table and a changed caption in the statistics dialog.
// And a removed "Database records" table and a changed caption in the statistics dialog.
await page.keyboard.press('Escape');
await page.locator('#workspace-main .section-nav').getByRole('button', { name: 'Overview', exact: true }).click();
await page.locator('#workspace-main').getByRole('button', { name: 'Explore', exact: true }).click();
@@ -156,8 +156,9 @@ test('at 390px the window fits the screen, actions stay reachable and the page s
expect(frame.bottom-frame.top).toBeLessThan(844);
expect(frame.bottom-frame.top).toBeGreaterThanOrEqual(280);
expect(await page.evaluate(()=>document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
// The last row's actions can be scrolled to inside the window and fit its width.
const last=rows(page).last().getByRole('button',{name:'Rename / merge',exact:true});
// The first page's last row's actions can be scrolled to inside the window and fit its width.
// Pinned to row 100: reaching it loads the next page (D183), and last() would then follow row 200.
const last=rows(page).nth(99).getByRole('button',{name:'Rename / merge',exact:true});
await last.scrollIntoViewIfNeeded();await expect(last).toBeInViewport();
const [button]=await boxes(last);const [after]=await boxes(tagWindow(page));
expect(button.right).toBeLessThanOrEqual(after.right);
+31 -6
View File
@@ -8,6 +8,8 @@ import { readCoverageTable, parseControl, CONTROL_EXEMPTIONS } from '../../tools
const workspace = '/prompt_manager/workspace';
const OVERVIEW_URL = '/prompt_manager/system/overview';
const THUMBNAILS_URL = '/prompt_manager/images/thumbnails/status';
// The sidebar's Images count (state.mjs NAV_COUNT_SOURCES): the Images page's own total.
const IMAGES_COUNT_URL = '/prompt_manager/images/output?mode=workspace&limit=1';
const number = value => value.toLocaleString('en-US');
test.beforeEach(async ({ page, request, browserName }) => {
@@ -55,6 +57,7 @@ test('overview shows real counts and Not checked', async ({ page, request }) =>
page.on('request', item => { if (!['GET', 'HEAD', 'OPTIONS'].includes(item.method())) writes.push(item.url()); });
const overview = await serverJson(request, OVERVIEW_URL);
const thumbnails = await serverJson(request, THUMBNAILS_URL);
const images = (await serverJson(request, IMAGES_COUNT_URL)).total;
// The concept fixture: six positives, their saved negatives and 24 linked images, all with thumbnails.
expect([overview.library.positive_prompts, overview.images.indexed, thumbnails.pending]).toEqual([6, 24, 0]);
// I2: the Overview never walks the sources for thumbnails; the row reads the status route itself.
@@ -66,7 +69,8 @@ test('overview shows real counts and Not checked', async ({ page, request }) =>
[item.querySelector('strong').textContent, item.querySelector('span').textContent]))).toEqual([
[number(overview.library.positive_prompts), 'Positive prompts'],
[number(overview.library.negative_variants), 'Negative variants'],
[number(overview.images.indexed), 'Images'],
// #555: the Images page's own count, the same number as its sidebar badge.
[number(images), 'Images'],
['Not checked', 'Database integrity'],
]);
expect(await rowsOf(layout, 'Library health')).toEqual([
@@ -79,11 +83,31 @@ test('overview shows real counts and Not checked', async ({ page, request }) =>
expect(await rowsOf(layout, 'Workspace preferences')).toEqual([
['One display configuration', 'Grid and density settings are shared across views.', 'Appearance'],
['2 output sources', 'Monitoring and enrichment share one source list.', 'Manage sources'],
['Statistics', 'Runs by category, with library totals.', 'Explore'],
['Statistics', 'Runs by category, with database record counts.', 'Explore'],
]);
expect(writes).toEqual([]);
});
test('#555: the Images metric is the sidebar Images count, not the indexed image rows', async ({ page, request }) => {
const overview = await serverJson(request, OVERVIEW_URL);
// The Images page lists files no row links (the demo library: 122 files, 110 rows); the listing says so.
const listed = overview.images.indexed + 12;
await page.route(`**${IMAGES_COUNT_URL}`, async route => {
const response = await route.fetch();
await route.fulfill({ response, json: { ...(await response.json()), total: listed } });
});
const layout = await openOverview(page);
const imagesMetric = layout.locator('.summary-metrics > .metric').filter({ has: page.locator('span', { hasText: /^Images$/ }) });
await expect(imagesMetric.locator('strong')).toHaveText(number(listed));
await expect(page.locator('#primary-nav a[data-route="images"] .nav-count')).toHaveText(number(listed));
// A failed count reads as a dash, never the row count.
await page.unroute(`**${IMAGES_COUNT_URL}`);
await page.route(`**${IMAGES_COUNT_URL}`, route => route.fulfill({ status: 503, json: { success: false, error: 'Unavailable' } }));
await page.goto(`${workspace}#/system/appearance`);
await page.locator('#workspace-main .section-nav').getByRole('button', { name: 'Overview', exact: true }).click();
await expect(imagesMetric.locator('strong')).toHaveText('–');
});
// D129 (#249 S6b): where the library lives. The hook makes the fixture's database read as a node-folder library; its
// Move library is a stand-in that touches no file (workspace_system_fixture.py).
const banner = page => page.locator('#library-banner');
@@ -364,7 +388,7 @@ test('View backups / Appearance / Manage sources switch sections', async ({ page
}
});
test('statistics dialog lists runs by category and library totals', async ({ page, request }) => {
test('statistics dialog lists runs by category and database record counts', async ({ page, request }) => {
const statistics = await serverJson(request, '/prompt_manager/system/statistics');
expect(statistics.by_category.length).toBeGreaterThan(1);
const layout = await openOverview(page);
@@ -373,13 +397,14 @@ test('statistics dialog lists runs by category and library totals', async ({ pag
const dialog = page.locator('dialog#modal[open]');
await expect(dialog.locator('#modal-title')).toHaveText('Usage statistics');
const tables = dialog.locator('#modal-body > table');
await expect(tables.locator('caption')).toHaveText(['Runs by category', 'Library totals']);
await expect(tables.locator('caption')).toHaveText(['Runs by category', 'Database records']);
// D153 (#447): no rating column and no rated-images total.
await expect(tables.first().locator('thead th')).toHaveText(['Category', 'Runs']);
const cells = table => table.locator('tbody tr').evaluateAll(rows => rows.map(row => [...row.cells].map(cell => cell.textContent)));
expect(await cells(tables.first())).toEqual(statistics.by_category.map(row => [row.category ?? 'Uncategorized', number(row.runs)]));
expect(await cells(tables.nth(1))).toEqual([['Prompts', number(statistics.totals.total_prompts)],
['Tags', number(statistics.totals.total_tags)], ['Images', number(statistics.totals.total_images)]]);
// #555: raw rows of the legacy /stats, labelled as rows so they never read as the Library or Images page counts.
expect(await cells(tables.nth(1))).toEqual([['Prompt records', number(statistics.totals.total_prompts)],
['Tags on prompts', number(statistics.totals.total_tags)], ['Image records', number(statistics.totals.total_images)]]);
await page.keyboard.press('Escape');
await expect(explore).toBeFocused();
// A statistics read that fails says so; the Overview stays.
+4 -3
View File
@@ -145,9 +145,10 @@ test('long vocabularies append and Load more is keyboard reachable',async({page,
await expect(loadMore(page)).toHaveCount(0);
await expect(rows(page).nth(200).getByRole('button',{name:'Browse',exact:true})).toBeFocused();
await expect(rows(page).locator('td:nth-child(1)')).toHaveText(manifest.tags);
// Reload with the window at its top: Firefox restores a scrolled window, whose end then appends a page before the check.
await main(page).locator('.two-col .table-wrap').evaluate(node=>new Promise(resolve=>{node.scrollTop=0;requestAnimationFrame(()=>requestAnimationFrame(resolve));}));
await page.reload();
// Open the screen as a new history entry, not a reload: Firefox restores a reloaded page's scrolled tag window, and
// its end then appends a page before the check (#567). Scrolling the window to its top first does not always stick.
await page.goto('about:blank');
await page.goto(route);
await expect(rows(page)).toHaveCount(100);
// D183: the end of the list arrives inside the tag window.
const scroll=top=>main(page).locator('.two-col .table-wrap').evaluate((node,y)=>new Promise(resolve=>{node.scrollTop=y ?? node.scrollHeight;requestAnimationFrame(()=>requestAnimationFrame(resolve));}),top);
+12 -2
View File
@@ -16,12 +16,22 @@ const OVERVIEW = Object.freeze({
test('metrics are the server counts and Not checked before any health check', async () => {
const { metricValues } = await overviewModule;
assert.deepEqual(metricValues(OVERVIEW), [
assert.deepEqual(metricValues(OVERVIEW, 24), [
[(1234).toLocaleString(), 'Positive prompts'], ['7', 'Negative variants'], ['24', 'Images'],
['Not checked', 'Database integrity'],
]);
});
test('#555: the Images metric is the Images page count (the sidebar badge), not the indexed rows', async () => {
const { metricValues } = await overviewModule;
// The demo library: 110 indexed rows, 122 files on the Images page (unlinked ones included).
const overview = { ...OVERVIEW, images: { indexed: 110, rated: 0 } };
assert.deepEqual(metricValues(overview, 122)[2], [(122).toLocaleString(), 'Images']);
// Not read yet (undefined) or failed (null): a dash, as on the workspace Overview, never the row count.
assert.deepEqual(metricValues(overview, undefined)[2], ['–', 'Images']);
assert.deepEqual(metricValues(overview, null)[2], ['–', 'Images']);
});
test('metric 4 is the last health result', async () => {
const { integrityText } = await overviewModule;
assert.equal(integrityText(null), 'Not checked');
@@ -150,7 +160,7 @@ test('statistics rows are the real runs, then the library totals, without rating
rated_images: 20,
};
assert.deepEqual(categoryRows(statistics), [['Landscapes', (1200).toLocaleString()], ['Uncategorized', '3']]);
assert.deepEqual(totalsRows(statistics), [['Prompts', '12'], ['Tags', '15'], ['Images', '24']]);
assert.deepEqual(totalsRows(statistics), [['Prompt records', '12'], ['Tags on prompts', '15'], ['Image records', '24']]);
});
/** A fake api answering each `get`/`post` from a queue per URL, recording the calls. */
+17
View File
@@ -840,6 +840,23 @@ class TestLoraScanRoute(LoraAPITestCase):
("error", error["message"], 0),
)
async def test_unexpected_scan_error_is_generic_in_the_stream(self):
self._enable()
secret = str(self.tmp_root / "x.db")
err = RuntimeError(f"could not open {secret}")
with patch.object(self.api.db, "replace_category_prompts", side_effect=err):
with self.assertLogs("prompt_manager.api", level="ERROR") as logs:
resp = await self.client.request("POST", "/prompt_manager/lora/scan")
body = await resp.text() # the stream ends inside the patch
self.assertNotIn("x.db", body)
error = self._sse_events(body)[-1]
self.assertEqual(
(error["type"], error["status"], error["progress"]),
("error", error["message"], 0),
)
self.assertIn("server log", error["message"])
self.assertIn(secret, "\n".join(logs.output))
@staticmethod
def _sse_events(text):
return [
+11 -2
View File
@@ -390,7 +390,16 @@ class TestConsoleBanner(unittest.TestCase):
stream.flush()
banner = stream.buffer.getvalue().decode("cp1252")
self.assertIn("Loaded:", banner)
lines = [
line
for line in banner.splitlines()
if "PromptManager" in line or "loaded" in line
]
self.assertEqual(len(lines), 1, banner)
self.assertRegex(
lines[0], r"\[ComfyUI-PromptManager\] Version:.* \S+ - \d+ tools loaded$"
)
self.assertNotIn("Loaded:", banner)
class PriorFileHandler(logging.FileHandler):
@@ -553,7 +562,7 @@ class TestPackageLoggerIsolation(unittest.TestCase):
original_print = print
def fail_banner(*args, **kwargs):
if any("Total:" in str(arg) for arg in args):
if any("tools loaded" in str(arg) for arg in args):
captured["package"] = sys.modules[name]
captured["handlers"] = [
h
+455
View File
@@ -0,0 +1,455 @@
"""PNG text without a pixel decode (#523), against the legacy reader floor.
Pillow's ``PngImageFile.text`` loads (decodes) every pixel to reach text
chunks written after IDAT. ``utils.png_text.png_text`` walks the remaining
chunks instead. The legacy reader (``Image.open(path).text``, every failure
read as ``{}``) is the floor: a file it read must read the same, and the
curated corpus below must read identically. The documented difference is
pixel data, which the walk never decodes: undecodable or cut-short pixel data
made the legacy reader fail, the walk still reads the text before it.
"""
import io
import random
import struct
import tempfile
import unittest
import zlib
from pathlib import Path
from unittest.mock import patch
from PIL import Image, ImageFile, PngImagePlugin
from utils.metadata_extractor import ComfyUIMetadataExtractor
from utils.png_text import png_text
FIXTURES = Path(__file__).resolve().parent / "fixtures"
IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"}
SIGNATURE = b"\x89PNG\r\n\x1a\n"
FUZZ_SEED = 523
FUZZ_CASES = 400
def legacy_text(path):
"""The legacy scan reader: Pillow's ``.text`` (a full decode)."""
try:
with Image.open(path) as image:
return dict(getattr(image, "text", None) or {})
except Exception:
return {}
def walked_text(path):
"""The new reader under the same failure rule."""
try:
with Image.open(path) as image:
return png_text(image)
except Exception:
return {}
def chunk(kind, data):
return (
struct.pack(">I", len(data))
+ kind
+ data
+ struct.pack(">I", zlib.crc32(kind + data) & 0xFFFFFFFF)
)
def chunks_of(raw):
"""``[(kind, data)]`` of a well-formed PNG."""
found, offset = [], 8
while offset < len(raw):
length, kind = struct.unpack(">I4s", raw[offset : offset + 8])
found.append((kind, raw[offset + 8 : offset + 8 + length]))
offset += 12 + length
return found
def assemble(parts):
return SIGNATURE + b"".join(
part if isinstance(part, bytes) else chunk(*part) for part in parts
)
def base_png(mode="RGB", size=(64, 64), text=None, noise=False):
"""A PNG from Pillow (text before IDAT), as ``[(kind, data)]``."""
image = Image.new(mode, size)
if noise:
rng = random.Random(7)
image = Image.frombytes(
"RGB", size, bytes(rng.randrange(256) for _ in range(size[0] * size[1] * 3))
)
info = PngImagePlugin.PngInfo()
for key, value in (text or {}).items():
info.add_text(key, value)
buffer = io.BytesIO()
image.save(buffer, format="PNG", pnginfo=info)
return chunks_of(buffer.getvalue())
def split_idat(parts, pieces=3):
"""Spread the IDAT payload over ``pieces`` consecutive IDAT chunks."""
data = b"".join(d for k, d in parts if k == b"IDAT")
step = max(1, len(data) // pieces)
idats = [(b"IDAT", data[i : i + step]) for i in range(0, len(data), step)]
first = next(i for i, (k, _) in enumerate(parts) if k == b"IDAT")
rest = [p for p in parts if p[0] != b"IDAT"]
return rest[:first] + idats + rest[first:]
def before_iend(parts, *extra):
return parts[:-1] + list(extra) + parts[-1:]
def text_chunk(key, value):
return (b"tEXt", key.encode("latin-1") + b"\0" + value.encode("latin-1"))
def ztxt_chunk(key, value):
return (b"zTXt", key.encode() + b"\0\0" + zlib.compress(value.encode("latin-1")))
def itxt_chunk(key, value, compressed=False):
body = value.encode("utf-8")
if compressed:
body = zlib.compress(body)
flag = b"\1\0" if compressed else b"\0\0"
return (b"iTXt", key.encode() + b"\0" + flag + b"en\0" + b"Title\0" + body)
def bad_crc(kind, data):
good = chunk(kind, data)
return good[:-4] + bytes(b ^ 0xFF for b in good[-4:])
PROMPT = '{"3": {"class_type": "KSampler", "inputs": {"seed": 1}}}'
WORKFLOW = '{"nodes": [], "links": []}'
def curated():
"""Name to PNG bytes; each must read the same with both readers."""
plain = base_png(text={"prompt": PROMPT})
trailing = before_iend(
base_png(), text_chunk("prompt", PROMPT), text_chunk("workflow", WORKFLOW)
)
cases = {
"no-text": assemble(base_png()),
"text-before-pixels": assemble(plain),
"text-after-pixels": assemble(trailing),
"ztxt-after-pixels": assemble(
before_iend(base_png(), ztxt_chunk("prompt", PROMPT))
),
"itxt-plain-and-compressed": assemble(
before_iend(
base_png(),
itxt_chunk("prompt", "café " + PROMPT),
itxt_chunk("workflow", WORKFLOW, compressed=True),
)
),
"xmp-after-pixels": assemble(
before_iend(
base_png(),
itxt_chunk("XML:com.adobe.xmp", "<x:xmpmeta/>"),
text_chunk("prompt", PROMPT),
)
),
"itxt-bad-utf8": assemble(
before_iend(
base_png(),
(b"iTXt", b"prompt\0\0\0\0\0\xff\xfe"),
text_chunk("workflow", WORKFLOW),
)
),
"later-copy-wins": assemble(
before_iend(plain, text_chunk("prompt", '{"later": 1}'))
),
"empty-key-and-no-separator": assemble(
before_iend(base_png(), (b"tEXt", b"\0value"), (b"tEXt", b"lonely"))
),
"split-pixels-text-between": assemble(
split_idat(before_iend(base_png(noise=True), text_chunk("prompt", PROMPT)))
),
"palette": assemble(
before_iend(base_png(mode="P"), text_chunk("prompt", PROMPT))
),
"sixteen-bit": assemble(
before_iend(base_png(mode="I;16"), text_chunk("prompt", PROMPT))
),
"bad-crc-before-pixels": assemble(
[base_png()[0], bad_crc(*text_chunk("prompt", PROMPT))] + base_png()[1:]
),
"bad-crc-after-pixels": assemble(
base_png()[:-1] + [bad_crc(*text_chunk("prompt", PROMPT))] + base_png()[-1:]
),
"unknown-chunk-after-pixels": assemble(
before_iend(base_png(), (b"prVt", b"x" * 40), text_chunk("prompt", PROMPT))
),
"garbage-chunk-id-after-pixels": assemble(
base_png()[:-1]
+ [b"\0\0\0\4\x00\x01\x02\x03abcd\0\0\0\0"]
+ [chunk(*text_chunk("prompt", PROMPT))]
+ base_png()[-1:]
),
"oversized-ztxt-after-pixels": assemble(
before_iend(
base_png(),
(
b"zTXt",
b"prompt\0\0"
+ zlib.compress(b"a" * (PngImagePlugin.MAX_TEXT_CHUNK + 10)),
),
)
),
"pixels-length-past-end": assemble(
[p for p in base_png() if p[0] != b"IDAT" and p[0] != b"IEND"]
)
+ b"\xff\xff\x00\x00IDAT"
+ b"\0" * 64,
}
full = assemble(trailing)
idat_at = full.index(b"IDAT")
cases["truncated-in-pixels"] = full[: idat_at + 20]
cases["no-end-after-pixels"] = full[: full.rindex(b"IEND") - 4]
cases["truncated-in-trailing-text"] = full[: full.rindex(b"workflow") + 4]
cases["unknown-chunk-past-end"] = (
assemble(before_iend(base_png(), ztxt_chunk("prompt", PROMPT))[:-1])
+ chunk(b"prVt", b"x" * 40)[:-20]
)
cases["not-a-png"] = b"nope, not an image at all"
cases["signature-only"] = SIGNATURE
return cases
def corrupt_pixels():
"""Complete IDAT chunks with valid CRCs over data zlib cannot inflate."""
parts = before_iend(base_png(), text_chunk("prompt", PROMPT))
return assemble(
[(k, b"\x78\x9c" + b"\xff" * 30) if k == b"IDAT" else (k, d) for k, d in parts]
)
def apng():
frames = [Image.new("RGB", (16, 16), colour) for colour in ("red", "blue")]
info = PngImagePlugin.PngInfo()
info.add_text("prompt", PROMPT)
buffer = io.BytesIO()
frames[0].save(
buffer, format="PNG", save_all=True, append_images=frames[1:], pnginfo=info
)
return assemble(
before_iend(chunks_of(buffer.getvalue()), text_chunk("workflow", WORKFLOW))
)
def fixture_images():
return sorted(p for p in FIXTURES.rglob("*") if p.suffix.lower() in IMAGE_SUFFIXES)
class _TempFiles(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.addCleanup(self._tmp.cleanup)
def write(self, name, raw):
path = Path(self._tmp.name) / name
path.write_bytes(raw)
return path
class PngTextParityTests(_TempFiles):
def test_every_fixture_image_reads_like_the_legacy_reader(self):
images = fixture_images()
self.assertGreaterEqual(len(images), 25)
for path in images:
with self.subTest(path=path.relative_to(FIXTURES)):
self.assertEqual(walked_text(path), legacy_text(path))
def test_curated_corpus_reads_like_the_legacy_reader(self):
for name, raw in curated().items():
with self.subTest(name):
path = self.write(name + ".png", raw)
self.assertEqual(walked_text(path), legacy_text(path))
def test_curated_corpus_exercises_trailing_text(self):
"""The corpus is not vacuous: trailing text exists and is read."""
cases = curated()
path = self.write("t.png", cases["text-after-pixels"])
self.assertEqual(walked_text(path), {"prompt": PROMPT, "workflow": WORKFLOW})
with Image.open(path) as image:
self.assertNotIn("prompt", image.info)
self.assertEqual(
walked_text(self.write("x.png", cases["truncated-in-pixels"])), {}
)
def test_itxt_values_keep_their_language_and_type(self):
path = self.write("i.png", curated()["itxt-plain-and-compressed"])
new, old = walked_text(path), legacy_text(path)
self.assertIsInstance(new["prompt"], PngImagePlugin.iTXt)
self.assertEqual(new["prompt"].lang, old["prompt"].lang)
self.assertEqual(new["prompt"].tkey, old["prompt"].tkey)
def test_animated_png_falls_back_to_the_legacy_read(self):
path = self.write("a.png", apng())
self.assertEqual(walked_text(path), legacy_text(path))
self.assertEqual(walked_text(path)["workflow"], WORKFLOW)
def test_truncated_pixels_read_alike_when_truncated_images_load(self):
"""With LOAD_TRUNCATED_IMAGES set (a node pack may set it) both readers
accept a short file alike."""
path = self.write("t.png", curated()["truncated-in-pixels"])
with patch.object(ImageFile, "LOAD_TRUNCATED_IMAGES", True):
self.assertEqual(walked_text(path), legacy_text(path))
def test_a_text_decode_error_ends_the_walk_like_legacy(self):
"""Pillow's ``load_end`` stops at a chunk whose handler raises
``UnicodeDecodeError`` and keeps the text read so far."""
path = self.write(
"u.png",
assemble(
before_iend(
base_png(),
ztxt_chunk("prompt", PROMPT),
text_chunk("workflow", WORKFLOW),
ztxt_chunk("parameters", "steps 20"),
)
),
)
def undecodable(stream, position, length):
raise UnicodeDecodeError("latin-1", b"", 0, 1, "test")
with patch.object(PngImagePlugin.PngStream, "chunk_tEXt", undecodable):
old = legacy_text(path)
self.assertEqual(walked_text(path), old)
self.assertEqual(old, {"prompt": PROMPT})
def test_the_text_memory_budget_counts_text_before_the_pixels(self):
"""Pillow's MAX_TEXT_MEMORY spans the whole file: text before and
after the pixels, each under it, fail together in both readers."""
path = self.write(
"m.png",
assemble(
before_iend(
base_png(text={"prompt": PROMPT}), text_chunk("workflow", WORKFLOW)
)
),
)
budget = len(PROMPT) + len(WORKFLOW) - 1
with patch.object(PngImagePlugin, "MAX_TEXT_MEMORY", budget):
self.assertEqual(legacy_text(path), {})
self.assertEqual(walked_text(path), {})
with patch.object(PngImagePlugin, "MAX_TEXT_MEMORY", budget + 1):
self.assertEqual(walked_text(path), legacy_text(path))
self.assertEqual(len(walked_text(path)), 2)
def test_complete_but_undecodable_pixels_read_more_than_legacy(self):
path = self.write("c.png", corrupt_pixels())
self.assertEqual(legacy_text(path), {})
self.assertEqual(walked_text(path), {"prompt": PROMPT})
def test_cut_short_pixels_read_the_text_before_them(self):
full = assemble(base_png(text={"prompt": PROMPT}))
path = self.write("s.png", full[: full.index(b"IDAT") + 12])
self.assertEqual(legacy_text(path), {})
self.assertEqual(walked_text(path), {"prompt": PROMPT})
def test_mutated_files_never_read_less_than_legacy(self):
"""Seeded fuzz: truncations and byte flips of a file with text before,
between and after its pixel chunks. Wherever the legacy reader found
text, the walk finds exactly the same."""
base = assemble(
split_idat(
before_iend(
base_png(text={"workflow": WORKFLOW}),
ztxt_chunk("prompt", PROMPT),
itxt_chunk("parameters", "steps 20"),
)
)
)
rng = random.Random(FUZZ_SEED)
compared = 0
for case in range(FUZZ_CASES):
raw = bytearray(base)
if case % 2:
del raw[rng.randrange(8, len(raw)) :]
else:
for _ in range(rng.randrange(1, 4)):
raw[rng.randrange(8, len(raw))] = rng.randrange(256)
path = self.write(f"fuzz{case}.png", bytes(raw))
old = legacy_text(path)
if old:
compared += 1
with self.subTest(case=case):
self.assertEqual(walked_text(path), old)
self.assertGreater(compared, FUZZ_CASES // 5)
def test_non_png_images_have_no_text(self):
for name in ("tiny.jpg", "tiny.webp"):
with Image.open(FIXTURES / "local-inspect" / name) as image:
self.assertEqual(png_text(image), {})
class NoPixelDecodeTests(_TempFiles):
"""Metadata reads never decode pixels (the #523 memory finding)."""
def png_fixtures(self):
return sorted((FIXTURES / "inspection_parity").glob("*.png"))
def test_png_text_does_not_load_pixels_and_leaves_the_image_usable(self):
path = self.write("t.png", curated()["text-after-pixels"])
with Image.open(path) as image:
with patch.object(ImageFile.ImageFile, "load", autospec=True) as load:
self.assertEqual(png_text(image)["workflow"], WORKFLOW)
load.assert_not_called()
image.load()
self.assertEqual(image.size, (64, 64))
self.assertEqual(image.text["workflow"], WORKFLOW)
def test_scan_reader_does_not_decode_pixels(self):
from py.api import PromptManagerAPI
api = PromptManagerAPI.__new__(PromptManagerAPI)
fixtures = self.png_fixtures() + [
self.write("t.png", curated()["text-after-pixels"])
]
with patch.object(ImageFile.ImageFile, "load", autospec=True) as load:
found = [api._extract_comfyui_metadata(str(p)) for p in fixtures]
load.assert_not_called()
self.assertEqual(found, [legacy_text(p) for p in fixtures])
self.assertTrue(all(found))
def test_monitor_extractor_does_not_decode_pixels(self):
extractor = ComfyUIMetadataExtractor()
fixtures = self.png_fixtures()
with patch.object(ImageFile.ImageFile, "load", autospec=True) as load:
found = [extractor.extract_metadata(str(p)) for p in fixtures]
load.assert_not_called()
self.assertTrue(all(metadata and len(metadata) > 1 for metadata in found))
class MonitorExtractorParityTests(_TempFiles):
"""``extract_metadata`` answers exactly as with the legacy ``.text``."""
def legacy_extract(self, path):
def old_text(image):
return dict(getattr(image, "text", None) or {})
with patch("utils.metadata_extractor.png_text", old_text):
return ComfyUIMetadataExtractor().extract_metadata(str(path))
def test_every_fixture_and_curated_file_extracts_like_legacy(self):
paths = fixture_images() + [
self.write(name + ".png", raw) for name, raw in curated().items()
]
extractor = ComfyUIMetadataExtractor()
for path in paths:
with self.subTest(path=path.name):
self.assertEqual(
extractor.extract_metadata(str(path)), self.legacy_extract(path)
)
if __name__ == "__main__":
unittest.main()
+9 -22
View File
@@ -129,11 +129,11 @@ test('Firefox/WebKit page workflows run only outside pull requests; coverage run
}
});
test('coverage runs as three shards that each upload their raw coverage', () => {
test('coverage runs as four shards that each upload their raw coverage', () => {
const coverage = allJobs.get('frontend-coverage');
assert.deepEqual(matrixList(coverage, 'shard'), ['1', '2', '3']);
assert.match(coverage, /^ {4}name: frontend coverage \(shard \$\{\{ matrix\.shard \}\}\/3\)$/m);
assert.match(coverage, /npm run --prefix tools\/vendor test:coverage:shard -- \$\{\{ matrix\.shard \}\}\/3/);
assert.deepEqual(matrixList(coverage, 'shard'), ['1', '2', '3', '4']);
assert.match(coverage, /^ {4}name: frontend coverage \(shard \$\{\{ matrix\.shard \}\}\/4\)$/m);
assert.match(coverage, /npm run --prefix tools\/vendor test:coverage:shard -- \$\{\{ matrix\.shard \}\}\/4/);
const upload = steps(coverage).find(step => /name: coverage-raw-\$\{\{ matrix\.shard \}\}/.test(step));
assert.ok(upload, 'each shard uploads coverage-raw-N');
assert.match(upload, /^ {10}path: coverage\/js\/raw\/?$/m);
@@ -331,7 +331,7 @@ const testJobs = () => jobs(readWorkflow('test.yml'));
test('the Python matrix is Linux 3.12 on a pull request and Linux 3.12-3.14 on every other event', () => {
const body = testJobs().get('test');
assert.deepEqual(matrixList(body, 'os'), ['ubuntu-latest'], 'Windows and macOS never gate a merge');
assert.deepEqual(matrixList(body, 'os'), ['ubuntu-latest'], 'Windows never gates a merge');
const match = /^ {8}python-version: \$\{\{ github\.event_name == 'pull_request' && fromJSON\('(\[[^\]]*\])'\) \|\| fromJSON\('(\[[^\]]*\])'\) \}\}$/m.exec(body);
assert.ok(match, 'python-version is chosen by event');
assert.deepEqual({ pr: JSON.parse(match[1]), other: JSON.parse(match[2]) }, { pr: ['3.12'], other: ['3.12', '3.13', '3.14'] });
@@ -339,34 +339,21 @@ test('the Python matrix is Linux 3.12 on a pull request and Linux 3.12-3.14 on e
assert.match(body, /^ {4}name: py\$\{\{ matrix\.python-version \}\} on \$\{\{ matrix\.os \}\}$/m);
});
test('Windows and macOS run 3.12-3.14 on main, nightly and on demand, never on a PR or in the queue', () => {
test('Windows runs 3.12-3.14 on main, nightly and on demand, never on a PR or in the queue', () => {
const text = readWorkflow('portability.yml');
const triggers = text.slice(text.indexOf('\non:'), text.indexOf('\nconcurrency:'));
for (const event of ['push', 'schedule', 'workflow_dispatch']) assert.match(triggers, new RegExp(`^ {2}${event}:`, 'm'));
for (const event of ['pull_request', 'merge_group']) assert.doesNotMatch(triggers, new RegExp(`^ {2}${event}`, 'm'));
assert.match(triggers, /^ {4}branches: \[main\]$/m, 'push runs only for main');
const body = jobs(text).get('test');
assert.deepEqual(matrixList(body, 'os'), ['windows-latest', 'macos-latest']);
assert.deepEqual(matrixList(body, 'os'), ['windows-latest'], 'macOS is not tested in CI (#571)');
assert.doesNotMatch(text, /macos-/, 'no macOS runner is used');
assert.deepEqual(matrixList(body, 'python-version'), ['"3.12"', '"3.13"', '"3.14"']);
assert.match(body, /^ {6}fail-fast: false$/m);
assert.match(body, /^ {8}run: python -m tests\.run_suite -v$/m);
assert.doesNotMatch(text, /cancel-in-progress: true|cancel-in-progress: \$/, 'no portability run is cancelled');
});
// #499: the native trash proof runs on both macOS CPUs, apart from the suite so a red suite never skips it.
test('portability runs the native trash proof on arm64 and Intel macOS', () => {
const body = jobs(readWorkflow('portability.yml')).get('native-trash');
assert.ok(body, 'portability.yml has a native-trash job');
assert.deepEqual(matrixList(body, 'os'), ['macos-latest', 'macos-15-intel']);
assert.match(body, /^ {6}fail-fast: false$/m);
assert.doesNotMatch(body, /^ {4}needs:/m, 'it does not wait for the suite job');
assert.match(body, /^ {10}PM_NATIVE_TRASH: macos$/m);
assert.match(body, /^ {8}run: python -m unittest tests\.test_trash_macos_native tests\.test_trash_macos -v$/m);
const jobMinutes = Number(/^ {4}timeout-minutes: (\d+)$/m.exec(body)[1]);
const stepMinutes = Number(/^ {8}timeout-minutes: (\d+)$/m.exec(body)?.[1]);
assert.ok(stepMinutes > 0 && jobMinutes - stepMinutes >= 2, 'the proof step stops before the job limit');
});
// #493: the portability limit follows the rule in its comment: ceil(1.5 x the slowest of three
// green main runs) + 3 minutes, never under 5, with those runs and times written above jobs:.
// The times are whole-job wall times. That the runs were green and on main is the author's word.
@@ -427,5 +414,5 @@ test('every required check reports on a pull request and the gates carry the ful
].sort());
assert.equal(contexts.filter(context => /^py/.test(context)).length, 1,
'a dropped matrix leg never reports on a pull request, so it cannot be required');
assert.ok(!contexts.some(context => /windows|macos/.test(context)), 'Windows and macOS never gate a merge');
assert.ok(!contexts.some(context => /windows|macos/.test(context)), 'Windows never gates a merge');
});
+12 -9
View File
@@ -37,6 +37,7 @@ from typing import Optional, Dict, Any
from PIL import Image
from .logging_config import get_logger
from .png_text import png_text
class ComfyUIMetadataExtractor:
@@ -88,12 +89,14 @@ class ComfyUIMetadataExtractor:
# Add basic file information
metadata["file_info"] = self.get_file_info(image_path, image)
# Extract ComfyUI-specific metadata from PNG text chunks
if hasattr(image, "text") and image.text:
# Extract ComfyUI-specific metadata from PNG text chunks,
# read without decoding the pixels (#523)
text = png_text(image)
if text:
# Look for ComfyUI workflow data
if "workflow" in image.text:
if "workflow" in text:
try:
workflow_data = json.loads(image.text["workflow"])
workflow_data = json.loads(text["workflow"])
metadata["workflow"] = workflow_data
# Extract text encoder nodes from workflow
@@ -107,9 +110,9 @@ class ComfyUIMetadataExtractor:
self.logger.warning(f"Failed to parse workflow JSON: {e}")
# Look for prompt data
if "prompt" in image.text:
if "prompt" in text:
try:
prompt_data = json.loads(image.text["prompt"])
prompt_data = json.loads(text["prompt"])
metadata["prompt"] = prompt_data
except json.JSONDecodeError as e:
self.logger.warning(f"Failed to parse prompt JSON: {e}")
@@ -129,13 +132,13 @@ class ComfyUIMetadataExtractor:
]
for field in metadata_fields:
if field in image.text:
if field in text:
try:
# Try to parse as JSON first
metadata[field] = json.loads(image.text[field])
metadata[field] = json.loads(text[field])
except (json.JSONDecodeError, TypeError):
# Store as string if not valid JSON
metadata[field] = image.text[field]
metadata[field] = text[field]
return (
metadata
+116
View File
@@ -0,0 +1,116 @@
"""PNG text chunks without decoding pixels (#523).
Pillow's ``PngImageFile.text`` calls ``load()``, which decodes every pixel, to
reach text chunks written after the image data. On a scan or a busy image
monitor that is a full-size buffer per file for a few kilobytes of text.
``png_text`` returns the same dictionary as ``image.text`` for an opened,
single-frame PNG, by walking the chunks after the first IDAT with Pillow's own
chunk handlers (same decoding, limits and duplicate rule) and seeking over the
pixel data. It follows Pillow's ``load_end`` loop: chunk checksums after the
pixels are not checked, an unreadable chunk header ends the walk, and another
chunk that runs past the end of the file fails like ``ImageFile._safe_read``
(unless ``ImageFile.LOAD_TRUNCATED_IMAGES``).
The one difference is pixel data, which is never decoded: pixel data that is
undecodable, or cut short, no longer hides the text read before it. Pillow
itself accepts a file cut inside its last pixel chunk once the decoder has
all rows, and only a decode can tell those apart, so the walk never reads
less than ``.text`` did (the PR #170 "legacy parity floor").
Anything else (other formats, animated PNGs, an image already loaded) is
answered by Pillow itself.
"""
import os
import struct
from PIL import ImageFile
_PIXEL_CHUNKS = (b"IDAT", b"fdAT")
# Signature, IHDR chunk and IDAT chunk header: the earliest pixel data offset.
_FIRST_DATA_OFFSET = 8 + 25 + 8
def png_text(image) -> dict:
"""``dict(image.text)`` for an opened image, without a pixel decode.
The image stays usable: its file position and Pillow state are restored.
Non-PNG images answer ``{}`` (only PNG has text chunks in Pillow).
"""
if not _walkable(image):
return dict(getattr(image, "text", None) or {})
fp = image.fp
resume = fp.tell()
try:
return _walk_after_pixels(image.png, fp, image.tile[0][2] - 8)
finally:
fp.seek(resume)
def _walkable(image) -> bool:
"""A plain single-frame PNG whose header pass stopped at its first IDAT."""
tile = getattr(image, "tile", None)
return (
image.format == "PNG"
and getattr(image, "png", None) is not None
and getattr(image, "fp", None) is not None
and not getattr(image, "is_animated", False)
and isinstance(tile, list)
and len(tile) == 1
and tile[0][0] == "zip"
# The first IDAT's data offset; anything else is not a layout we know.
and isinstance(tile[0][2], int)
and tile[0][2] >= _FIRST_DATA_OFFSET
)
def _walk_after_pixels(stream, fp, offset: int) -> dict:
"""Pillow's ``load_end`` chunk loop from the first IDAT header, skipping pixels.
``stream`` is the image's own ``PngStream``: its text so far and its text
memory budget carry on exactly as a ``load()`` would carry them. The text
and info dictionaries are copied first, so ``image.text`` and
``image.info`` are unchanged. Like ``load_end``, a handler for a chunk
misplaced after the pixels (a second IHDR or PLTE) may still update the
stream's other header fields; Pillow ignores them once the image is open.
"""
end = fp.seek(0, os.SEEK_END)
saved = stream.im_text, stream.im_info, stream.text_memory
stream.im_text, stream.im_info = dict(saved[0]), dict(saved[1])
try:
fp.seek(offset)
while True:
try:
kind, position, length = stream.read()
except (struct.error, SyntaxError):
break
if kind == b"IEND":
break
try:
handled = kind not in _PIXEL_CHUNKS and _handle(
stream, kind, position, length
)
except UnicodeDecodeError:
break
if not handled:
if position + length > end:
if kind in _PIXEL_CHUNKS or ImageFile.LOAD_TRUNCATED_IMAGES:
break
raise OSError("Truncated File Read")
fp.seek(position + length + 4)
continue
fp.read(4) # checksum: not verified after the pixels, as in Pillow
return stream.im_text
finally:
stream.im_text, stream.im_info, stream.text_memory = saved
def _handle(stream, kind: bytes, position: int, length: int) -> bool:
"""Run Pillow's handler; ``False`` when the chunk's bytes must be skipped."""
try:
stream.call(kind, position, length)
except (EOFError, AttributeError):
# A chunk Pillow reads past (EOFError) or has no handler for.
return False
return True
+7 -1
View File
@@ -176,6 +176,12 @@ function mountWorkspace() {
function notifyCounts() { for (const listener of countListeners) listener(); }
/** Re-read the given counts unless a read is already in flight. */
function freshenCounts(ids) { refreshCounts(ids.filter(id => !countRequests.has(id))); }
/** #555: the counts as System › Overview reads them, so its Images metric and the Images badge cannot differ. */
const sharedCounts = Object.freeze({
get: id => knownCounts.get(id),
subscribe(listener) { countListeners.add(listener); return () => countListeners.delete(listener); },
freshen: freshenCounts,
});
/** A count a page has just read itself: it replaces the badge, and an older read in flight no longer lands. */
function setCount(id, count) {
countRequests.get(id)?.abort();
@@ -488,7 +494,7 @@ function mountWorkspace() {
if (route.id === 'system') {
// The concept's System (app.js renderSystem): its heading description, section nav and Save row.
description.textContent = route.description;
system = mountSystem(main, api, view, { section: route.section, activity });
system = mountSystem(main, api, view, { section: route.section, activity, counts: sharedCounts });
return;
}
if (route.id === 'inspect') {
+48 -17
View File
@@ -11,8 +11,10 @@
* D99: its Review thumbnails opens the same "Thumbnail maintenance" dialog as Sources (thumbnail-review.mjs).
* #317: it reads "N missing · M outdated · K failed"; while previews are outdated (another format than the
* configured one) a second button, "Convert outdated", opens that dialog on its Convert outdated scope (D131).
* - D96: the Statistics dialog lists the real runs per category, then "Library totals" (D153: no rating column or
* rated-images total).
* - #555: metric 3, "Images", is the shell's Images count (the Images page's total, its sidebar badge), not the
* overview's indexed rows, which leave out files no row links: a dash until it is read or when its read failed.
* - D96: the Statistics dialog lists the real runs per category, then "Database records" (D153: no rating column or
* rated-images total; #555: the legacy row counts, labelled as records).
* - D95: the "Health report" lists every check the server ran, Missing files per image source, with a chip per real
* result and when it was checked. The check runs from Maintenance's notice ("Run health checks", D123) as a
* read job of the registry, tracked in the Activity store (its event stream); the report opens when it ends.
@@ -61,12 +63,20 @@ export function integrityText(health) {
return health.status === 'passed' ? 'Passed' : 'Incomplete';
}
/** The concept's four metrics `[value, label]`, in its order. */
export function metricValues(overview) {
/** #555: the Images metric's value from the shell's Images count; a dash while unread (undefined) or failed (null). */
export function imagesText(images) {
return Number.isSafeInteger(images) && images >= 0 ? number(images) : '–';
}
/**
* The concept's four metrics `[value, label]`, in its order. `images` is the shell's Images count (#555): the concept's
* `images.length`, the same number as the Images sidebar badge.
*/
export function metricValues(overview, images) {
return [
[number(overview.library.positive_prompts), 'Positive prompts'],
[number(overview.library.negative_variants), 'Negative variants'],
[number(overview.images.indexed), 'Images'],
[imagesText(images), 'Images'],
[integrityText(overview.health), 'Database integrity'],
];
}
@@ -174,10 +184,15 @@ export function categoryRows(statistics) {
return statistics.by_category.map(row => [row.category ?? 'Uncategorized', number(row.runs)]);
}
/** D96: the added "Library totals" rows `[label, count]` (the legacy statistics; D153 drops the rated images). */
/**
* D96: the added "Database records" rows `[label, count]` (the legacy statistics; D153 drops the rated images). #555:
* they count raw rows (every prompt row, tags on a prompt, every image row), so they say so and never read as the
* Library or Images page counts.
*/
export function totalsRows(statistics) {
const { totals } = statistics;
return [['Prompts', totals.total_prompts], ['Tags', totals.total_tags], ['Images', totals.total_images]]
return [['Prompt records', totals.total_prompts], ['Tags on prompts', totals.total_tags],
['Image records', totals.total_images]]
.map(([label, value]) => [label, number(value)]);
}
@@ -208,11 +223,11 @@ function table(caption, head, rows, empty) {
return node;
}
/** The Statistics dialog: "Runs by category" (the concept's table), then "Library totals" (D96). */
/** The Statistics dialog: "Runs by category" (the concept's table), then "Database records" (D96, #555). */
export function statisticsNodes(statistics) {
return [
table('Runs by category', ['Category', 'Runs'], categoryRows(statistics), 'No prompts in the library yet.'),
table('Library totals', ['Total', 'Count'], totalsRows(statistics), ''),
table('Database records', ['Total', 'Count'], totalsRows(statistics), ''),
];
}
@@ -332,9 +347,9 @@ export function healthNotice({ api, activity, scope, navigate = navigateTo, noti
return notice;
}
function metricsNode(overview) {
function metricsNode(overview, images) {
const metrics = el('div', undefined, 'summary-metrics');
for (const [value, label] of metricValues(overview)) {
for (const [value, label] of metricValues(overview, images)) {
const metric = el('div', undefined, 'metric');
metric.append(el('strong', value), el('span', label));
metrics.append(metric);
@@ -349,7 +364,7 @@ function surface(heading, rows) {
}
/** The Overview's nodes, and its thumbnail row (painted by its own read). */
function overviewNodes(overview, { api, activity, notify, scope, navigate, openStatistics, openThumbnails }) {
function overviewNodes(overview, { api, activity, notify, scope, navigate, openStatistics, openThumbnails, images }) {
const go = section => () => navigate(`#/system/${section}`);
const review = operationRow({ scope, title: ambiguousTitle(overview.needs_review),
description: Number.isSafeInteger(overview.needs_review?.pending)
@@ -374,26 +389,36 @@ function overviewNodes(overview, { api, activity, notify, scope, navigate, openS
operationRow({ scope, title: 'One display configuration', description: 'Grid and density settings are shared across views.',
label: 'Appearance', onClick: go('appearance') }),
operationRow({ scope, title: sourcesTitle, description: sourcesDescription, label: 'Manage sources', onClick: go('sources') }),
operationRow({ scope, title: 'Statistics', description: 'Runs by category, with library totals.', label: 'Explore',
operationRow({ scope, title: 'Statistics', description: 'Runs by category, with database record counts.', label: 'Explore',
onClick: openStatistics }),
]);
const columns = el('div', undefined, 'two-col');
columns.append(health, preferences);
return { nodes: [metricsNode(overview), columns], thumbnails, backup: backupRow };
const metrics = metricsNode(overview, images);
return { nodes: [metrics, columns], thumbnails, backup: backupRow, metrics };
}
/**
* The Overview section's mount (`mountSystem`'s `sections.overview`): returns its loading line, which the server's
* answer replaces in place (the concept's metrics and columns sit directly in `.settings-layout`). A read that a newer
* one superseded, or one answering after the section left, paints nothing.
*
* `counts` is the shell's sidebar counts (`{get(id), subscribe(listener), freshen(ids)}`, index.mjs): the Images metric
* shows its `images` count and follows it, and a visit re-reads it as the workspace Overview does (#555).
*/
export function mountOverview(container, { api, activity, scope, navigate = navigateTo, notify = showWorkspaceToast,
latestOnly = window.ApiClient.latestOnly, STALE = window.ApiClient.STALE }) {
export function mountOverview(container, { api, activity, scope, counts = null, navigate = navigateTo,
notify = showWorkspaceToast, latestOnly = window.ApiClient.latestOnly, STALE = window.ApiClient.STALE }) {
const overviewRead = latestOnly({ lifetime: scope.signal });
const statisticsRead = latestOnly({ lifetime: scope.signal });
const thumbnailsRead = latestOnly({ lifetime: scope.signal });
const backupRead = latestOnly({ lifetime: scope.signal });
let shown = [];
// #555: the Images metric's value node, repainted whenever the shell's Images count changes.
let imagesValue = null;
const imagesCount = () => counts?.get('images');
const paintImages = () => {
if (imagesValue) imagesValue.textContent = imagesText(imagesCount());
};
// The backup status (undefined while read, null when it failed), painted into its row once both are there (S9).
let backupStatus;
let backupRow = null;
@@ -439,8 +464,10 @@ export function mountOverview(container, { api, activity, scope, navigate = navi
try {
const overview = await overviewRead(signal => api.get(OVERVIEW_URL, { signal, cache: 'no-store' }));
if (overview === STALE || scope.signal.aborted) return;
const { nodes, thumbnails, backup } = overviewNodes(overview, { api, activity, notify, scope, navigate, openStatistics,
const { nodes, thumbnails, backup, metrics } = overviewNodes(overview, { api, activity, notify, scope, navigate,
openStatistics, images: imagesCount(),
openThumbnails: trigger => openThumbnailReview({ api, store: activity, scope, trigger, navigate }) });
imagesValue = metrics.children[2].querySelector('strong');
thumbnailRow = thumbnails;
backupRow = backup;
sources = overview.sources;
@@ -488,6 +515,10 @@ export function mountOverview(container, { api, activity, scope, navigate = navi
const first = swap(loading());
// D129: a finished Move library changes where the library lives.
scope.cleanup(onMoveEnded(activity, load));
if (counts) {
scope.cleanup(counts.subscribe(paintImages));
counts.freshen(['images']);
}
load();
loadThumbnails();
loadBackup();
+4 -3
View File
@@ -135,7 +135,7 @@ export const DELIVERED_SECTIONS = Object.freeze({
/**
* Mount System under the heading `renderHeading` wrote in `main`. Returns `{show(section)}`, which shows another
* section in place. `scope` is the page's view scope; `sections` maps a section id to a delivered section's mount
* (`(container, {api, activity, scope, drafts}) => nodes`) for every section. `activity` is the page's Activity store, which follows the jobs a section starts.
* (`(container, {api, activity, scope, drafts, counts}) => nodes`; `counts` is the shell's sidebar counts, #555) for every section. `activity` is the page's 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
@@ -159,7 +159,8 @@ function legacyPagesLine() {
return line;
}
export function mountSystem(main, api, scope, { section, activity, sections = DELIVERED_SECTIONS, navigate = navigateTo }) {
export function mountSystem(main, api, scope, { section, activity, counts = null, sections = DELIVERED_SECTIONS,
navigate = navigateTo }) {
const store = systemDrafts(api);
jobs = activity;
store.reloadHost();
@@ -208,7 +209,7 @@ export function mountSystem(main, api, scope, { section, activity, sections = DE
for (const button of nav.children) button.setAttribute('aria-pressed', String(button.dataset.value === next));
while (layout.firstChild !== saveRow) layout.firstChild.remove();
// Every section is delivered (S12), and the router only names known sections (state.mjs).
const nodes = sections[next](layout, { api, activity, scope: sectionScope, drafts: store });
const nodes = sections[next](layout, { api, activity, scope: sectionScope, drafts: store, counts });
layout.prepend(...nodes);
if (next === 'overview') main.append(legacyPages);
else legacyPages.remove();