Compare commits

..
Author SHA1 Message Date
Vito Sansevero 95478a0aed fix(viewer): destroy a pending viewer whose element left the stage
rebuild() empties the stage while a view is pending, and the deferred destroy then failed on the viewer's null parent: a page error, and the image kept its src. The destroy now finishes and the src is released.
2026-10-30 18:32:57 -07:00
Vito Sansevero df0a798eea test(viewer): wait for the viewer's first fit before reading it
The keys spec read the fit as soon as Zoom enabled, a frame before the viewer fits the image to the stage. Under load it then compared 873.6px with 901.2px. Failed 3 of 3 under --cpus=2 on WebKit, on the combined head and on #534's own head; with the wait the spec passes 96 of 96.
2026-10-30 18:32:56 -07:00
Vito Sansevero 2ebc5d9280 fix(nodes): Pair node fills the prompt's empty category and tags (#551)
Closes #546
2026-10-30 16:54:50 -07:00
Vito Sansevero 7f414e7c45 fix(workspace): Overview cards show the sidebar's counts (#549)
Closes #547
2026-10-30 16:54:50 -07:00
Vito Sansevero 096e2472bd docs(testing): record D190, the trash review primary that cannot act 2026-10-30 16:54:50 -07:00
Vito Sansevero 4a25db545c test(trash): classify the Needs review link route and prove it refuses a trashed file
POST /review/items/{id}/link needs a capture of the item's bytes. A capture is refused for a path in a trash folder, and a capture taken earlier stops working when its file moves to a trash folder or its folder becomes a link to one, so no code change is needed. The decision route takes the same path, so it moves to the same audit group.
2026-10-30 16:54:50 -07:00
Vito Sansevero cdaf4122de fix(trash): trash on an output folder that is a mount point (#543) (#544)
Closes #543
2026-10-30 16:54:50 -07:00
Vito Sansevero 4be48c2ee5 test(workspace): wait for the delete job before its end toast in the sticky chrome spec 2026-10-30 16:54:22 -07:00
Vito Sansevero e272c84efd feat(workspace): selection bars stay in view, Back to top, side panels stay with you (#531) (#541)
Closes #531
2026-10-30 16:52:45 -07:00
Vito Sansevero 494a5d0bfa fix(viewer): arrow keys navigate from anywhere; a shortcuts overlay (#532) (#534)
Closes #532
2026-10-30 16:52:44 -07:00
Vito Sansevero 06ea91da66 feat(review): link an image to a library prompt from Needs review (#536) (#540)
Closes #536
2026-10-30 16:52:42 -07:00
Vito Sansevero 07c2442e23 fix(send): name ComfyUI windows by their tab and explain why a send is blocked (#539)
Closes #535
2026-10-30 16:52:41 -07:00
Vito Sansevero e6e425ed35 fix(workspace): level two-column cards, window the Reusable tags list, full-width System (#530) (#538)
Closes #530
2026-10-30 16:52:41 -07:00
Vito Sansevero db1c7ef20d fix(images): show every Show in folder outcome above the viewer and report opener failures (#537)
Closes #533
2026-10-30 16:52:38 -07:00
Vito Sansevero 0f2f88d48c test(images): wait for the delete and return jobs, not 5 s (#554)
Closes #553
2026-10-30 16:52:37 -07:00
204 changed files with 1784 additions and 11474 deletions
+33 -5
View File
@@ -1,9 +1,9 @@
name: Windows
name: Windows and macOS
permissions:
contents: read
# Non-blocking Windows runs (#420; macOS dropped from CI in #571): never on pull requests or in the merge queue, and no context
# Non-blocking portability runs (#420): 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,7 +25,10 @@ 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)
# (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.
# 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.
@@ -37,7 +40,7 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [windows-latest]
os: [windows-latest, macos-latest]
python-version: ["3.12", "3.13", "3.14"]
steps:
@@ -57,7 +60,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. Tests write their
# disk the same took 0.5 ms and 23 ms (macOS: 19 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
@@ -80,6 +83,31 @@ 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 moved to portability.yml (#420; macOS left CI in #571), which has its own measured limit (#493);
# Windows and macOS moved to portability.yml (#420), 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 runs in portability.yml (push to main, nightly, on demand) and never gate a merge.
# Windows and macOS run in portability.yml (push to main, nightly, on demand) and never gate a merge.
jobs:
test:
name: py${{ matrix.python-version }} on ${{ matrix.os }}
+8 -12
View File
@@ -41,13 +41,9 @@ 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 }}/4)
name: frontend coverage (shard ${{ matrix.shard }}/3)
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;
@@ -63,7 +59,7 @@ jobs:
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
shard: [1, 2, 3]
steps:
- uses: actions/checkout@v7
# The image's own Python 3.12 and Node 24: setup-python/setup-node install the runner's toolcache
@@ -92,13 +88,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 == 4
- if: matrix.shard == 3
run: npm run --prefix tools/vendor check
- if: matrix.shard == 4
- if: matrix.shard == 3
run: npm run --prefix tools/vendor test
- name: Page workflows with coverage, shard ${{ matrix.shard }}/4
- name: Page workflows with coverage, shard ${{ matrix.shard }}/3
timeout-minutes: 18
run: npm run --prefix tools/vendor test:coverage:shard -- ${{ matrix.shard }}/4
run: npm run --prefix tools/vendor test:coverage:shard -- ${{ matrix.shard }}/3
- name: Upload raw coverage
uses: actions/upload-artifact@v7
with:
@@ -140,13 +136,13 @@ jobs:
with:
pattern: coverage-raw-*
path: ${{ runner.temp }}/coverage-raw
# coverage:merge only checks that raw files exist; merging three of four shards would report
# coverage:merge only checks that raw files exist; merging two of three 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: 4
EXPECTED_SHARDS: 3
run: |
found=0
for shard in $(seq 1 "$EXPECTED_SHARDS"); do
-1196
View File
File diff suppressed because it is too large Load Diff
+1084 -140
View File
File diff suppressed because it is too large Load Diff
+32 -170
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/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 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 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 four arrived). Measured on a 32-CPU
every shard first (CI's merge job checks all three 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 four `frontend coverage (shard N/4)` jobs and
aggregate job. It requires the three `frontend coverage (shard N/3)` 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/4`
(shard 1 also runs the node units and the viewer suite, shard 4 the vendor pins
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
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 jobs (`portability.yml`) set it to 40, so every
limit. The Windows and macOS 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 failures show up only
(`gh-readonly-queue/main/pr-<N>-...`). Windows and macOS 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
@@ -345,16 +345,11 @@ and the time left. The steps, in order:
monitor's callbacks (Needs review recorder, preview queue, Images listing
listener).
2. `ImageMonitor.stop_monitoring` works against one 5 s deadline. Closing the
image handler joins the image worker; watchdog's `observer.stop()` and the
observer join get what is left, which can be 0 s. `stop()` joins watchdog's
emitters without a limit, so it runs on a daemon thread and is waited for
only until the deadline; a later stop waits for that same call (#561).
3. `close_auto_thumbnails(final=True)` stops the preview worker (30 s),
kills ffmpeg and shuts the interactive image pool down.
4. `shutdown_job_pool` (`utils/parallel.py`) cancels the long-lived job pool's
queued items and joins its threads (5 s; part `workers`, only when a job
used the pool, #523).
5. `PromptTracker.shutdown` stops the prompt cleanup thread (2 s).
image handler joins the image worker; watchdog's `observer.stop()` (not
bounded here) and the observer join get what is left, which can be 0 s.
3. `close_auto_thumbnails(final=True)` stops the preview worker (30 s) and
kills ffmpeg.
4. `PromptTracker.shutdown` stops the prompt cleanup thread (2 s).
A failing step, or a second Ctrl+C, is logged with its traceback and does not
skip the later steps. A clean run logs one INFO line to PromptManager's log:
@@ -366,15 +361,6 @@ stored, with the parts not reached False, and the `atexit` callback is
removed: the later steps are skipped, no summary line is logged, and a later
call returns that result and runs nothing.
The observer is started from a short-lived daemon thread, so every thread it
creates is a daemon (a thread inherits that flag from the thread that creates
it). watchdog 7 on Windows starts a plain `threading.Thread` per watch for its
`ReadDirectoryChangesW` reader; created from ComfyUI's main thread it would not
be a daemon, and the interpreter waits for every such thread before it runs any
`atexit` callback, so the teardown would never start (#561).
`tests/test_package_exit.py` runs a child whose observer does both (a plain
reader thread, a `stop()` that never returns) and requires its exit.
ComfyUI never runs aiohttp's `on_cleanup` hooks, because it never calls
`runner.cleanup()`. So `atexit` is the live host's path. It runs on Ctrl+C
(`main.py` catches `KeyboardInterrupt` and returns) and on a normal exit.
@@ -459,44 +445,12 @@ 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`.
Image work runs on PromptManager's own threads (#523). glibc keeps each thread's
malloc arena at its high-water mark, so image buffers spread over the default
executor's threads stay resident. Interactive image work (view previews behind their
gate, workspace card facts, both inspector routes) runs on the API's fixed
`pm-image` pool (`IMAGE_POOL_SIZE` 3, `_run_image`, `_map_image`; card facts already
cached answer without a trip; while the gate's 3 preview writes hold every thread, a
card or inspector read waits for one of them, by design of the merged pool; after the
package teardown the pool is not recreated and late calls use the default executor). Jobs (scan metadata and file facts, thumbnail and
convert jobs, the legacy gallery generation, capture previews) run through
`map_parallel` on one long-lived `pm-worker` pool of Worker threads, reused between
jobs; a new Worker threads value replaces it (the teardown also joins a replaced pool
still finishing), and a call made from a job thread runs inline. After a scan, thumbnail or convert job, or the legacy gallery generation,
`release_free_memory` (`utils/native_memory.py`) calls glibc's `malloc_trim(0)` once;
it does nothing on Windows, macOS and musl and never raises. No allocator setting
changes. `tests/test_image_pools.py` asserts the thread of each read and write and the
trim; `tests/test_utils_parallel.py` the pool's reuse, resize, nesting and shutdown.
`tools/perf/memory_drive.py` (manual, Linux, never in CI) measures RSS, heap, threads
and arenas under the #523 load against the disposable browser test server.
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 runners do not run them; the structural tests cover
Shared Windows and macOS 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.
@@ -692,10 +646,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 on the branch with
queue run only Linux, so start Windows and macOS on the branch with
`gh workflow run portability.yml --ref <branch>`.
Include a Windows impact and evidence note in the PR. All three Windows
legs in that run, every Linux leg and every applicable
Include a Windows impact and evidence note in the PR. All six Windows/macOS
combinations 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.
@@ -710,11 +664,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/4)`, `frontend coverage (merge)` (vendor pins and tooling tests on shard 4) | run | run |
| `frontend coverage (shard N/3)`, `frontend coverage (merge)` (vendor pins and tooling tests on shard 3) | 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` (`portability.yml`) | not run | push to `main`, nightly, `workflow_dispatch` only; never required |
| `py3.12-3.14 on windows-latest/macos-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
@@ -727,9 +681,8 @@ guarantee:
So a merge group cannot turn green unless all three Linux Python legs, real
ComfyUI and the four browser shards passed.
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`,
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`,
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
@@ -1454,8 +1407,6 @@ 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)
@@ -2598,8 +2549,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` 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
`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
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).
@@ -3893,8 +3844,8 @@ workspace never asks; previews are a cache the server keeps:
- `GET /images/serve/thumbnails/<rel>_thumb<ext>` for a missing preview is refused as
text first (traversal, a backslash, a drive or stream colon, empty or dot segments, so
`D:/x`, `\\server\share` and a leading `/` never reach a joined path), then admitted
on the event loop (`_PreviewGate`): 3 previews are written at once on the API's
interactive `pm-image` pool (never the default executor; #523), one flight runs per file (the history
on the event loop (`_PreviewGate`): 3 previews are written at once on the API's own
`pm-preview` pool (never the default executor), one flight runs per file (the history
route shares the flight key and the admission), and at most 64 requests wait; one
more gets its fallback at once without listing a folder. Inside the admission the
preview maps back to its media file under the same root (exact names, then one folder
@@ -3927,7 +3878,7 @@ workspace never asks; previews are a cache the server keeps:
`exclusive` job runs (`_auto_thumbnail_blocker`), checked when a write starts (after
its slot) and again just before its rename.
- Shutdown is bounded and logged: the worker stops at a file boundary, ffmpeg is
killed, an idle worker leaves (the next output starts a fresh one), the image pool
killed, an idle worker leaves (the next output starts a fresh one), the view pool
shuts down, and after the app's cleanup a late monitor event
starts nothing. A package reload stops the workers the previous import left.
- Cards and strips load previews only: Images in every layout and size, Library cards,
@@ -4055,11 +4006,7 @@ 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 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
and active saved negatives and the indexed `generated_images` rows (Q2). "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,
@@ -4097,11 +4044,10 @@ 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 "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
table "Library totals" (Total / Count: Prompts, Tags, Images from the legacy
statistics; 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 database record counts." because
The Overview row says "Runs by category, with library totals." 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
@@ -4404,23 +4350,6 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
choice. The host's checks still refuse a linked field or a changed workflow, and the
sender also refuses if the destination's `revision` (node object plus graph version)
moved since discovery; it is undefined on an older tab, which skips the comparison. A host error reads as a refusal, not "may have applied".
- **D191** (#542, beyond the concept; maintainer 2026-10-31, Q542-1 A to 5 A): a window with two or more
eligible nodes adds a last Destination node option "All N eligible nodes" (`ALL_NODES` in
`prompt-sender.mjs`), never selected for you (D173). Eligible is D173's filter; the nodes it leaves out
stay counted in the discovery sentence. One window only. Preview stays optional (D175): Preview changes
shows one `section.prompt-sender-node` per node, headed "#<id> <title>", in send order (node id,
numeric). Apply previews every node first and writes nothing if any refuses ("#<id> <title>: <reason>
Nothing was sent."). Then it sends one node after another. Only the first node of a pass taken in that
click keeps its ticket; every other node, and every node of a held Preview (host tickets expire after
30 s), takes a fresh ticket that is refused unless its Before texts and composition equal the reviewed
ones. That fresh preview checks the discovered revision until something has been posted, and not after,
since our own write may move the graph version. Refusals are best effort: "Sent to 2 of 3 nodes in
<window>. #12 <title>: <host's refusal>."; Apply stays available and a retry sends to every node again
(rewriting the same text). A lost reply stops the run, names that node as unknown and lists the nodes
not sent. Closing the dialog stops after the current node. The review sections are labelled by their
`h4` node heading. Unit tests in `tests/js/prompt-sender.test.js`, a fake three-node host in
`tests/browser/prompt-inspector.spec.mjs`, real Classic and Nodes 2.0 widgets in
`tests/comfyui/prompt-sender.spec.mjs`.
- **D106** (Integrations, S10, Q8): the send bridge always asks for the ComfyUI window
and node, and the nodes always follow ComfyUI's own renderer, so "Default send
destination" offers only "Ask each time" and "Host compatibility" only "Follow host
@@ -5484,7 +5413,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 database record counts (workspace-system.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) |
| 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) |
@@ -5723,73 +5652,6 @@ are closed before `os.replace`; renames stay inside one folder; folder identity
folder are refused. The browser test server closes its own log handlers before its temp
root (which now holds the logs) is removed.
### Thumbnail location contract (#320)
Previews live in each source's `thumbnails/` (the default) or in one folder, `<folder>/<source key>/…`. Every reader,
writer and remover asks `py/api/preview_store.py`; `tests/test_preview_store.py` fails on a `thumbnails` path join
anywhere else. The setting is `thumbnails.location` in config.json, and only the Move thumbnails job writes it: POST
`/settings` answers 409 `location-needs-move`.
- **Keys and marker.** A key is a slug plus a hash of the source's identity. The folder's
`.promptmanager-thumbnails.json` stores each key with its identity and install. Only a single `<slug>-<hex>` name
is ever joined onto the folder, and anything else in a marker is dropped. Every write and delete passes
`preview_store.may_write`: below a key folder, with no link, junction or reparse point on the way
(`tests/test_preview_store_folder.py`, including tampered markers).
- **Unavailable.** A folder is unavailable when it is missing, is a link, holds no marker or holds another
folder's marker. A probe checks it every 5 s, and a store call that fails or stalls marks it at once. While it is
unavailable:
- originals are served and nothing is written anywhere;
- generate, convert, clear and prune answer 409 `location-unavailable`;
- the folder is never made again except by Recreate folder.
The configured location is in force before any request. A bad value is `invalid`, never a fall-back to the
sources.
- **Move thumbnails.** The job is a `thumbnails` job with `scope.mode` `move` (`tests/test_thumbnail_move.py`). It
runs in this order:
1. The review answers `POST /images/thumbnails/plan`; not enough space answers 507.
2. A token starts the job.
3. Automatic previews are held.
4. Copies run on the job pool in chunks of 2 or 4 files, each with its size checked and its mtime rounded up to
2 s.
5. One config commit makes the switch and records the old stores under `cleanup`. The job cannot be cancelled
from here.
6. The old copies are removed.
If it fails or is cancelled before the switch, nothing changes. A crash leaves
`<data>/data/thumbnail-move.json`, which the next start undoes. Old copies still held open are removed by the
next start's "Move thumbnails (finishing)" job. Leaving a folder also removes the previews of this install's
sources that are no longer listed (Q320-8).
- **Remove thumbnails of removed sources** (`scope.mode` `prune`). It is offered in Thumbnail maintenance only in
folder mode, and it touches only this install's keys.
- **Page tests.** `tests/browser/workspace-thumbnail-location.spec.mjs` covers the row, both moves, the in-place
refusal, the confirmation, the notice with Recreate and with Use this folder. The legacy gallery runs once in
folder mode (`workflows.spec.mjs`). The hooks are `tests/browser/thumbnail_location_fixture.py`.
Beyond the concept, against the approved static mock `docs/egm/post-3.4.1/320-mock`:
- D196: the Thumbnail location row in System › Sources & scans, after Thumbnail generation.
- The status line is "Now: inside each source." or "Now: one folder, <path>.". It does not show the mock's
"(3 sources, 8,512 thumbnails)" counts, because counting means walking every preview on each view. It says
"Using the default folder next to your library." and names old copies that wait to be removed.
- D197: the Move thumbnails review uses the shared review.
- A grid value may be a lead sentence with a list (`[lead, items]`).
- Sources no longer listed appear under "Removed after the move", where the mock has a "Not moved" row
(maintainer answer to Q320-8: remove).
- The Free space line is "<size> free", without naming the disk.
- D198: Activity's view of `mode: move` and `mode: prune`.
- Tiles for a move: Moved, Already there, Left in place, Errors. Tiles for a prune: Removed, Left in place,
Errors.
- The step line under the bar is `#job-phase`.
- Cancel shows only until the switch.
- D199: the unavailable notice on Overview and Sources & scans.
- Recreate folder appears for a missing folder, after a confirmation.
- Use this folder appears for another folder's marker.
- Change location focuses the row; on Overview it opens Sources & scans.
- D200: Thumbnail maintenance's "Remove thumbnails of removed sources" scope, with the plan line and a grid of
key folders.
- D201: Move library's review adds "Thumbnails: Stay in <path>. Move them in System › Sources & scans." in folder
mode.
### Backup and restore contract (#249 System S8)
A restore is staged, then published (`database/restore_stage.py`). The upload is
@@ -6935,7 +6797,7 @@ checkout or point this harness at a live ComfyUI checkout.
The package-qualified config import also corrects #211: the configured database
path is honored when PromptManager runs as a ComfyUI custom node. Follow the
[database path compatibility and safe backup sequence](README-legacy.md#database-path-compatibility-211)
[database path compatibility and safe backup sequence](README.md#database-path-compatibility-211)
before restarting an existing deployment.
The ledger records only new dequeued jobs and observed outcomes. Existing
+16 -42
View File
@@ -92,21 +92,10 @@ try:
routes = config.routes
api = PromptManagerAPI()
api.add_routes(routes)
# The thumbnail location's startup check (#320), then the temp files of
# preview writes a crash interrupted (#317), off the import path: the
# walk may cover a large thumbnails folder.
# #320: the configured thumbnail location is in force before any request
# (no I/O); the thread below checks it.
api.use_preview_location()
def _preview_startup():
api.start_preview_location()
api.recover_thumbnail_move() # an interrupted move, before the sweep
api.sweep_thumbnail_temps()
# Temp files of preview writes a crash interrupted (#317), off the
# import path: the walk may cover a large thumbnails folder.
threading.Thread(
target=_preview_startup,
target=api.sweep_thumbnail_temps,
name="PromptManagerPreviewTempSweep",
daemon=True,
).start()
@@ -119,8 +108,6 @@ try:
# Build each output root's Images listing once the server's loop runs,
# so the first open does not wait for the walk (#348).
host_app.on_startup.append(api.warm_gallery_listings)
# #320: old thumbnails a move left to remove, once it is checked.
host_app.on_startup.append(api.finish_thumbnail_move)
if host_app is not None and hasattr(host_app, "on_shutdown"):
# Open Needs review change streams end before the server waits on them.
host_app.on_shutdown.append(api.close_review_events)
@@ -133,7 +120,6 @@ except Exception as e:
logger = get_logger("prompt_manager.init")
logger.error(f"Failed to register API routes: {e}")
except Exception:
# logging failed too; a report must never stop the node loading
pass
# Shared database, set up with the monitoring system below. Stays None when that
@@ -293,11 +279,11 @@ def _teardown_logger():
def _stop_background_work():
"""Stop the image monitor, the preview worker, the job worker threads
and the prompt cleanup thread against one TEARDOWN_SECONDS deadline, once.
"""Stop the image monitor, the preview worker and the prompt cleanup
thread against one TEARDOWN_SECONDS deadline, once.
Returns ``{"monitor", "thumbnails", "workers", "tracker"}`` booleans
(True: it stopped), leaving out parts that never started. Later calls return a
Returns ``{"monitor", "thumbnails", "tracker"}`` booleans (True: it
stopped), leaving out parts that never started. Later calls return a
copy of the first result. A Ctrl+C inside a step is logged and the
step is False; one between steps is re-raised after the result (parts
not reached are False) is stored and the ``atexit`` entry removed.
@@ -311,14 +297,11 @@ def _stop_background_work():
api,
_global_prompt_tracker,
)
from .utils import parallel
# A part whose global is set has started; False until its step says
# otherwise, so an interrupt between steps leaves an honest result.
started = (
("monitor", monitor),
("thumbnails", thumbnails),
("workers", parallel.job_pool_started() or None),
("tracker", tracker),
)
result = {name: False for name, part in started if part is not None}
@@ -361,16 +344,6 @@ def _stop_background_work():
result["thumbnails"] = _teardown_step(
"the preview worker", close_previews, logger
)
if "workers" in result:
def stop_workers():
from .utils.parallel import JOB_POOL_JOIN_SECONDS
return parallel.shutdown_job_pool(left(JOB_POOL_JOIN_SECONDS))
result["workers"] = _teardown_step(
"the job worker threads", stop_workers, logger
)
if tracker is not None:
def stop_tracker():
@@ -438,7 +411,6 @@ except Exception as e:
f"Failed to register the exit signal: {e}"
)
except Exception:
# logging failed too; a report must never stop the node loading
pass
try:
@@ -456,14 +428,16 @@ except Exception as e:
f"Failed to register the background work teardown: {e}"
)
except Exception:
# logging failed too; a report must never stop the node loading
pass
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
# 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"
)
# 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()
Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

-5
View File
@@ -78,10 +78,6 @@ MESSAGES = {
),
"same-folder": "The new folder is the node folder itself.",
"target-invalid": "The new folder cannot hold the library.",
"data-dir-served": (
"The new folder is inside Prompt Manager's thumbnail folder. Nothing was "
"moved."
),
"target-has-library": (
"The new folder already holds a Prompt Manager library. Nothing was moved."
),
@@ -594,7 +590,6 @@ class _Move:
try:
os.rmdir(self.plan.target_dir)
except OSError:
# not empty or already gone: the undo leaves it alone
pass
+24 -64
View File
@@ -35,51 +35,38 @@ from __future__ import annotations
import contextlib
import logging
import operator
import sys
import threading
import time
import weakref
from collections import deque
from typing import Any, Callable, Iterable, Iterator, List, Optional
from typing import Iterable, Iterator, List, Optional
import sqlite3
CLOSED_MESSAGE = "Cannot operate on a closed database."
# Rows fetched per step when a cursor is iterated: the connection is marked
# in use once per chunk instead of once per row (iteration costs about what
# a plain cursor costs). Prefetched rows belong to the cursor so iteration
# and explicit fetches always advance the same position.
# a plain cursor costs). A loop that stops early leaves up to this many rows
# fetched; ``fetchone``/``fetchmany`` after a partial loop skip them.
ITER_CHUNK = 128
class ManagedCursor(sqlite3.Cursor):
"""A cursor whose calls into SQLite count as its connection being in use."""
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self._pending = deque()
self._closed = False
def execute(self, sql, parameters=(), /):
return self._run_statement(super().execute, sql, parameters)
def executemany(self, sql, seq_of_parameters, /):
return self._run_statement(super().executemany, sql, seq_of_parameters)
def _run_statement(self, operation: Callable, *args: Any) -> ManagedCursor:
conn = self.connection
conn._enter()
description = self.description
completed = False
try:
result = operation(*args)
completed = True
return result
return super().execute(sql, parameters)
finally:
conn._leave()
def executemany(self, sql, seq_of_parameters, /):
conn = self.connection
conn._enter()
try:
return super().executemany(sql, seq_of_parameters)
finally:
# Argument parsing can fail before SQLite replaces the old statement.
if completed or self.description is not description:
self._pending.clear()
conn._leave()
def executescript(self, sql_script, /):
@@ -94,8 +81,6 @@ class ManagedCursor(sqlite3.Cursor):
conn = self.connection
conn._enter()
try:
if self._pending and not self._closed:
return self._pending.popleft()
return super().fetchone()
finally:
conn._leave()
@@ -104,20 +89,7 @@ class ManagedCursor(sqlite3.Cursor):
conn = self.connection
conn._enter()
try:
# Let SQLite validate the original arguments, including version-specific
# handling of zero/negative sizes, before touching prefetched rows.
rows = super().fetchmany(*args, **kwargs)
if not self._pending:
return rows
size = operator.index(
kwargs.get("size", args[0] if args else self.arraysize)
)
# Python 3.12 reads all rows for size=0; 3.13+ reads none.
if size == 0 and sys.version_info >= (3, 13):
return rows
self._pending.extend(rows)
count = len(self._pending) if size <= 0 else min(size, len(self._pending))
return [self._pending.popleft() for _ in range(count)]
return super().fetchmany(*args, **kwargs)
finally:
conn._leave()
@@ -125,31 +97,25 @@ class ManagedCursor(sqlite3.Cursor):
conn = self.connection
conn._enter()
try:
rows = super().fetchall()
pending = list(self._pending)
self._pending.clear()
return pending + rows
return super().fetchall()
finally:
conn._leave()
def __iter__(self) -> Iterator:
return self
return self._chunks()
def _chunks(self) -> Iterator:
while True:
rows = self.fetchmany(ITER_CHUNK)
if not rows:
return
yield from rows
def __next__(self):
conn = self.connection
pending = self._pending
# Buffered Python rows need no SQLite lock, but retirement and cursor
# close must still stop an iterator obtained before either operation.
if conn._retired or self._closed:
raise sqlite3.ProgrammingError(CLOSED_MESSAGE)
if pending:
return pending.popleft()
conn._enter()
try:
pending.extend(super().fetchmany(ITER_CHUNK))
if not pending:
raise StopIteration
return pending.popleft()
return super().__next__()
finally:
conn._leave()
@@ -160,10 +126,7 @@ class ManagedCursor(sqlite3.Cursor):
except sqlite3.ProgrammingError:
return super().close() # the connection is closed or closing
try:
result = super().close()
self._pending = deque()
self._closed = True
return result
return super().close()
finally:
conn._leave()
@@ -330,9 +293,6 @@ class ManagedConnection(sqlite3.Connection):
cursors = list(self._cursors)
try:
for cursor in cursors:
if isinstance(cursor, ManagedCursor):
cursor._pending = deque()
cursor._closed = True
with contextlib.suppress(sqlite3.Error):
sqlite3.Cursor.close(cursor)
super().close()
+1 -1
View File
@@ -34,7 +34,7 @@ from .history_reads import HistoryReadOperations
from .health import HealthOperations, iter_consistency_issues
from .duplicate_copies import DuplicateCopyOperations
from .workflow_storage import WorkflowStorageOperations
from .category_import import CategoryImportOperations
from .category_import import CategoryImportOperations, EmptyReplacement # noqa: F401
from .image_categories import ImageCategoryOperations
from .workflow_documents import (
DocumentReader,
-1
View File
@@ -46,7 +46,6 @@ def remove_quietly(*paths):
if path and os.path.exists(path):
os.remove(path)
except OSError:
# cleanup after a failure must not replace that failure
pass
+1
View File
@@ -66,6 +66,7 @@ def _tag_rows(page: str, order: str = "page.id") -> str:
)
_TAG_PAGE = _tag_rows("SELECT id,name FROM tags WHERE id>? ORDER BY id LIMIT ?")
# #444 tag sorts: sort -> (key expression on tags/counted, direction). Ties and
# "created" follow the id, so every order is total and keyset pages are exact.
TAG_SORTS = {
+14 -31
View File
@@ -11,7 +11,6 @@ Split into domain-specific mixins for maintainability:
- JobRoutesMixin: the session job registry behind Activity (py/api/jobs.py)
- ImageTagJobsMixin: bulk Add tags / Auto-tag as image-tags jobs
- ThumbnailJobsMixin: thumbnail plan, thumbnail job and the gallery stream on it
- ThumbnailLocationMixin: the thumbnail location check and payloads (#320)
- ThumbnailConvertMixin: Convert outdated thumbnails (#317), a thumbnail job mode
- AutoThumbnailMixin: previews made on first view, on capture and after a scan
- ReviewRoutesMixin: the Needs review queue, item detail and decisions
@@ -61,11 +60,6 @@ from .settings_routes import SettingsRoutesMixin
from .job_routes import JobRoutesMixin
from .image_tag_jobs import ImageTagJobsMixin
from .thumbnail_jobs import ThumbnailJobsMixin
from .thumbnail_location import ThumbnailLocationMixin
from .thumbnail_move import ThumbnailMoveMixin
from .thumbnail_move_plan import ThumbnailMovePlanMixin
from .thumbnail_move_recovery import ThumbnailMoveRecoveryMixin
from .thumbnail_prune import ThumbnailPruneMixin
from .thumbnail_convert import ThumbnailConvertMixin
from .thumbnail_auto import AutoThumbnailMixin
from .system import SystemRoutesMixin
@@ -91,8 +85,6 @@ try:
from ...database.operations import PromptDatabase
from ...utils.file_prompt import parse_comfyui_prompt
from ...utils.logging_config import get_logger
from ...utils.native_memory import release_free_memory
from ...utils.png_text import png_text
from ...utils.video_metadata import is_video_path, read_video_metadata
except ImportError:
import sys
@@ -103,8 +95,6 @@ except ImportError:
from database.operations import PromptDatabase
from utils.file_prompt import parse_comfyui_prompt
from utils.logging_config import get_logger
from utils.native_memory import release_free_memory
from utils.png_text import png_text
from utils.video_metadata import is_video_path, read_video_metadata
@@ -344,11 +334,6 @@ class PromptManagerAPI(
JobRoutesMixin,
ImageTagJobsMixin,
ThumbnailJobsMixin,
ThumbnailLocationMixin,
ThumbnailMovePlanMixin,
ThumbnailMoveMixin,
ThumbnailMoveRecoveryMixin,
ThumbnailPruneMixin,
ThumbnailConvertMixin,
AutoThumbnailMixin,
SystemRoutesMixin,
@@ -541,14 +526,6 @@ class PromptManagerAPI(
return await loop.run_in_executor(None, call)
return await loop.run_in_executor(None, func, *args)
async def _release_job_memory(self):
"""After a scan or thumbnail job: glibc's free heap pages go back to
the OS once (``malloc_trim(0)``; nothing elsewhere, #523)."""
try:
await self._run_in_executor(release_free_memory)
except Exception: # best effort: the job's result already stands
self.logger.debug("Releasing free memory failed", exc_info=True)
def _root_listing(self, root) -> RootListing:
"""The shared listing record of one output root (created empty)."""
return self._root_listings.setdefault(str(root), RootListing())
@@ -727,7 +704,6 @@ class PromptManagerAPI(
# ── Register domain-specific routes from mixins ───────────────
self._register_pair_routes(routes)
self._register_thumbnail_move_routes(routes)
self._register_library_routes(routes)
self._register_library_delete_routes(routes)
self._register_vocabulary_routes(routes)
@@ -864,11 +840,20 @@ class PromptManagerAPI(
f"{url_quote(rel_path.as_posix(), safe='/')}"
)
# Any preview the file owns, the current format first
# (#317); no store I/O here in folder mode (#320).
from .images import listing_thumbnail_url
# Any preview the file owns, the current format first (#317)
from . import thumbnail_settings
from .prompts import thumbnail_url_for
thumbnail_url = listing_thumbnail_url(output_path, rel_path)
existing = thumbnail_settings.existing_thumbnail(
output_path, rel_path
)
thumbnail_url = None
if existing is not None:
thumb_rel = (
thumbnail_settings.thumbnail_stem_rel(rel_path)
+ existing.suffix
)
thumbnail_url = thumbnail_url_for(thumb_rel, existing)
if thumbnail_url:
image["thumbnail_url"] = thumbnail_url
break # Found matching root, stop searching
@@ -1063,8 +1048,6 @@ 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.
@@ -1073,7 +1056,7 @@ class PromptManagerAPI(
return read_video_metadata(image_path)
try:
with Image.open(image_path) as img:
return png_text(img)
return dict(getattr(img, "text", None) or {})
except Exception as e:
self.logger.warning(
f"Unreadable image {os.path.basename(str(image_path))}: {e}"
+4 -7
View File
@@ -176,7 +176,7 @@ def _physical_file_key(path, stat):
volumes on Windows) falls back to the resolved, case-folded path.
"""
if stat.st_ino:
return ("inode", (stat.st_dev, stat.st_ino))
return ("inode", stat.st_dev, stat.st_ino)
return ("path", os.path.normcase(os.path.realpath(str(path))))
@@ -539,7 +539,6 @@ def _diagnose_output_dirs(configured_dirs, public_path=None):
candidates.append(folder_paths.get_output_directory())
except ImportError:
# outside ComfyUI there is no output folder to add
pass
output_dirs = []
@@ -1454,7 +1453,6 @@ class AdminRoutesMixin:
verb = "cancelled" if state == "cancelled" else "finished"
job.log_line(f"Scan {verb} · {_plural(job.processed, 'file')} processed")
job.finish(state)
await self._release_job_memory()
if state == "finished":
# After the scan, so the guarded thumbnail job is admitted (#249).
await self._hand_off_scan_thumbnails(job, output_dirs)
@@ -1669,10 +1667,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 the long-lived job pool of
``PromptManagerConfig.WORKER_THREADS`` threads (#523).
PNG text is read chunk by chunk without decoding pixels (#523); file
reads and ffprobe release the GIL.
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.
"""
from ..config import PromptManagerConfig
+1
View File
@@ -13,6 +13,7 @@ import math
from ..tagging_defaults import ( # noqa: F401 - re-exported for the route
LLM_MODEL_TYPES as LLM_MODELS,
MAX_INSTRUCTION_BYTES,
MAX_INSTRUCTION_CHARS,
MODEL_TYPES,
WD14_MODEL_TYPES as WD14_MODELS,
validate_template,
+22 -21
View File
@@ -19,8 +19,8 @@ from .jobs import (
)
from .maintenance import disk_plan
from .images import listing_thumbnail_url
from . import preview_store
from .prompts import thumbnail_url_for
from . import thumbnail_settings
from .. import model_gate
try:
@@ -424,7 +424,6 @@ class AutotagRoutesMixin:
title = f"Tagging model download · {label}"
_download_titles.clear()
_download_titles[owner] = title
admitted = False
try:
job = self._jobs.start(
MODEL_DOWNLOAD_JOB_KIND,
@@ -440,13 +439,13 @@ class AutotagRoutesMixin:
self._run_model_download, service, model_type, owner
),
)
admitted = True
except JobBusy as refused:
return None, busy_response(refused)
finally:
if not admitted: # JobBusy, or anything else: give the gate back.
model_gate.release(owner)
_download_titles.pop(owner, None)
except BaseException as refused:
# JobBusy, or anything else the registry raised: give the gate back.
model_gate.release(owner)
_download_titles.pop(owner, None)
if isinstance(refused, JobBusy):
return None, busy_response(refused)
raise
def ended(_task):
model_gate.release(owner)
@@ -1042,12 +1041,12 @@ class AutotagRoutesMixin:
return self._bad_request('existing must be "skip" or "merge"')
try:
options = self._single_options(data, model_type)
except OptionError as error:
return self._bad_request(str(error))
except (ValueError, TypeError):
return self._bad_request(
"general_threshold and character_threshold must be numeric"
)
except OptionError as error:
return self._bad_request(str(error))
target, error = await self._select_autotag_target(data)
if error is not None:
return error
@@ -1371,21 +1370,23 @@ class AutotagRoutesMixin:
files = await self._run_in_executor(
self._without_removed, output_path, files
)
location = preview_store.current()
if location.mode == preview_store.FOLDER_MODE:
has_thumbnails = location.available # no store I/O here (#320)
else:
store = preview_store.store_dir(output_path)
has_thumbnails = store is not None and store.is_dir()
has_thumbnails = (output_path / "thumbnails").is_dir()
images = []
for image_path in files:
rel_path = image_path.relative_to(output_path)
thumbnail_url = None
if has_thumbnails:
# Any preview the file owns, the current format first
# (#317); in folder mode without store I/O (#320).
thumbnail_url = listing_thumbnail_url(output_path, rel_path)
# Any preview the file owns, the current format first (#317)
existing = thumbnail_settings.existing_thumbnail(
output_path, rel_path
)
if existing is not None:
thumb_rel = (
thumbnail_settings.thumbnail_stem_rel(rel_path)
+ existing.suffix
)
thumbnail_url = thumbnail_url_for(thumb_rel, existing)
images.append(
{
"filename": image_path.name,
-1
View File
@@ -971,7 +971,6 @@ class BackupRestoreRoutesMixin:
line = restored_line(staged.summary)
job.log_line(line)
job.update(progress=100, status_line=line)
return None
async def _outlive_cancel(future):
+1 -2
View File
@@ -574,13 +574,12 @@ class DuplicateFilesMixin:
return _fail_job(job, "sources-changed", SOURCES_CHANGED)
ok, backup = await self._safety_backup_step(job, db, backup_on)
if not ok:
return None
return
try:
await self._remove_duplicates(job, db, plan, backup)
finally:
if backup:
await self._prune_backup_pool(job, db)
return None
async def _remove_duplicates(self, job, db, plan, backup):
targets = plan.payload["targets"]
+1 -1
View File
@@ -27,7 +27,7 @@ def _identity(path):
except OSError:
return None
if info.st_ino:
return ("inode", (info.st_dev, info.st_ino))
return ("inode", info.st_dev, info.st_ino)
return ("path", os.path.normcase(os.path.realpath(str(path))))
+1 -4
View File
@@ -843,10 +843,7 @@ class HistoryRoutesMixin:
raise
async def execute():
# An inspection reads the file's header and text: the
# interactive image pool (#523); media stays on the default.
run = self._run_image if kind == "inspection" else self._run_in_executor
pending = asyncio.create_task(run(project))
pending = asyncio.create_task(self._run_in_executor(project))
try:
return await asyncio.shield(pending)
except asyncio.CancelledError:
+15 -40
View File
@@ -15,10 +15,10 @@ from typing import BinaryIO
from aiohttp import web
from . import preview_store, thumbnail_settings
from . import thumbnail_settings
from .images import _canonical, _public_path, SERVABLE_EXTENSIONS, VIDEO_EXTENSIONS
from .images import in_trash_below_roots
from .images import existing_media_thumbnail, media_preview_path
from .images import existing_media_thumbnail, media_thumbnail_rel
try:
from ...utils.execution_outputs import (
@@ -244,36 +244,19 @@ def resolve_current_candidate(
return _unavailable("temporary_unavailable", error=True)
def _target_authorized(target, namespace, scope, store_root=None) -> bool:
"""A preview in the thumbnail folder (#320, folder mode) is authorized by
the store of its source (``preview_store.owns``: inside the key folder,
no link); everything else by the output namespace and the roots."""
if (
store_root is not None
and preview_store.current().mode == preview_store.FOLDER_MODE
):
return preview_store.owns(store_root, target)
return _authorized(target, namespace, scope)
def _open_current_target(
target: Path,
namespace: Path,
scope: CurrentOutputScope,
preview: bool = False,
store_root: Path | None = None,
target: Path, namespace: Path, scope: CurrentOutputScope, preview: bool = False
) -> BinaryIO:
"""Open only the canonical target and verify authority against the actual handle.
A *preview* (a file under ``thumbnails/``) may also be JPEG XL (#317); in
folder mode it is authorized by the store of *store_root* (#320)."""
A *preview* (a file under ``thumbnails/``) may also be JPEG XL (#317)."""
if _canonical(target) != target:
raise CurrentLocationUnavailable("not_authorized", stale=True)
if in_trash_below_roots(target, [namespace]):
# Also at the open: the cover chooses its preview after the candidate
# check, and a preview can be a link into a trash folder (#543).
raise CurrentLocationUnavailable("missing")
if not _target_authorized(target, namespace, scope, store_root):
if not _authorized(target, namespace, scope):
raise CurrentLocationUnavailable("not_authorized")
suffix = target.suffix.lower()
if suffix not in SERVABLE_EXTENSIONS and not (
@@ -306,7 +289,7 @@ def _open_current_target(
if (
_canonical(target) != target
or (observed.st_dev, observed.st_ino) != (current.st_dev, current.st_ino)
or not _target_authorized(target, namespace, scope, store_root)
or not _authorized(target, namespace, scope)
):
raise CurrentLocationUnavailable("not_authorized", stale=True)
return os.fdopen(fd, "rb")
@@ -343,10 +326,12 @@ class _OpenedCurrentMedia:
def _preview_writable(root: Path, preview: Path) -> bool:
"""A preview may be written: the store of *root* and every link segment
of the preview's folder resolve inside it (the serve route's rule, #249;
``preview_store.owns``)."""
return preview_store.owns(root, preview)
"""A preview may be written: ``thumbnails`` and every link segment of the
preview's folder resolve inside *root* (the serve route's rule, #249)."""
folder = root / "thumbnails"
return _canonical(folder).is_relative_to(_canonical(root)) and _canonical(
preview
).is_relative_to(_canonical(folder))
def _is_jxl(path) -> bool:
@@ -396,12 +381,8 @@ def _cover_target(resolved, ensure_preview, placeholder, accepts_jxl):
return _canonical(preview)
if preview is not None:
return _canonical(preview)
preview = media_preview_path(resolved.gallery_root, relative, create=True)
if (
ensure_preview is not None
and preview is not None
and _preview_writable(resolved.gallery_root, preview)
):
preview = resolved.gallery_root / media_thumbnail_rel(relative)
if ensure_preview is not None and _preview_writable(resolved.gallery_root, preview):
ensure_preview(target, preview, is_video)
if is_video and placeholder is not None:
return _placeholder_media(placeholder)
@@ -437,13 +418,7 @@ def _prepare_current_media(
return chosen
if chosen is not None:
target, is_preview = chosen, True
handle = _open_current_target(
target,
resolved.namespace,
scope,
is_preview,
resolved.gallery_root if is_preview else None,
)
handle = _open_current_target(target, resolved.namespace, scope, is_preview)
try:
return _OpenedCurrentMedia(
handle,
+1 -2
View File
@@ -330,13 +330,12 @@ class ImageDeleteMixin:
return _fail_job(job, "sources-changed", SOURCES_CHANGED)
ok, backup = await self._safety_backup_step(job, db, backup_on)
if not ok:
return None
return
try:
await self._delete_images(job, db, targets, mode, backup)
finally:
if backup:
await self._prune_backup_pool(job, db)
return None
async def _delete_images(self, job, db, targets, mode, backup):
job.update(total=len(targets), status_line=TITLES[mode])
+5 -7
View File
@@ -531,7 +531,6 @@ class ImageTagJobsMixin:
run = AutotagRun(
model_type, existing, options, keep_in_memory, backup_on, owner
)
admitted = False
try:
job = self._jobs.start(
AUTOTAG_BATCH_JOB_KIND,
@@ -542,12 +541,11 @@ class ImageTagJobsMixin:
groups=AUTOTAG_GROUP_NAMES,
runner=functools.partial(self._run_autotag_batch, run),
)
admitted = True
except JobBusy as refused:
return None, _busy_refusal(refused)
finally:
if not admitted:
model_gate.release(owner)
except BaseException as refused:
model_gate.release(owner)
if isinstance(refused, JobBusy):
return None, _busy_refusal(refused)
raise
job.task.add_done_callback(lambda _task: model_gate.release(owner))
return job, None
+38 -206
View File
@@ -34,14 +34,15 @@ from .gallery_listing import (
WalkOutcome,
walk_root,
)
from . import preview_store, thumbnail_settings
from .thumbnail_location import location_refusal
from . import thumbnail_settings
from .thumbnail_encode import (
PREVIEW_HELD,
EncoderClosed,
PreviewWriteHeld,
replace_unless_held,
run_encoder,
sweep_stray_temps,
temp_sibling,
thumbnail_frame,
writer_settings,
write_thumbnail,
@@ -338,6 +339,11 @@ def _thumbnail_batch_size(workers):
return max(4, workers * 2)
# Preview files are written by thumbnail_encode in the configured format
# (thumbnail_settings, #317); these names stay importable from here.
_temp_sibling = temp_sibling
_replace_unless_held = replace_unless_held
# A preview that could not be replaced (#249 S5 M6). Windows refuses to rename
# over a file another process has open (WinError 5 or 32), such as a preview
# the gallery is serving; the old preview stays and the file counts as an error.
@@ -390,7 +396,6 @@ def _video_placeholder_image(thumbnail_size):
font=font,
)
except (OSError, AttributeError):
# a placeholder without its label still works
pass
return img
@@ -494,7 +499,6 @@ def _preview_names(folders, folder):
except OSError:
continue
except OSError:
# an unreadable folder lists as empty; nothing there to cache
pass
folders[folder] = names
return names
@@ -1069,24 +1073,13 @@ def _iter_media_files(output_path, max_files):
def media_thumbnail_rel(rel_path, settings=None):
"""``thumbnails/<rel>_thumb<ext>`` (POSIX URL text) for the media file
*rel_path*: the name the preview gets when it is written now (#317),
images and videos alike (``writer_settings``' extension). Where it is on
disk is ``media_preview_path``."""
"""``thumbnails/<rel>_thumb<ext>`` (POSIX) for the media file *rel_path*:
the name the preview gets when it is written now (#317), images and
videos alike (``writer_settings``' extension)."""
settings = settings or writer_settings()
return thumbnail_settings.thumbnail_rel(rel_path, settings.extension)
def media_preview_path(output_path, rel_path, settings=None, *, create=False):
"""Where the preview of *rel_path* is written now (``preview_store``);
*create* for a writer (a new source gets its key). None when nothing
may be written (the thumbnail folder is unavailable)."""
settings = settings or writer_settings()
return preview_store.preview_path(
output_path, rel_path, settings.extension, create=create
)
def existing_media_thumbnail(output_path, rel_path):
"""The existing preview *rel_path* owns that a listing or cover shows
(the format written now first, then older ones), or None. The Images
@@ -1096,27 +1089,6 @@ def existing_media_thumbnail(output_path, rel_path):
)
def listing_thumbnail_url(output_path, rel_path):
"""The thumbnail URL of an older listing (``_enrich_images``, the
auto-tag scan), or None. These run on the event loop: in folder mode
(#320) the store is never touched there, so the URL names the preview
as it is written now and the serve route finds or makes it off the loop
(None while the thumbnail folder is unavailable)."""
from .prompts import thumbnail_url_for
location = preview_store.current()
if location.mode == preview_store.FOLDER_MODE:
if not location.available:
return None
quoted = urllib.parse.quote(media_thumbnail_rel(rel_path), safe="/")
return f"/prompt_manager/images/serve/{quoted}"
existing = thumbnail_settings.existing_thumbnail(output_path, rel_path)
if existing is None:
return None
thumb_rel = thumbnail_settings.thumbnail_stem_rel(rel_path) + existing.suffix
return thumbnail_url_for(thumb_rel, existing)
def media_thumbnail(output_path, rel_path, root_index, *, root_scope=None):
"""``(thumbnail_url, ready)`` for *rel_path* under *output_path* (a few
blocking stats). The Images listing, the Needs review queue cards and the
@@ -2831,56 +2803,13 @@ class ImageRoutesMixin:
output_dirs[0], filepath, int(root_param), len(outputs)
)
# Preview text is mapped by the resolver (#320); any other text,
# ``Thumbnails/...`` media on a case-sensitive disk included,
# keeps the plain join.
preview_parts = (
tuple(filepath.split("/"))
if preview_store.is_preview_text(filepath)
else None
)
location = preview_store.current()
if location.mode == preview_store.FOLDER_MODE:
if preview_parts is None and await self._run_image(
self._in_source_thumbnails, output_dirs, filepath
):
# Previews live in the thumbnail folder: anything else
# under a source's thumbnails/ is a leftover (#320), in
# any case on a case-insensitive disk.
return _forbidden()
if preview_parts is not None:
# Store I/O never runs on the event loop (a share may
# stall): the lookup runs on the image pool.
found = await self._run_image(
self._find_preview, output_dirs, preview_parts, location
)
if found == "forbidden":
return _forbidden()
if found is not None:
return self._file_response(found)
for output_path in output_dirs:
if (
preview_parts is not None
and location.mode == preview_store.FOLDER_MODE
):
break # looked up above
if preview_parts is None:
image_path = output_path / filepath
else:
image_path = preview_store.request_path(output_path, preview_parts)
if image_path is None:
continue
image_path = output_path / filepath
if not image_path.exists():
continue
# A symlink that points outside the root (a preview: outside
# the folder holding its store) is a hard deny, not a
# fall-through to the next root.
container = (
output_path
if preview_parts is None
else preview_store.store_container(output_path)
)
if not _is_within(image_path, container):
# A symlink that points outside the root is a hard deny, not
# a fall-through to the next root.
if not _is_within(image_path, output_path):
return _forbidden()
if in_trash_below_roots(image_path, output_dirs):
return _file_not_found()
@@ -2906,48 +2835,6 @@ class ImageRoutesMixin:
{"success": False, "error": _safe_error(e)}, status=500
)
@staticmethod
def _in_source_thumbnails(output_dirs, filepath):
"""Whether *filepath* lies in a source's ``thumbnails`` folder: its
first part is ``thumbnails`` exactly, or another case of it that is
the same folder (a case-insensitive disk). ``Thumbnails/`` media on
a case-sensitive disk is another folder and stays served."""
first = filepath.split("/", 1)[0]
if first == "thumbnails":
return True
if first.casefold() != "thumbnails":
return False
for output_path in output_dirs:
spelled = output_path / first
cache = preview_store.source_folder(output_path)
try:
if spelled.is_dir() and cache.is_dir() and spelled.samefile(cache):
return True
except OSError:
continue
return False
@staticmethod
def _find_preview(output_dirs, parts, location):
"""The preview file *parts* names in folder mode, ``"forbidden"``
(it resolves out of its store or through a link) or None (blocking;
the first root whose store holds it, in ``_select_root`` order)."""
for output_path in output_dirs:
image_path = preview_store.request_path(output_path, parts, location)
if image_path is None:
continue
try:
with preview_store.store_io():
if not os.path.lexists(image_path):
continue
if not preview_store.owns(output_path, image_path, location):
return "forbidden"
if image_path.is_file():
return image_path
except OSError:
return None
return None
def _is_recorded_media(self, path) -> bool:
"""Whether the database records ``path`` as a linked image.
@@ -3074,9 +2961,6 @@ class ImageRoutesMixin:
data, error = await _json_body_or_error(request)
if error is not None:
return error
refused = location_refusal()
if refused is not None:
return refused
return await self._run_held(
THUMBNAILS_JOB_KIND,
THUMBNAIL_GENERATION_TITLE,
@@ -3096,7 +2980,6 @@ class ImageRoutesMixin:
result = await self._run_in_executor(
self._generate_gallery_thumbnails_sync, output_dirs
)
await self._release_job_memory()
return web.json_response(result)
except Exception as e:
@@ -3109,7 +2992,6 @@ class ImageRoutesMixin:
"""Blocking thumbnail generation loop (run in executor)."""
settings = writer_settings(settings)
start = _time.monotonic()
thumbnails_dir = preview_store.given_store(output_path, thumbnails_dir)
plan = self._plan_thumbnails(output_path, thumbnails_dir, settings=settings)
payload = self._execute_thumbnail_plan(plan, settings, start)
payload["thumbnails_path"] = _public_path(thumbnails_dir)
@@ -3122,8 +3004,7 @@ class ImageRoutesMixin:
start = _time.monotonic()
plan = self._plan_gallery_thumbnails(output_dirs, settings=settings)
payload = self._execute_thumbnail_plan(plan, settings, start)
store = preview_store.store_dir(output_dirs[0])
payload["thumbnails_path"] = None if store is None else _public_path(store)
payload["thumbnails_path"] = _public_path(output_dirs[0] / "thumbnails")
return payload
def _execute_thumbnail_plan(self, plan: dict, settings, start: float) -> dict:
@@ -3166,12 +3047,9 @@ class ImageRoutesMixin:
if stop is not None and stop():
break
root = Path(root)
store = preview_store.ensure_store(root)
if store is None:
continue # the thumbnail folder is unavailable
plan = self._plan_thumbnails(
root,
store,
root / "thumbnails",
mode=mode,
stop=stop,
settings=settings,
@@ -3210,8 +3088,6 @@ class ImageRoutesMixin:
"format": writer_settings().format,
# Files whose automatic preview failed this session (#249).
"failed": self._auto_thumbnail_failures(),
# #320: where previews live (no path here, D6A).
"location": self._thumbnail_location_payload(with_path=False),
"collection_id": hashlib.sha256(collection.encode()).hexdigest()[
:16
],
@@ -3257,11 +3133,9 @@ class ImageRoutesMixin:
"""
settings = writer_settings(settings)
output_path = Path(output_path)
thumbnails_dir = preview_store.given_store(output_path, thumbnails_dir)
thumbnails_dir = Path(thumbnails_dir)
plan = {key: 0 for key in _PLAN_COUNTS}
plan.update(targets=[], fresh_files=[])
if thumbnails_dir is None:
return plan # the thumbnail folder is unavailable
folders = {}
removed = self._removal_check_under(output_path)
for src in _iter_media_files(output_path, MAX_GALLERY_FILES):
@@ -3351,9 +3225,6 @@ class ImageRoutesMixin:
"dir": src.parent.name,
"type": "video" if is_video else "image",
}
# Taken when the write starts: a thumbnail location switched since
# then drops the write at its rename (#320).
guard = preview_store.write_guard(guard)
dst = Path(dst)
if dst.suffix != settings.extension:
dst = dst.with_suffix(settings.extension)
@@ -3364,7 +3235,7 @@ class ImageRoutesMixin:
return {**result, "action": "error", "error": "File no longer exists"}
if not rebuild and _thumbnail_is_fresh(src, dst):
return {**result, "action": "skipped", "path": str(dst)}
preview_store.make_parent(dst)
dst.parent.mkdir(parents=True, exist_ok=True)
if is_video:
made = self._generate_video_thumbnail(
src,
@@ -3569,12 +3440,8 @@ class ImageRoutesMixin:
Invalid thumbnail directories return 400; unavailable gallery roots
return 404, and unexpected filesystem failures return 500. Holds the
registry as guarded work, like generation. Refused (409) while the
thumbnail folder is unavailable (#320).
registry as guarded work, like generation.
"""
refused = location_refusal()
if refused is not None:
return refused
return await self._run_held(
THUMBNAILS_JOB_KIND,
THUMBNAIL_CLEAR_TITLE,
@@ -3638,12 +3505,8 @@ class ImageRoutesMixin:
self.logger.debug(f"No roots to sweep for preview temp files: {exc}")
return 0
for output_path in output_dirs:
folder = preview_store.store_dir(output_path)
if (
folder is None
or preview_store.store_is_link(folder)
or not folder.is_dir()
):
folder = Path(output_path) / "thumbnails"
if folder.is_symlink() or not folder.is_dir():
continue
removed += sweep_stray_temps(folder)[0]
if removed:
@@ -3661,16 +3524,13 @@ class ImageRoutesMixin:
directories = []
for output_path in output_dirs:
output_path = Path(output_path)
thumbnails_dir = preview_store.store_dir(output_path)
if thumbnails_dir is None:
continue
thumbnails_dir = output_path / "thumbnails"
if not thumbnails_dir.exists() and not thumbnails_dir.is_symlink():
continue
container = preview_store.store_container(output_path)
if (
preview_store.store_is_link(thumbnails_dir)
or not _is_within(thumbnails_dir, container)
or _canonical(thumbnails_dir) == _canonical(container)
thumbnails_dir.is_symlink()
or not _is_within(thumbnails_dir, output_path)
or _canonical(thumbnails_dir) == _canonical(output_path)
):
raise ValueError(
"Safety check failed: invalid thumbnails directory path"
@@ -3685,15 +3545,7 @@ class ImageRoutesMixin:
temps, temp_bytes = sweep_stray_temps(thumbnails_dir, min_age=0)
cleared_count += temps
cleared_size += temp_bytes
folder_mode = preview_store.current().mode == preview_store.FOLDER_MODE
for root, dirs, files in os.walk(thumbnails_dir):
if folder_mode:
# Never into a junction or reparse point (#320).
dirs[:] = [
d
for d in dirs
if not preview_store.store_is_link(Path(root) / d)
]
for root, _dirs, files in os.walk(thumbnails_dir):
for name in files:
path = Path(root) / name
if (
@@ -3701,7 +3553,6 @@ class ImageRoutesMixin:
or path.suffix.lower()
not in thumbnail_settings.THUMBNAIL_EXTENSIONS
or path.is_symlink()
or not preview_store.may_write(path)
):
continue
try:
@@ -4151,8 +4002,7 @@ class ImageRoutesMixin:
**observed,
)
# Header and text reads: the interactive image pool (#523).
body = await self._run_image(inspect)
body = await self._run_in_executor(inspect)
if body is None:
return web.json_response(
{
@@ -4365,22 +4215,6 @@ class ImageRoutesMixin:
# (which Windows refuses on open files) is not held up by it.
return _CardFacts(width, height, *self._card_parse(path, records))
def _cached_card_facts(self, entry: tuple) -> _CardFacts | None:
path, mtime, root, index = entry[:4]
return self._workspace_card_cache.get((index, str(root)), {}).get((path, mtime))
def _card_facts_of(self, entries: list, *, parse: bool) -> list[_CardFacts]:
"""Card facts of *entries*, in order; files not cached yet are read on
the interactive image pool (#523), cached ones answer here."""
found = [self._cached_card_facts(entry) for entry in entries]
missing = [i for i, facts in enumerate(found) if facts is None]
read = self._map_image(
lambda i: self._card_facts(entries[i], parse=parse), missing
)
for i, facts in zip(missing, read):
found[i] = facts
return found
def _card_facts(self, entry: tuple, *, parse: bool) -> _CardFacts:
"""One listed image's card facts, read once per ``(path, mtime)``.
@@ -4389,11 +4223,11 @@ class ImageRoutesMixin:
and drops the rest, and a roots change drops them all. Only files of
the current scan are stored, so the cache is bounded by the listing.
"""
cached = self._cached_card_facts(entry)
if cached is not None:
return cached
path, mtime, root, index = entry[:4]
root_key, key = (index, str(root)), (path, mtime)
cached = self._workspace_card_cache.get(root_key, {}).get(key)
if cached is not None:
return cached
facts = self._read_card_facts(path, parse=parse)
if facts is None:
# Not stored: a locked or half-written file may read next time.
@@ -4419,17 +4253,15 @@ class ImageRoutesMixin:
if linked
else {}
)
# Videos: the card reads no video metadata. Library previews show
# no card facts, so they skip the parse (``parse=linked``).
stills = [image for image in images if not image["is_video"]]
read = self._card_facts_of(
[entries[(image["root_index"], image["path"])] for image in stills],
parse=linked,
)
facts_of = {id(image): facts for image, facts in zip(stills, read)}
for image in images:
entry = entries[(image["root_index"], image["path"])]
facts = facts_of.get(id(image), _NO_CARD_FACTS)
# Videos: the card reads no video metadata. Library previews show
# no card facts, so they skip the parse (``parse=linked``).
facts = (
_NO_CARD_FACTS
if image["is_video"]
else self._card_facts(entry, parse=linked)
)
image["width"], image["height"] = facts.width, facts.height
if not linked:
continue
+1 -1
View File
@@ -250,7 +250,7 @@ class JobRoutesMixin:
try:
await response.write(_summary_event(job))
async with contextlib.aclosing(job.events()) as events:
async for _ in events:
async for _payload in events:
await response.write(_summary_event(job))
# The last event is published before the job takes its final state.
await response.write(_summary_event(job))
-25
View File
@@ -50,7 +50,6 @@ _REFUSAL_STATUS = {
"not-movable": 400,
"same-folder": 400,
"target-invalid": 400,
"data-dir-served": 400,
"target-has-library": 409,
"target-conflict": 409,
"insufficient-space": 507,
@@ -75,24 +74,6 @@ def _failure(message, status, code):
)
def _thumbnail_folder():
"""The thumbnail folder in use, or None (previews inside each source)."""
from . import preview_store
setting = preview_store.setting()
return setting.path if setting.mode == preview_store.FOLDER_MODE else None
def _inside_thumbnail_folder(path):
"""Whether *path* lies in the thumbnail folder in use (#320)."""
from . import preview_store
setting = preview_store.setting()
if setting.mode != preview_store.FOLDER_MODE or not setting.path:
return False
return ll._inside_any(path, [setting.path])
def _plural(count, one, many=None):
return f"{count:,} {one if count == 1 else (many or one + 's')}"
@@ -226,7 +207,6 @@ class LibraryLocationRoutesMixin:
try:
size = os.path.getsize(location.db_path)
except OSError:
# no library file yet: report size 0
pass
return {
"path": self._library_public_path(location.db_path),
@@ -244,8 +224,6 @@ class LibraryLocationRoutesMixin:
),
"problem": location.problem or self._node_folder_risk(location),
"size_bytes": size,
# #320 D201: thumbnails kept in one folder stay there (D8A).
"thumbnails_folder": _thumbnail_folder(),
}
# The node folder (tests point it at a temp tree).
@@ -293,8 +271,6 @@ class LibraryLocationRoutesMixin:
def _plan_library_move(self):
"""The checked plan (blocking); raises MoveFailed."""
plan = lm.plan_move(self._live_location())
if _inside_thumbnail_folder(plan.target_dir):
raise lm.MoveFailed("data-dir-served")
if lm.space_problem(plan) is not None:
raise lm.MoveFailed("insufficient-space")
return plan
@@ -397,7 +373,6 @@ class LibraryLocationRoutesMixin:
line += ". Logs move at the next restart."
job.log_line(line)
job.update(progress=100, status_line=line)
return None
@staticmethod
def _fail_library_move(job, code, message):
-1
View File
@@ -205,4 +205,3 @@ class LogCleanupMixin:
line += f", {failed} could not be deleted"
job.log_line(line)
job.update(processed=len(files), progress=100, status_line=line)
return None
+3 -5
View File
@@ -436,7 +436,7 @@ class LoraImportRoutesMixin:
job.log_line(f"{CANCELLED} {CACHE_NOTE}")
job.update(status_line=CANCELLED)
job.finish("cancelled")
return None
return
except Exception:
self.logger.exception("LoRA import staging failed")
return _fail_job(job, "import-failed", f"{FAILED} {CACHE_NOTE}")
@@ -455,10 +455,10 @@ class LoraImportRoutesMixin:
job.log_line(f"{CANCELLED} {CACHE_NOTE}")
job.update(status_line=CANCELLED)
job.finish("cancelled")
return None
return
ok, _backup = await self._safety_backup_step(job, db, backup_on)
if not ok:
return None
return
# I1: the files are listed and fingerprinted again right before the
# replace, so a file changed, removed or added while the downloads ran
# (LoraManager refreshing its CivitAI info) aborts with nothing deleted.
@@ -466,7 +466,6 @@ class LoraImportRoutesMixin:
return _fail_job(job, "plan-changed", f"{SOURCE_CHANGED} {CACHE_NOTE}")
job.update(status_line="Replacing the LoRA collection", progress=95)
await self._replace_in_job(job, db, plan, staged)
return None
def _count_downloads(self, job, staged):
downloaded = staged.stats.get("downloaded", 0)
@@ -519,4 +518,3 @@ class LoraImportRoutesMixin:
staged.stats.get("download_errors", 0),
)
job.update(progress=100, processed=job.total, status_line=line)
return None
+2 -15
View File
@@ -44,7 +44,6 @@ _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."
@@ -408,21 +407,10 @@ class LoraIntegrationMixin:
return
if isinstance(error, PlanChanged):
message = _IMPORTS_CHANGED
elif isinstance(
error,
(
LoraFilesChanged,
LoraSettingsUnusable,
EmptyReplacement,
PairDependencyError,
),
):
elif isinstance(error, (LoraFilesChanged, LoraSettingsUnusable)):
message = str(error)
elif isinstance(error, OSError):
# Reduced to its reason and the file's basename.
message = self._public_error(error)
else:
message = _LORA_SCAN_FAILED
message = self._public_error(error)
if isinstance(
error,
(
@@ -548,7 +536,6 @@ class LoraIntegrationMixin:
except asyncio.CancelledError:
interrupted = True
except Exception:
# the await after this loop raises it for the caller
pass
if interrupted:
raise asyncio.CancelledError
-1
View File
@@ -673,7 +673,6 @@ class MaintenanceRoutesMixin:
line = _changed_line(plan.operation, result["changed"], result.get("marks", 0))
job.log_line(line)
job.update(progress=100, status_line=f"{line} · {backup_note(backup)}")
return None
def _maintenance_rows(self, operation, described):
"""Outcome rows for the changed records (root-relative paths only)."""
-648
View File
@@ -1,648 +0,0 @@
"""The thumbnail location setting (#320): parsing, validation, source keys
and the thumbnail folder's marker.
``preview_store`` holds the runtime state and maps paths; this module holds
the rules it applies. Nothing here keeps state.
Schema (config.json ``thumbnails.location``)::
{"mode": "folder", "path": "D:\\\\Thumbs", "id": "<uuid4>",
"install": "<uuid4>", "cleanup": []}
The default (and a missing key) is ``{"mode": "source"}``: previews inside
each source. An invalid value never falls back to the sources: it parses as
a folder location with problem ``invalid``, so nothing is written anywhere.
Layout in folder mode: ``<folder>/<source key>/<rel dir>/<stem>_thumb<ext>``.
A source key is a readable slug plus a short hash of the source's identity
(``output-3f9a2c1b``), assigned once and stored in the marker
``.promptmanager-thumbnails.json`` with the identity it belongs to.
"""
import errno
import hashlib
import json
import logging
import ntpath
import os
import posixpath
import re
import stat as stat_module
import threading
import unicodedata
import uuid
from dataclasses import dataclass, field
try:
from ...utils import library_location
except ImportError: # tests import the package top-level
from utils import library_location
logger = logging.getLogger("prompt_manager.thumbnails")
SOURCE_MODE = "source"
FOLDER_MODE = "folder"
MARKER_NAME = ".promptmanager-thumbnails.json"
MARKER_VERSION = 1
# A source key: one folder name, a slug and a 8-16 hex hash (``source_key``).
KEY_PATTERN = re.compile(r"^[a-z0-9_-]{1,24}-[0-9a-f]{8,16}$")
# A marker is a few keys; anything larger is not ours.
MAX_MARKER_BYTES = 4 * 2**20
# OS metadata files a folder may hold and still count as empty.
OS_METADATA = frozenset({"thumbs.db", "desktop.ini", ".ds_store"})
KEY_HASH_LENGTHS = (8, 12, 16)
SLUG_LENGTH = 24
# Folder + the longest key (24 + 1 + 16) + this margin must fit (Windows).
WINDOWS_FOLDER_LIMIT = 247
LONGEST_KEY = SLUG_LENGTH + 1 + KEY_HASH_LENGTHS[-1]
PATH_MARGIN = 40
# A validation stat on a share that does not answer in time is unavailable.
VALIDATION_TIMEOUT = 5.0
FILE_ATTRIBUTE_REPARSE_POINT = 0x400
_RESERVED = re.compile(r"^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$", re.IGNORECASE)
_BAD_CHARS = re.compile(r'[<>:"|?*\x00-\x1f]')
# Problem codes, their HTTP status and the text the UI shows (no path).
MESSAGES = {
"location-not-absolute": (
400,
"Enter a full folder path, such as D:\\Thumbnails or /mnt/fast/thumbnails.",
),
"location-device-path": (
400,
"Enter the folder as a drive letter or \\\\server\\share path, "
"without \\\\?\\.",
),
"location-invalid-name": (
400,
"This folder name can't be used on Windows. Choose another name.",
),
"location-too-broad": (
400,
"Choose a folder inside a drive or share, not the drive itself.",
),
"location-served": (
400,
"Choose a folder outside your image sources and ComfyUI's output, input "
"and temp folders.",
),
"location-node-folder": (
400,
"Choose a folder outside the Prompt Manager node folder.",
),
"location-contains-source": (
400,
"This folder contains an image source. Choose a folder beside it instead.",
),
"location-same": (400, "Thumbnails are already stored here."),
"location-parent-missing": (
400,
"The folder that would hold it doesn't exist. Check the path and that its "
"drive is connected.",
),
"location-unavailable": (
409,
"This folder can't be reached right now. Check that its drive or share is "
"connected.",
),
"location-not-empty": (
409,
"This folder already holds other files. Choose an empty folder or one "
"Prompt Manager used before.",
),
"location-not-writable": (
409,
"Prompt Manager can't write to this folder. Check its permissions.",
),
"location-path-too-long": (
400,
"This path is too long for Windows. Choose a shorter folder path.",
),
"insufficient-space": (
507,
"There is not enough space for the thumbnails. Free some space or choose "
"another folder.",
),
"location-needs-move": (
409,
"Use Move thumbnails in System › Sources & scans.",
),
}
HOLDS_THUMBNAILS_REASON = "This folder holds Prompt Manager's thumbnails."
def message(code):
"""``(status, text)`` of a problem code."""
return MESSAGES[code]
# ── parsing ──────────────────────────────────────────────────────────
@dataclass(frozen=True)
class ParsedLocation:
"""A ``thumbnails.location`` value, checked as data (no filesystem)."""
mode: str = SOURCE_MODE
path: str | None = None
id: str | None = None
install: str | None = None
cleanup: tuple = field(default_factory=tuple)
problem: str | None = None
def to_config(self):
"""The config.json form (``invalid`` values are never written)."""
if self.mode == SOURCE_MODE:
value = {"mode": SOURCE_MODE}
else:
value = {"mode": FOLDER_MODE, "path": self.path, "id": self.id}
if self.install:
value["install"] = self.install
value["cleanup"] = list(self.cleanup)
return value
def _cleanup(value):
if not isinstance(value, list):
return ()
return tuple(item for item in value if isinstance(item, dict))
def parse_location(value, *, flavor=os.path):
"""The location a ``thumbnails.location`` value names.
Missing: inside each source. Anything else that is not a well-formed
location (an unknown mode, a relative path, a missing id) becomes a
folder location with problem ``invalid``: never a silent fall-back.
"""
if value is None:
return ParsedLocation()
if not isinstance(value, dict):
return ParsedLocation(mode=FOLDER_MODE, problem="invalid")
mode = value.get("mode", SOURCE_MODE)
install = value.get("install") if isinstance(value.get("install"), str) else None
cleanup = _cleanup(value.get("cleanup"))
if mode == SOURCE_MODE:
return ParsedLocation(install=install, cleanup=cleanup)
if mode != FOLDER_MODE:
return ParsedLocation(mode=FOLDER_MODE, problem="invalid", cleanup=cleanup)
path, marker_id = value.get("path"), value.get("id")
if (
not isinstance(path, str)
or form_problem(path, flavor=flavor) is not None
or not isinstance(marker_id, str)
or not marker_id.strip()
):
return ParsedLocation(
mode=FOLDER_MODE,
path=path if isinstance(path, str) else None,
problem="invalid",
cleanup=cleanup,
)
return ParsedLocation(
mode=FOLDER_MODE,
path=flavor.abspath(path),
id=marker_id,
install=install,
cleanup=cleanup,
)
# ── form (text only) ─────────────────────────────────────────────────
def _is_windows(flavor):
return flavor is ntpath or getattr(flavor, "__name__", "") == "ntpath"
def form_problem(path, *, flavor=os.path):
"""The text-only problem of *path*, or None (no filesystem access).
``\\\\?\\`` and ``\\\\.\\`` prefixes, relative, drive-relative (``C:x``)
and root-relative (``\\x``) spellings, and names Windows cannot hold
(checked on every OS: the folder may be moved to Windows).
"""
if not isinstance(path, str) or not path.strip() or path != path.lstrip():
return "location-not-absolute"
if path.replace("/", "\\").startswith(("\\\\?\\", "\\\\.\\")):
return "location-device-path"
if _is_windows(flavor):
drive, rest = ntpath.splitdrive(path)
if not drive or not rest.startswith(("\\", "/")):
return "location-not-absolute"
elif not posixpath.isabs(path):
return "location-not-absolute"
drive, rest = flavor.splitdrive(path)
for part in re.split(r"[\\/]+", rest):
if not part:
continue
if (
_RESERVED.match(part)
or part.endswith((".", " "))
or _BAD_CHARS.search(part)
):
return "location-invalid-name"
return None
def is_too_broad(path, *, home=None, flavor=os.path):
"""A filesystem or share root, or the home folder itself."""
normalized = flavor.normpath(path)
drive, rest = flavor.splitdrive(normalized)
if rest in ("", "\\", "/"):
return True
home = home if home is not None else os.path.expanduser("~")
return bool(home) and flavor.normcase(normalized) == flavor.normcase(
flavor.normpath(home)
)
def too_long_for_windows(path, long_paths=None):
"""Folder + the longest key + a margin over the folder limit (Windows
without long paths; *long_paths* injects the registry answer)."""
if long_paths is None:
long_paths = long_paths_enabled()
if long_paths:
return False
return len(path) + 1 + LONGEST_KEY + PATH_MARGIN > WINDOWS_FOLDER_LIMIT
def long_paths_enabled():
"""``LongPathsEnabled`` on Windows (True on other systems)."""
if os.name != "nt":
return True
try:
import winreg
with winreg.OpenKey(
winreg.HKEY_LOCAL_MACHINE,
r"SYSTEM\CurrentControlSet\Control\FileSystem",
) as key:
return winreg.QueryValueEx(key, "LongPathsEnabled")[0] == 1
except OSError:
return False
# ── links ────────────────────────────────────────────────────────────
def is_link_like(path):
"""A symlink, a junction or any reparse point (one lstat), or missing.
Raises OSError other than "not found" (an unreachable share).
"""
try:
info = os.lstat(path)
except FileNotFoundError:
return False
if stat_module.S_ISLNK(info.st_mode):
return True
attributes = getattr(info, "st_file_attributes", 0)
if attributes & FILE_ATTRIBUTE_REPARSE_POINT:
return True
is_junction = getattr(os.path, "isjunction", None)
return bool(is_junction and is_junction(path))
# ── source keys ──────────────────────────────────────────────────────
def slug(name):
"""``NFKD`` to ASCII, lower case, runs outside ``[a-z0-9_]`` as ``-``,
trimmed and cut to 24 characters; empty becomes ``source``."""
text = unicodedata.normalize("NFKD", str(name))
text = text.encode("ascii", "ignore").decode("ascii").lower()
text = re.sub(r"[^a-z0-9_]+", "-", text).strip("-")
text = text[:SLUG_LENGTH].strip("-")
return text or "source"
def source_key(spelling, identity, taken):
"""The key of a source: ``slug(basename) + "-" + sha256(identity)[:n]``.
*taken* maps existing keys to their identity; a key taken by another
identity grows its hash to 12, then 16 characters.
"""
base = slug(ntpath.basename(str(spelling).rstrip("\\/")))
digest = hashlib.sha256(str(identity).encode("utf-8")).hexdigest()
for length in KEY_HASH_LENGTHS:
key = f"{base}-{digest[:length]}"
owner = taken.get(key)
if owner is None or owner == identity:
return key
raise ValueError("no free source key")
def file_identity(path):
"""``[st_dev, st_ino]`` of an existing folder, or None (no file ids)."""
try:
info = os.stat(path)
except (OSError, ValueError):
return None
if not info.st_ino:
return None
return [info.st_dev, info.st_ino]
def find_key(keys, spelling, identity, dev_ino):
"""The marker key of a source: the same configured spelling, then the
same identity text, then the same file id; or None."""
keys = {k: v for k, v in keys.items() if valid_key(k) and isinstance(v, dict)}
for key, entry in keys.items():
if entry.get("path") == spelling:
return key
for key, entry in keys.items():
if entry.get("identity") == identity:
return key
if dev_ino:
for key, entry in keys.items():
if entry.get("dev_ino") == list(dev_ino):
return key
return None
# ── the marker ───────────────────────────────────────────────────────
def marker_path(folder):
return os.path.join(folder, MARKER_NAME)
def read_marker(folder):
"""The marker in *folder* as a dict, or None (absent or unreadable as
ours). OSError other than "not found" propagates (an unreachable share)."""
try:
with open(marker_path(folder), "r", encoding="utf-8") as handle:
text = handle.read(MAX_MARKER_BYTES + 1)
except FileNotFoundError:
return None
except (ValueError, UnicodeDecodeError):
return None
if len(text) > MAX_MARKER_BYTES:
return None
try:
data = json.loads(text)
except ValueError:
return None
if not isinstance(data, dict) or not isinstance(data.get("id"), str):
return None
data["keys"] = valid_keys(data.get("keys"))
return data
def valid_key(key):
"""Whether *key* can name a source's folder: one folder name of the
``source_key`` form, never a separator, ``..`` or a drive."""
return isinstance(key, str) and KEY_PATTERN.fullmatch(key) is not None
def valid_keys(keys):
"""The marker's keys that are safe to join onto the folder; a hand-edited
or foreign entry that is not is dropped (and logged), never followed."""
if not isinstance(keys, dict):
return {}
kept = {}
for key, entry in keys.items():
if valid_key(key) and isinstance(entry, dict):
kept[key] = entry
else:
logger.warning(
"Ignoring a thumbnail folder marker entry that is not a source " "key"
)
return kept
def write_marker(folder, mutate):
"""Re-read the marker, apply ``mutate(marker)`` and write it atomically
(a temp sibling renamed over it). Callers hold ``preview_store.write_lock``.
Returns the marker written."""
current = read_marker(folder) or {}
marker = {
"version": MARKER_VERSION,
"id": current.get("id"),
"created_by_pm": bool(current.get("created_by_pm", False)),
"keys": dict(current.get("keys") or {}),
}
mutate(marker)
if not marker.get("id"):
raise ValueError("a marker needs an id")
target = marker_path(folder)
temp = os.path.join(
folder, f".{MARKER_NAME}.{os.getpid()}-{threading.get_ident()}.tmp"
)
try:
with open(temp, "w", encoding="utf-8") as handle:
json.dump(marker, handle, indent=2, sort_keys=True)
handle.flush()
os.fsync(handle.fileno())
os.replace(temp, target)
finally:
try:
os.unlink(temp)
except OSError:
pass # gone with the rename; a leftover is swept with the temps
return marker
def new_id():
return str(uuid.uuid4())
def init_folder(folder, *, created_by_pm=False):
"""The marker id of *folder*: an existing marker is adopted; a folder
without one gets a new marker (new id, no keys)."""
existing = read_marker(folder)
if existing is not None:
return existing["id"]
def create(marker):
if not marker.get("id"):
marker["id"] = new_id()
marker["created_by_pm"] = created_by_pm
return write_marker(folder, create)["id"]
def folder_state(path, expected_id):
"""``(available, problem, marker)`` of a configured thumbnail folder.
Available: the folder exists, is a directory and not a link, junction or
reparse point, and holds our marker with *expected_id*. Raises OSError
when the folder cannot be reached (an I/O error other than not found).
"""
try:
info = os.lstat(path)
except FileNotFoundError:
return False, "missing", None
except OSError as error:
if error.errno in (errno.ENOENT, errno.ENOTDIR):
return False, "missing", None
raise
if is_link_like(path):
return False, "invalid", None
if not stat_module.S_ISDIR(info.st_mode):
return False, "invalid", None
marker = read_marker(path)
if marker is None:
return False, "missing", None
if marker["id"] != expected_id:
return False, "marker-mismatch", marker
return True, None, marker
def holds_only_metadata(path):
"""Whether an existing folder is empty but for OS metadata files."""
with os.scandir(path) as entries:
for entry in entries:
name = entry.name.lower()
if name in OS_METADATA or name.startswith("._"):
continue
return False
return True
# ── validation ───────────────────────────────────────────────────────
@dataclass(frozen=True)
class Context:
"""What a location is checked against (gathered by the caller)."""
roots: tuple = () # every image source of every root list
contained: tuple = () # ComfyUI output/input/temp and the library folder
node_folder: str | None = None
default_path: str | None = None
current_path: str | None = None # the folder in use (folder mode)
home: str | None = None
flavor: object = os.path
long_paths: bool | None = None
environ: object = None
# ComfyUI's folder_paths module (None: none; unset: imported).
folder_paths: object = library_location._UNSET
def _timed(func, timeout):
"""``func()`` on a daemon thread; raises TimeoutError past *timeout*.
A stuck call is left to finish on its own thread."""
result = {}
done = threading.Event()
def run():
try:
result["value"] = func()
except BaseException as error: # handed to the caller
result["error"] = error
finally:
done.set()
threading.Thread(target=run, name="pm-preview-stat", daemon=True).start()
if not done.wait(timeout):
raise TimeoutError("the folder did not answer in time")
if "error" in result:
raise result["error"]
return result.get("value")
def _inside(path, folders):
return library_location._inside_any(path, folders)
def _same(first, second):
return library_location.same_folder(first, second) or os.path.normcase(
os.path.realpath(first)
) == os.path.normcase(os.path.realpath(second))
def _served(path, context):
environ = os.environ if context.environ is None else context.environ
return (
library_location.data_dir_problem(
path,
folder_paths=context.folder_paths,
environ=environ,
gallery_roots=list(context.roots),
)
== "data-dir-served"
)
def _probe_write(path):
"""Create and delete a probe file in *path*; False when refused."""
probe = os.path.join(path, f".pm-probe-{uuid.uuid4().hex[:8]}.tmp")
try:
with open(probe, "wb") as handle:
handle.write(b"")
except OSError:
return False
try:
os.unlink(probe)
except OSError:
return False
return True
def location_problem(path, *, purpose, context, timeout=VALIDATION_TIMEOUT):
"""Why *path* cannot be the thumbnail folder, as a code, or None.
*purpose*: ``move`` (every rule), ``startup`` (no ``same``, ``empty``,
``writable`` or parent rules: availability covers them) or ``recreate``
(the folder is missing and is made again).
"""
flavor = context.flavor
code = form_problem(path, flavor=flavor)
if code is not None:
return code
path = flavor.abspath(path)
default = bool(context.default_path) and flavor.normcase(path) == flavor.normcase(
flavor.abspath(context.default_path)
)
if is_too_broad(path, home=context.home, flavor=flavor):
return "location-too-broad"
try:
return _timed(
lambda: _filesystem_problem(path, purpose, context, default), timeout
)
except (TimeoutError, OSError):
return "location-unavailable"
def _filesystem_problem(path, purpose, context, default):
if _served(path, context):
return "location-served"
if context.node_folder and not default and _inside(path, [context.node_folder]):
return "location-node-folder"
inner = list(context.roots) + list(context.contained)
if any(folder and _inside(folder, [path]) for folder in inner):
if not default:
return "location-contains-source"
if (
purpose == "move"
and context.current_path
and (
os.path.normcase(path) == os.path.normcase(context.current_path)
or (os.path.isdir(path) and _same(path, context.current_path))
)
):
return "location-same"
# Windows, or a test injecting its limit (``long_paths=False``).
if os.name == "nt" or context.long_paths is False:
if too_long_for_windows(path, context.long_paths):
return "location-path-too-long"
if purpose == "startup":
return None
if is_link_like(path):
# Never through a link: the folder itself must be a real folder.
return "location-not-empty"
exists = os.path.isdir(path)
if not exists:
if os.path.lexists(path):
return "location-not-empty"
if not default and not os.path.isdir(os.path.dirname(path)):
return "location-parent-missing"
return None
if read_marker(path) is None and not holds_only_metadata(path):
return "location-not-empty"
if not _probe_write(path):
return "location-not-writable"
return None
-678
View File
@@ -1,678 +0,0 @@
"""Where previews live on disk (#320): the one resolver.
Every mapping between an image source (a "root"), one of its media files and
that file's preview on disk goes through this module. URL text stays in
``thumbnail_settings`` (``thumbnail_rel``: ``thumbnails/<rel>_thumb<ext>``,
the shape the serve route, ``inspection-state.mjs`` and ``admin.js`` share);
this module answers where such a preview is on disk.
The store of a source is where its previews live. With the default location
(mode ``source``, "Inside each source") it is ``<source>/thumbnails``, exactly
the folder every caller joined by hand before, so nothing moves.
Writes and the switch: every preview writer takes ``write_guard`` when it
starts (it records the location's ``generation``); the final rename runs
under ``write_lock`` and only when the generation is unchanged and no hold
applies (``thumbnail_encode.replace_unless_held``). Changing the location
bumps the generation under the same lock, so no write that began before can
land in the old store afterwards. Nothing reads or commits config while
``write_lock`` is held. The hold check runs under it too, so hold checks take
no lock (the job registry's ``running()`` is a lock-free snapshot) and
``bump_generation`` is never called with a registry or config lock held.
All functions are blocking: call them off the event loop.
"""
import errno
import logging
import os
import threading
import time
from contextlib import contextmanager
from dataclasses import dataclass, replace
from pathlib import Path
from . import preview_location, thumbnail_settings
try:
from ...utils.media_folders import PREVIEW_FOLDER
except ImportError: # tests import the package top-level
from utils.media_folders import PREVIEW_FOLDER
logger = logging.getLogger("prompt_manager.thumbnails")
SOURCE_MODE = preview_location.SOURCE_MODE
FOLDER_MODE = preview_location.FOLDER_MODE
# The probe checks the folder this often, and a check (or a store call)
# taking longer than the budget marks it unreachable.
PROBE_INTERVAL = 5.0
IO_BUDGET = 2.0
PROBE_THREAD = "pm-preview-probe"
@dataclass(frozen=True)
class Location:
"""One snapshot of the thumbnail location (take one per request or job)."""
mode: str = SOURCE_MODE
path: str | None = None
available: bool = True
problem: str | None = None
generation: int = 0
write_lock = threading.Lock()
_generation = 0
# The runtime location: the parsed setting, availability and the marker keys
# last read. Swapped as a whole; readers take one snapshot.
_state_lock = threading.Lock()
_parsed = preview_location.ParsedLocation()
_available = True
_problem = None
_keys = {}
_key_cache = {}
_clock = time.monotonic
def current():
"""The thumbnail location now."""
with _state_lock:
parsed, available, problem, generation_now = (
_parsed,
_available,
_problem,
_generation,
)
if parsed.mode == SOURCE_MODE:
return Location(generation=generation_now)
return Location(FOLDER_MODE, parsed.path, available, problem, generation_now)
def setting():
"""The parsed setting in use (``preview_location.ParsedLocation``)."""
return _parsed
def generation():
return _generation
def bump_generation():
"""Invalidate every write that started before (under ``write_lock``)."""
global _generation
with write_lock:
_generation += 1
return _generation
def _canonical(path):
"""Resolved and case-folded, so paths compare on every OS."""
return Path(os.path.normcase(os.path.realpath(str(path))))
def _within(path, folder):
return _canonical(path).is_relative_to(_canonical(folder))
# ── the setting and availability ─────────────────────────────────────
def configure(value, *, context=None, check=True):
"""Use the ``thumbnails.location`` *value* (config.json) from now on.
A folder location is validated against *context* (``startup`` rules: a
problem other than an unreachable folder makes it ``invalid``), then
checked once; the probe keeps checking it. Swapped under ``write_lock``
with a generation bump, so no write that started before lands.
"""
global _parsed, _available, _problem, _generation
parsed = preview_location.parse_location(value)
problem = parsed.problem
if parsed.mode == FOLDER_MODE and problem is None and context is not None:
code = preview_location.location_problem(
parsed.path, purpose="startup", context=context
)
if code == "location-unavailable":
problem = "unreachable"
elif code is not None:
problem = "invalid"
parsed = preview_location.ParsedLocation(
mode=FOLDER_MODE,
path=parsed.path,
id=parsed.id,
install=parsed.install,
cleanup=parsed.cleanup,
problem="invalid",
)
with write_lock:
with _state_lock:
_parsed = parsed
_available = parsed.mode == SOURCE_MODE
_problem = problem if parsed.mode == FOLDER_MODE else None
_keys.clear()
_key_cache.clear()
_generation += 1
if parsed.mode == FOLDER_MODE:
if check and parsed.problem is None:
refresh()
_probe.start()
else:
_probe.stop()
return current()
def refresh():
"""Check the folder now (bounded by IO_BUDGET); returns availability."""
parsed = _parsed
if parsed.mode != FOLDER_MODE or parsed.problem is not None:
return parsed.mode == SOURCE_MODE
try:
available, problem, marker = _probe.check(parsed.path, parsed.id)
except (OSError, TimeoutError, RuntimeError):
available, problem, marker = False, "unreachable", None
_set_state(parsed, available, problem, marker)
return available
def _set_keys(parsed, marker):
"""Take the keys of a marker just written (availability unchanged)."""
with _state_lock:
if _parsed is not parsed:
return
keys = dict((marker or {}).get("keys") or {})
if keys != _keys:
_keys.clear()
_keys.update(keys)
_key_cache.clear()
def set_cleanup(entries):
"""The setting's ``cleanup`` after a removal was committed."""
global _parsed
with _state_lock:
_parsed = replace(_parsed, cleanup=tuple(entries))
def mark_invalid():
"""The setting cannot be used (a startup check that failed): folder
mode stays in force, unavailable, so nothing is written anywhere."""
global _parsed, _available, _problem
with _state_lock:
if _parsed.mode != FOLDER_MODE:
_parsed = preview_location.ParsedLocation(
mode=FOLDER_MODE, problem="invalid"
)
else:
_parsed = replace(_parsed, problem="invalid")
_available, _problem = False, "invalid"
def _set_state(parsed, available, problem, marker):
global _available, _problem
with _state_lock:
if _parsed is not parsed:
return # the setting changed meanwhile
_available, _problem = available, problem
keys = dict((marker or {}).get("keys") or {})
if keys != _keys:
_keys.clear()
_keys.update(keys)
_key_cache.clear()
def note_failure():
"""A store call failed or stalled: unreachable until the probe finds the
folder again (requests do no store I/O meanwhile)."""
global _available, _problem
if _parsed.mode != FOLDER_MODE:
return
with _state_lock:
_available, _problem = False, "unreachable"
_probe.wake()
@contextmanager
def store_io():
"""Around a store call in folder mode: an I/O error (other than a file
that is not there) or a call over IO_BUDGET marks the folder
unreachable."""
if _parsed.mode != FOLDER_MODE:
yield
return
started = _clock()
try:
yield
except OSError as error:
if not isinstance(error, (FileNotFoundError, NotADirectoryError)):
note_failure()
raise
finally:
if _clock() - started > IO_BUDGET:
note_failure()
class _Probe:
"""The background check of the thumbnail folder (one thread, one check
at a time; a stuck check is left to finish on its own thread)."""
def __init__(self):
self._lock = threading.Lock()
self._wake = threading.Event()
self._stop = threading.Event()
self._thread = None
self._checking = None
def check(self, path, expected_id):
"""``folder_state`` within IO_BUDGET; raises TimeoutError. A check
already running (the probe's, or a stuck one) is joined, never run
twice at once."""
with self._lock:
pending = self._checking
if pending is None or pending[0] != (path, expected_id):
pending = self._start_check(path, expected_id)
key, done, result = pending
if not done.wait(IO_BUDGET):
raise TimeoutError("the thumbnail folder did not answer in time")
with self._lock:
if self._checking is pending:
self._checking = None
if "error" in result:
raise result["error"]
return result["value"]
def _start_check(self, path, expected_id):
"""Start one check on its own thread (lock held)."""
result = {}
done = threading.Event()
def run():
try:
result["value"] = preview_location.folder_state(path, expected_id)
except BaseException as error: # handed to the caller
result["error"] = error
finally:
done.set()
pending = ((path, expected_id), done, result)
self._checking = pending
threading.Thread(target=run, name=PROBE_THREAD + "-check", daemon=True).start()
return pending
def start(self):
with self._lock:
if self._thread is not None and self._thread.is_alive():
return
# Each thread has its own stop: a stopped one never resumes.
stop = self._stop = threading.Event()
thread = threading.Thread(
target=self._run, args=(stop,), name=PROBE_THREAD, daemon=True
)
try:
thread.start()
except RuntimeError:
return # refused at interpreter exit: no probe
self._thread = thread
def _run(self, stop):
while not stop.is_set():
self._wake.wait(PROBE_INTERVAL)
self._wake.clear()
if stop.is_set():
return
try:
refresh()
except Exception: # one failed check never stops the probe
logger.debug("Thumbnail folder check failed", exc_info=True)
def wake(self):
self._wake.set()
def stop(self, timeout=2.0):
"""Stop the probe; True when its thread ended."""
with self._lock:
thread, self._thread = self._thread, None
self._stop.set()
self._wake.set()
if thread is None or thread is threading.current_thread():
return True
thread.join(timeout)
return not thread.is_alive()
_probe = _Probe()
def stop_probe(timeout=2.0):
"""Stop the folder probe (the package teardown, tests)."""
return _probe.stop(timeout)
# ── stores ───────────────────────────────────────────────────────────
def source_folder(root):
"""``<source>/thumbnails``: the store in source mode, a leftover in
folder mode."""
return Path(root) / PREVIEW_FOLDER
def store_container(root, location=None):
"""The folder a store of *root* must stay inside: the source itself, or
the thumbnail folder in folder mode."""
location = location or current()
if location.mode == FOLDER_MODE:
return Path(location.path) if location.path else None
return Path(root)
def _identity(root):
return os.path.normcase(os.path.realpath(str(root)))
def _lookup_key(root):
spelling = str(root)
with _state_lock:
if spelling in _key_cache:
return _key_cache[spelling]
keys = dict(_keys)
key = preview_location.find_key(
keys,
spelling,
_identity(root),
preview_location.file_identity(root),
)
if key is not None:
with _state_lock:
_key_cache[spelling] = key
return key
def store_dir(root, location=None):
"""The store of *root*: ``<source>/thumbnails``, or in folder mode
``<folder>/<key>`` (None while the folder is unavailable or before the
source has a key: a reader then finds no preview)."""
location = location or current()
if location.mode == SOURCE_MODE:
return Path(root) / PREVIEW_FOLDER
if not location.available or not location.path:
return None
key = _lookup_key(root)
return None if key is None else _key_folder(location.path, key)
def _key_folder(folder, key):
"""``<folder>/<key>``, only for a key that is one folder name directly
below the thumbnail folder (a marker is data, never a path)."""
if not preview_location.valid_key(key):
return None
store = Path(folder) / key
if store.parent != Path(folder) or store.name != key:
return None
return store
def ensure_store(root, location=None, *, create=False):
"""``store_dir`` for a writer: in folder mode a source without a key gets
one (recorded in the marker); with *create* its key folder is made (never
the thumbnail folder itself). None while the folder is unavailable."""
location = location or current()
store = store_dir(root, location)
if location.mode == SOURCE_MODE or (store is None and not location.available):
return store
if store is None:
store = _assign_key(root, location)
if store is None:
return None
if create:
try:
with store_io():
os.mkdir(store)
except FileExistsError:
pass
except OSError:
return None
return store
def _assign_key(root, location):
parsed = _parsed
if location.path != parsed.path:
return None
spelling = str(root)
identity = _identity(root)
dev_ino = preview_location.file_identity(root)
assigned = {}
def add(marker):
if marker.get("id") != parsed.id:
raise ValueError("another folder's marker") # marker-mismatch
keys = marker["keys"]
key = preview_location.find_key(keys, spelling, identity, dev_ino)
if key is None:
taken = {k: v.get("identity") for k, v in keys.items()}
key = preview_location.source_key(spelling, identity, taken)
keys[key] = {
"path": spelling,
"identity": identity,
"dev_ino": dev_ino,
"install": parsed.install,
}
assigned["key"] = key
try:
with write_lock, store_io():
marker = preview_location.write_marker(location.path, add)
except (OSError, ValueError):
return None
_set_keys(parsed, marker)
return _key_folder(location.path, assigned["key"])
def may_write(path, location=None):
"""The one ownership check every preview write and delete passes.
Source mode: True (the callers' own root rules apply, as before). Folder
mode: *path* lies below a source key folder of the thumbnail folder,
lexically and resolved, and no link, junction or reparse point sits
anywhere from the key folder down to it (the file itself included).
"""
location = location or current()
if location.mode == SOURCE_MODE:
return True
if not location.path:
return False
folder = Path(os.path.abspath(location.path))
path = Path(os.path.abspath(path))
try:
relative = path.relative_to(folder)
except ValueError:
return False
if len(relative.parts) < 2 or ".." in relative.parts:
return False
store = _key_folder(folder, relative.parts[0])
if store is None or not _within(path, folder):
return False
try:
return _no_link_below(store, path)
except OSError:
note_failure()
return False
def make_parent(dst, location=None):
"""Make the folders a preview *dst* is written into. In folder mode only
folders below the thumbnail folder are made, never the folder itself,
and only while it holds this install's marker (an unplugged drive or an
unmounted mount point raises FileNotFoundError); a *dst* that does not
pass ``may_write`` raises PermissionError."""
location = location or current()
dst = Path(dst)
if location.mode == SOURCE_MODE or not location.path:
if location.mode != SOURCE_MODE:
raise FileNotFoundError(errno.ENOENT, "thumbnail folder unavailable")
dst.parent.mkdir(parents=True, exist_ok=True)
return
folder = Path(os.path.abspath(location.path))
with store_io():
if not location.available or not folder.is_dir():
raise FileNotFoundError(errno.ENOENT, "thumbnail folder unavailable")
marker = preview_location.read_marker(str(folder))
if marker is None or marker.get("id") != _parsed.id:
note_failure()
raise FileNotFoundError(errno.ENOENT, "thumbnail folder unavailable")
if not may_write(dst, location):
raise PermissionError(errno.EACCES, "outside the thumbnail folder")
current_folder = folder
for part in Path(os.path.abspath(dst.parent)).relative_to(folder).parts:
current_folder = current_folder / part
try:
os.mkdir(current_folder)
except FileExistsError:
pass
if preview_location.is_link_like(current_folder):
raise PermissionError(errno.EACCES, "a link in the thumbnail folder")
def store_is_link(store, location=None):
"""Whether a store folder is a link (a junction or reparse point too in
folder mode): it is skipped by sweeps and refused by clears."""
location = location or current()
if location.mode == SOURCE_MODE:
return Path(store).is_symlink()
return preview_location.is_link_like(store)
def given_store(root, store):
"""A caller's explicit store argument: the default spelling
(``<root>/thumbnails``) means the resolver's store; another folder (only
tests pass one) is used as given."""
store = Path(store)
if store == Path(root) / PREVIEW_FOLDER:
return store_dir(root)
return store
def preview_path(root, media_rel, ext=None, location=None, *, create=False):
"""Where the preview of *media_rel* (relative to *root*) is, or is to be
written (*create*: a writer, which gives a new source its key), in *ext*
(the extension written now when None)."""
if create:
store = ensure_store(root, location)
else:
store = store_dir(root, location)
if store is None:
return None
if ext is None:
from .thumbnail_encode import writer_settings
ext = writer_settings().extension
return store / (thumbnail_settings.preview_stem(media_rel) + ext)
def request_path(root, parts, location=None):
"""The disk path of the URL parts ``("thumbnails", ...)``, or None when
*parts* is not under an exact ``thumbnails`` first part or the store has
no place for it. The text is checked before (``is_preview_text``)."""
parts = tuple(parts)
if len(parts) < 2 or parts[0] != PREVIEW_FOLDER:
return None
store = store_dir(root, location)
if store is None:
return None
return store.joinpath(*parts[1:])
def _no_link_below(store, path):
"""No folder from *store* down to *path* (and *store* itself) is a link,
junction or reparse point (lexical walk, one lstat each)."""
store = Path(os.path.abspath(store))
path = Path(os.path.abspath(path))
try:
relative = path.relative_to(store)
except ValueError:
return False
current = store
for part in ("",) + relative.parts:
current = current / part if part else current
if preview_location.is_link_like(current):
return False
return True
def owns(root, path, location=None):
"""Whether *path* lies inside the store of *root*, resolved: the store
stays inside its container and no link takes *path* out of the store.
In folder mode no link, junction or reparse point may sit anywhere from
the store down to *path*."""
location = location or current()
store = store_dir(root, location)
if store is None:
return False
container = store_container(root, location)
if not (_within(store, container) and _within(path, store)):
return False
if location.mode == SOURCE_MODE:
return True
return may_write(path, location)
# ── preview text ─────────────────────────────────────────────────────
def is_preview_text(filepath):
"""URL text naming a preview: ``thumbnails/<...>/<name>_thumb<ext>``.
Text only, before any join: no backslash, drive or stream colon, and no
empty, ``.`` or ``..`` segment; ``thumbnails`` exactly as the first part
and nowhere else among the folders; the extension, in any case, one a
preview may carry (``X_thumb.JPG`` of an older release included).
"""
if not filepath or "\\" in filepath or ":" in filepath:
return False
parts = filepath.split("/")
if len(parts) < 2 or parts[0] != PREVIEW_FOLDER:
return False
if any(part in ("", ".", "..") for part in parts):
return False
if PREVIEW_FOLDER in parts[1:-1]:
return False
stem, extension = os.path.splitext(parts[-1])
return (
stem.endswith(thumbnail_settings.THUMB_SUFFIX)
and len(stem) > len(thumbnail_settings.THUMB_SUFFIX)
and extension.lower() in thumbnail_settings.THUMBNAIL_EXTENSIONS
)
# ── writes ───────────────────────────────────────────────────────────
class _WriteGuard:
"""``guard()`` for one preview write: true while the location has not
changed since the write started and *hold* (if any) allows it."""
__slots__ = ("generation", "_hold")
def __init__(self, hold=None):
self.generation = _generation
self._hold = hold
def __call__(self):
if self.generation != _generation:
return False
return self._hold is None or bool(self._hold())
def write_guard(hold=None):
"""Take when a preview write starts; hand to its final rename."""
if isinstance(hold, _WriteGuard):
return hold
return _WriteGuard(hold)
@contextmanager
def committed(guard=None):
"""Around a preview's final rename: holds ``write_lock`` and yields
whether the write may land (``guard()``, re-checked under the lock)."""
with write_lock:
yield guard is None or bool(guard())
+1 -12
View File
@@ -30,7 +30,6 @@ from . import thumbnail_settings
from .admin import _current_image_monitor
from .job_routes import busy_response
from .jobs import THUMBNAILS_JOB_KIND, JobBusy
from .thumbnail_location import needs_move_response
try:
from ...utils.validators import validate_result_timeout, validate_worker_threads
@@ -265,12 +264,6 @@ class SettingsRoutesMixin:
"sources": source_status,
"monitor": monitor,
"thumbnail_encoders": encoders,
# #320: the full path only here (D6A).
"thumbnail_location": await self._run_in_executor(
functools.partial(
self._thumbnail_location_payload, with_path=True
)
),
"settings": {
"monitoring_enabled": GalleryConfig.MONITORING_ENABLED,
"save_foreign_runs": GalleryConfig.SAVE_FOREIGN_RUNS,
@@ -384,9 +377,6 @@ class SettingsRoutesMixin:
if error_response is not None:
return error_response
if "thumbnail_location" in data:
# #320: only the Move thumbnails job changes the location.
return needs_move_response()
expected_revision = data.get("expected_revision")
if expected_revision is not None and not isinstance(expected_revision, str):
return web.json_response(
@@ -491,8 +481,7 @@ class SettingsRoutesMixin:
for key in _THUMBNAIL_SETTINGS
)
job = self._jobs.active(THUMBNAILS_JOB_KIND) if changed else None
# A move copies previews of the current settings (#320).
if job is None or job.scope.get("mode") not in ("convert", "move"):
if job is None or job.scope.get("mode") != "convert":
return None
return web.json_response(
{
+1 -22
View File
@@ -30,8 +30,6 @@ import ntpath
import os
import re
from .preview_location import HOLDS_THUMBNAILS_REASON
logger = logging.getLogger("prompt_manager.api")
MAX_SOURCE_PATH = 4096 # characters
@@ -116,26 +114,7 @@ def source_problem(text):
code = source_form_error(text)
if code is not None:
return code, FORM_REASONS[code]
code, reason = GalleryConfig.gallery_root_problem(text)
if code is None and _holds_thumbnails(GalleryConfig.resolve_gallery_root(text)):
return "holds-thumbnails", HOLDS_THUMBNAILS_REASON
return code, reason
def _holds_thumbnails(path):
"""Whether *path* is inside or around the thumbnail folder in use (#320):
a source there would list Prompt Manager's own previews."""
from . import preview_store
try:
from ...utils.library_location import _inside_any
except ImportError: # tests import the package top-level
from utils.library_location import _inside_any
setting = preview_store.setting()
if setting.mode != preview_store.FOLDER_MODE or not setting.path:
return False
return _inside_any(path, [setting.path]) or _inside_any(setting.path, [path])
return GalleryConfig.gallery_root_problem(text)
def public_spelling(text):
+48 -192
View File
@@ -8,8 +8,7 @@ previews behind those URLs are made here, in three ways:
- **On first view.** ``GET /prompt_manager/images/serve/thumbnails/<rel>_thumb
<ext>`` for a preview that does not exist yet is admitted on the event loop
(``_PreviewGate``): at most ``ON_DEMAND_LIMIT`` previews are written at once,
on this API's interactive image pool (``IMAGE_POOL_SIZE`` threads, shared
with card facts and inspector reads; never the default executor), one flight runs
on this API's own small pool (never the default executor), one flight runs
per file, and the requests waiting are capped (``MAX_WAITING``); past the
cap a request gets its fallback at once. Inside the admission the preview is
mapped back to its media file under the same root (the request text is
@@ -25,8 +24,7 @@ previews behind those URLs are made here, in three ways:
- **On capture.** The image monitor hands every new output it settles to
``queue_capture_thumbnail``, which queues its preview for one background
worker thread (``AutoThumbnailer``). Each wake takes at most Worker threads
(``WORKER_THREADS``) files and writes them on the long-lived job pool
(``utils.parallel``, shared with scans and thumbnail jobs); enqueuing
(``WORKER_THREADS``) files and writes them on that many threads; enqueuing
is O(1), so the monitor and ComfyUI's queue never wait. A file already
queued is not queued again, the queue is bounded (``MAX_QUEUED``) and a
preview left out is made on its first view instead.
@@ -51,7 +49,7 @@ write starts (after its slot) and again just before its rename.
Shutdown: ``close_auto_thumbnails`` stops the worker at a file boundary,
kills a running ffmpeg, waits a bounded time (and logs when the wait ran
out) and shuts the image pool down; after the app's cleanup
out) and shuts the view pool down; after the app's cleanup
(``final=True``) nothing starts a worker again. Importing this module again
(a package reload) stops the workers the previous import left.
"""
@@ -62,12 +60,12 @@ import functools
import io
import os
import threading
from concurrent.futures import CancelledError, ThreadPoolExecutor
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from aiohttp import web
from . import preview_store, thumbnail_settings
from . import thumbnail_settings
from .images import (
in_trash_below_roots,
MEDIA_EXTENSIONS,
@@ -78,7 +76,7 @@ from .images import (
_thumbnail_is_fresh,
_video_placeholder_image,
_worker_threads,
media_preview_path,
media_thumbnail_rel,
)
from .jobs import (
DUPLICATE_CLEANUP_JOB_KIND,
@@ -106,11 +104,8 @@ PLACEHOLDER_SIZE = (thumbnail_settings.DEFAULT_EDGE, thumbnail_settings.DEFAULT_
PLACEHOLDER_QUALITY = 85
# Source extensions in the order a shared preview name looks for them (R2).
_SOURCE_ORDER = thumbnail_settings.IMAGE_SOURCE_ORDER + tuple(sorted(VIDEO_EXTENSIONS))
# Threads of the interactive image pool (#523): view previews, card facts and
# inspector reads. Fixed, so image buffers stay in a few threads' arenas.
IMAGE_POOL_SIZE = 3
# Previews views may write at once (the whole image pool).
ON_DEMAND_LIMIT = IMAGE_POOL_SIZE
# Previews views may write at once (the size of the view pool).
ON_DEMAND_LIMIT = 3
# View requests that may wait on the gate; one more gets its fallback at once.
MAX_WAITING = 64
# How long a pool thread waits for the capture worker's flight of the same file.
@@ -129,7 +124,7 @@ AUTO_REFUSED_KINDS = frozenset(
{RESTORE_JOB_KIND, DUPLICATE_CLEANUP_JOB_KIND, SOURCE_CHANGE_JOB_KIND}
)
WORKER_NAME = "PromptManagerThumbnailWorker"
POOL_PREFIX = "pm-image"
POOL_PREFIX = "pm-preview"
# Identifies this import's workers: a reload stops the ones it did not start.
_MODULE_TOKEN = object()
@@ -158,36 +153,6 @@ def _dst_key(dst):
return flight_key(Path(dst).parent, (Path(dst).name,))
class DeferredPreview:
"""The preview of *rel* under *root*, resolved when its write starts
(#320): a queued capture follows the thumbnail location at write time,
not the one when it was queued."""
__slots__ = ("root", "rel", "strict")
def __init__(self, root, rel, *, strict=True):
self.root = Path(root)
self.rel = Path(rel)
# A capture refuses a store linked out of its container (the serve
# route's rule); a planned target keeps the plan's rule: inside the
# store, as the thumbnail job writes it.
self.strict = strict
def resolve(self):
"""The preview's path now, or None when nothing may be written."""
dst = media_preview_path(self.root, self.rel, create=True)
if dst is None:
return None
if self.strict:
return dst if preview_store.owns(self.root, dst) else None
store = preview_store.store_dir(self.root)
return dst if _is_within(dst, store) else None
def _resolved(dst):
return dst.resolve() if isinstance(dst, DeferredPreview) else Path(dst)
@functools.lru_cache(maxsize=1)
def video_placeholder_jpeg():
"""The placeholder frame as JPEG bytes (a video whose preview is missing)."""
@@ -245,19 +210,10 @@ def _candidates(parts):
def _current_preview(root, parts):
"""The preview *parts* names, in the format written now (#317): a request
for an older name (``_thumb.png`` before a conversion) is answered with
the current file. None when the store of *root* has no place for it."""
the current file."""
_folder, base, _wanted = _candidates(parts)
name = base + thumbnail_settings.THUMB_SUFFIX + writer_settings().extension
return preview_store.request_path(root, (*parts[:-1], name))
def _request_key(root, parts):
"""The flight key of the preview *parts* under *root*: the key of its
disk path (so the serve and history routes share one flight)."""
path = preview_store.request_path(root, parts)
if path is None:
return flight_key(root, parts)
return _dst_key(path)
return Path(root).joinpath(*parts[:-1], name)
def _quick_source(root, parts):
@@ -298,14 +254,12 @@ def _thumbnail_source(root, parts):
if in_trash_below_roots(found, [root]):
return None
rel = Path(*folder_parts, found.name)
dst = media_preview_path(root, rel, create=True)
if dst is None:
# Nowhere to write (the thumbnail folder is unavailable): the
# original is served and nothing is written.
return found, None, found.suffix.lower() in VIDEO_EXTENSIONS
dst = root / media_thumbnail_rel(rel)
if dst != _current_preview(root, parts):
return None
if not preview_store.owns(root, dst):
if not _is_within(root / "thumbnails", root) or not _is_within(
dst, root / "thumbnails"
):
dst = None
return found, dst, found.suffix.lower() in VIDEO_EXTENSIONS
@@ -325,21 +279,6 @@ def _scan_folder(folder, base, extensions):
return None
def _deferred(roots, src, dst):
"""The ``DeferredPreview`` of a planned ``(src, dst)``: the root whose
store the plan wrote *dst* into (nested roots plan one each); *dst* as
planned when none matches."""
src = Path(src)
for root in roots:
root = Path(root)
if not src.is_relative_to(root):
continue
rel = src.relative_to(root)
if media_preview_path(root, rel) == Path(dst):
return DeferredPreview(root, rel, strict=False)
return dst
def _stop_stale_workers():
"""Stop capture workers a previous import of this module started."""
for thread in threading.enumerate():
@@ -355,7 +294,7 @@ class AutoThumbnailer:
``generate(src, dst, is_video, *, stop, guard, track)`` writes one
preview and returns ``_generate_one``'s result dict; ``may_write()``
says whether automatic previews may be written now. Views call
``ensure`` on the image pool, the monitor calls ``enqueue`` from its
``ensure`` on the view pool, the monitor calls ``enqueue`` from its
thread.
"""
@@ -481,23 +420,16 @@ class AutoThumbnailer:
def enqueue(self, src, dst, is_video):
"""Queue *dst*'s preview for the worker; False when already queued,
full, closed, nowhere to write it or its worker is refused at
interpreter exit. Never blocks on a write. *dst* may be a
``DeferredPreview``: its path is resolved again when the write
starts."""
now = _resolved(dst)
if now is None:
return False
key = _dst_key(now)
if not isinstance(dst, DeferredPreview):
dst = now
full, closed or its worker is refused at interpreter exit. Never
blocks on a write."""
key = _dst_key(dst)
with self._changed:
if self._closed or key in self._queued:
return False
if len(self._queue) >= self._max_queued:
return False
self._queued.add(key)
self._queue.append((key, Path(src), dst, bool(is_video)))
self._queue.append((key, Path(src), Path(dst), bool(is_video)))
self._busy = True
started = True
if self._start_worker and not self._worker_running:
@@ -559,15 +491,12 @@ class AutoThumbnailer:
self._worker_running = False
self._changed.notify_all()
return
workers = max(1, int(self._workers() or 1))
take = min(len(self._queue), workers)
take = min(len(self._queue), max(1, int(self._workers() or 1)))
batch = [self._queue.popleft() for _ in range(take)]
self._batch = len(batch)
self._changed.notify_all()
try:
# The job pool's size is the setting; a smaller batch uses
# fewer of its threads (#523: one long-lived pool).
map_parallel(self._write_queued, batch, workers)
map_parallel(self._write_queued, batch, len(batch))
finally:
with self._changed:
self._batch = 0
@@ -577,9 +506,6 @@ class AutoThumbnailer:
def _write_queued(self, item):
_key, src, dst, is_video = item
try:
dst = _resolved(dst)
if dst is None:
return # nowhere to write it now: made on its first view
self.ensure(src, dst, is_video)
except Exception: # one file never stops the worker
self._logger.warning(
@@ -607,7 +533,6 @@ class AutoThumbnailer:
try:
process.kill()
except OSError:
# the process has already exited
pass
if worker is None:
return True
@@ -641,7 +566,7 @@ class _PreviewGate:
"""Admission of view requests on the event loop (#249 I2).
Waiters are coroutines: at most ``limit`` previews are written at once
on the image pool, one flight runs per key, and past ``MAX_WAITING``
on the view pool, one flight runs per key, and past ``MAX_WAITING``
waiting requests ``run`` answers ``OVER`` at once.
"""
@@ -696,17 +621,13 @@ class _PreviewGate:
flight.set_result(result)
def _on_image_thread():
return threading.current_thread().name.startswith(POOL_PREFIX + "_")
class AutoThumbnailMixin:
"""Automatic previews on the API: views, the monitor's queue, scans."""
_auto_thumbnailer = None
_auto_thumbnails_shut = False
_preview_gate_obj = None
_image_pool_obj = None
_preview_pool_obj = None
_auto_thumbnailer_lock = threading.Lock()
def _auto_thumbnails(self):
@@ -729,7 +650,7 @@ class AutoThumbnailMixin:
self._auto_thumbnails_shut = True
def close_auto_thumbnails(self, final=False, timeout=None):
"""Stop the capture worker and the image pool, and forget them.
"""Stop the capture worker and the view pool, and forget them.
Bounded by ``timeout`` (see AutoThumbnailer.close). ``final`` (the
package teardown) also keeps a late monitor event from starting a
@@ -740,7 +661,7 @@ class AutoThumbnailMixin:
if final:
self._auto_thumbnails_shut = True
thumbnailer, self._auto_thumbnailer = self._auto_thumbnailer, None
pool, self._image_pool_obj = self._image_pool_obj, None
pool, self._preview_pool_obj = self._preview_pool_obj, None
self._preview_gate_obj = None
closed = True
if thumbnailer is not None:
@@ -749,8 +670,6 @@ class AutoThumbnailMixin:
pool.shutdown(wait=False, cancel_futures=True)
if final:
shutdown_encoders() # kill JPEG XL / ffmpeg encoders still running
# The thumbnail folder's probe (#320), bounded like the rest.
closed = preview_store.stop_probe() and closed
return closed
def _auto_thumbnail_failures(self):
@@ -775,9 +694,6 @@ class AutoThumbnailMixin:
return job
if job.kind == THUMBNAILS_JOB_KIND and job.title == THUMBNAIL_CLEAR_TITLE:
return job
# Move thumbnails holds automatic previews for its whole run (D2A).
if job.kind == THUMBNAILS_JOB_KIND and job.scope.get("mode") == "move":
return job
return None
# ── the view admission ────────────────────────────────────────────
@@ -789,67 +705,16 @@ class AutoThumbnailMixin:
gate = self._preview_gate_obj = _PreviewGate(ON_DEMAND_LIMIT, self.logger)
return gate
def _image_pool(self):
"""The interactive image pool (#523): IMAGE_POOL_SIZE threads for view
previews (behind their gate), card facts and inspector reads, so
image buffers never spread over the default executor's threads.
None after the package teardown (``close_auto_thumbnails(final=True)``):
a late request then runs on the default executor (``run_in_executor``
with None) and starts no thread the teardown no longer stops.
"""
def _preview_pool(self):
with AutoThumbnailMixin._auto_thumbnailer_lock:
if self._image_pool_obj is None and not self._auto_thumbnails_shut:
self._image_pool_obj = ThreadPoolExecutor(
IMAGE_POOL_SIZE, thread_name_prefix=POOL_PREFIX
if self._preview_pool_obj is None:
self._preview_pool_obj = ThreadPoolExecutor(
ON_DEMAND_LIMIT, thread_name_prefix=POOL_PREFIX
)
return self._image_pool_obj
return self._preview_pool_obj
async def _run_image(self, func, *args, **kwargs):
"""``_run_in_executor`` for interactive image work: the image pool.
After the teardown, or with the pool shut down in between (a test
reset), the call runs on the default executor as before.
"""
call = functools.partial(func, *args, **kwargs)
loop = asyncio.get_running_loop()
try:
future = loop.run_in_executor(self._image_pool(), call)
except RuntimeError: # submit refused: the pool was shut down
future = loop.run_in_executor(None, call)
return await future
def _map_image(self, func, items):
"""``func`` over *items* on the image pool, in order (blocking).
Called from an image pool thread, after the teardown, or with the
pool shut down in between (its queued items are cancelled), the
items it did not run run on the calling thread.
"""
items = list(items)
pool = None if not items or _on_image_thread() else self._image_pool()
if pool is None:
return [func(item) for item in items]
futures = []
try:
for item in items:
futures.append(pool.submit(func, item))
except RuntimeError:
pass # shut down in between: the rest run here
results = []
for index, item in enumerate(items):
future = futures[index] if index < len(futures) else None
if future is None or future.cancelled():
results.append(func(item))
continue
try:
results.append(future.result())
except CancelledError: # cancelled while this caller waited
results.append(func(item))
return results
def _image_pool_threads(self):
pool = self._image_pool_obj
def _preview_pool_threads(self):
pool = self._preview_pool_obj
return [] if pool is None else list(getattr(pool, "_threads", ()))
def _video_placeholder_bytes(self):
@@ -864,7 +729,7 @@ class AutoThumbnailMixin:
return await self._preview_gate().run(
flight_key(Path(dst).parent, (Path(dst).name,)),
functools.partial(self._ensure_view, src, dst, is_video),
self._image_pool(),
self._preview_pool(),
)
def _ensure_view(self, src, dst, is_video):
@@ -908,17 +773,17 @@ class AutoThumbnailMixin:
The flight answers only the outcome (a history flight may be the one
joined); the file it previews is derived here: the preview path is
the request's own, and a fallback looks its source up again on the
image pool (bounded by it, never the default executor).
view pool (bounded by it, never the default executor).
"""
if _has_traversal(filepath) or not _is_preview_request(filepath):
return None
parts = tuple(filepath.split("/"))
gate = self._preview_gate()
pool = self._image_pool()
pool = self._preview_pool()
for root in output_dirs:
root = Path(root)
outcome = await gate.run(
_request_key(root, parts),
flight_key(root, parts),
functools.partial(self._preview_on_demand, root, parts),
pool,
)
@@ -931,7 +796,7 @@ class AutoThumbnailMixin:
continue
return self._preview_response(BUSY, quick[0], None, quick[1])
dst = _current_preview(root, parts)
if outcome == READY and dst is not None:
if outcome == READY:
return self._file_response(dst)
located = await gate.loop.run_in_executor(
pool, _thumbnail_source, root, parts
@@ -954,31 +819,17 @@ class AutoThumbnailMixin:
"""
if self._auto_thumbnails_shut:
return "skipped"
located = self._capture_source(image_path)
located = self._capture_target(image_path)
if located is None:
return "skipped"
src, root, rel, is_video = located
dst = DeferredPreview(root, rel).resolve()
if dst is None:
return "skipped"
src, dst, is_video = located
if _thumbnail_is_fresh(src, dst):
return "fresh"
if self._auto_thumbnails().enqueue(src, DeferredPreview(root, rel), is_video):
if self._auto_thumbnails().enqueue(src, dst, is_video):
return "queued"
return "skipped"
def _capture_target(self, image_path):
"""``(src, dst, is_video)`` of a new output's preview now, or None."""
located = self._capture_source(image_path)
if located is None:
return None
src, root, rel, is_video = located
dst = DeferredPreview(root, rel).resolve()
return None if dst is None else (src, dst, is_video)
def _capture_source(self, image_path):
"""``(src, root, rel, is_video)`` of a media file under a current
root (not a preview, a link or in a trash folder), or None."""
path = Path(os.path.abspath(str(image_path)))
if path.suffix.lower() not in MEDIA_EXTENSIONS or not _regular_file(path):
return None
@@ -991,7 +842,12 @@ class AutoThumbnailMixin:
rel = Path(os.path.relpath(path, root))
if "thumbnails" in rel.parts[:-1] or in_trash_below_roots(path, [root]):
return None
return path, root, rel, path.suffix.lower() in VIDEO_EXTENSIONS
dst = root / media_thumbnail_rel(rel)
if not _is_within(root / "thumbnails", root) or not _is_within(
dst, root / "thumbnails"
):
return None
return path, dst, path.suffix.lower() in VIDEO_EXTENSIONS
return None
# ── after a scan ──────────────────────────────────────────────────
@@ -1033,7 +889,7 @@ class AutoThumbnailMixin:
return "none"
thumbnailer = self._auto_thumbnails()
for src, dst, is_video in plan["targets"]:
thumbnailer.enqueue(src, _deferred(output_dirs, src, dst), is_video)
thumbnailer.enqueue(src, dst, is_video)
self.logger.info(f"{missing:,} previews missing after the scan; queued")
return "queued"
-1
View File
@@ -153,7 +153,6 @@ class ThumbnailConvertMixin:
job.log_line("Conversion failed · internal error")
state = "failed"
job.finish(state)
await self._release_job_memory()
async def _convert_job_body(self, job, options):
output_dirs = await self._run_in_executor(self._get_all_output_dirs)
+7 -25
View File
@@ -46,7 +46,7 @@ from pathlib import Path
from PIL import Image
from . import preview_store, thumbnail_settings
from . import thumbnail_settings
try:
from ...utils.logging_config import get_logger
@@ -101,20 +101,14 @@ def temp_sibling(dst, tag="part"):
def replace_unless_held(tmp, dst, guard=None):
"""Rename *tmp* over *dst*, unless ``guard()`` is false (PreviewWriteHeld).
The check and the rename run under ``preview_store.write_lock`` (#320): a
guard from ``preview_store.write_guard`` also refuses a write that started
before the thumbnail location changed."""
with preview_store.committed(guard) as allowed:
if not allowed:
raise PreviewWriteHeld(PREVIEW_HELD)
os.replace(tmp, dst)
"""Rename *tmp* over *dst*, unless ``guard()`` is false (PreviewWriteHeld)."""
if guard is not None and not guard():
raise PreviewWriteHeld(PREVIEW_HELD)
os.replace(tmp, dst)
# ``temp_sibling``'s names: ``.<stem>.<tag>-<pid>-<thread id><ext>``.
_TEMP_NAME = re.compile(r"^\..+\.(?:part|src)-(\d+)-\d+\.[A-Za-z0-9]+$")
_MOVE_TEMP_NAME = re.compile(r"\.pm-move-[0-9a-f]{8}\.tmp$")
# A temp at least this old (seconds) outlived its write: no encode runs longer.
STRAY_TEMP_AGE = 4 * ENCODE_TIMEOUT
# When this process started (this module's import): a temp carrying its PID
@@ -139,9 +133,7 @@ def sweep_stray_temps(folder, min_age=None):
for root, _dirs, files in os.walk(folder):
for name in files:
match = _TEMP_NAME.match(name)
# A move's copy temp (#320): ``<name>.pm-move-<8 hex>.tmp``.
move_temp = match is None and _MOVE_TEMP_NAME.search(name)
if match is None and not move_temp:
if match is None:
continue
path = Path(root) / name
try:
@@ -150,13 +142,7 @@ def sweep_stray_temps(folder, min_age=None):
continue
if min_age and (
now - stat.st_mtime < min_age
or move_temp
and stat.st_mtime >= PROCESS_STARTED
or (
match is not None
and match.group(1) == own
and stat.st_mtime >= PROCESS_STARTED
)
or (match.group(1) == own and stat.st_mtime >= PROCESS_STARTED)
):
continue
path.unlink()
@@ -172,7 +158,6 @@ def _unlink_quietly(path):
try:
Path(path).unlink()
except FileNotFoundError:
# already gone, which is what was wanted
pass
except OSError as exc:
logger.debug(f"Could not remove temp file {Path(path).name}: {exc}")
@@ -236,7 +221,6 @@ def shutdown_encoders():
try:
process.kill()
except OSError:
# the process has already exited
pass
@@ -375,12 +359,10 @@ def thumbnail_frame(img, edge):
try:
img.seek(0)
except (EOFError, AttributeError, ValueError):
# not seekable or a single frame: use the current one
pass
try:
img.draft("RGB", (edge, edge)) # JPEG: decode at a reduced scale
except (AttributeError, ValueError, OSError):
# draft is only a speedup; the full decode below still works
pass
frame = img.convert("RGB") if img.mode != "RGB" else img.copy()
frame.thumbnail((edge, edge), Image.Resampling.LANCZOS)
+4 -41
View File
@@ -49,7 +49,6 @@ from .images import (
_worker_threads,
)
from .job_routes import busy_response
from .thumbnail_location import location_refusal
from .jobs import THUMBNAIL_GROUP_NAMES, THUMBNAILS_JOB_KIND, JobBusy
from .thumbnail_convert import CONVERT_JOB_TITLE
from .thumbnail_encode import writer_settings
@@ -132,10 +131,7 @@ class ThumbnailJobsMixin:
"""The thumbnail plan, the thumbnail job and the legacy stream on it."""
async def get_thumbnail_plan(self, request):
"""What a thumbnail run would do (read-only; capped like a run).
``mode=prune`` lists the removed sources' thumbnails (#320)."""
if request.query.get("mode") == "prune":
return await self.get_prune_plan()
"""What a thumbnail run would do (read-only; capped like a run)."""
options, error = thumbnail_options(request.query, from_query=True)
if error:
return _invalid(error)
@@ -183,39 +179,15 @@ class ThumbnailJobsMixin:
)
except (ValueError, UnicodeDecodeError):
return _invalid(_OPTIONS_ERROR)
if isinstance(body, dict) and body.get("mode") in ("move", "prune"):
return await self._start_location_job(body)
options, error = thumbnail_options(body)
if error:
return _invalid(error)
refused = location_refusal()
if refused is not None:
return refused
try:
job = self._start_thumbnail_job(options)
except JobBusy as busy:
return busy_response(busy)
return web.json_response({"success": True, "job": job.summary()}, status=202)
async def _start_location_job(self, body):
"""Move thumbnails or Remove thumbnails of removed sources (#320)."""
if body["mode"] == "prune":
refused = location_refusal()
if refused is not None:
return refused
try:
if body["mode"] == "move":
started = self.start_thumbnail_move(body)
else:
started = self.start_thumbnail_prune()
except JobBusy as busy:
return busy_response(busy)
if isinstance(started, web.Response):
return started
return web.json_response(
{"success": True, "job": started.summary()}, status=202
)
def _start_thumbnail_job(self, options):
"""Register the thumbnail job synchronously; raises JobBusy.
@@ -254,7 +226,6 @@ class ThumbnailJobsMixin:
)
state = "failed"
job.finish(state)
await self._release_job_memory()
async def _thumbnail_job_body(self, job, options):
"""Plan, then generate in worker batches; returns the final state.
@@ -487,12 +458,8 @@ class ThumbnailJobsMixin:
or attaches to the running thumbnail job, and streams its frames. A
legacy hold (the blocking generate or clear) or a record-changing
job refuses it with the stream's error frame. The job goes on when
the client goes away. Refused (409) while the thumbnail folder is
unavailable (#320).
the client goes away.
"""
refused = location_refusal()
if refused is not None:
return refused
response = web.StreamResponse(
status=200,
headers={
@@ -539,12 +506,8 @@ class ThumbnailJobsMixin:
the way.
"""
running = self._jobs.active(THUMBNAILS_JOB_KIND)
if running is not None and running.scope.get("mode") in (
"convert",
"move",
"prune",
):
return None, False, running # no legacy frames to follow (#317, #320)
if running is not None and running.scope.get("mode") == "convert":
return None, False, running # no legacy frames to follow (#317)
if running is not None and running.cancellable:
return running, False, None
busy = self._jobs.blocker(THUMBNAILS_JOB_KIND, admission="guarded")
-162
View File
@@ -1,162 +0,0 @@
"""The thumbnail location on the API (#320): startup, the settings payload
and the refusals while the thumbnail folder is unavailable.
The setting itself is config.json ``thumbnails.location``
(``preview_location``); the runtime state is ``preview_store``. POST
``/settings`` never changes it (409 ``location-needs-move``): the Move
thumbnails job is its only writer.
"""
import os
from aiohttp import web
from . import preview_location, preview_store
try:
from ...utils import library_location
except ImportError: # tests import the package top-level
from utils import library_location
# The node folder (its web/ is served and a reinstall wipes it).
NODE_FOLDER = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
)
def default_path():
"""``<data folder>/data/thumbnails`` (D7B), or None when unknown."""
try:
return library_location.cache_dir("thumbnails")
except Exception: # no data folder can be decided: no default
return None
def location_refusal(location=None):
"""The 409 ``location-unavailable`` response while the thumbnail folder
is unavailable, else None (thumbnail generate, convert, clear, prune)."""
location = location or preview_store.current()
if location.mode != preview_store.FOLDER_MODE or location.available:
return None
status, text = preview_location.message("location-unavailable")
return web.json_response(
{"success": False, "code": "location-unavailable", "error": text},
status=status,
)
def needs_move_response():
status, text = preview_location.message("location-needs-move")
return web.json_response(
{"success": False, "code": "location-needs-move", "error": text},
status=status,
)
class ThumbnailLocationMixin:
"""Startup check and payloads of the thumbnail location."""
def _all_source_roots(self):
"""Every image source of every root list: the Images roots, the
configured sources and the extra gallery roots (as strings)."""
from ..config import GalleryConfig, _extra_gallery_roots
roots = [str(root) for root in self._get_all_output_dirs()]
roots += [str(root) for root in GalleryConfig.MONITORING_DIRECTORIES]
roots += list(_extra_gallery_roots())
return tuple(dict.fromkeys(root for root in roots if root))
def _thumbnail_location_context(self):
"""What a thumbnail folder is validated against (blocking)."""
contained = []
try:
import folder_paths
for name in (
"get_output_directory",
"get_input_directory",
"get_temp_directory",
):
getter = getattr(folder_paths, name, None)
folder = getter() if getter is not None else None
if folder:
contained.append(str(folder))
except ImportError:
pass
try:
contained.append(library_location.config_location()[1])
except Exception: # no data folder can be decided
self.logger.debug("No library folder for the thumbnail check")
setting = preview_store.setting()
return preview_location.Context(
roots=self._all_source_roots(),
contained=tuple(contained),
node_folder=NODE_FOLDER,
default_path=default_path(),
current_path=(
setting.path if setting.mode == preview_store.FOLDER_MODE else None
),
)
def use_preview_location(self):
"""Put the configured location in force at once, unchecked (the
package's import, before any request): folder mode is unavailable
until ``start_preview_location`` has checked it, so nothing is ever
written to the sources meanwhile. No filesystem access."""
from ..config import PromptManagerConfig
try:
return preview_store.configure(
PromptManagerConfig.THUMBNAIL_LOCATION, check=False
)
except Exception:
self.logger.warning("The thumbnail location is unusable", exc_info=True)
preview_store.mark_invalid()
return preview_store.current()
def start_preview_location(self):
"""Parse and check the configured location once (startup thread);
the probe takes over. Never raises; a check that fails leaves a
folder location unavailable (``invalid``), never the sources."""
from ..config import PromptManagerConfig
try:
value = PromptManagerConfig.THUMBNAIL_LOCATION
parsed = preview_location.parse_location(value)
context = None
if parsed.mode == preview_store.FOLDER_MODE:
context = self._thumbnail_location_context()
location = preview_store.configure(value, context=context)
except Exception:
self.logger.warning("The thumbnail location check failed", exc_info=True)
if (
preview_location.parse_location(
PromptManagerConfig.THUMBNAIL_LOCATION
).mode
== preview_store.FOLDER_MODE
):
preview_store.mark_invalid()
return preview_store.current()
if location.mode == preview_store.FOLDER_MODE and not location.available:
self.logger.warning(
"The thumbnail folder is unavailable "
f"({location.problem or 'checking'}): originals are shown and "
"no thumbnails are written until it is back"
)
return location
def _thumbnail_location_payload(self, *, with_path):
"""``{mode, available, problem}`` plus, for the settings GET only,
``path`` and ``default_path`` (D6A)."""
location = preview_store.current()
payload = {
"mode": location.mode,
"available": location.available,
"problem": location.problem,
}
if with_path:
payload["path"] = location.path
payload["default_path"] = default_path()
# Old copies a move still has to remove (#320 Part 3).
payload["cleanup_pending"] = len(preview_store.setting().cleanup)
return payload
-535
View File
@@ -1,535 +0,0 @@
"""Move thumbnails (#320 Part 3): the reviewed job that changes where previews
live, the removal of old copies after it, recovery at startup and the
"Remove thumbnails of removed sources" scope (Part 5).
Kind ``thumbnails`` with ``mode: "move"`` (D1A): admission ``guarded``, so
scans, deletes, tagging, source changes and every other thumbnail job wait
for it, while the monitor's Needs review recording runs beside it.
Automatic previews are held for the whole job (D2A, ``_auto_thumbnail_blocker``).
Phases: review (``POST /images/thumbnails/plan``, no writes but the
validation probe), start (a plan token), hold (generation bump, journal,
target folder), copy (cancellable at every file boundary, on the job pool
in small chunks, D3A), switch (one config commit sets the location and
records the old stores under ``cleanup``; not cancellable from here),
remove old copies, end. Per-file errors switch anyway (D10A); a folder-wide
error or a cancel before the switch removes what the job wrote and changes
nothing. The move is the only writer of the setting (D4B).
Moving away from a thumbnail folder also removes the previews of this
install's sources that are no longer listed (Q320-8: remove).
"""
import functools
import os
import time
from pathlib import Path
from aiohttp import web
from . import preview_location, preview_store
from . import thumbnail_move_files as files
from .jobs import THUMBNAILS_JOB_KIND
from .thumbnail_move_plan import (
MOVE_GROUPS,
MOVE_TITLE,
PLAN_TTL,
_number,
_plural,
_remove,
_roots_revision,
_volume,
_is_network,
_write_json,
)
from . import thumbnail_move_plan as move_plan
try:
from ...utils import exit_signal
from ...utils.parallel import map_parallel
except ImportError: # tests import the package top-level
from utils import exit_signal
from utils.parallel import map_parallel
class ThumbnailMoveMixin:
"""Start and run the move job; the removal of old copies."""
# ── start ─────────────────────────────────────────────────────────
def _take_move_plan(self, body):
"""The plan a start names, or ``(status, code)``."""
token = body.get("token")
plan = (self._move_plans or {}).get(token) if isinstance(token, str) else None
if plan is None or time.monotonic() - plan.created > PLAN_TTL:
return 409, "plan-stale"
roots = [str(root) for root in self._get_all_output_dirs()]
if _roots_revision(roots) != plan.revision:
return 409, "plan-stale"
if preview_store.setting() != plan.old:
return 409, "plan-stale"
if plan.confirm_required and body.get("confirmed") is not True:
return 400, "confirmation-required"
return plan
def start_thumbnail_move(self, body):
"""Start the move job (sync, on the loop); raises JobBusy. Returns
the job or a refusal response."""
if not self._move_recovered:
blocker = self._jobs.active(THUMBNAILS_JOB_KIND)
return web.json_response(
{
"success": False,
"code": "busy",
"error": "Prompt Manager is still checking an earlier move. "
"Try again in a moment.",
"running": None if blocker is None else {"id": blocker.id},
},
status=409,
)
taken = self._take_move_plan(body)
if isinstance(taken, tuple):
status, code = taken
text = {
"plan-stale": "The review is out of date. Review the move again.",
"confirmation-required": "Confirm that Prompt Manager may "
"create its folders there.",
}[code]
return web.json_response(
{"success": False, "code": code, "error": text}, status=status
)
plan = taken
self._move_plans.pop(plan.token, None)
return self._jobs.start(
THUMBNAILS_JOB_KIND,
MOVE_TITLE,
admission="guarded",
cancellable=True,
scope={
"mode": "move",
"from": plan.old.mode,
"to": plan.target["mode"],
"to_path": plan.target["path"],
},
groups=MOVE_GROUPS,
runner=functools.partial(self._run_thumbnail_move, plan=plan),
)
# ── the job ───────────────────────────────────────────────────────
async def _run_thumbnail_move(self, job, *, plan):
state = {"written": [], "created": [], "created_folder": False}
try:
await self._run_in_executor(self._move_hold, job, plan, state)
except (OSError, ValueError) as error:
await self._run_in_executor(self._move_rollback, plan, state)
self._end_move(
job, "failed", "The new folder could not be made; nothing changed."
)
self.logger.warning(f"Move thumbnails could not start: {error}")
return
left = [
{
"path": "/".join(item),
"root": s.label,
"detail": "Not a thumbnail; left in place",
}
for s in plan.sources
if s.scan is not None
for item in s.scan.left
]
if left:
job.record_counted("left", len(left), left)
items = [
(source, item)
for source in plan.sources
if source.scan is not None
for item in source.scan.copy
]
total = len(items)
job.update(total=total, processed=0, progress=0)
chunk = self._move_chunk(plan)
workers = self._move_workers()
done = 0
for start in range(0, total, chunk):
if job.cancelled:
return await self._move_cancelled(job, plan, state)
batch = items[start : start + chunk]
try:
results = await self._run_in_executor(
map_parallel,
functools.partial(self._move_copy_one, state=state),
batch,
workers,
)
except exit_signal.ExitRequested:
return # exiting: the next start undoes the copies (journal)
except files.FolderGone as error:
self.logger.warning(f"Move thumbnails stopped: {error}")
await self._run_in_executor(self._move_rollback, plan, state)
self._end_move(
job,
"failed",
"The new folder could not be reached or is full; nothing changed.",
)
return
for (source, item), (outcome, detail) in zip(batch, results):
row = {
"path": "/".join(item[0]),
"root": source.label,
"detail": detail,
}
job.record(
"moved" if outcome == "moved" else outcome,
row,
f"{outcome}: {source.label} · {'/'.join(item[0])}",
)
done += len(batch)
label = batch[-1][0].label
job.update(
processed=done,
progress=int(done * 90 / total) if total else 90,
status_line=(
f"Copying {_number(done)} of {_number(total)} previews · {label}"
),
)
job.publish({"type": "progress", "summary": job.summary()})
if job.cancelled:
return await self._move_cancelled(job, plan, state)
# The switch: not cancellable from here (an exit still stops between
# files and leaves the cleanup to the next start).
job.cancellable = False
job.update(status_line="Switching to the new location", progress=92)
job.publish({"type": "progress", "summary": job.summary()})
try:
await self._run_in_executor(self._move_switch, plan)
except Exception:
self.logger.exception("Move thumbnails could not switch")
await self._run_in_executor(self._move_rollback, plan, state)
self._end_move(
job, "failed", "The setting could not be saved; nothing changed."
)
return
await self._remove_old_copies(job, plan)
moved = job.counters.get("moved", 0) + job.counters.get("skipped", 0)
errors = job.counters.get("errors", 0)
where = plan.target["path"] or "inside each source"
parts = [f"{_number(moved)} moved"]
if errors:
parts.append(_plural(errors, "error"))
parts.append(
"old copies removed"
if not job.scope.get("cleanup_left")
else "some old copies are removed at the next start"
)
self._end_move(
job, "finished", f"Thumbnails moved to {where}: " + " · ".join(parts) + "."
)
def _end_move(self, job, state, line):
job.update(status_line=line, progress=100)
job.log_line(line)
job.publish({"type": "complete", "summary": job.summary()})
job.finish(state)
def _move_workers(self):
from .images import _worker_threads
return max(1, int(_worker_threads() or 1))
def _move_chunk(self, plan):
"""2 files at a time across volumes or to/from a share, else 4, never
more than Worker threads (D3A: the pool keeps its size)."""
sides = [plan.target["path"] or ""] + [s.old_store or "" for s in plan.sources]
sides += [s.new_store for s in plan.sources]
network = any(_is_network(side) for side in sides if side)
volumes = {_volume(side) for side in sides if side}
size = 2 if network or len(volumes) > 1 else 4
return max(1, min(size, self._move_workers()))
def _move_hold(self, job, plan, state):
"""Phase 3: bump the generation, write the journal, make the target."""
preview_store.bump_generation()
if plan.to_folder:
folder = plan.target["path"]
if not os.path.isdir(folder):
if plan.target_default:
os.makedirs(folder, exist_ok=True)
else:
os.mkdir(folder)
state["created_folder"] = True
plan.target_id = preview_location.init_folder(
folder, created_by_pm=state["created_folder"]
)
install = plan.install
def add_keys(marker):
for source in plan.sources:
if source.key and source.key not in marker["keys"]:
marker["keys"][source.key] = {
"path": source.root,
"identity": os.path.normcase(os.path.realpath(source.root)),
"dev_ino": preview_location.file_identity(source.root),
"install": install,
}
with preview_store.write_lock:
preview_location.write_marker(folder, add_keys)
_write_json(
move_plan.journal_path(),
{
"state": "copying",
"from": plan.old.to_config(),
"to": plan.target,
"stores": [s.new_store for s in plan.sources],
# Stores that do not exist yet are made by this job only:
# after a crash everything in them goes (B4).
"created_dirs": [
s.new_store for s in plan.sources if not os.path.isdir(s.new_store)
],
"created_folder": state["created_folder"],
"install": plan.install,
},
)
job.log_line(
f"Move thumbnails started: {plan.old.path or 'inside each source'} → "
f"{plan.target['path'] or 'inside each source'}"
)
def _move_copy_one(self, pair, *, state):
"""Copy one preview (a pool thread); ``(group, detail)``."""
source, (relative, size, mtime_ns) = pair
destination = Path(source.new_store).joinpath(*relative)
try:
if not os.path.isdir(destination.parent):
base = Path(source.new_store)
if not base.is_dir():
if plan_parent_missing(base):
raise files.FolderGone("the new folder is gone")
created = []
files._make_folders(base.parent, (base.name,), created)
state["created"].extend(created)
created = []
files._make_folders(base, relative[:-1], created)
state["created"].extend(created)
outcome = files.copy_one(
Path(source.old_store).joinpath(*relative),
destination,
size,
mtime_ns,
state["created"],
)
except files.CopyFailed as error:
return "errors", str(error)
if outcome == "moved":
state["written"].append(str(destination))
return "moved", "Copied and checked"
return "skipped", "Already there"
async def _move_cancelled(self, job, plan, state):
if exit_signal.requested():
return # the next start removes the partial copies (journal)
await self._run_in_executor(self._move_rollback, plan, state)
self._end_move(job, "cancelled", "Move cancelled: nothing changed.")
def _move_rollback(self, plan, state):
"""Remove what this job wrote (files, folders it made, a folder and
marker it created) and the journal: nothing changed."""
for path in state["written"]:
files.remove_file(path)
for folder in reversed(state["created"]):
files.remove_empty_folders(folder)
if plan.to_folder and state.get("created_folder"):
folder = plan.target["path"]
try:
names = os.listdir(folder)
except OSError:
names = None
if names is not None and set(names) <= {preview_location.MARKER_NAME}:
_remove(preview_location.marker_path(folder))
files.remove_empty_folders(folder)
_remove(move_plan.journal_path())
def _move_switch(self, plan):
"""Phase 5: one config commit sets the location and records the old
stores under ``cleanup``; the runtime follows under the write lock."""
from ..config import PromptManagerConfig
from ..config_store import get_config_store
entry = {
"from": plan.old.mode,
"folder": plan.old.path if plan.from_folder else None,
"stores": [s.old_store for s in plan.sources if s.old_store],
"drop_keys": self._old_keys_to_drop(plan),
"install": plan.install,
}
# An older entry never removes a store the move just filled (a move
# back while old copies still waited).
filled = {os.path.normcase(s.new_store) for s in plan.sources}
cleanup = []
for old_entry in plan.old.cleanup:
stores = [
store
for store in old_entry.get("stores") or []
if isinstance(store, str) and os.path.normcase(store) not in filled
]
cleanup.append({**old_entry, "stores": stores})
cleanup.append(entry)
if plan.to_folder:
value = {
"mode": preview_store.FOLDER_MODE,
"path": plan.target["path"],
"id": plan.target_id,
"install": plan.install,
"cleanup": cleanup,
}
else:
value = {
"mode": preview_store.SOURCE_MODE,
"install": plan.install,
"cleanup": cleanup,
}
def mutate(config):
section = config.get("thumbnails")
if not isinstance(section, dict):
section = {}
section["location"] = value
config["thumbnails"] = section
return config
def publish(_config):
PromptManagerConfig.THUMBNAIL_LOCATION = value
preview_store.configure(value, check=False)
get_config_store().commit(mutate, expected_revision=None, publish=publish)
preview_store.refresh()
_remove(move_plan.journal_path())
def _old_keys_to_drop(self, plan):
if not plan.from_folder:
return []
keys = [s.old_store and os.path.basename(s.old_store) for s in plan.sources]
keys = [key for key in keys if key]
if plan.to_folder and os.path.normcase(plan.old.path or "") == os.path.normcase(
plan.target["path"] or ""
):
return []
return keys + [key for key, _store, _scan in plan.unlisted]
async def _remove_old_copies(self, job, plan):
"""Phase 6: run the cleanup the switch recorded (and earlier ones)."""
job.update(status_line="Removing old copies", progress=95)
job.publish({"type": "progress", "summary": job.summary()})
left = await self._run_in_executor(self._run_cleanup, job)
if left:
job.scope["cleanup_left"] = True
def _run_cleanup(self, job=None):
"""Remove every recorded old store's previews, temps and empty
folders; keep in ``cleanup`` what could not go. Returns how many
entries are left (blocking)."""
setting = preview_store.setting()
remaining = []
for entry in setting.cleanup:
if not self._cleanup_entry(entry, job):
remaining.append(entry)
if list(setting.cleanup) != remaining:
self._commit_cleanup(remaining)
return len(remaining)
def _cleanup_entry(self, entry, job):
"""One recorded removal; True when it is complete."""
folder = entry.get("folder")
stores = [
store for store in entry.get("stores") or [] if isinstance(store, str)
]
if folder:
if not os.path.isdir(folder):
return False # the old folder is not reachable: kept for later
folder_marker = preview_location.read_marker(folder) or {}
keys = folder_marker.get("keys") or {}
stores += [
os.path.join(folder, key)
for key in entry.get("drop_keys") or []
if preview_location.valid_key(key) and key in keys
]
complete = True
for store in dict.fromkeys(stores):
if not self._remove_store(store, folder, job):
complete = False
if folder and complete:
drop = set(entry.get("drop_keys") or [])
def forget(marker):
for key in drop:
marker["keys"].pop(key, None)
try:
with preview_store.write_lock:
marker = preview_location.write_marker(folder, forget)
except (OSError, ValueError):
return False
if not marker["keys"] and marker.get("created_by_pm"):
names = set(os.listdir(folder))
rest = {n for n in names if not files.is_os_metadata(n)}
if rest <= {preview_location.MARKER_NAME}:
_remove(preview_location.marker_path(folder))
files.remove_empty_folders(folder)
return complete
def _remove_store(self, store, folder, job):
"""Remove the previews and temps of one old store; True when done."""
store = Path(store)
if folder and not preview_location.valid_key(store.name):
return True
if not store.exists():
return True
if preview_location.is_link_like(store):
return True # never through a link; nothing of ours there
complete = True
removed = 0
for current, dirs, names in os.walk(store):
dirs[:] = [
d for d in dirs if not preview_location.is_link_like(Path(current) / d)
]
for name in names:
path = Path(current) / name
if files.is_temp_name(name) or files._preview_parts(name):
if preview_location.is_link_like(path):
continue
if files.remove_file(path):
removed += 1
else:
complete = False
if job is not None:
job.log_line(
"In use by another program; removed at the next "
f"start: {name}"
)
files.remove_empty_folders(store)
if job is not None and removed:
job.log_line(f"Removed {_number(removed)} old files from {store.name}")
return complete
def _commit_cleanup(self, remaining):
from ..config import PromptManagerConfig
from ..config_store import get_config_store
def mutate(config):
section = config.get("thumbnails")
if isinstance(section, dict) and isinstance(section.get("location"), dict):
section["location"]["cleanup"] = remaining
return config
def publish(config):
value = (config.get("thumbnails") or {}).get("location")
PromptManagerConfig.THUMBNAIL_LOCATION = value
preview_store.set_cleanup(remaining)
get_config_store().commit(mutate, expected_revision=None, publish=publish)
def plan_parent_missing(store):
"""A key folder's parent (the thumbnail folder) is gone mid-copy."""
return not Path(store).parent.is_dir()
-316
View File
@@ -1,316 +0,0 @@
"""The file side of Move thumbnails (#320 Part 3): what is copied, how a
copy is made and checked, and how old copies are removed.
No job, HTTP or config code lives here; ``thumbnail_move`` drives it.
Copy set (B5): each old store is walked with ``os.scandir``, never through a
symlink, junction or reparse point (counted as "skipped link"). A regular
file is
- copied when it has the preview grammar (``<name>_thumb<ext>``, any written
or legacy extension) and its original still exists in the source;
- removed with the old copies, not copied, when it is an orphan (no
original), lies under a trash folder, or is a temp sibling of a write
(``.<stem>.part-…``) or of a move (``….pm-move-<8 hex>.tmp``);
- left in place otherwise ("Left in place"). OS metadata files (``Thumbs.db``,
``desktop.ini``, ``.DS_Store``, ``._*``) are removed only when they alone
keep a preview folder from being removed.
A copy goes to ``<name>.pm-move-<8 hex>.tmp`` beside its destination, its
size is compared, its mtime is set to the source preview's rounded up to the
next 2-second step (FAT/exFAT keep it fresh, a stale preview stays stale),
then it is renamed into place. Every handle is closed before a rename or an
unlink (Windows).
"""
import errno
import os
import re
import secrets
import shutil
import stat as stat_module
import time
from dataclasses import dataclass, field
from pathlib import Path
from . import preview_location, thumbnail_settings
from .thumbnail_encode import _TEMP_NAME
try:
from ...utils.media_folders import in_trash_folder
except ImportError: # tests import the package top-level
from utils.media_folders import in_trash_folder
MOVE_TEMP = re.compile(r"\.pm-move-[0-9a-f]{8}\.tmp$")
MOVE_TEMP_SUFFIX = ".pm-move-{token}.tmp"
# The temp name adds this much to a destination path (".pm-move-xxxxxxxx.tmp").
TEMP_EXTRA = 21
WINDOWS_FILE_LIMIT = 259
MTIME_STEP_NS = 2_000_000_000
# A file another program holds open (Windows) is retried this often.
REMOVE_RETRIES = (0.5, 1.0, 2.0)
_WINDOWS_IN_USE = {32, 33} # ERROR_SHARING_VIOLATION, ERROR_LOCK_VIOLATION
def is_os_metadata(name):
folded = name.lower()
return folded in preview_location.OS_METADATA or folded.startswith("._")
def is_temp_name(name):
return bool(_TEMP_NAME.match(name) or MOVE_TEMP.search(name))
def _preview_parts(name):
"""``(base, extension)`` of a preview file name, or None."""
stem, extension = os.path.splitext(name)
suffix = thumbnail_settings.THUMB_SUFFIX
if not stem.endswith(suffix) or len(stem) <= len(suffix):
return None
if extension.lower() not in thumbnail_settings.THUMBNAIL_EXTENSIONS:
return None
return stem[: -len(suffix)], extension
def _has_original(root, rel_dir, base, extension):
"""Whether a media file in ``<root>/<rel_dir>`` owns this preview."""
folder = Path(root).joinpath(*rel_dir)
try:
with os.scandir(folder) as entries:
for entry in entries:
stem, source_ext = os.path.splitext(entry.name)
if stem != base or not source_ext:
continue
if not thumbnail_settings.owns(extension, source_ext):
continue
if entry.is_file(follow_symlinks=False):
return True
except OSError:
return False
return False
@dataclass
class StoreScan:
"""One old store's files, sorted into what the move does with them."""
store: Path
copy: list = field(default_factory=list) # (relative parts, size, mtime_ns)
orphans: list = field(default_factory=list) # relative parts
temps: list = field(default_factory=list)
left: list = field(default_factory=list)
links: int = 0
bytes: int = 0
@property
def removable(self):
return [item[0] for item in self.copy] + self.orphans + self.temps
def scan_store(store, root, *, stop=None):
"""Sort the files of *store* (the previews of the source *root*).
A store that does not exist, or is itself a link, scans empty (a link
counts once).
"""
store = Path(store)
scan = StoreScan(store)
try:
if preview_location.is_link_like(store):
scan.links += 1
return scan
if not store.is_dir():
return scan
except OSError:
return scan
_walk(scan, store, (), root, stop)
return scan
def _walk(scan, folder, rel_dir, root, stop):
try:
with os.scandir(folder) as iterator:
entries = list(iterator)
except OSError:
return
for entry in entries:
if stop is not None and stop():
return
relative = rel_dir + (entry.name,)
path = Path(entry.path)
try:
if preview_location.is_link_like(path):
scan.links += 1
continue
info = entry.stat(follow_symlinks=False)
except OSError:
continue
if stat_module.S_ISDIR(info.st_mode):
_walk(scan, path, relative, root, stop)
continue
if not stat_module.S_ISREG(info.st_mode):
scan.left.append(relative)
continue
if is_temp_name(entry.name):
scan.temps.append(relative)
continue
if is_os_metadata(entry.name):
continue # removed only when it alone keeps a folder (rmdir)
parts = _preview_parts(entry.name)
if parts is None:
scan.left.append(relative)
continue
if in_trash_folder("/".join(relative)) or not _has_original(
root, rel_dir, *parts
):
scan.orphans.append(relative)
continue
scan.copy.append((relative, info.st_size, info.st_mtime_ns))
scan.bytes += info.st_size
# ── copying ──────────────────────────────────────────────────────────
def rounded_mtime_ns(mtime_ns):
"""*mtime_ns* rounded up to the next 2-second step (FAT/exFAT)."""
return -(-mtime_ns // MTIME_STEP_NS) * MTIME_STEP_NS
def too_long(path, long_paths=None):
"""A destination (with its temp name) over Windows' file limit."""
if os.name != "nt" and long_paths is not False:
return False
if long_paths is None:
long_paths = preview_location.long_paths_enabled()
if long_paths:
return False
return len(str(path)) + TEMP_EXTRA > WINDOWS_FILE_LIMIT
class CopyFailed(Exception):
"""One preview could not be copied (a per-file error, D10A)."""
class FolderGone(Exception):
"""The destination cannot take writes any more (abort the move)."""
def _make_folders(base, relative_dir, created):
"""Make ``base/relative_dir`` one folder at a time (*base* must exist);
every folder made is added to *created*."""
current = Path(base)
for part in relative_dir:
current = current / part
try:
os.mkdir(current)
created.append(str(current))
except FileExistsError:
pass
if preview_location.is_link_like(current):
raise CopyFailed("a link in the destination")
def copy_one(source, destination, size, mtime_ns, created, *, long_paths=None):
"""Copy one preview; returns ``"moved"`` or ``"skipped"`` (already
there). Raises CopyFailed (this file) or FolderGone (the destination)."""
destination = Path(destination)
if too_long(destination, long_paths):
raise CopyFailed("Path too long for Windows; the original is shown")
wanted = rounded_mtime_ns(mtime_ns)
try:
existing = os.stat(destination, follow_symlinks=False)
except FileNotFoundError:
existing = None
except OSError as error:
raise FolderGone(str(error)) from error
if (
existing is not None
and stat_module.S_ISREG(existing.st_mode)
and existing.st_size == size
and existing.st_mtime_ns == wanted
):
return "skipped"
temp = destination.with_name(
destination.name + MOVE_TEMP_SUFFIX.format(token=secrets.token_hex(4))
)
try:
shutil.copyfile(source, temp)
if os.stat(temp).st_size != size:
raise CopyFailed("The copy did not match; made again on view")
os.utime(temp, ns=(wanted, wanted))
os.replace(temp, destination)
except CopyFailed:
_unlink(temp)
raise
except FileNotFoundError as error:
_unlink(temp)
if not Path(source).exists():
raise CopyFailed("Could not be read; made again on view") from error
raise FolderGone(str(error)) from error
except OSError as error:
_unlink(temp)
if error.errno in (errno.ENOSPC, errno.EDQUOT, errno.EROFS):
raise FolderGone(str(error)) from error
raise CopyFailed("Could not be copied; made again on view") from error
return "moved"
def _unlink(path):
try:
os.unlink(path)
except OSError:
pass
# ── removing ─────────────────────────────────────────────────────────
def _in_use(error):
return getattr(error, "winerror", None) in _WINDOWS_IN_USE
def remove_file(path, *, sleep=time.sleep):
"""Remove one file; a file another program holds open (Windows) is
retried 3 times. Returns True when it is gone."""
for wait in (0.0,) + REMOVE_RETRIES:
if wait:
sleep(wait)
try:
os.unlink(path)
return True
except FileNotFoundError:
return True
except OSError as error:
if not _in_use(error):
return False
return False
def remove_empty_folders(top, *, keep_top=False):
"""``rmdir`` the empty folders below *top* (and *top*), bottom-up, never
through a link. A folder holding only OS metadata files loses them first.
Returns the folders that could not be removed."""
top = Path(top)
left = []
if not top.is_dir() or preview_location.is_link_like(top):
return left
for folder, dirs, _files in os.walk(top, topdown=False):
folder = Path(folder)
if folder == top and keep_top:
continue
if preview_location.is_link_like(folder):
continue
try:
names = os.listdir(folder)
except OSError:
left.append(folder)
continue
if names and all(is_os_metadata(name) for name in names):
for name in names:
remove_file(folder / name)
try:
os.rmdir(folder)
except OSError:
left.append(folder)
return left
-490
View File
@@ -1,490 +0,0 @@
"""Move thumbnails, the review (#320 Part 3 phase 1): what a move would copy,
remove and leave, checked before anything is written."""
import hashlib
import json
import os
import secrets
import shutil
import time
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from aiohttp import web
from . import preview_location, preview_store
from . import thumbnail_move_files as files
from .job_routes import busy_response
from .jobs import THUMBNAILS_JOB_KIND, JobBusy
from .thumbnail_location import default_path
try:
from ...utils import library_location
except ImportError: # tests import the package top-level
from utils import library_location
MOVE_TITLE = "Move thumbnails"
FINISHING_TITLE = "Move thumbnails (finishing)"
PRUNE_TITLE = "Remove thumbnails of removed sources"
MOVE_GROUPS = ("moved", "skipped", "left", "errors")
PRUNE_GROUPS = ("removed", "left", "errors")
PLAN_TTL = 600.0
JOURNAL_NAME = "thumbnail-move.json"
SPACE_MARGIN = 64 * 2**20
SPACE_SHARE = 0.05
LISTED = 5
SENTENCE = (
"Every thumbnail is copied and checked before Prompt Manager switches. "
"Scans, deletes and tagging wait until it finishes; closing this page does "
"not stop it."
)
def _number(value):
return f"{value:,}"
def _plural(count, one, many=None):
return f"{_number(count)} {one if count == 1 else (many or one + 's')}"
def _size(value):
size = float(value)
for unit in ("B", "KB", "MB", "GB"):
if size < 1024 or unit == "GB":
return f"{size:.1f} {unit}" if unit != "B" else f"{int(size)} B"
size /= 1024
return f"{size:.1f} GB"
def _refusal(code, status=None, **extra):
known_status, text = preview_location.MESSAGES.get(code, (400, code))
return web.json_response(
{"success": False, "code": code, "error": text, **extra},
status=status or known_status,
)
def journal_path():
"""``<data folder>/data/thumbnail-move.json`` (only while a move copies)."""
return library_location.cache_dir(JOURNAL_NAME)
def _read_json(path):
try:
with open(path, "r", encoding="utf-8") as handle:
data = json.load(handle)
except (OSError, ValueError):
return None
return data if isinstance(data, dict) else None
def _write_json(path, data):
os.makedirs(os.path.dirname(path), exist_ok=True)
temp = f"{path}.{os.getpid()}.tmp"
with open(temp, "w", encoding="utf-8") as handle:
json.dump(data, handle, indent=2)
handle.flush()
os.fsync(handle.fileno())
os.replace(temp, path)
def _remove(path):
try:
os.unlink(path)
except FileNotFoundError:
pass
def _is_network(path):
text = str(path)
if text.startswith(("\\\\", "//")):
return True
if os.name == "nt":
try:
import ctypes
drive = os.path.splitdrive(os.path.abspath(text))[0] + "\\"
return ctypes.windll.kernel32.GetDriveTypeW(drive) == 4 # DRIVE_REMOTE
except (AttributeError, OSError):
return False
return False
def _volume(path):
current = Path(os.path.abspath(path))
while not current.exists() and current.parent != current:
current = current.parent
try:
return os.stat(current).st_dev
except OSError:
return None
def _free(path):
current = Path(os.path.abspath(path))
while not current.exists() and current.parent != current:
current = current.parent
try:
return shutil.disk_usage(current).free
except OSError:
return None
# ── the plan ─────────────────────────────────────────────────────────
@dataclass
class SourcePlan:
"""One listed source: its old store, its new store and what moves."""
root: str
label: str
key: str | None
old_store: str | None
new_store: str
scan: files.StoreScan | None
available: bool = True
too_long: list = field(default_factory=list)
@dataclass
class MovePlan:
"""A reviewed move: from, to, the sources and the removals."""
token: str
created: float
revision: str
old: preview_location.ParsedLocation
target: dict # {"mode", "path"}
target_id: str | None
target_exists: bool
target_default: bool
install: str
sources: list
unlisted: list # (key, store path, scan)
needs_bytes: int
confirm_required: bool
@property
def to_folder(self):
return self.target["mode"] == preview_store.FOLDER_MODE
@property
def from_folder(self):
return self.old.mode == preview_store.FOLDER_MODE
def _roots_revision(roots):
text = json.dumps(sorted(str(root) for root in roots))
return hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]
class ThumbnailMovePlanMixin:
"""The move's review and its routes."""
_move_plans = None
# ── routes ────────────────────────────────────────────────────────
def _register_thumbnail_move_routes(self, routes):
@routes.post("/prompt_manager/images/thumbnails/plan")
async def thumbnail_move_plan_route(request):
return await self.plan_thumbnail_move(request)
@routes.post("/prompt_manager/images/thumbnails/location/recreate")
async def thumbnail_location_recreate_route(request):
return await self.recreate_thumbnail_folder(request)
@routes.post("/prompt_manager/images/thumbnails/location/adopt")
async def thumbnail_location_adopt_route(request):
return await self.adopt_thumbnail_folder(request)
# ── review ────────────────────────────────────────────────────────
async def plan_thumbnail_move(self, request):
try:
body = await request.json()
except (ValueError, UnicodeDecodeError):
body = None
if not isinstance(body, dict) or body.get("mode") != "move":
return web.json_response(
{
"success": False,
"code": "invalid-request",
"error": "mode must be move",
},
status=400,
)
target = body.get("location")
if not isinstance(target, dict) or target.get("mode") not in (
preview_store.SOURCE_MODE,
preview_store.FOLDER_MODE,
):
return web.json_response(
{
"success": False,
"code": "invalid-request",
"error": "location must name a mode",
},
status=400,
)
blocker = self._jobs.blocker(THUMBNAILS_JOB_KIND, admission="guarded")
if blocker is not None:
return busy_response(JobBusy(blocker))
try:
outcome = await self._run_in_executor(self._build_move_plan, target)
except Exception:
self.logger.exception("The thumbnail move could not be planned")
return web.json_response(
{
"success": False,
"code": None,
"error": "The move could not be planned",
},
status=500,
)
if isinstance(outcome, str):
return _refusal(outcome)
plan, payload = outcome
if payload.get("insufficient"):
return _refusal(
"insufficient-space",
status=507,
error_detail=payload["insufficient"],
plan=payload,
)
self._store_move_plan(plan)
return web.json_response({"success": True, **payload})
def _store_move_plan(self, plan):
if self._move_plans is None:
self._move_plans = {}
now = time.monotonic()
self._move_plans = {
token: kept
for token, kept in self._move_plans.items()
if now - kept.created < PLAN_TTL
}
self._move_plans[plan.token] = plan
def _build_move_plan(self, target):
"""``(plan, payload)`` or a refusal code (blocking)."""
old = preview_store.setting()
location = preview_store.current()
install = old.install or str(uuid.uuid4())
target_path = None
default = default_path()
if target["mode"] == preview_store.FOLDER_MODE:
raw = target.get("path")
if not isinstance(raw, str):
return "location-not-absolute"
context = self._thumbnail_location_context()
code = preview_location.location_problem(
raw, purpose="move", context=context
)
if code is not None:
return code
target_path = os.path.abspath(raw)
elif old.mode == preview_store.SOURCE_MODE and not old.problem:
return "location-same"
target_default = bool(default) and os.path.normcase(
target_path or ""
) == os.path.normcase(os.path.abspath(default))
target_exists = bool(target_path) and os.path.isdir(target_path)
target_marker = (
preview_location.read_marker(target_path) if target_exists else None
)
roots = [str(root) for root in self._get_all_output_dirs()]
labels = self._source_labels(roots)
old_keys = {}
if old.mode == preview_store.FOLDER_MODE and location.available:
marker = preview_location.read_marker(old.path) or {}
old_keys = marker.get("keys") or {}
new_keys = dict((target_marker or {}).get("keys") or {})
taken = {key: entry.get("identity") for key, entry in new_keys.items()}
sources = []
for root in roots:
identity = os.path.normcase(os.path.realpath(root))
dev_ino = preview_location.file_identity(root)
old_key = None
old_store = None
available = True
if old.mode == preview_store.SOURCE_MODE:
old_store = str(preview_store.source_folder(root))
elif location.available:
old_key = preview_location.find_key(old_keys, root, identity, dev_ino)
if old_key:
old_store = os.path.join(old.path, old_key)
else:
available = False
key = None
if target_path:
key = old_key or preview_location.find_key(
new_keys, root, identity, dev_ino
)
if key is None or taken.get(key, identity) != identity:
key = preview_location.source_key(root, identity, taken)
taken[key] = identity
new_store = os.path.join(target_path, key)
else:
new_store = str(preview_store.source_folder(root))
scan = None
if old_store:
scan = files.scan_store(old_store, root)
plan = SourcePlan(
root, labels.get(root, root), key, old_store, new_store, scan, available
)
if scan is not None and target_path:
plan.too_long = [
item[0]
for item in scan.copy
if files.too_long(Path(new_store).joinpath(*item[0]))
]
sources.append(plan)
unlisted = self._unlisted_keys(old, location, old_keys, install)
needs = sum(source.scan.bytes for source in sources if source.scan is not None)
plan = MovePlan(
token=secrets.token_urlsafe(16),
created=time.monotonic(),
revision=_roots_revision(roots),
old=old,
target={"mode": target["mode"], "path": target_path},
target_id=(target_marker or {}).get("id"),
target_exists=target_exists,
target_default=target_default,
install=install,
sources=sources,
unlisted=unlisted,
needs_bytes=needs,
confirm_required=bool(target_path) and not target_default,
)
return plan, self._plan_payload(plan, location)
def _unlisted_keys(self, old, location, keys, install):
"""This install's keys whose source no longer is in any root list
(``(key, store, scan)``), in the folder in use."""
if old.mode != preview_store.FOLDER_MODE or not location.available:
return []
listed = {
os.path.normcase(os.path.realpath(root))
for root in self._all_source_roots()
}
found = []
for key, entry in sorted(keys.items()):
if entry.get("install") not in (install, None):
continue
if entry.get("identity") in listed:
continue
store = os.path.join(old.path, key)
found.append(
(key, store, files.scan_store(store, entry.get("path") or store))
)
return found
def _source_labels(self, roots):
from . import sources
labels = sources._comfyui_labels()
return {root: sources.source_label(root, labels) for root in roots}
def _plan_payload(self, plan, location):
sources = plan.sources
copies = sum(len(s.scan.copy) for s in sources if s.scan)
orphans = sum(len(s.scan.orphans) for s in sources if s.scan)
temps = sum(len(s.scan.temps) for s in sources if s.scan)
links = sum(s.scan.links for s in sources if s.scan)
left = [
f"{s.label} · {'/'.join(item)}"
for s in sources
if s.scan
for item in s.scan.left
]
too_long = [
f"{s.label} · {'/'.join(item)}" for s in sources for item in s.too_long
]
unlisted_count = sum(
len(scan.copy) + len(scan.orphans) for _k, _s, scan in plan.unlisted
)
free = None
insufficient = None
if plan.needs_bytes:
volumes = {}
for source in sources:
if source.scan is None or not source.scan.bytes:
continue
volume = _volume(source.new_store)
entry = volumes.setdefault(volume, [source.new_store, 0])
entry[1] += source.scan.bytes
for where, needed in volumes.values():
available = _free(where)
if available is None:
continue
free = available if free is None else min(free, available)
margin = max(SPACE_MARGIN, int(needed * SPACE_SHARE))
if needed + margin > available:
insufficient = {
"needed": needed + margin,
"free": available,
"text": (
f"There is not enough space for the thumbnails: "
f"{_size(available)} free, {_size(needed + margin)} "
"needed. Free some space or choose another folder."
),
}
payload = {
"mode": "move",
"from": {
"mode": plan.old.mode,
"path": plan.old.path,
"available": location.available,
},
"to": {
"mode": plan.target["mode"],
"path": plan.target["path"],
"exists": plan.target_exists,
"will_create": bool(plan.target["path"]) and not plan.target_exists,
"default": plan.target_default,
},
"sources": [
{
"label": s.label,
"key": s.key,
"files": len(s.scan.copy) if s.scan else 0,
"bytes": s.scan.bytes if s.scan else 0,
"too_long": len(s.too_long),
"links": s.scan.links if s.scan else 0,
"orphans": len(s.scan.orphans) if s.scan else 0,
"left_in_place": len(s.scan.left) if s.scan else 0,
"available": s.available,
}
for s in sources
],
"totals": {
"sources": len(sources),
"files": copies,
"bytes": plan.needs_bytes,
"orphans": orphans,
"temps": temps,
"links": links,
"left_in_place": len(left),
"too_long": len(too_long),
"unlisted_sources": len(plan.unlisted),
"unlisted_files": unlisted_count,
},
"too_long_listed": too_long[:LISTED],
"left_listed": left[:LISTED],
"unlisted": [
{"key": key, "files": len(scan.copy) + len(scan.orphans)}
for key, _store, scan in plan.unlisted
],
"needs_bytes": plan.needs_bytes,
"free_bytes": free,
"confirm_required": plan.confirm_required,
"token": plan.token,
"sentence": SENTENCE,
}
if insufficient:
payload["insufficient"] = insufficient
return payload
-246
View File
@@ -1,246 +0,0 @@
"""Move thumbnails after a restart and the unavailable folder's actions
(#320 Parts 3 and 4): undo an interrupted copy, finish a removal that
waited, Recreate folder and Use this folder."""
import os
import threading
from pathlib import Path
from aiohttp import web
from . import preview_location, preview_store
from . import thumbnail_move_files as files
from .job_routes import busy_response
from .jobs import THUMBNAILS_JOB_KIND, JobBusy
from .thumbnail_move_plan import (
FINISHING_TITLE,
MOVE_GROUPS,
_read_json,
_refusal,
_remove,
)
from . import thumbnail_move_plan as move_plan
class ThumbnailMoveRecoveryMixin:
"""Startup recovery, the finishing job, Recreate and Use this folder."""
_move_recover_lock = threading.Lock()
# Per API: set once recover_thumbnail_move has run (a move waits for it).
_move_recovered = False
# ── startup ───────────────────────────────────────────────────────
def recover_thumbnail_move(self):
"""Startup (B4): undo an interrupted copy, or forget a journal the
switch already made true. Idempotent; the move start waits for it."""
with self._move_recover_lock:
if self._move_recovered:
return
try:
self._recover_journal()
except Exception:
self.logger.warning(
"The interrupted thumbnail move check failed", exc_info=True
)
self._move_recovered = True
def _recover_journal(self):
path = move_plan.journal_path()
journal = _read_json(path)
if journal is None:
return
setting = preview_store.setting()
target = journal.get("to") or {}
now = setting.path if setting.mode == preview_store.FOLDER_MODE else None
if setting.mode == target.get("mode") and (
now is None
or os.path.normcase(now) == os.path.normcase(target.get("path") or "")
):
_remove(path) # the switch happened; only the journal was left
return
for store in journal.get("stores") or []:
if not isinstance(store, str) or not os.path.isdir(store):
continue
for current, dirs, names in os.walk(store):
dirs[:] = [
d
for d in dirs
if not preview_location.is_link_like(Path(current) / d)
]
for name in names:
if files.MOVE_TEMP.search(name):
files.remove_file(Path(current) / name)
for folder in reversed(journal.get("created_dirs") or []):
if not isinstance(folder, str) or not os.path.isdir(folder):
continue
for current, dirs, names in os.walk(folder):
dirs[:] = [
d
for d in dirs
if not preview_location.is_link_like(Path(current) / d)
]
for name in names:
if files._preview_parts(name) or files.is_temp_name(name):
files.remove_file(Path(current) / name)
files.remove_empty_folders(folder)
folder = target.get("path")
if journal.get("created_folder") and folder and os.path.isdir(folder):
names = set(os.listdir(folder))
if names <= {preview_location.MARKER_NAME}:
_remove(preview_location.marker_path(folder))
files.remove_empty_folders(folder)
_remove(path)
self.logger.info("An interrupted thumbnail move was undone; nothing changed")
def _cleanup_reachable(self, entry):
folder = entry.get("folder")
if folder:
return os.path.isdir(folder)
return True
async def finish_thumbnail_move(self, _app=None):
"""Startup: recover, then start "Move thumbnails (finishing)" when old
copies wait and every one is reachable (on the server's loop)."""
await self._run_in_executor(self.recover_thumbnail_move)
cleanup = list(preview_store.setting().cleanup)
if not cleanup:
return None
reachable = await self._run_in_executor(
lambda: all(self._cleanup_reachable(entry) for entry in cleanup)
)
if not reachable:
return None
try:
return self._jobs.start(
THUMBNAILS_JOB_KIND,
FINISHING_TITLE,
admission="guarded",
cancellable=False,
scope={"mode": "move", "phase": "remove"},
groups=MOVE_GROUPS,
runner=self._run_finishing,
)
except JobBusy:
return None
async def _run_finishing(self, job):
await self._remove_old_copies(job, None)
left = len(preview_store.setting().cleanup)
self._end_move(
job,
"finished",
(
"Old thumbnails removed."
if not left
else (
"Some old thumbnails are still in use; they are removed at "
"the next start."
)
),
)
# ── Recreate and Use this folder (Part 4) ─────────────────────────
async def recreate_thumbnail_folder(self, _request):
blocker = self._jobs.blocker(THUMBNAILS_JOB_KIND, admission="guarded")
if blocker is not None:
return busy_response(JobBusy(blocker))
code = await self._run_in_executor(self._recreate_folder)
if code is not None:
status = (
409 if code in ("location-has-marker", "location-not-empty") else 400
)
if code == "location-has-marker":
return web.json_response(
{
"success": False,
"code": code,
"error": "The folder is back with its thumbnails. Nothing "
"was recreated.",
},
status=status,
)
return _refusal(code, status=status)
return web.json_response(
{
"success": True,
"location": self._thumbnail_location_payload(with_path=True),
}
)
def _recreate_folder(self):
setting = preview_store.setting()
if setting.mode != preview_store.FOLDER_MODE or not setting.path:
return "location-same"
folder = setting.path
if os.path.isdir(folder):
if preview_location.read_marker(folder) is not None:
return "location-has-marker"
if not preview_location.holds_only_metadata(folder):
return "location-not-empty"
elif not os.path.isdir(os.path.dirname(folder)):
return "location-parent-missing"
else:
try:
os.mkdir(folder)
except OSError:
return "location-not-writable"
try:
marker_id = preview_location.init_folder(folder, created_by_pm=True)
except OSError:
return "location-not-writable"
return self._commit_location_id(marker_id)
async def adopt_thumbnail_folder(self, _request):
blocker = self._jobs.blocker(THUMBNAILS_JOB_KIND, admission="guarded")
if blocker is not None:
return busy_response(JobBusy(blocker))
code = await self._run_in_executor(self._adopt_folder)
if code is not None:
return _refusal(code)
return web.json_response(
{
"success": True,
"location": self._thumbnail_location_payload(with_path=True),
}
)
def _adopt_folder(self):
setting = preview_store.setting()
if setting.mode != preview_store.FOLDER_MODE or not setting.path:
return "location-same"
try:
marker = preview_location.read_marker(setting.path)
except OSError:
return "location-unavailable"
if marker is None:
return "location-unavailable"
return self._commit_location_id(marker["id"])
def _commit_location_id(self, marker_id):
from ..config import PromptManagerConfig
from ..config_store import get_config_store
value = dict(preview_store.setting().to_config())
value["id"] = marker_id
def mutate(config):
section = config.get("thumbnails")
if not isinstance(section, dict):
section = {}
section["location"] = value
config["thumbnails"] = section
return config
def publish(_config):
PromptManagerConfig.THUMBNAIL_LOCATION = value
preview_store.configure(value, check=False)
try:
get_config_store().commit(mutate, expected_revision=None, publish=publish)
except Exception:
self.logger.exception("The thumbnail folder could not be saved")
return "location-not-writable"
preview_store.refresh()
return None
-175
View File
@@ -1,175 +0,0 @@
"""Remove thumbnails of removed sources (#320 Part 5, D200): a Thumbnail
maintenance scope, offered only in folder mode (31A).
*Removed* means a key of this install in the thumbnail folder's marker whose
identity matches no root in any root list. The job removes the
preview-grammar files and temps of those key folders, ``rmdir``s the empty
folders, drops the keys from the marker and lists anything else as "Left in
place". Kind ``thumbnails``, ``mode: "prune"``.
"""
import functools
import os
from pathlib import Path
from aiohttp import web
from . import preview_location, preview_store
from . import thumbnail_move_files as files
from .jobs import THUMBNAILS_JOB_KIND
from .thumbnail_location import location_refusal
from .thumbnail_move_plan import _number, _plural, _size
PRUNE_TITLE = "Remove thumbnails of removed sources"
PRUNE_GROUPS = ("removed", "left", "errors")
def _prune_line(count, files_count, size):
if not count:
return "No sources were removed: nothing to remove."
return (
f"{_plural(count, 'source')} no longer listed: "
f"{_plural(files_count, 'thumbnail')} ({_size(size)}) are removed."
)
class ThumbnailPruneMixin:
"""The prune plan and job."""
def _prune_targets(self):
"""``[(key, store, scan)]`` of this install's unlisted keys (blocking)."""
setting = preview_store.setting()
location = preview_store.current()
if setting.mode != preview_store.FOLDER_MODE or not location.available:
return []
marker = preview_location.read_marker(setting.path) or {}
install = setting.install
return self._unlisted_keys(setting, location, marker.get("keys") or {}, install)
def _prune_payload(self, targets):
files_count = sum(
len(scan.copy) + len(scan.orphans) for _k, _s, scan in targets
)
size = sum(scan.bytes for _k, _s, scan in targets)
return {
"success": True,
"mode": "prune",
"sources": [
{
"key": key,
"files": len(scan.copy) + len(scan.orphans),
"bytes": scan.bytes,
"left_in_place": len(scan.left),
}
for key, _store, scan in targets
],
"targets": files_count,
"bytes": size,
"line": _prune_line(len(targets), files_count, size),
"folder": preview_store.setting().path,
}
async def get_prune_plan(self):
refused = location_refusal()
if refused is not None:
return refused
if preview_store.current().mode != preview_store.FOLDER_MODE:
return web.json_response(
{
"success": False,
"code": "invalid-request",
"error": "Thumbnails are kept inside each source: no removed "
"source has thumbnails here.",
},
status=400,
)
targets = await self._run_in_executor(self._prune_targets)
return web.json_response(
self._prune_payload(targets), headers={"Cache-Control": "no-store"}
)
def start_thumbnail_prune(self):
"""Start the prune job (sync, on the loop); raises JobBusy."""
return self._jobs.start(
THUMBNAILS_JOB_KIND,
PRUNE_TITLE,
admission="guarded",
cancellable=True,
scope={"mode": "prune"},
groups=PRUNE_GROUPS,
runner=self._run_thumbnail_prune,
)
async def _run_thumbnail_prune(self, job):
targets = await self._run_in_executor(self._prune_targets)
job.update(total=len(targets), processed=0)
setting = preview_store.setting()
for done, (key, store, scan) in enumerate(targets, 1):
if job.cancelled:
break
removed, failed = await self._run_in_executor(
functools.partial(self._prune_store, store)
)
job.record_counted(
"removed", removed, [{"path": key, "detail": "Thumbnails removed"}]
)
if failed:
job.record_counted("errors", failed)
if scan.left:
job.record_counted(
"left",
len(scan.left),
[{"path": f"{key}/{'/'.join(item)}"} for item in scan.left],
)
await self._run_in_executor(
functools.partial(self._forget_key, setting.path, key, store)
)
job.update(
processed=done,
progress=int(done * 100 / len(targets)),
status_line=f"Removing thumbnails of {_number(done)} of "
f"{_number(len(targets))} removed sources",
)
job.publish({"type": "progress", "summary": job.summary()})
line = f"{_number(job.counters.get('removed', 0))} thumbnails removed"
if job.counters.get("errors"):
line += f" · {_plural(job.counters['errors'], 'error')}"
job.update(status_line=line, progress=100)
job.log_line(line)
job.finish("cancelled" if job.cancelled else "finished")
@staticmethod
def _prune_store(store):
"""Remove the previews and temps of one key folder; ``(removed, failed)``."""
removed = failed = 0
store = Path(store)
if not store.is_dir() or preview_location.is_link_like(store):
return 0, 0
for current, dirs, names in os.walk(store):
dirs[:] = [
d for d in dirs if not preview_location.is_link_like(Path(current) / d)
]
for name in names:
path = Path(current) / name
if not (files.is_temp_name(name) or files._preview_parts(name)):
continue
if preview_location.is_link_like(path):
continue
if files.remove_file(path):
removed += 1
else:
failed += 1
files.remove_empty_folders(store)
return removed, failed
@staticmethod
def _forget_key(folder, key, store):
"""Drop *key* from the marker once its folder is gone."""
if os.path.exists(store):
return
def forget(marker):
marker["keys"].pop(key, None)
with preview_store.write_lock:
preview_location.write_marker(folder, forget)
+4 -22
View File
@@ -114,16 +114,9 @@ def validate_edge(value):
# ── names ────────────────────────────────────────────────────────────
def preview_stem(rel_path):
"""``<rel without extension>_thumb`` (POSIX): a preview's name inside the
store of its source (``preview_store``)."""
return f"{Path(rel_path).with_suffix('').as_posix()}{THUMB_SUFFIX}"
def thumbnail_stem_rel(rel_path):
"""``thumbnails/<rel without extension>_thumb`` (POSIX): URL text only.
Where the preview is on disk is ``preview_store``'s answer."""
return f"thumbnails/{preview_stem(rel_path)}"
"""``thumbnails/<rel without extension>_thumb`` (POSIX)."""
return f"thumbnails/{Path(rel_path).with_suffix('').as_posix()}{THUMB_SUFFIX}"
def thumbnail_rel(rel_path, extension=None):
@@ -214,20 +207,9 @@ def owned_in_folder(folder, stem, source_extension, preferred=None):
def _owned(root, rel_path, preferred):
from . import preview_store
rel_path = Path(rel_path)
store = preview_store.store_dir(root)
if store is None:
return iter(())
stem = store / preview_stem(rel_path)
found = owned_in_folder(stem.parent, stem.name, rel_path.suffix, preferred)
if preview_store.current().mode == preview_store.SOURCE_MODE:
return found
# Folder mode: timed, so a stalled share marks the folder unreachable,
# and only previews no link takes out of the key folder (deletes use it).
with preview_store.store_io():
return iter([path for path in found if preview_store.may_write(path)])
stem = Path(root) / thumbnail_stem_rel(rel_path)
return owned_in_folder(stem.parent, stem.name, rel_path.suffix, preferred)
def owned_thumbnails(root, rel_path, preferred=None):
-1
View File
@@ -275,4 +275,3 @@ class WorkflowStorageRoutesMixin:
job.record_counted("changed", 1)
job.log_line(line)
job.update(progress=100, status_line=line)
return None
-13
View File
@@ -767,8 +767,6 @@ class PromptManagerConfig:
THUMBNAIL_QUALITY = 80
THUMBNAIL_EDGE = 512
THUMBNAIL_EDGE_RANGE = (128, 2048)
# #320 thumbnail location (config.json ``thumbnails.location``), as read.
THUMBNAIL_LOCATION = None
@classmethod
def max_worker_threads(cls) -> int:
@@ -830,12 +828,6 @@ class PromptManagerConfig:
"format": cls.THUMBNAIL_FORMAT,
"quality": cls.THUMBNAIL_QUALITY,
"edge": cls.THUMBNAIL_EDGE,
# #320: as read; only the Move thumbnails job writes it.
**(
{"location": cls.THUMBNAIL_LOCATION}
if cls.THUMBNAIL_LOCATION is not None
else {}
),
},
"gallery": GalleryConfig.get_config(),
"integrations": IntegrationConfig.get_config(),
@@ -1010,11 +1002,6 @@ class PromptManagerConfig:
low, high = cls.THUMBNAIL_EDGE_RANGE
if type(edge) is int and low <= edge <= high:
cls.THUMBNAIL_EDGE = edge
# #320: kept as read (``preview_location.parse_location`` checks it;
# a bad value becomes an unavailable ``invalid`` location, never a
# silent fall-back to the sources).
if "location" in section:
cls.THUMBNAIL_LOCATION = section["location"]
# Load configuration on import
-1
View File
@@ -109,7 +109,6 @@ def _write_atomically(path: str, payload: bytes) -> None:
try:
os.unlink(temp_path)
except OSError:
# nothing to clean up; the original error is raised below
pass
raise
-1
View File
@@ -642,7 +642,6 @@ def layout_summary(layout: LoraManagerLayout, home: Path) -> Dict:
relative = Path(settings_file).relative_to(home)
settings_file = os.path.join("~", str(relative))
except ValueError:
# outside the home folder: show the full path
pass
return {
"source": layout.source,
-2
View File
@@ -585,7 +585,6 @@ def _read_capped(resp, cap: int) -> Optional[bytes]:
if int(declared) > cap:
return None
except ValueError:
# a bad header says nothing; the read below enforces the cap
pass
chunks = []
@@ -708,7 +707,6 @@ def _save_atomically(image, local_path: Path) -> None:
try:
os.remove(temp)
except OSError:
# nothing to clean up; the original error is raised below
pass
raise
+1 -1
View File
@@ -1,7 +1,7 @@
[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.1"
version = "3.3.1"
license = {file = "LICENSE"}
requires-python = ">=3.12"
dependencies = ["watchdog>=2.1.0", "Pillow>=8.0.0"]
+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 # Output directory monitoring
Pillow>=8.0.0 # Image metadata extraction and thumbnails
+1
View File
@@ -6,6 +6,7 @@ import { THEMES, seedConcept, openConcept, openProduction, diffInventory, mutate
// app.js 1428–1432) and its backup box with the concept's System `.notice`; the differences are D55. The job view
// has no concept counterpart: it is compared with the approved mockup `activity-scan.html` (a local, ignored file),
// whose structure and CSS values are restated below (D56). Each comparison is shown to notice a mutation.
const workspace = '/prompt_manager/workspace';
test.beforeEach(async ({ page, request, browserName }) => {
await startCoverage(page, browserName);
-41
View File
@@ -320,44 +320,3 @@ test('copy toast and fallback dialog',async({page})=>{
await expect(fallback).toContainText('Automatic clipboard access requires a secure browser context.');
await page.keyboard.press('Escape');await expect(fallback).toBeHidden();await expect(copy).toBeFocused();
});
// #542 D191: "All N eligible nodes" previews every node, sends one after another and names each refusal.
test('All eligible nodes previews each node, sends in node id order and names a refusal',async({page,context},testInfo)=>{
const host=await context.newPage();await host.goto('/prompt_manager/workspace#/overview');
await host.evaluate(async()=>{
const {createBridgeHost}=await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');
const targets=['12','3','7'].map(id=>({id,label:id==='7'?'Edited in ComfyUI':'Prompt Manager (+/-)',type:'PromptManagerPair',roles:['positive','negative']}));
window.hostValues=Object.fromEntries(targets.map(target=>[target.id,{positive:`Existing ${target.id}`,negative:'Existing negative'}]));window.applied=[];
window.fixtureHost=createBridgeHost({origin:location.origin,namespace:'/',label:'Test ComfyUI',adapter:{
listTargets:()=>targets,
preview:(id,roles)=>{const target=targets.find(item=>item.id===id);return{preview:{target,fields:Object.entries(roles).map(([role,after])=>({role,before:window.hostValues[id][role],after}))},snapshot:{id,roles}};},
apply:({id,roles})=>{if(id==='7')throw new Error('Workflow or prompt changed. Preview again.');Object.assign(window.hostValues[id],roles);window.applied.push(id);return{targetId:id,values:roles};},
}});
});
try {
await page.goto(workspace);await select(page,'0:shared/same.png');await details(page).getByRole('button',{name:/^Send prompt pair to ComfyUI: /}).click();
const sender=dialog(page);const nodes=sender.getByRole('combobox',{name:'Destination node',exact:true});
await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'Test ComfyUI'});
await expect(nodes.locator('option')).toHaveText(['Choose a destination node','#12 Prompt Manager (+/-)','#3 Prompt Manager (+/-)','#7 Edited in ComfyUI','All 3 eligible nodes']);
await expect(nodes).toHaveValue('');
await nodes.selectOption({label:'All 3 eligible nodes'});
await expect(sender.getByRole('status')).toHaveText('Sending to all 3 eligible nodes in Test ComfyUI. Apply to send it, or preview the changes first.');
await page.screenshot({path:testInfo.outputPath('send-all-option.png')});
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
const reviews=sender.locator('section.prompt-sender-node');
await expect(reviews.locator('> h4')).toHaveText(['#3 Prompt Manager (+/-)','#7 Edited in ComfyUI','#12 Prompt Manager (+/-)']);
await expect(sender.getByRole('region',{name:'#12 Prompt Manager (+/-)',exact:true})).toContainText('Existing 12');
expect(await host.evaluate(()=>window.applied)).toEqual([]);
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();
await expect(sender.getByRole('alert')).toHaveText('Sent to 2 of 3 nodes in Test ComfyUI. #7 Edited in ComfyUI: Workflow or prompt changed. Preview again.');
expect(await host.evaluate(()=>window.applied)).toEqual(['3','12']);
expect(await host.evaluate(()=>window.hostValues['7'].positive)).toBe('Existing 7');
await page.screenshot({path:testInfo.outputPath('send-all-result.png')});
await page.setViewportSize({width:390,height:844});
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();await expect(reviews).toHaveCount(3);
await reviews.last().scrollIntoViewIfNeeded();
expect(await page.evaluate(()=>document.querySelector('dialog.prompt-sender').scrollWidth<=document.querySelector('dialog.prompt-sender').clientWidth+1)).toBe(true);
await page.screenshot({path:testInfo.outputPath('send-all-mobile.png')});
const apply=sender.getByRole('button',{name:'Apply to ComfyUI',exact:true});await apply.focus();await expect(apply).toBeFocused();
} finally {await host.evaluate(()=>window.fixtureHost.dispose());await host.close();}
});
-7
View File
@@ -22,9 +22,6 @@ from PIL.PngImagePlugin import PngInfo
from tests.browser.folder_trash import close_trash, replace_folder
from tests.browser.malloc_tuning import pin_mmap_threshold
from tests.browser.request_hold import register_request_holds
from tests.browser.thumbnail_location_fixture import (
register_thumbnail_location_commands,
)
from tests.browser import slow_reset_watch
from tests.browser.fast_reset import SEED_ON_LIVE_ENV, ScratchSeeder
from tests.browser.workspace_activity_fixture import register_activity_commands
@@ -243,7 +240,6 @@ def create_app(root: Path) -> web.Application:
library_editor_commands = register_library_editor_commands(routes, api, root)
register_vocabulary_commands(routes, api, root)
restore_sources = register_source_change_commands(routes, api)
reset_thumbnail_location = register_thumbnail_location_commands(routes, api, root)
register_tagging_commands(routes, api, root, model, on_reset=restore_sources)
register_review_commands(routes, api, root)
register_collections_commands(routes, api, root)
@@ -455,8 +451,6 @@ def create_app(root: Path) -> web.Application:
restore_sources()
# No preview queued by the last test may land in the new fixture.
await api._run_in_executor(api.close_auto_thumbnails)
# Previews inside each source again (#320).
await api._run_in_executor(reset_thumbnail_location)
library_editor_commands.reset()
await api._run_in_executor(reset_logs)
data = await request.json() if request.can_read_body else {}
@@ -601,7 +595,6 @@ def create_app(root: Path) -> web.Application:
history_commands.reset()
library_editor_commands.reset()
reveal_stub.reset()
await api._run_in_executor(reset_thumbnail_location)
history_fixture = None
manifest = await api._run_in_executor(
seeder.run,
-1
View File
@@ -109,7 +109,6 @@ class ResetWatch:
stream.write(text)
stream.flush()
except (OSError, ValueError):
# the stream closed; a watcher must not fail the server
pass
+13 -22
View File
@@ -162,15 +162,13 @@ 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, images) {
function mapOverviewConcept(reference, overview) {
// 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(images) };
'Negative variants': number(overview.library.negative_variants), Images: number(overview.images.indexed) };
const rows = {
// D93: the real pending count; Review metadata opens Needs review (the concept's go-review).
'3 ambiguous image links': row => ({ ...row,
@@ -181,9 +179,8 @@ function mapOverviewConcept(reference, overview, images) {
'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 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.' }),
// 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.' }),
};
const surfaces = reference.columns.surfaces.map(surface => ({ ...surface,
rows: surface.rows.map(row => (rows[row.title] ?? (value => value))(row)) }));
@@ -221,8 +218,6 @@ 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).
@@ -236,7 +231,6 @@ 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);
@@ -245,7 +239,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, images), production)).toEqual([]);
expect(diffInventory(mapOverviewConcept(concept, overview), production)).toEqual([]);
expect(production.pageScroll).toBe(false);
});
}
@@ -271,9 +265,8 @@ 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, images);
const expected = mapOverviewConcept(concept, overview);
const production = await productionOverview(page, 'dark');
expect(diffInventory(expected, production)).toEqual([]);
// Unmapped, the concept differs exactly where a deviation is recorded, and nowhere else.
@@ -297,7 +290,7 @@ test('system comparison detects mutations', async ({ page, request }) => {
});
/**
* D96: the concept's table, its rows the real ones; then "Database records" (#555) in the same table markup. D153 (#447): the
* D96: the concept's table, its rows the real ones; then "Library totals" 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) {
@@ -309,10 +302,9 @@ 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: '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)]] },
{ 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)]] },
],
};
}
@@ -428,7 +420,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 "Database records" table and a changed caption in the statistics dialog.
// And a removed "Library totals" 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();
@@ -656,10 +648,9 @@ function mapSourcesConcept(reference, { settings, sources }) {
// D113: the range ends at the server's core count (the concept's fixed 16) and shows the real worker count.
return [{ ...row, control: { ...row.control, max: String(settings.cpu_count), value: String(settings.worker_threads) } }];
}),
// D158: the inserted row after the first setting row; D196 (#320): the Thumbnail location block after
// "Thumbnail generation"; D98: "Refresh files" closes the surface.
// D158: the inserted row after the first setting row; D98: "Refresh files" closes the surface.
surface: { ...reference.surface, tree: [...reference.surface.tree.slice(0, rowAt + 1), 'div.setting-row',
...reference.surface.tree.slice(rowAt + 1), 'div.setting-block', 'div.operation'] },
...reference.surface.tree.slice(rowAt + 1), 'div.operation'] },
operations: [...reference.operations, { ...reference.operations[0], title: REFRESH_FILES.title,
description: REFRESH_FILES.description, actions: [{ ...reference.operations[0].actions[0], text: REFRESH_FILES.label }] }],
};
-139
View File
@@ -1,139 +0,0 @@
"""The thumbnail location on the test server (#320), undone by every reset.
Folder mode is reachable from config.json and tests only until the Move
thumbnails job lands, so page tests set it here, inside the server's root:
- ``POST /__test__/thumbnail-location`` ``{"mode": "folder"}`` (or
``"source"``): previews live in ``<root>/thumbs/<key>`` from now on (a new
folder with Prompt Manager's marker).
- ``GET /__test__/thumbnail-files``: the preview files in the thumbnail
folder and whether any source grew a ``thumbnails/`` folder.
- ``POST /__test__/thumbnail-folder/remove``: the thumbnail folder goes, as
when its drive is unplugged; the server notices it on its next check.
"""
from __future__ import annotations
import shutil
from pathlib import Path
from typing import TYPE_CHECKING, Callable
from aiohttp import web
if TYPE_CHECKING:
from py.api import PromptManagerAPI
FOLDER_NAME = "thumbs"
def register_thumbnail_location_commands(
routes: web.RouteTableDef, api: PromptManagerAPI, root: Path
) -> Callable[[], None]:
"""Register the commands; returns the reset every reset runs."""
from py.api import preview_location, preview_store
from py.api import thumbnail_location, thumbnail_move_plan
from py.config import PromptManagerConfig
from py.config_store import get_config_store
folder = root / FOLDER_NAME
default = root / "default-thumbs"
custom = root / "custom-thumbs" # a folder a page types in
journal = root / "data" / "thumbnail-move.json"
# The move's journal and default folder live in this server's root, never
# in the checkout's data folder.
thumbnail_move_plan.journal_path = lambda: str(journal)
thumbnail_move_plan.default_path = lambda: str(default)
thumbnail_location.default_path = lambda: str(default)
api.recover_thumbnail_move()
def forget_config(config: dict) -> dict:
section = config.get("thumbnails")
if isinstance(section, dict):
section.pop("location", None)
return config
def reset() -> None:
preview_store.configure(None)
PromptManagerConfig.THUMBNAIL_LOCATION = None
store = get_config_store()
config, _revision = store.read()
if (
isinstance(config.get("thumbnails"), dict)
and "location" in config["thumbnails"]
):
store.commit(forget_config, expected_revision=None)
for path in (folder, default, custom):
shutil.rmtree(path, ignore_errors=True)
journal.unlink(missing_ok=True)
def use(mode: str) -> dict:
reset()
if mode == "source":
return {"mode": "source"}
folder.mkdir()
marker_id = preview_location.init_folder(str(folder), created_by_pm=True)
location = preview_store.configure(
{
"mode": "folder",
"path": str(folder),
"id": marker_id,
"install": "browser-tests",
}
)
return {"mode": location.mode, "available": location.available}
@routes.post("/__test__/thumbnail-location")
async def set_location(request: web.Request) -> web.Response:
data = await request.json()
mode = data.get("mode") if isinstance(data, dict) else None
if mode not in ("source", "folder"):
raise web.HTTPBadRequest()
return web.json_response(await api._run_in_executor(use, mode))
def files() -> dict:
setting = preview_store.setting()
where = Path(setting.path) if setting.path else folder
previews = sorted(
path.relative_to(where).as_posix()
for path in where.rglob("*_thumb.*")
if path.is_file()
)
sources = [str(path) for path in api._get_all_output_dirs()]
return {
"folder": previews,
"folder_exists": folder.exists() or default.exists() or custom.exists(),
"custom_path": str(custom),
"source_thumbnails": any(
(Path(source) / "thumbnails").exists() for source in sources
),
}
@routes.get("/__test__/thumbnail-files")
async def thumbnail_files(_request: web.Request) -> web.Response:
return web.json_response(await api._run_in_executor(files))
def remove() -> bool:
setting = preview_store.setting()
shutil.rmtree(setting.path or folder, ignore_errors=True)
return preview_store.refresh()
@routes.post("/__test__/thumbnail-folder/remove")
async def remove_folder(_request: web.Request) -> web.Response:
available = await api._run_in_executor(remove)
return web.json_response({"available": available})
def foreign() -> bool:
"""Another install recreated the folder: its marker has another id."""
setting = preview_store.setting()
def other(marker: dict) -> None:
marker["id"] = "another-install"
preview_location.write_marker(setting.path, other)
return preview_store.refresh()
@routes.post("/__test__/thumbnail-folder/foreign")
async def foreign_folder(_request: web.Request) -> web.Response:
return web.json_response({"available": await api._run_in_executor(foreign)})
return reset
-17
View File
@@ -261,20 +261,3 @@ test('prompt deletion requires confirmation and can retry after a network failur
await expect(await request.get(url)).toBeOK();
}
});
// #320: with previews in one folder, the legacy gallery still shows them, made
// and served from that folder (the fixture's own previews inside the sources
// are never served then).
test('the legacy gallery shows thumbnails kept in one folder', async ({ page, request }) => {
await expect(await request.post('/__test__/thumbnail-location', { data: { mode: 'folder' } })).toBeOK();
const served = page.waitForResponse(response =>
response.url().includes('/prompt_manager/images/serve/thumbnails/') && response.status() === 200);
await page.goto('/prompt_manager/gallery');
await expect(page.locator('#galleryGrid .image-item')).toHaveCount(2);
const response = await served; // made in the folder before it was served
const name = decodeURIComponent(new URL(response.url()).pathname.split('/serve/thumbnails/')[1]);
const files = await (await request.get('/__test__/thumbnail-files')).json();
const stem = name.replace(/\.[^.]+$/, '');
expect(files.folder.some(file => /^[a-z0-9_-]+-[0-9a-f]{8}\//.test(file)
&& file.slice(file.indexOf('/') + 1).replace(/\.[^.]+$/, '') === stem)).toBe(true);
});
@@ -156,9 +156,8 @@ 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 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});
// 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});
await last.scrollIntoViewIfNeeded();await expect(last).toBeInViewport();
const [button]=await boxes(last);const [after]=await boxes(tagWindow(page));
expect(button.right).toBeLessThanOrEqual(after.right);
+1 -1
View File
@@ -233,7 +233,7 @@ test('a view stored under older rules still lists and opens with its usable valu
// D153 (#447): the workspace no longer offers "Highest rated"; the API still stores such a view as it was saved.
test('a view saved under Highest rated opens on Recently added on Images and the Library', async ({ page, request }) => {
const images = await create(request, { page: 'images', name: 'Rated cats', state: { ...IMAGE_DEFAULTS, q: 'cat', sort: 'rating' } });
await create(request, { page: 'library', name: 'Rated dogs', state: { q: 'dog', category: '', sort: 'rating', tags: [], tag_mode: 'all', folder: '' } });
const library = await create(request, { page: 'library', name: 'Rated dogs', state: { q: 'dog', category: '', sort: 'rating', tags: [], tag_mode: 'all', folder: '' } });
expect((await listed(request)).map(view => view.state.sort)).toEqual(['rating', 'rating']);
const listings = [];
page.on('request', item => {
@@ -60,7 +60,7 @@ async function openReview(page, request, items = 30) {
const inViewport = (rect, page) => rect.top >= 0 && rect.bottom <= page.viewportSize().height;
test.beforeEach(async ({ page, browserName }) => { await startCoverage(page, browserName); });
test.afterEach(async ({ page, browserName }) => { await saveCoverage(page, browserName); });
test.afterEach(async ({ page, browserName }, testInfo) => { await saveCoverage(page, browserName, testInfo); });
for (const viewport of [null, PHONE]) {
const label = viewport ? ' at 390px' : '';
@@ -284,23 +284,6 @@ test('a restore seen finishing elsewhere closes an open restore review and disca
expect((await serverJson(request, '/__test__/system/backups')).stages).toBe(0);
});
// #583: in WebKit, while a page.route is armed, a file part of a FormData body is read only when the request goes on;
// a garbage collection in between dropped the form once nothing referenced it, and the server got an empty file
// ("Uploaded file is empty"). The page keeps the form until its upload is answered. The route holds the upload
// through a collection; without the fix WebKit lost the file every time (0/10 in the pinned image). Chromium and
// Firefox keep the body anyway, so there it only checks that a held upload still opens the review.
test('a review upload survives a garbage collection while it is in flight', async ({ page, request }) => {
const bytes = await backupBytes(request);
await page.route(`**${PREVIEW_URL}`, async route => {
await page.requestGC();
await route.continue();
});
await openBackups(page);
expect((await chooseBackup(page, bytes)).status()).toBe(200);
await expect(dialog(page).locator('#modal-title')).toHaveText('Restore preview');
expect((await serverJson(request, '/__test__/system/backups')).stages).toBe(1);
});
test('invalid file shows the validator message; nothing is staged', async ({ page, request }) => {
const before = await serverJson(request, '/__test__/system/digest');
await openBackups(page);
@@ -11,6 +11,7 @@ const REPORT_URL = '/prompt_manager/system/diagnostics';
const LOGS_URL = '/prompt_manager/logs';
const CONFIG_URL = '/prompt_manager/logs/config';
const PREVIEW_URL = '/prompt_manager/maintenance/preview';
const APPLY_URL = '/prompt_manager/maintenance/apply';
const TEST_LINK_URL = '/prompt_manager/diagnostics/test-link';
const CHANGED = 'The log files changed since the review; nothing was deleted. Review again.';
const KEY = 'sk-s12-browser-SECRET-0123456789';
+6 -31
View File
@@ -8,8 +8,6 @@ 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 }) => {
@@ -57,7 +55,6 @@ 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.
@@ -69,8 +66,7 @@ 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'],
// #555: the Images page's own count, the same number as its sidebar badge.
[number(images), 'Images'],
[number(overview.images.indexed), 'Images'],
['Not checked', 'Database integrity'],
]);
expect(await rowsOf(layout, 'Library health')).toEqual([
@@ -83,31 +79,11 @@ 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 database record counts.', 'Explore'],
['Statistics', 'Runs by category, with library totals.', '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');
@@ -388,7 +364,7 @@ test('View backups / Appearance / Manage sources switch sections', async ({ page
}
});
test('statistics dialog lists runs by category and database record counts', async ({ page, request }) => {
test('statistics dialog lists runs by category and library totals', async ({ page, request }) => {
const statistics = await serverJson(request, '/prompt_manager/system/statistics');
expect(statistics.by_category.length).toBeGreaterThan(1);
const layout = await openOverview(page);
@@ -397,14 +373,13 @@ test('statistics dialog lists runs by category and database record counts', asyn
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', 'Database records']);
await expect(tables.locator('caption')).toHaveText(['Runs by category', 'Library totals']);
// 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)]));
// #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)]]);
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)]]);
await page.keyboard.press('Escape');
await expect(explore).toBeFocused();
// A statistics read that fails says so; the Overview stays.
@@ -1,148 +0,0 @@
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
import { seedConcept } from './concept-inventory.mjs';
// #320 Thumbnail location (D196–D201, the approved 320 mock) on the concept-shaped real fixture: the row in System ›
// Sources & scans reviews and runs Move thumbnails both ways, the review refuses a folder in place, and an unplugged
// thumbnail folder shows the notice whose Recreate folder brings it back. The hooks are thumbnail_location_fixture.py's.
const workspace = '/prompt_manager/workspace';
const NOTICE_KEY = 'pm_update_notice_dismissed';
test.beforeEach(async ({ page, request, browserName }) => {
await startCoverage(page, browserName);
await seedConcept(request);
await expect(await request.post('/__test__/system/reset')).toBeOK();
await expect(await request.post('/__test__/thumbnail-location', { data: { mode: 'source' } })).toBeOK();
page.locationErrors = [];
page.on('pageerror', error => page.locationErrors.push(error.message));
await page.addInitScript(key => { if (location.protocol.startsWith('http')) localStorage.setItem(key, '3.2.0'); }, NOTICE_KEY);
});
test.afterEach(async ({ page, request, browserName }) => {
await saveCoverage(page, browserName);
await request.post('/__test__/thumbnail-location', { data: { mode: 'source' } });
expect(page.locationErrors).toEqual([]);
});
const block = page => page.locator('#location-block');
const dialog = page => page.locator('dialog#modal[open]');
async function files(request) {
const response = await request.get('/__test__/thumbnail-files');
await expect(response).toBeOK();
return response.json();
}
async function openSources(page) {
await page.goto(`${workspace}#/system/sources`);
await expect(block(page).locator('#location-status')).not.toHaveText('');
}
/** Review the row's choice and start the move; resolves once its job view says it finished. */
async function moveFromRow(page) {
const planned = page.waitForResponse(response => response.url().endsWith('/images/thumbnails/plan')
&& response.request().method() === 'POST');
await block(page).getByRole('button', { name: 'Review move…' }).click();
expect((await planned).status()).toBe(200);
await expect(dialog(page).locator('h2')).toHaveText('Move thumbnails');
await dialog(page).getByRole('button', { name: 'Move thumbnails' }).click();
await expect(page.locator('#workspace-main h1')).toHaveText('Move thumbnails');
await expect(page.locator('#workspace-main')).toContainText('Thumbnails moved to');
}
test('the row moves thumbnails into one folder and back', async ({ page, request }) => {
await openSources(page);
const row = block(page);
await expect(row.getByRole('radio', { name: /Inside each source/ })).toBeChecked();
await expect(row.locator('#location-status')).toHaveText('Now: inside each source.');
await expect(row.getByRole('button', { name: 'Review move…' })).toHaveAttribute('aria-disabled', 'true');
await expect(row.locator('#location-path')).toBeDisabled();
await row.getByRole('radio', { name: /One folder/ }).check();
await expect(row.locator('#location-path')).toBeEnabled();
await expect(row.locator('#location-path')).toHaveValue(/default-thumbs$/);
await expect(row.locator('#location-status')).toHaveText(/^Now: inside each source\. The suggested folder is next to your library/);
await expect(row.getByRole('button', { name: 'Review move…' })).toHaveAttribute('aria-disabled', 'false');
await moveFromRow(page);
await expect(page.locator('#job-phase')).toHaveText(/^Thumbnails are now read from the new location/);
const moved = await files(request);
expect(moved.folder.length).toBeGreaterThan(0);
expect(moved.folder.every(name => /^[a-z0-9_-]+-[0-9a-f]{8}\//.test(name))).toBe(true);
expect(moved.source_thumbnails).toBe(false);
// Images shows thumbnails served from the folder.
const served = page.waitForResponse(response => response.url().includes('/images/serve/thumbnails/')
&& response.status() === 200);
await page.goto(`${workspace}#/images`);
await served;
await openSources(page);
await expect(block(page).locator('#location-status')).toHaveText(/^Now: one folder, .*default-thumbs\. Using the default folder/);
await block(page).getByRole('radio', { name: /Inside each source/ }).check();
await moveFromRow(page);
const back = await files(request);
expect(back.source_thumbnails).toBe(true);
expect(back.folder_exists).toBe(false);
});
test('a folder the server refuses is explained under the field', async ({ page }) => {
await openSources(page);
const row = block(page);
await row.getByRole('radio', { name: /One folder/ }).check();
await row.locator('#location-path').fill('thumbnails');
await row.getByRole('button', { name: 'Review move…' }).click();
await expect(row.locator('#location-problem')).toHaveText(
'Enter a full folder path, such as D:\\Thumbnails or /mnt/fast/thumbnails.');
await expect(row.locator('.location-path')).toHaveAttribute('aria-invalid', 'true');
await expect(dialog(page)).toHaveCount(0);
await row.locator('#location-path').fill('/somewhere/else');
await expect(row.locator('#location-problem')).toHaveText('');
});
test('an unplugged folder shows the notice and Recreate folder brings it back', async ({ page, request }) => {
await expect(await request.post('/__test__/thumbnail-location', { data: { mode: 'folder' } })).toBeOK();
const removed = await request.post('/__test__/thumbnail-folder/remove');
expect((await removed.json()).available).toBe(false);
await openSources(page);
const notice = page.locator('#unavailable-notice');
await expect(notice.locator('h2')).toHaveText('The thumbnail folder is unavailable');
await expect(notice).toContainText('is missing. Originals are shown and no thumbnails are written until it is back.');
await expect(block(page).locator('#location-status')).toHaveText(/· unavailable\. Originals are shown\.$/);
await notice.getByRole('button', { name: 'Recreate folder' }).click();
await expect(dialog(page).locator('h2')).toHaveText('Recreate the thumbnail folder?');
const recreated = page.waitForResponse(response => response.url().endsWith('/location/recreate'));
await dialog(page).getByRole('button', { name: 'Recreate folder' }).click();
expect((await recreated).status()).toBe(200);
await expect(notice).toHaveCount(0);
await expect(block(page).locator('#location-status')).toHaveText(/^Now: one folder, .*thumbs\.$/);
});
test('a folder of your own needs the confirmation before it moves', async ({ page, request }) => {
const custom = (await files(request)).custom_path;
await openSources(page);
const row = block(page);
await row.getByRole('radio', { name: /One folder/ }).check();
await row.locator('#location-path').fill(custom);
await row.getByRole('button', { name: 'Review move…' }).click();
const review = dialog(page);
await expect(review.locator('dt')).toContainText(['From', 'To', 'Thumbnails', 'Free space', 'Removed after the move']);
await expect(review.locator('dd').nth(1)).toHaveText(`${custom} (new folder)`);
const move = review.getByRole('button', { name: 'Move thumbnails' });
await expect(move).toHaveAttribute('aria-disabled', 'true');
await review.getByRole('checkbox', { name: /may create its own folders/ }).check();
await expect(move).toHaveAttribute('aria-disabled', 'false');
await move.click();
await expect(page.locator('#workspace-main')).toContainText('Thumbnails moved to');
expect((await files(request)).folder.length).toBeGreaterThan(0);
});
test('another folder’s marker offers Use this folder', async ({ page, request }) => {
await expect(await request.post('/__test__/thumbnail-location', { data: { mode: 'folder' } })).toBeOK();
await expect(await request.post('/__test__/thumbnail-folder/foreign')).toBeOK();
await openSources(page);
const notice = page.locator('#unavailable-notice');
await expect(notice).toContainText('holds another thumbnail folder');
await notice.getByRole('button', { name: 'Use this folder' }).click();
await expect(notice).toHaveCount(0);
await expect(block(page).locator('#location-status')).toHaveText(/^Now: one folder, .*thumbs\.$/);
});
+3 -4
View File
@@ -145,10 +145,9 @@ 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);
// 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);
// 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();
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);
+2 -31
View File
@@ -131,40 +131,11 @@ test('native image inspector previews and sends a pair into real visible widgets
const twinId=await page.evaluate(async()=>{const {app}=await import('/scripts/app.js');const node=LiteGraph.createNode('PromptManagerPair');app.graph.add(node);return node.id;});
await find0.click();
const targets=sender.getByRole('combobox',{name:'Destination node',exact:true});
await expect(targets.locator('option')).toHaveCount(4);
await expect(targets.locator('option')).toHaveCount(3);
await expect(targets).toHaveValue('');
await expect(apply).toBeDisabled();
await expect(targets.locator('option').nth(2)).toHaveText(new RegExp(`^#${twinId} `));
// #542 D191: "All 2 eligible nodes" writes both real nodes, one after another; a linked third node is not offered.
const linkedId=await page.evaluate(async()=>{
const {app}=await import('/scripts/app.js');const {convertToInput}=await import('/extensions/core/widgetInputs.js');
const node=LiteGraph.createNode('PromptManagerPair');app.graph.add(node);
const widget=node.widgets.find(widget=>widget.name==='text');
if(!node.inputs.some(input=>input.name==='text'))convertToInput(node,widget);
const source=LiteGraph.createNode('PrimitiveString');app.graph.add(source);
source.connect(0,node,node.inputs.findIndex(input=>input.name==='text'));
for(const each of app.graph._nodes.filter(item=>item.type==='PromptManagerPair')){const text=each.widgets.find(widget=>widget.name==='text');if(text)text.value=`Before all ${each.id}`;}
return node.id;
});
await find0.click();
await expect(targets.locator('option')).toHaveText(['Choose a destination node',new RegExp(`^#${id} `),new RegExp(`^#${twinId} `),'All 2 eligible nodes']);
await expect(sender.getByRole('status')).toContainText('1 other Prompt Manager node cannot receive this prompt.');
await targets.selectOption({label:'All 2 eligible nodes'});
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
await expect(sender.locator('section.prompt-sender-node > h4')).toHaveText([new RegExp(`^#${id} `),new RegExp(`^#${twinId} `)]);
await apply.click();
// The tab title gains ComfyUI's unsaved mark once the workflow changed, so the window is matched loosely.
await expect(sender.getByRole('status')).toHaveText(/^Sent to 2 of 2 nodes in .*ComfyUI\.$/);
await expect(apply).toBeDisabled();
const all=await page.evaluate(async ids=>{
const {app}=await import('/scripts/app.js');const output=(await app.graphToPrompt()).output;
return ids.map(nodeId=>{const node=app.graph.getNodeById(nodeId);const serialized=node.serialize();const index=node.widgets.findIndex(widget=>widget.name==='negative_text');
return{prompt:output[String(nodeId)]?.inputs.negative_text,serialized:serialized.widgets_values[index],text:node.widgets.find(widget=>widget.name==='text')?.value};});
},[id,twinId,linkedId]);
expect(all.slice(0,2)).toEqual([{prompt:negative,serialized:negative,text:positive},{prompt:negative,serialized:negative,text:positive}]);
expect(all[2].text).toBe(`Before all ${linkedId}`);
expect(await page.evaluate(()=>window.senderEffects.queue-window.senderSendBaseline.queue)).toBe(0);
await page.evaluate(async ids=>{const {app}=await import('/scripts/app.js');for(const nodeId of ids)app.graph.remove(app.graph.getNodeById(nodeId));},[twinId,linkedId]);
await page.evaluate(async twinId=>{const {app}=await import('/scripts/app.js');app.graph.remove(app.graph.getNodeById(twinId));},twinId);
// #535: the status line says why a window cannot receive.
const find=sender.getByRole('button',{name:'Find ComfyUI windows',exact:true});
const preview=sender.getByRole('button',{name:'Preview changes',exact:true});
+1 -11
View File
@@ -125,9 +125,6 @@
"POST /prompt_manager/images/removed/return",
"POST /prompt_manager/images/rescope",
"POST /prompt_manager/images/reveal",
"POST /prompt_manager/images/thumbnails/location/adopt",
"POST /prompt_manager/images/thumbnails/location/recreate",
"POST /prompt_manager/images/thumbnails/plan",
"POST /prompt_manager/jobs/autotag",
"POST /prompt_manager/jobs/duplicates",
"POST /prompt_manager/jobs/image-tags",
@@ -215,13 +212,6 @@
"thumbnail_encoders",
"thumbnail_encoders.jpeg",
"thumbnail_encoders.jxl",
"thumbnail_encoders.webp",
"thumbnail_location",
"thumbnail_location.available",
"thumbnail_location.cleanup_pending",
"thumbnail_location.default_path",
"thumbnail_location.mode",
"thumbnail_location.path",
"thumbnail_location.problem"
"thumbnail_encoders.webp"
]
}
+10
View File
@@ -21,6 +21,14 @@ function jsonResponse(status, body) {
json: async () => body };
}
function sseResponse(frames) {
const bytes = new TextEncoder().encode(frames.map(frame => `data: ${JSON.stringify(frame)}\n\n`).join(''));
let sent = false;
return { ok: true, status: 200, body: { getReader: () => ({
read: async () => (sent ? { done: true } : (sent = true, { done: false, value: bytes })),
}) } };
}
function load(file, className, fetchUrl, extra = {}, dom = elements()) {
const timers = [];
const notifications = [];
@@ -55,6 +63,8 @@ function load(file, className, fetchUrl, extra = {}, dom = elements()) {
return { page, dom, timers, notifications, storage };
}
const BUSY = 'Scan for metadata already running';
test('admin settings save shows why config.json refused it', async () => {
const message = 'config.json cannot be read, so nothing was saved. Fix or remove the file, then save again.';
const { page, notifications } = load('admin.js', 'PromptAdmin',
-138
View File
@@ -225,141 +225,3 @@ test('acknowledgement cannot claim an extra role beyond approved changes',async(
const {createSendSession}=await source;const s=setup(createSendSession,{apply:async()=>({targetId:'one',values:{positive:'new',negative:'also changed'}})});
await s.session.discover();s.session.selectHost('host');s.session.selectTarget('one');await s.session.preview();await s.session.apply();assert.equal(s.session.state.phase,'error');assert.match(s.session.state.message,/unexpected acknowledgement/);s.session.dispose();
});
// #542: "All N eligible nodes" sends to every eligible node of one window, previewing every node first (Q542-2 C).
function allHost({nodes=['10','9','2'],revision,preview,apply,discover}={}) {
const calls=[];const sent=new Set();
const targets=nodes.map(id=>pm(id,['positive'],revision?{revision}:{}));
const client={
discover:discover ?? (async()=>[window1(targets)]),
preview:async(host,id,roles)=>{calls.push(['preview',id]);if(preview)return preview(id,calls,sent);return{ticket:`t-${id}-${calls.length}`,revision,target:{id,label:`Node ${id}`},fields:[{role:'positive',before:sent.has(id)?'new':'old',after:roles.positive}]};},
apply:async(host,ticket)=>{const id=ticket.split('-')[1];calls.push(['apply',id,ticket]);if(apply)return apply(id,calls);sent.add(id);return{targetId:id,values:{positive:'new'}};},
dispose:()=>calls.push(['dispose']),
};
return {client,calls,sent};
}
async function allSession(createSendSession,options) {
const host=allHost(options);const session=createSendSession({client:host.client,roles:{positive:'new'},onChange:()=>{}});
await session.discover();return {...host,session};
}
const order=calls=>calls.filter(call=>call[0]!=='dispose').map(call=>`${call[0]} ${call[1]}`);
test('All is offered only with two or more eligible nodes and is never selected for you',async()=>{
const {createSendSession,ALL_NODES}=await source;
const one=await allSession(createSendSession,{nodes:['1']});
one.session.selectTarget(ALL_NODES);assert.equal(one.session.state.targetId,'');assert.equal(one.calls.length,0);one.session.dispose();
const two=await allSession(createSendSession,{nodes:['1','2']});
assert.equal(two.session.state.targetId,'');
two.session.selectTarget(ALL_NODES);assert.equal(two.session.state.targetId,ALL_NODES);
assert.match(two.session.state.message,/all 2 eligible nodes in Flow - ComfyUI/);
await two.session.discover();assert.equal(two.session.state.targetId,'');two.session.dispose();
});
test('Apply to All previews every node first, then sends one after another in node id order',async()=>{
const {createSendSession,ALL_NODES}=await source;const s=await allSession(createSendSession);
s.session.selectTarget(ALL_NODES);await s.session.apply();
assert.deepEqual(order(s.calls),['preview 2','preview 9','preview 10','apply 2','preview 9','apply 9','preview 10','apply 10']);
assert.equal(s.session.state.phase,'success');assert.equal(s.session.state.message,'Sent to 3 of 3 nodes in Flow - ComfyUI.');
await s.session.apply();assert.equal(s.calls.filter(call=>call[0]==='apply').length,3);s.session.dispose();
});
test('a double click on Apply to All sends to each node once',async()=>{
const {createSendSession,ALL_NODES}=await source;const s=await allSession(createSendSession,{nodes:['1','2']});
s.session.selectTarget(ALL_NODES);await Promise.all([s.session.apply(),s.session.apply()]);
assert.deepEqual(s.calls.filter(call=>call[0]==='apply').map(call=>call[1]),['1','2']);s.session.dispose();
});
test('a node refusing its preview stops All before anything is written and names the node',async()=>{
const {createSendSession,ALL_NODES}=await source;
const s=await allSession(createSendSession,{preview:id=>{if(id==='9')throw Error('positive is connected to an upstream input.');return{ticket:`t-${id}`,target:{id},fields:[{role:'positive',before:'old',after:'new'}]};}});
s.session.selectTarget(ALL_NODES);await s.session.apply();
assert.equal(s.calls.filter(call=>call[0]==='apply').length,0);assert.equal(s.session.state.phase,'error');
assert.match(s.session.state.message,/^#9 Node 9: positive is connected to an upstream input\. Nothing was sent\./);
assert.equal(s.session.state.outcomeUnknown,false);s.session.dispose();
});
test('a refusal while sending to All is reported by node and the other nodes are still sent',async()=>{
const {createSendSession,ALL_NODES}=await source;
const s=await allSession(createSendSession,{apply:id=>{if(id==='9')throw Error('Workflow or prompt changed. Preview again.');return{targetId:id,values:{positive:'new'}};}});
s.session.selectTarget(ALL_NODES);await s.session.apply();
assert.deepEqual(s.calls.filter(call=>call[0]==='apply').map(call=>call[1]),['2','9','10']);
assert.equal(s.session.state.phase,'error');assert.equal(s.session.state.outcomeUnknown,false);
assert.equal(s.session.state.message,'Sent to 2 of 3 nodes in Flow - ComfyUI. #9 Node 9: Workflow or prompt changed. Preview again.');s.session.dispose();
});
test('a lost reply on one node of All leaves the later nodes unsent and that outcome unknown',async()=>{
const {createSendSession,ALL_NODES}=await source;
const s=await allSession(createSendSession,{apply:id=>{if(id==='9')throw Error('Bridge request timed out');return{targetId:id,values:{positive:'new'}};}});
s.session.selectTarget(ALL_NODES);await s.session.apply();
assert.deepEqual(s.calls.filter(call=>call[0]==='apply').map(call=>call[1]),['2','9']);
assert.equal(s.session.state.outcomeUnknown,true);
assert.match(s.session.state.message,/^Sent to 1 of 3 nodes in Flow - ComfyUI\. No confirmation received from #9 Node 9; the update may have applied\. Not sent: #10 Node 10\. Check ComfyUI, then preview again\. Bridge request timed out$/);
await s.session.apply();assert.equal(s.calls.filter(call=>call[0]==='apply').length,2);s.session.dispose();
});
test('closing the dialog during All stops after the current node',async()=>{
const {createSendSession,ALL_NODES}=await source;let release;
const s=await allSession(createSendSession,{apply:id=>id==='2'?new Promise(done=>release=()=>done({targetId:id,values:{positive:'new'}})):{targetId:id,values:{positive:'new'}}});
s.session.selectTarget(ALL_NODES);const pending=s.session.apply();
for(let i=0;i<50 && !release;i++)await tick();assert.ok(release,'the first node was sent');
s.session.dispose();release();await pending;
assert.deepEqual(s.calls.filter(call=>call[0]==='apply').map(call=>call[1]),['2']);
});
test('All accepts the revision our own write moved, but not a workflow changed before the run',async()=>{
const {createSendSession,ALL_NODES}=await source;
// The host moves the graph version on every write: later nodes preview with the new revision.
let version=4;
const bumped=await allSession(createSendSession,{revision:'1:4',preview:id=>({ticket:`t-${id}`,revision:`1:${version}`,target:{id},fields:[{role:'positive',before:'old',after:'new'}]}),apply:id=>{version++;return{targetId:id,values:{positive:'new'}};}});
bumped.session.selectTarget(ALL_NODES);await bumped.session.apply();
assert.equal(bumped.session.state.message,'Sent to 3 of 3 nodes in Flow - ComfyUI.');bumped.session.dispose();
const moved=await allSession(createSendSession,{revision:'1:4',preview:id=>({ticket:`t-${id}`,revision:'1:5',target:{id},fields:[{role:'positive',before:'old',after:'new'}]})});
moved.session.selectTarget(ALL_NODES);await moved.session.apply();
assert.match(moved.session.state.message,/workflow changed since this window was found/i);assert.equal(moved.calls.filter(call=>call[0]==='apply').length,0);moved.session.dispose();
});
test('a node whose text changed after the preview pass is refused, not overwritten',async()=>{
const {createSendSession,ALL_NODES}=await source;let edited=false;
const s=await allSession(createSendSession,{preview:id=>({ticket:`t-${id}`,target:{id},fields:[{role:'positive',before:id==='10'&&edited?'typed in ComfyUI':'old',after:'new'}]}),apply:id=>{edited=true;return{targetId:id,values:{positive:'new'}};}});
s.session.selectTarget(ALL_NODES);await s.session.apply();
assert.deepEqual(s.calls.filter(call=>call[0]==='apply').map(call=>call[1]),['2','9']);
assert.match(s.session.state.message,/^Sent to 2 of 3 nodes in Flow - ComfyUI\. #10 Node 10: Its prompt changed since the preview\./);s.session.dispose();
});
test('Preview with All shows every node in order, and Apply sends fresh tickets that match the review',async()=>{
const {createSendSession,ALL_NODES}=await source;const s=await allSession(createSendSession);
s.session.selectTarget(ALL_NODES);await s.session.preview();
assert.equal(s.session.state.phase,'ready');assert.equal(s.session.state.preview,null);
assert.deepEqual(s.session.state.previews.map(item=>item.label),['#2 Node 2','#9 Node 9','#10 Node 10']);
assert.match(s.session.state.message,/Sending to all 3 eligible nodes in Flow - ComfyUI\. Review the complete changes, then apply\./);
const first=s.session.state.previews[0].preview.ticket;
await s.session.apply();
// A held review may have outlived the host's 30 s tickets, so even the first node takes a fresh one.
assert.deepEqual(order(s.calls),['preview 2','preview 9','preview 10','preview 2','apply 2','preview 9','apply 9','preview 10','apply 10']);
assert.notEqual(s.calls.find(call=>call[0]==='apply')[2],first);assert.equal(s.session.state.phase,'success');
s.session.selectTarget('2');assert.deepEqual(s.session.state.previews,[]);s.session.dispose();
});
test('the dialog offers All for two or more nodes and reviews each node under its name',async()=>{
const {createPromptSender,ALL_NODES}=await source;const {createScope}=await import('../../web/js/shared/toolkit/core.js');
const dom=fakeDOM();const scope=createScope();
const {client}=allHost({nodes:['1','2']});
try {
createPromptSender({scope,clientFactory:()=>client}).open({positive:'new'});await tick();
const dialog=dom.body.children.at(-1);const target=dom.find(dialog,node=>node.id==='prompt-sender-target');
assert.deepEqual(target.children.map(option=>[option.value,option.textContent]),[['','Choose a destination node'],['1','#1 Node 1'],['2','#2 Node 2'],[ALL_NODES,'All 2 eligible nodes']]);
assert.equal(target.value,'');target.value=ALL_NODES;target.dispatchEvent(new Event('change'));
dom.find(dialog,node=>node.textContent==='Preview changes').dispatchEvent(new Event('click'));for(let i=0;i<5;i++)await tick();
const sections=[];(function walk(node){if(node.className==='prompt-sender-node')sections.push(node);node.children.forEach(walk);})(dialog);
assert.deepEqual(sections.map(section=>section.children[0].textContent),['#1 Node 1','#2 Node 2']);
dom.find(dialog,node=>node.textContent==='Apply to ComfyUI').dispatchEvent(new Event('click'));for(let i=0;i<10;i++)await tick();
assert.ok(dom.find(dialog,node=>node.textContent==='Sent to 2 of 2 nodes in Flow - ComfyUI.'));
} finally {scope.dispose();dom.restore();}
});
test('an expired review still sends every node, and a held review checks the discovered revision first',async()=>{
const {createSendSession,ALL_NODES}=await source;let reviewing=true;
// The host refuses every ticket taken during the review, as after its 30 s expiry.
const s=await allSession(createSendSession,{preview:(id,calls)=>({ticket:`t-${id}-${reviewing?'old':calls.length}`,target:{id},fields:[{role:'positive',before:'old',after:'new'}]}),apply:id=>({targetId:id,values:{positive:'new'}})});
const realApply=s.client.apply;s.client.apply=async(host,ticket,options)=>{if(ticket.endsWith('-old'))throw Error('Preview expired or unavailable; preview again');return realApply(host,ticket,options);};
s.session.selectTarget(ALL_NODES);await s.session.preview();reviewing=false;await s.session.apply();
assert.equal(s.session.state.message,'Sent to 3 of 3 nodes in Flow - ComfyUI.');s.session.dispose();
const moved=await allSession(createSendSession,{revision:'1:4',preview:(id,calls)=>({ticket:`t-${id}`,revision:calls.length>3?'1:5':'1:4',target:{id},fields:[{role:'positive',before:'old',after:'new'}]})});
moved.session.selectTarget(ALL_NODES);await moved.session.preview();assert.equal(moved.session.state.phase,'ready');await moved.session.apply();
assert.match(moved.session.state.message,/^Sent to 0 of 3 nodes in Flow - ComfyUI\. #2 Node 2: The workflow changed since this window was found/);
assert.equal(moved.calls.filter(call=>call[0]==='apply').length,0);moved.session.dispose();
});
test('a changed prepend or append after the review refuses that node',async()=>{
const {createSendSession,ALL_NODES}=await source;let edited=false;
const s=await allSession(createSendSession,{preview:id=>({ticket:`t-${id}`,target:{id},fields:[{role:'positive',before:'old',after:'new'}],composition:{prepend:id==='9'&&edited?'typed':'trigger',append:'',before:'trigger old',after:'trigger new'}}),apply:id=>{edited=true;return{targetId:id,values:{positive:'new'}};}});
s.session.selectTarget(ALL_NODES);await s.session.apply();
assert.deepEqual(s.calls.filter(call=>call[0]==='apply').map(call=>call[1]),['2','10']);
assert.match(s.session.state.message,/^Sent to 2 of 3 nodes in Flow - ComfyUI\. #9 Node 9: Its prompt changed since the preview\./);s.session.dispose();
});
-18
View File
@@ -128,24 +128,6 @@ test('invalid file shows the validator message; a refusal names its job; a lost
assert.deepEqual(await uploadRestore(file, { ApiError, fetchUrl: async () => response(200, PREVIEW) }), PREVIEW);
});
// #583: WebKit reads a FormData's file part by reference, possibly after a garbage collection; the page holds the form
// until the upload is answered, then lets it go, whether the server staged it, refused it or the request failed.
test('a review upload keeps its form referenced until the upload is answered', async () => {
const { uploadRestore, uploadsInFlight } = await backups;
const file = new File(['bytes'], 'b.db');
for (const answer of [async () => response(200, PREVIEW), async () => response(400, { success: false, error: 'x' }),
async () => { throw new TypeError('offline'); }]) {
let held;
const upload = uploadRestore(file, { ApiError, fetchUrl: async (url, init) => {
held = uploadsInFlight.has(init.body) && init.body.get('database_file').size === file.size;
return answer();
} }).catch(() => {});
await upload;
assert.equal(held, true);
assert.equal(uploadsInFlight.size, 0);
}
});
test('Cancel discards the stage through the api client and never throws', async () => {
const { discardRestore, PREVIEW_URL } = await backups;
const calls = [];
+2 -12
View File
@@ -16,22 +16,12 @@ 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, 24), [
assert.deepEqual(metricValues(OVERVIEW), [
[(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');
@@ -160,7 +150,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), [['Prompt records', '12'], ['Tags on prompts', '15'], ['Image records', '24']]);
assert.deepEqual(totalsRows(statistics), [['Prompts', '12'], ['Tags', '15'], ['Images', '24']]);
});
/** A fake api answering each `get`/`post` from a queue per URL, recording the calls. */
@@ -1,137 +0,0 @@
// Thumbnail location (#320, D196–D201): the row's state and status line, the move review's rows, the unavailable
// notice's text, the Activity views of a move and a prune, the prune scope's plan line and the Move library row.
const test = require('node:test');
const assert = require('node:assert/strict');
const locationModule = import('../../web/js/workspace/system-thumbnail-location.mjs');
const kindsModule = import('../../web/js/workspace/activity-kinds.mjs');
const reviewModule = import('../../web/js/workspace/thumbnail-review.mjs');
const libraryModule = import('../../web/js/workspace/library-location.mjs');
const SOURCE = Object.freeze({ mode: 'source', path: null, default_path: '/data/thumbnails', available: true,
problem: null, cleanup_pending: 0 });
const FOLDER = Object.freeze({ mode: 'folder', path: 'E:\\Thumbnails', default_path: '/data/thumbnails',
available: true, problem: null, cleanup_pending: 0 });
const PLAN = Object.freeze({
from: { mode: 'source', path: null, available: true },
to: { mode: 'folder', path: 'E:\\Thumbnails', exists: true, will_create: false, default: false },
totals: { sources: 3, files: 8512, bytes: 1.2 * 1024 ** 3, orphans: 41, temps: 2, links: 1, left_in_place: 1,
too_long: 32, unlisted_sources: 0, unlisted_files: 0 },
too_long_listed: ['Archive 2025 · a_thumb.webp', 'Archive 2025 · b_thumb.webp'],
left_listed: ['Archive 2025 · notes.txt'],
unlisted: [],
free_bytes: 1.6 * 1024 ** 4,
confirm_required: true,
token: 't',
});
test('the status line says where previews are now', async () => {
const { statusText } = await locationModule;
assert.equal(statusText(SOURCE), 'Now: inside each source.');
assert.equal(statusText(FOLDER), 'Now: one folder, E:\\Thumbnails.');
assert.equal(statusText({ ...FOLDER, path: '/data/thumbnails' }),
'Now: one folder, /data/thumbnails. Using the default folder next to your library.');
assert.equal(statusText({ ...FOLDER, available: false, problem: 'missing' }),
'Now: one folder, E:\\Thumbnails · unavailable. Originals are shown.');
assert.match(statusText({ ...SOURCE, cleanup_pending: 1 }), /Old thumbnails in 1 place wait to be removed/);
assert.equal(statusText(undefined), '');
assert.match(statusText(SOURCE, { mode: 'folder', path: '/data/thumbnails' }),
/^Now: inside each source\. The suggested folder is next to your library/);
assert.equal(statusText(SOURCE, { mode: 'folder', path: '/other' }), 'Now: inside each source.');
});
test('Review move… is offered only for another location', async () => {
const { choiceChanged } = await locationModule;
assert.equal(choiceChanged(SOURCE, { mode: 'source' }), false);
assert.equal(choiceChanged(SOURCE, { mode: 'folder', path: ' ' }), false);
assert.equal(choiceChanged(SOURCE, { mode: 'folder', path: '/data/thumbnails' }), true);
assert.equal(choiceChanged(FOLDER, { mode: 'folder', path: 'E:\\Thumbnails' }), false);
assert.equal(choiceChanged(FOLDER, { mode: 'folder', path: 'F:\\Thumbs' }), true);
assert.equal(choiceChanged(FOLDER, { mode: 'source' }), true);
assert.equal(choiceChanged({ ...SOURCE, problem: 'invalid' }, { mode: 'source' }), true);
assert.equal(choiceChanged(undefined, { mode: 'source' }), false);
});
test('the review lists what moves, what is removed and what stays', async () => {
const { reviewRows } = await locationModule;
const rows = Object.fromEntries(reviewRows(PLAN));
assert.equal(rows.From, 'Inside each source (3 sources)');
assert.equal(rows.To, 'E:\\Thumbnails (empty folder)');
assert.equal(rows.Thumbnails, '8,512 in 3 sources · 1.2 GB');
assert.equal(rows['Free space'], '1638.4 GB free');
assert.match(rows['Too long for Windows'][0], /^32 thumbnails cannot be stored there/);
assert.deepEqual(rows['Too long for Windows'][1], [...PLAN.too_long_listed, 'and 30 more']);
assert.deepEqual(rows['Removed after the move'][1],
['41 thumbnails whose original is gone', '2 unfinished temporary files']);
assert.equal(rows['Left in place'][0], '1 file that is not a thumbnail:');
assert.equal(rows['Links skipped'], '1 link inside a thumbnails folder is not followed');
});
test('the review of a move back names the folder and the removed sources', async () => {
const { reviewRows } = await locationModule;
const back = reviewRows({ ...PLAN, from: { mode: 'folder', path: '/data/thumbnails' },
to: { mode: 'source', path: null }, unlisted: [{ key: 'old-1c22e0a4', files: 240 }],
totals: { sources: 1, files: 1, bytes: 10 }, free_bytes: undefined });
const rows = Object.fromEntries(back);
assert.equal(rows.From, '/data/thumbnails');
assert.equal(rows.To, 'Inside each source (1 source)');
assert.equal(rows['Free space'], undefined);
assert.match(rows['Removed after the move'][0], /The folder itself is removed once empty/);
assert.deepEqual(rows['Removed after the move'][1],
['1 source no longer listed: 240 thumbnails are removed; they are made again if it returns']);
const fresh = Object.fromEntries(reviewRows({ ...PLAN, to: { mode: 'folder', path: '/d', default: true, will_create: true } }));
assert.equal(fresh.To, '/d (new, next to your library)');
});
test('the notice says why the folder is unavailable', async () => {
const { noticeText } = await locationModule;
assert.match(noticeText({ ...FOLDER, problem: 'missing' }), /^E:\\Thumbnails is missing\. Originals are shown/);
assert.match(noticeText({ ...FOLDER, problem: 'unreachable' }), /can’t be reached/);
assert.match(noticeText({ ...FOLDER, problem: 'marker-mismatch' }), /holds another thumbnail folder/);
assert.match(noticeText({ ...FOLDER, problem: 'invalid' }), /config\.json can’t be used/);
assert.match(noticeText({ ...FOLDER, problem: null }), /is being checked/);
});
test('sizes are written as the server writes them', async () => {
const { sizeText } = await locationModule;
assert.equal(sizeText(512), '512 B');
assert.equal(sizeText(85.2 * 1024 * 1024), '85.2 MB');
});
test('a move and a prune show their own tiles in Activity', async () => {
const { viewOf, movePhase, finishToast } = await kindsModule;
const move = { kind: 'thumbnails', state: 'running', cancellable: true, scope: { mode: 'move', from: 'source' },
counters: { moved: 3, skipped: 0, left: 1, errors: 2 }, status_line: '' };
assert.deepEqual(viewOf(move).tiles.map(([group, label]) => label), ['Moved', 'Already there', 'Left in place', 'Errors']);
assert.equal(viewOf(move).cancelLabel, 'Cancel move');
assert.match(movePhase(move), /^Step 1 of 3: copying\. Thumbnails are still read from inside each source/);
assert.match(movePhase({ ...move, cancellable: false }), /can no longer be cancelled/);
assert.match(movePhase({ ...move, scope: { mode: 'move', phase: 'remove' } }), /Removing the old copies/);
assert.match(movePhase({ ...move, state: 'finished' }), /1 file that is not a thumbnail was left in place/);
assert.match(movePhase({ ...move, state: 'cancelled' }), /^Nothing changed/);
const done = { ...move, state: 'finished', status_line: 'Thumbnails moved to E:\\T: 3 moved · old copies removed.' };
assert.equal(finishToast(done), done.status_line);
const prune = { kind: 'thumbnails', state: 'running', cancellable: true, scope: { mode: 'prune' },
counters: { removed: 1, left: 0, errors: 0 } };
assert.deepEqual(viewOf(prune).tiles.map(([, label]) => label), ['Removed', 'Left in place', 'Errors']);
const generate = { kind: 'thumbnails', state: 'running', cancellable: true, scope: { mode: 'missing' },
counters: { generated: 0, skipped: 0, errors: 0 } };
assert.deepEqual(viewOf(generate).tiles.map(([, label]) => label), ['Generated', 'Skipped', 'Errors']);
});
test('the prune scope reads its plan line from the server', async () => {
const { planText, PLAN_FAILED, PRUNE_OPTION } = await reviewModule;
assert.equal(PRUNE_OPTION[1], 'Remove thumbnails of removed sources');
assert.equal(planText({ mode: 'prune', line: '2 sources no longer listed: 1,240 thumbnails (85.2 MB) are removed.' }),
'2 sources no longer listed: 1,240 thumbnails (85.2 MB) are removed.');
assert.equal(planText({ mode: 'prune' }), PLAN_FAILED);
});
test('Move library says where thumbnails kept in one folder stay', async () => {
const { reviewGrid } = await libraryModule;
const base = { path: 'p', target: 't', size_bytes: 0, config_moves: true };
assert.equal(reviewGrid(base).some(([term]) => term === 'Thumbnails'), false);
const rows = Object.fromEntries(reviewGrid({ ...base, thumbnails_folder: '/data/thumbnails' }));
assert.equal(rows.Thumbnails, 'Stay in /data/thumbnails. Move them in System › Sources & scans.');
});

Some files were not shown because too many files have changed in this diff Show More