Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ecde6c410 | ||
|
|
3dc7d6f26f | ||
|
|
f935521eac | ||
|
|
5ad4777e77 | ||
|
|
024ccd3acf | ||
|
|
e6f4bf9214 | ||
|
|
ea29af0976 | ||
|
|
2561d3b789 | ||
|
|
b8489d0369 | ||
|
|
7633179fcb | ||
|
|
1b8fbdbb75 | ||
|
|
0095db8cbc | ||
|
|
b4c0189225 | ||
|
|
b7cf210f0f | ||
|
|
bc14f29db7 | ||
|
|
f95f66d76a |
@@ -16,9 +16,19 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
# Timeouts are the measured job time plus at least 50%, and never under 20 minutes for a browser job,
|
||||
# because runner speed varies. Before sharding (main 2cecfcf, one worker): frontend coverage 32m10s,
|
||||
# page workflows firefox 18m59s, webkit 19m26s. First sharded run (PR #274 run 39712909047, 3 workers,
|
||||
# no npm cache, where npm ci stalled for 5m in six of eight jobs): coverage shards 11m19s/10m47s/10m49s
|
||||
# (tests 5m20s/4m42s/4m46s), merge 5m52s (report and check 4s), firefox shards 7m53s/9m27s (tests
|
||||
# 4m41s/3m29s), webkit shards 12m25s+ (cancelled at the old 12-minute limit after its 267 tests passed in
|
||||
# 6m29s) and 4m53s (tests 3m08s). With the npm cache hit (run 39725176320 at cbc8a61, npm ci 0-2s):
|
||||
# coverage shards 7m34s/6m04s/5m22s (tests 6m38s/4m53s/4m04s), merge 54s, firefox 7m57s/5m21s (tests
|
||||
# 7m08s/4m34s), webkit 8m16s/5m03s (tests 7m26s/3m58s); a cache miss adds up to 5m to npm ci, which
|
||||
# the 20-minute limits absorb.
|
||||
jobs:
|
||||
frontend-coverage:
|
||||
name: frontend coverage (chromium and touch)
|
||||
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;
|
||||
@@ -26,12 +36,15 @@ jobs:
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble
|
||||
options: --user 1001
|
||||
# This job runs every page workflow twice (chromium, then touch) on one worker with V8 coverage on, so it
|
||||
# takes about twice a single-browser job. Measured 2026-10-20: whole job 18m26s-24m57s (main 5ded3cf
|
||||
# 22m15s; PR head 6359999 was cancelled at the old 25-minute limit while still passing), coverage step
|
||||
# alone 20m55s-22m17s+, against ~12-15 minutes for the firefox/webkit jobs. 40 minutes leaves headroom
|
||||
# for suite growth while still stopping a hung run; re-measure if a run passes 30 minutes.
|
||||
timeout-minutes: 40
|
||||
timeout-minutes: 20
|
||||
# Public-repo ubuntu-latest runners have 4 vCPUs: three Playwright workers, each with its own test
|
||||
# server, leave one CPU for the servers and the runner.
|
||||
env:
|
||||
PM_TEST_WORKERS: 3
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
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
|
||||
@@ -50,24 +63,107 @@ jobs:
|
||||
"$venv/bin/python" -m pip install --no-cache-dir -r requirements-dev.txt
|
||||
echo "$venv/bin" >> "$GITHUB_PATH"
|
||||
- run: python --version && node --version && node --version | grep -q '^v24\.'
|
||||
# npm's download cache, keyed on the lockfile; inside the container ~ is the job's HOME (/github/home),
|
||||
# which npm also uses. node_modules itself is still built fresh by npm ci.
|
||||
- name: Cache npm downloads
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: npm-${{ runner.os }}-playwright-v1.64.1-${{ hashFiles('tools/vendor/package-lock.json') }}
|
||||
- run: npm ci --prefix tools/vendor --ignore-scripts
|
||||
- run: npm run --prefix tools/vendor check
|
||||
- run: npm run --prefix tools/vendor test
|
||||
- name: Frontend unit, viewer, workflow, and coverage checks
|
||||
run: npm run --prefix tools/vendor test:coverage
|
||||
- name: Upload frontend coverage and Chromium traces
|
||||
# Shard 1 also runs the node units and the viewer suite, so the vendor pins and the tooling tests
|
||||
# run on the lightest shard instead.
|
||||
- if: matrix.shard == 3
|
||||
run: npm run --prefix tools/vendor check
|
||||
- if: matrix.shard == 3
|
||||
run: npm run --prefix tools/vendor test
|
||||
- name: Page workflows with coverage, shard ${{ matrix.shard }}/3
|
||||
run: npm run --prefix tools/vendor test:coverage:shard -- ${{ matrix.shard }}/3
|
||||
- name: Upload raw coverage
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: coverage-raw-${{ matrix.shard }}
|
||||
path: coverage/js/raw/
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- name: Upload Chromium and touch traces
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: frontend-traces-shard-${{ matrix.shard }}
|
||||
path: tools/vendor/test-results/
|
||||
retention-days: 14
|
||||
|
||||
frontend-coverage-merge:
|
||||
name: frontend coverage (merge)
|
||||
needs: frontend-coverage
|
||||
runs-on: ubuntu-latest
|
||||
# Same image as the shards: the raw files name sources by the container's workspace path.
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble
|
||||
options: --user 1001
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- run: node --version && node --version | grep -q '^v24\.'
|
||||
# npm's download cache, keyed on the lockfile; inside the container ~ is the job's HOME (/github/home),
|
||||
# which npm also uses. node_modules itself is still built fresh by npm ci.
|
||||
- name: Cache npm downloads
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: npm-${{ runner.os }}-playwright-v1.64.1-${{ hashFiles('tools/vendor/package-lock.json') }}
|
||||
- run: npm ci --prefix tools/vendor --ignore-scripts
|
||||
# Each artifact lands in its own coverage-raw-N directory, so the next step can count them.
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: coverage-raw-*
|
||||
path: ${{ runner.temp }}/coverage-raw
|
||||
# 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: 3
|
||||
run: |
|
||||
found=0
|
||||
for shard in $(seq 1 "$EXPECTED_SHARDS"); do
|
||||
directory="$STAGED_RAW/coverage-raw-$shard"
|
||||
count=0
|
||||
if [ -d "$directory" ]; then count=$(find "$directory" -maxdepth 1 -type f -name '*.json' | wc -l); fi
|
||||
echo "coverage-raw-$shard: $count raw files"
|
||||
if [ "$count" -gt 0 ]; then found=$((found + 1)); fi
|
||||
done
|
||||
echo "shard artifacts with raw coverage: $found of $EXPECTED_SHARDS"
|
||||
if [ "$found" -ne "$EXPECTED_SHARDS" ]; then
|
||||
echo "::error::Expected raw coverage from $EXPECTED_SHARDS shards, got $found of $EXPECTED_SHARDS"
|
||||
exit 1
|
||||
fi
|
||||
staged=$(find "$STAGED_RAW" -mindepth 2 -maxdepth 2 -type f -name '*.json' | wc -l)
|
||||
mkdir -p coverage/js/raw
|
||||
# cp -n never overwrites; a clash shows up as fewer merged files than staged ones.
|
||||
find "$STAGED_RAW" -mindepth 2 -maxdepth 2 -type f -name '*.json' -exec cp -n -t coverage/js/raw {} + || true
|
||||
merged=$(find coverage/js/raw -maxdepth 1 -type f -name '*.json' | wc -l)
|
||||
if [ "$merged" -ne "$staged" ]; then
|
||||
echo "::error::Shards wrote clashing raw file names: $staged staged, $merged merged"
|
||||
exit 1
|
||||
fi
|
||||
echo "merged $merged raw files"
|
||||
- name: Frontend coverage report and baseline check
|
||||
run: npm run --prefix tools/vendor coverage:merge
|
||||
- name: Upload frontend coverage report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: frontend-test-reports
|
||||
path: |
|
||||
tools/vendor/test-results/
|
||||
coverage/js/
|
||||
!coverage/js/raw/**
|
||||
retention-days: 14
|
||||
|
||||
browser-workflows:
|
||||
name: page workflows (${{ matrix.browser }})
|
||||
name: page workflows (${{ matrix.browser }}, shard ${{ matrix.shard }}/2)
|
||||
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;
|
||||
@@ -75,11 +171,16 @@ jobs:
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble
|
||||
options: --user 1001
|
||||
timeout-minutes: 25
|
||||
timeout-minutes: 20
|
||||
# Public-repo ubuntu-latest runners have 4 vCPUs: three Playwright workers, each with its own test
|
||||
# server, leave one CPU for the servers and the runner.
|
||||
env:
|
||||
PM_TEST_WORKERS: 3
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
browser: [firefox, webkit]
|
||||
shard: [1, 2]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
# The image's own Python 3.12 and Node 24: setup-python/setup-node install the runner's toolcache
|
||||
@@ -98,22 +199,29 @@ jobs:
|
||||
"$venv/bin/python" -m pip install --no-cache-dir -r requirements-dev.txt
|
||||
echo "$venv/bin" >> "$GITHUB_PATH"
|
||||
- run: python --version && node --version && node --version | grep -q '^v24\.'
|
||||
# npm's download cache, keyed on the lockfile; inside the container ~ is the job's HOME (/github/home),
|
||||
# which npm also uses. node_modules itself is still built fresh by npm ci.
|
||||
- name: Cache npm downloads
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: npm-${{ runner.os }}-playwright-v1.64.1-${{ hashFiles('tools/vendor/package-lock.json') }}
|
||||
- run: npm ci --prefix tools/vendor --ignore-scripts
|
||||
- name: Page workflows in ${{ matrix.browser }}
|
||||
run: npm run --prefix tools/vendor test:workflows -- --project ${{ matrix.browser }}
|
||||
- name: Page workflows in ${{ matrix.browser }}, shard ${{ matrix.shard }}/2
|
||||
run: npm run --prefix tools/vendor test:workflows -- --project ${{ matrix.browser }} --shard ${{ matrix.shard }}/2
|
||||
- name: Upload browser traces
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: browser-traces-${{ matrix.browser }}
|
||||
name: browser-traces-${{ matrix.browser }}-shard-${{ matrix.shard }}
|
||||
path: tools/vendor/test-results/
|
||||
retention-days: 14
|
||||
|
||||
# Branch protection requires the exact context "viewerjs". This job keeps that
|
||||
# name and fails unless every frontend job above succeeded, including when one
|
||||
# was cancelled or skipped.
|
||||
# name and fails unless every frontend job above succeeded (every shard, the
|
||||
# coverage merge and every browser shard), including when one was cancelled or skipped.
|
||||
viewerjs:
|
||||
needs: [frontend-coverage, browser-workflows]
|
||||
needs: [frontend-coverage, frontend-coverage-merge, browser-workflows]
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
@@ -121,8 +229,10 @@ jobs:
|
||||
- name: Require every frontend job to succeed
|
||||
env:
|
||||
COVERAGE_RESULT: ${{ needs.frontend-coverage.result }}
|
||||
MERGE_RESULT: ${{ needs.frontend-coverage-merge.result }}
|
||||
WORKFLOWS_RESULT: ${{ needs.browser-workflows.result }}
|
||||
run: |
|
||||
echo "frontend-coverage: $COVERAGE_RESULT"
|
||||
echo "frontend-coverage-merge: $MERGE_RESULT"
|
||||
echo "browser-workflows: $WORKFLOWS_RESULT"
|
||||
test "$COVERAGE_RESULT" = success && test "$WORKFLOWS_RESULT" = success
|
||||
test "$COVERAGE_RESULT" = success && test "$MERGE_RESULT" = success && test "$WORKFLOWS_RESULT" = success
|
||||
|
||||
+417
-28
@@ -39,9 +39,44 @@ npm run --prefix tools/vendor test:coverage
|
||||
npm run --prefix tools/vendor test:workflows -- --project firefox --project webkit
|
||||
```
|
||||
|
||||
`PM_TEST_PYTHON` selects the Python executable; `PM_TEST_PORT` selects the local
|
||||
port (default 8765). Set these environment variables when Python is outside PATH
|
||||
or the port is occupied. Test servers are never reused. On unsupported Linux
|
||||
`PM_TEST_PYTHON` selects the Python executable; set it when Python is outside
|
||||
PATH. Every Playwright worker starts its own `tests/browser/server.py --port 0`,
|
||||
which binds a port the OS picks and prints it as `PM_TEST_SERVER_PORT=<n>`, with
|
||||
its own temp root and database (`tests/browser/fixtures.mjs`), so
|
||||
parallel workers and concurrent runs never share state. `PM_TEST_WORKERS` sets the
|
||||
worker count (default 1 under `CI`, otherwise a quarter of the CPUs, at most 8).
|
||||
Test servers are never reused across runs. The launcher holds each server's
|
||||
stdin open and the server (`--exit-on-stdin-eof`) shuts down cleanly, removing its
|
||||
temp root, when that pipe closes, so a worker killed without teardown leaves no
|
||||
server behind. A test that fails gets the stderr its server wrote during the test
|
||||
attached to the report as `server-stderr` (an aiohttp traceback behind a 500).
|
||||
|
||||
The coverage run can be split the way CI splits it, and each shard keeps the raw
|
||||
V8 files it writes under `coverage/js/raw`:
|
||||
|
||||
```sh
|
||||
PM_TEST_WORKERS=8 npm run --prefix tools/vendor test:coverage # whole run, one report
|
||||
npm run --prefix tools/vendor test:coverage:shard -- 1/3 # node units, viewer suite, workflows 1/3
|
||||
npm run --prefix tools/vendor test:coverage:shard -- 2/3 # workflows 2/3 (likewise 3/3)
|
||||
npm run --prefix tools/vendor coverage:merge # report and baseline check of every raw file
|
||||
npm run --prefix tools/vendor test:workflows -- --project webkit --shard 2/2
|
||||
```
|
||||
|
||||
`coverage:merge` refuses an empty raw directory; it does not count shards, so run
|
||||
every shard first (CI's merge job checks all three arrived). Measured on a 32-CPU
|
||||
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`,
|
||||
`PM_TEST_WORKERS=3`, all four projects once, 2044 tests) takes 25.5 minutes.
|
||||
|
||||
Specs must pass beside each other, not only alone. Wait for the page's own
|
||||
signal, never for time: a rendering frame (`requestAnimationFrame`) for layout
|
||||
work queued by a render or a scroll, a response or a visible state for data. A
|
||||
timer that "usually" covers a frame is a race on a loaded runner. Library
|
||||
virtualization specs settle with `libraryObservation.settled()` (two consecutive
|
||||
frames with no Library frame queued). To look for such races, repeat a spec under
|
||||
load (`--workers 8 --repeat-each 10`, and in the image with `--cpus=1` or `2`).
|
||||
On unsupported Linux
|
||||
distributions, browser system dependencies may need manual installation. For a
|
||||
quick browser run, use `test:workflows -- --project chromium`.
|
||||
|
||||
@@ -114,16 +149,39 @@ second without a browser and fails, naming the files, when a measured
|
||||
`web/js` file has no baseline entry or the baseline lists a file that no longer
|
||||
exists. It uses the gate's own `discoverSources`, and `npm test` runs the same
|
||||
check, so run it before pushing a new or deleted frontend file instead of
|
||||
waiting for the `frontend coverage` job.
|
||||
waiting for the `frontend coverage` jobs.
|
||||
|
||||
The `viewerjs` check runs on every PR, including backend-only changes. It is an
|
||||
aggregate job that passes only when the `frontend coverage (chromium and touch)`
|
||||
job and both `page workflows (firefox)` and `page workflows (webkit)` jobs
|
||||
succeed; the browsers run on separate runners in parallel. GitHub artifacts
|
||||
named `frontend-test-reports` contain HTML coverage, a JSON summary, and the
|
||||
Chromium/touch failure screenshots/traces; `browser-traces-firefox` and
|
||||
`browser-traces-webkit` hold the other browsers' traces. Open traces with
|
||||
Playwright's `show-trace` tool.
|
||||
aggregate job that passes only when every frontend job succeeds: the three
|
||||
`frontend coverage (shard N/3)` jobs, `frontend coverage (merge)`, and the four
|
||||
`page workflows (firefox|webkit, shard N/2)` jobs. A failed, cancelled or skipped
|
||||
job fails it. Every browser job runs with `PM_TEST_WORKERS: 3` (the public
|
||||
runners have 4 vCPUs). Each coverage shard runs `test:coverage:shard -- N/3`
|
||||
(shard 1 also runs the node units and the viewer suite, shard 3 the vendor pins
|
||||
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
|
||||
baseline check). `tests/vendor/ci-workflow.test.mjs` checks this shape. GitHub
|
||||
artifacts named `frontend-test-reports` contain the merged HTML coverage and
|
||||
JSON summary; `frontend-traces-shard-N` hold the Chromium/touch failure
|
||||
screenshots/traces and `browser-traces-<browser>-shard-N` the other browsers'
|
||||
traces. Open traces with Playwright's `show-trace` tool.
|
||||
|
||||
Every `npm ci` first restores npm's download cache (`~/.npm`), keyed on the vendor
|
||||
lockfile and the job's pinned image tag (`tests/vendor/ci-workflow.test.mjs` checks
|
||||
the key names the same tag as the job's `container.image`). Without it, `npm ci` stalled for about 300 s
|
||||
(npm's fetch timeout) in most of the eight concurrent jobs; with a hit it takes
|
||||
0 to 2 s. A lockfile change misses the cache once, so expect one slow run after it.
|
||||
Measured on PR #274 (run 39725176320 at cbc8a61, cache hit): the whole workflow
|
||||
took 8m38s, against about 33 minutes with one worker and no shards. Jobs: coverage
|
||||
shards 7m34s/6m04s/5m22s (tests 6m38s/4m53s/4m04s), merge 54s, firefox shards
|
||||
7m57s/5m21s (tests 7m08s/4m34s), webkit shards 8m16s/5m03s (tests 7m26s/3m58s).
|
||||
Browser jobs keep a 20-minute limit: the slowest job is about 8.5 minutes, a cache
|
||||
miss adds up to 5 to `npm ci`, and runner speed varies (the first sharded run was
|
||||
cancelled at a 12-minute limit).
|
||||
Playwright shards by test count, whole files at a time, so shard 1 carries the
|
||||
slow concept-comparison specs (`*-concept.spec.mjs`, alphabetically first) and
|
||||
runs longer; the remedy, if it starts to matter, is more shards, not reordering.
|
||||
|
||||
The real-host suite covers both Classic and Nodes 2.0 startup, switching both
|
||||
ways, actual database prompt selection, execution-text serialization, workflow
|
||||
@@ -234,8 +292,9 @@ The shared inline and collection inspector reads the current rooted file's suppo
|
||||
embedded records, exact generation facts and recorded or inferred prompt evidence.
|
||||
It renders in two concept surfaces (#249 D20–D22, D38). The Images side panel shows
|
||||
only the concept content: cover, title, filename, the evidence badge with a category
|
||||
chip, the + and − prompt blocks with copy and send, tag chips (the linked positive's
|
||||
own tags filter Images, D21), the six facts, and a footer with "Send to ComfyUI" and
|
||||
chip, the + and − prompt blocks with copy and send, tag chips (the file's own image
|
||||
tags first, D88, then the linked tags; image tags and the linked positive's own tags
|
||||
filter Images, D21), the six facts, and a footer with "Send to ComfyUI" and
|
||||
"Prompt history" only when prompt roles resolved or a link is saved; the sticky panel
|
||||
scrolls inside the window. The viewer's details (`#viewer-details`) repeat that
|
||||
content without the cover, then "File & provenance" (the honest file facts, the
|
||||
@@ -274,7 +333,30 @@ becomes record format `limited` (no raw text, graph `limited`, roles and facts
|
||||
unaffected); other text chunks are skipped unread; byte-identical duplicates are
|
||||
accepted within a bounded read budget, while differing duplicates (and any duplicate
|
||||
role record over that budget) still fail. Other read bounds are unchanged: 8 MiB
|
||||
non-PNG header, 10,000 PNG chunks and a three-second ffprobe. Resolution under
|
||||
non-PNG header, 10,000 PNG chunks and a three-second ffprobe (per probe, at most two
|
||||
probes). Video containers are read through one shared carrier selector
|
||||
(`utils/video_metadata.select_video_carrier`, used by the inspector and by the admin
|
||||
scan): carriers are, in order, the per-key tags that current savers write (`prompt`,
|
||||
`workflow`, `parameters`, `comfyassets_prompt_pairs`, each `original-text`; Matroska
|
||||
upper-cased names fold on our side), then the old VideoHelperSuite `comment` JSON
|
||||
envelope, then `description` (object values are `reserialized-json`; envelope keys are
|
||||
matched exactly, as the legacy readers did, never folded). The first carrier holding a
|
||||
role key wins, otherwise the first holding a workflow, and carriers never merge. Two
|
||||
per-key tags folding to one key are a duplicate record: byte-identical ones are one
|
||||
record, differing ones fail the read. ffprobe is asked for exactly those six tags.
|
||||
Its JSON output escapes tag text (at most doubling it), so the stdout bounds are sized
|
||||
so that only the decoded per-record budgets above can fail or limit a record, and a
|
||||
video reads exactly as the identical PNG: the first probe allows twice the role and
|
||||
workflow budgets plus 64 KiB of envelope (12 MiB + 64 KiB); past that the inspector
|
||||
probes once more without the `workflow` tag within twice the role budget plus 64 KiB
|
||||
(4 MiB + 64 KiB) and the per-key workflow becomes `limited`, while an envelope over
|
||||
that bound still fails the read because it cannot be split. The admin scan reads the
|
||||
same carrier with the default resolver profile, so a per-key video links like the same
|
||||
graph in a PNG; the live monitor processes images only (unchanged, see the follow-up
|
||||
below) (`tests/test_utils_video_metadata.py`, `tests/test_api_admin.py`
|
||||
`TestScanReadsVideos`, `tests/test_api_prompt_inspector.py` `test_video_*`,
|
||||
`tests/test_inspection_response.py` `TestVideoProbeBoundsMatchPngBudgets`; the
|
||||
real-container round trips skip without ffmpeg). Resolution under
|
||||
`INSPECTION_PROFILE` bounds work, not graph size: 2,000 visits per candidate chain,
|
||||
20,000 in total, 64 link/JSON-container levels, 100,000 resolved characters, and a
|
||||
two 20,000-node ceilings: one over the top-level entries of the API prompt, and one
|
||||
@@ -688,8 +770,12 @@ Recorded shell deviations:
|
||||
"Save", not "Apply in prototype", and its toast drops the demo's word "Sample".
|
||||
- **D31**: at `max-width: 950px` the overlay caps the one-column shell at
|
||||
`minmax(0, 1fr)`, so extreme zoom cannot widen it to the sidebar's min-content.
|
||||
- **#245 D2**: no "Auto-tag images" on the Tags heading until the image-annotation
|
||||
slice.
|
||||
On the Images cards (#249 C2) a card's info row stacks its text above Info and Tag
|
||||
once the row is narrower than the narrowest bounded card (120px; a list row's info
|
||||
is what its art leaves), and the two buttons wrap when even that row cannot hold
|
||||
them (`stacksCardInfo`, `tests/js/workspace-image-layout.test.js`).
|
||||
- **#245 D2 (retired, #249 C2)**: the Tags heading shows the concept's "Auto-tag images";
|
||||
it opens the Tag image dialog (D115 when nothing is selected).
|
||||
- **Brand name**: not a deviation. The brand link's accessible name matches the
|
||||
concept's, mark included, because production's `.brand-mark` is not `aria-hidden`
|
||||
either. The inventory compares `brand.name` and `brand.mark.hidden`.
|
||||
@@ -712,13 +798,15 @@ zoom, the 2nd card opens the viewer titled "<title> · <filename>", Escape retur
|
||||
to it (production only: the concept re-renders its grid and drops focus), Info on the
|
||||
3rd card moves `.selected`, searches for "alpine" and for one image, and Load more with
|
||||
its toast. `mapImagesConcept` applies D3, D8 (the selection bar's real actions only),
|
||||
D9, D10, D14, D15, D17, D29, D32, D35 (singular counts) and D36 (categories
|
||||
D10, D14, D15, D17, D29, D32, D35 (singular counts) and D36 (categories
|
||||
sorted alphabetically). Removed toolbar and filter-row items flow again from the
|
||||
concept's own boxes (`reflow`), checked against the concept page with those items
|
||||
removed at runtime. Masonry tops and heights are compared unrounded, within one layout
|
||||
unit per card above in the column (D3). `one footer height for the collection (D3)`
|
||||
seeds two seedless files (`seedless_images` on `/__test__/concept/reset`) and asserts
|
||||
that every row reserves the tallest footer.
|
||||
seeds two seedless files (`seedless_images` on `/__test__/concept/reset`, with
|
||||
`short_seedless` so their "96 × 72 · PNG" subtitle stays on one line beside Info and
|
||||
Tag) and asserts that every row reserves the tallest footer and that their own footer
|
||||
is shorter than the row it reserves.
|
||||
|
||||
### Images page controls and recorded deviations (#249 PR C)
|
||||
|
||||
@@ -804,7 +892,9 @@ Recorded deviations on this page (spec §8):
|
||||
the concept` compares the bar and the checked card with one card checked; the omitted
|
||||
actions flow again from the concept's own boxes, checked against the concept page with
|
||||
them removed at runtime.
|
||||
- **D9**: no Tag button until the image-annotations slice.
|
||||
- **D9 (retired, #249 C2)**: every card shows the concept's Tag (`tagImage`, "Tag or
|
||||
auto-tag <filename>") after Info; it opens the Tag image dialog without selecting
|
||||
the card (see "Tag image dialog" below).
|
||||
- **D10**: the card title is the linked positive's display title, else the filename;
|
||||
the `h3` keeps the relative path in `title`.
|
||||
- **D11**: the subtitle is "W × H · EXT · Seed N" with unknown parts omitted.
|
||||
@@ -862,6 +952,8 @@ Recorded deviations on this page (spec §8):
|
||||
history appear only with resolved prompt roles or a saved link; a video file keeps
|
||||
`video.detail-cover`; and only the linked positive's own tags are `button.chip`
|
||||
filters, while recipe, recorded-run and embedded tags stay plain `span.chip`.
|
||||
Amended by D88 (#249 PR C): the file's own current image tags lead the same row
|
||||
and are filters too; a linked tag already shown (case-insensitive) is left out.
|
||||
- **D22**: an empty positive reads "No positive prompt (explicitly empty)" (the concept
|
||||
applies its negative wording to both roles), and a confirmed `p.prompt-text` is
|
||||
focusable (`tabindex=0`, marked focus ring) so long prompts stay keyboard-readable.
|
||||
@@ -968,6 +1060,186 @@ npm run --prefix tools/vendor test:workflows -- images-concept.spec.mjs \
|
||||
--project chromium --project touch --project firefox --project webkit
|
||||
```
|
||||
|
||||
### Tag image dialog and recorded deviations (#249 PR C)
|
||||
|
||||
Every Images card and every Library-embedded gallery card shows Info then the
|
||||
concept's Tag (`app.js` mediaCard). Tag opens the concept's **Tag image** dialog
|
||||
(`web/js/workspace/image-tagging.mjs` over the C1 controller
|
||||
`image-tagging-state.mjs`) in the shared modal shell with the concept's
|
||||
`tagging-modal` class, so its `.dialog-actions` stay sticky while the content
|
||||
scrolls. Focus starts on × and returns to the Tag button. The preview is the
|
||||
capture's own `preview_url` (through `ApiClient.resolveUrl`): an `img`, or a
|
||||
`video preload="metadata" muted`. The capture is released on ×, Escape, Cancel,
|
||||
Save, a route change (the shell closes its dialog) and `pagehide`, which closes the
|
||||
dialog (a page restored from the back/forward cache shows no dead dialog); the
|
||||
release `DELETE` and the Save `PUT` are sent with `keepalive` (`ApiClient`'s
|
||||
additive option), so they finish while the page unloads. A save still in flight
|
||||
when the dialog closes keeps its capture until the `PUT` settles, then releases it
|
||||
and still reports the outcome: the saved toast and every page's refresh, or the
|
||||
failure as a toast. Generate is explicit and uses the server's real defaults
|
||||
(`GET /prompt_manager/autotag/models`), and the fields show exactly those values
|
||||
(they are re-rendered when the observation lands and on a model change, never while
|
||||
typing); an invalid field reports native validity and sends nothing. A tag conflict
|
||||
shows the current set at once: the status line appends "Saved now: <tags>." and the
|
||||
manual field holds the current set ahead of the draft (repeats dropped), so the next
|
||||
Save sends what is shown against the current revision. Save writes the manual
|
||||
names, then the remaining chips, deduplicated case-insensitively (first spelling
|
||||
wins), and toasts the mock's copy verbatim, "<n> tags saved for <filename>." even
|
||||
for one tag, plus " Prompt association still awaits review." from Needs review.
|
||||
Tagging one file never changes another file (an equal-byte copy included), a
|
||||
prompt, a pair or a link. States the mock lacks (generating, busy, conflict,
|
||||
changed storage or file, expired capture, unconfirmed save, unavailable model,
|
||||
video) are text in `#tag-generation-status` only; **Retry** (D116) leads the
|
||||
actions while the controller allows it, where the other workspace dialogs place
|
||||
their Retry. The Tags
|
||||
page re-reads its lists after every confirmed save, so the Images counts follow.
|
||||
|
||||
The image-tagging plan's deviation numbers were renumbered by +23 because the
|
||||
Inspect plan keeps D60–D66 (plan D60 → D83, D61 → D84, D65 → D88, D68 → D91).
|
||||
|
||||
- **D83** (plan D60, ruling T4): the suggestions are removable chips instead of
|
||||
checkbox rows: `span.chip.tag-suggestion` in a `.tags` row, in the concept's
|
||||
checked-row tokens (accent border, `--soft-blue`), each with the name, the
|
||||
confidence (`98%` for WD14, "Suggested" for language models) and a × "Remove
|
||||
<tag>". A chip has the concept `.chip`'s 4px corner (the later `.tag-suggestion`
|
||||
row rule would give 5px) around the row's own 12px text. At most the requested
|
||||
count, all preselected. While chips exist, ruling T4's "Remove anything you do not
|
||||
want to add." (`p.small`) leads them inside `#tag-suggestions`; removing the last
|
||||
chip shows "All suggestions removed. Generate again or enter tags manually." The
|
||||
opening sentence is the concept's on-screen copy, "Generate suggestions with the
|
||||
selected model before approving.": the concept's markup carries another ("Choose a
|
||||
model… Uncheck anything…"), but its `renderTagModelOptions` (a1062–1076) replaces it
|
||||
as soon as the dialog opens, so the opened state is compared unmapped.
|
||||
- **D84** (plan D61, rulings T1/T2): the Tagging model options are legacy's four
|
||||
engines in the mock's order with legacy labels and sizes (`WD14 SwinV2 · ~400
|
||||
MB`, `WD14 ViT · ~400 MB`, `GGUF JoyCaption (Recommended) · ~4.5 GB` in the
|
||||
mock's Ollama slot, `HuggingFace JoyCaption · ~16 GB`); the instructions start
|
||||
from the server's `default_prompt`.
|
||||
- **D91** (plan D68, ruling T3-bulk): a **Suggestions** number field (1–100) for
|
||||
every model, prefilled from `autotag.defaults.suggestion_limit` (10); a model
|
||||
change keeps it.
|
||||
- **D88** (plan D65, ruling T7/C2): the inspector's chip row (side panel and viewer
|
||||
details, after the prompt blocks) shows the file's current image tags first (the
|
||||
B3 envelope's `image_tags`, `state: "current"`, S1), then D21's linked tags not
|
||||
already shown (case-insensitive, the image tag's spelling wins). In the Images
|
||||
panel every image tag is a Tags filter `button.chip` (the filter matches image
|
||||
tags too) and linked tags keep D21's rule; in the viewer, and wherever no Tags
|
||||
filter exists (the Library-embedded gallery, Activity and recorded runs), every
|
||||
chip is a plain `span.chip`. `none` and `unavailable` add no chips and no other
|
||||
chrome; a malformed facet fails the inspection like any malformed field. The
|
||||
concept has one row of the image's own tags (`i.tags`, app.js a402), so with the
|
||||
concept image's tags saved as its image tags production still equals the concept.
|
||||
After a Tag save the selected file's inspection is re-read, so the panel redraws
|
||||
the row without reselecting and the viewer, which shares that inspection, shows it.
|
||||
- **D115** (beyond the plan's list): the concept always has a selected image, so
|
||||
its "Auto-tag images" always opens the dialog. Production opens it for the image
|
||||
selected in Images when it was last left; with none it is `aria-disabled` and
|
||||
pressing it toasts "Select an image in Images first." (no new chrome).
|
||||
- **D116** (ruling T5): **Retry** leads `.dialog-actions` (before Cancel and the
|
||||
primary) while a failed observation, a failed Generate, an unconfirmed save or an
|
||||
answer that needs a fresh observation (changed storage or file, expired capture)
|
||||
can be retried; the concept has no failure states, so it has no Retry. After a
|
||||
failed Generate it takes Generate's own path (an invalid field reports its
|
||||
validity); an unconfirmed save resends the identical body; after a changed storage,
|
||||
file or expired capture it observes again and keeps the typed draft (when the saved
|
||||
set changed meanwhile, the current set leads the draft and the status line says
|
||||
"The tags of this image changed since the dialog opened. Saved now: <tags>.").
|
||||
Browser tests: "D116: Retry leads the actions after an unconfirmed save…", "D116:
|
||||
Retry after an expired capture observes again and keeps the typed draft (M5)" and
|
||||
the paired `production-unknown-outcome` capture.
|
||||
- **D29** (no prototype copy) in the dialog: no "Prototype · suggestions are
|
||||
simulated…" note, the status reads "Suggestions from <model>. Approve tags to save."
|
||||
(not "Sample suggestions from …"), and the toast has no " in this tab".
|
||||
|
||||
After a confirmed save the selected file's inspection is re-read quietly: the panel
|
||||
and the viewer keep the current details until the new answer lands, with no "Loading
|
||||
file details…" in between (`createInspectionController().refresh()`). While a Tags
|
||||
filter or a search is active the saved file may start or stop matching, so Images
|
||||
re-reads its loaded range in place (`createImageStream().refresh()`: the same query
|
||||
from the first page until the loaded count, published once). The cards, the scroll
|
||||
position and the selection stay while the selected file still matches; when it no
|
||||
longer does, the panel follows the collection (its first file), as after a reload,
|
||||
and the selection bar keeps only the checked files still listed, so its count is what
|
||||
Set category and Export metadata apply to. A refresh never runs beside a page load
|
||||
already in flight: asked during one, it is pending and runs once that page settles
|
||||
(success or failure; a reset drops it), and Load more waits for a running refresh
|
||||
(it neither loads nor moves focus). The re-read asks for pages as large as the rest of
|
||||
the loaded range (at most 200 files, at least one batch), one after another (each
|
||||
cursor comes from the page before); a failed re-read toasts "Images could not be
|
||||
refreshed after the tag save. Refresh image collection to retry." and keeps the
|
||||
cards. A save made while Images is left (Tags › Auto-tag images) marks a saved Images
|
||||
visit with a Tags filter or a search stale: returning lists it again with its
|
||||
filters instead of restoring the old cards. Browser tests: "the post-save re-read
|
||||
keeps the panel content until the new read lands", "a save under a Tags filter
|
||||
re-reads the listing: …", "a save under a search keeps the scroll position and the
|
||||
loaded range", "a failed listing re-read after a save says so and keeps the cards
|
||||
(M2)" and "a tag save from Tags reloads a filtered Images visit instead of restoring
|
||||
it (M6)"; the pending refresh and the page sizes are unit-tested.
|
||||
|
||||
Concept comparison (`tests/browser/tagging-concept.spec.mjs` with
|
||||
`tagging-canonical-comparison.mjs`, plan Task C4): production runs on the concept
|
||||
fixture with the concept image's own tags (`cinematic, blue_hour, architecture`)
|
||||
saved as its image tags, and the fake model answers the concept's own sample
|
||||
(`/__test__/tagging/suggestions`: mountain 0.98, observatory 0.95, snow 0.88,
|
||||
blue_hour 0.81), so the dialog's inventory (order, text, field attributes, tokens,
|
||||
preview box, chips, status, sticky actions) must equal the concept's at 1600×1100 and
|
||||
390×844, dark and light: opened, generated with WD14 and generated with a language
|
||||
model, plus the empty result (the concept above its own threshold), the review-scoped
|
||||
dialog with its scope note and the card's Info and Tag group (M1). `mapConceptTagging`
|
||||
applies only D29, D83, D84 and D91; a mutation check (the model panel's border
|
||||
removed, the primary relabelled) shows the comparison notices a change. Every
|
||||
production-versus-concept difference of PR C and its record:
|
||||
|
||||
| Difference | Record |
|
||||
| --- | --- |
|
||||
| Removable chips with × instead of checked checkbox rows; "Remove anything…" while chips exist; the all-removed copy | D83 (ruling T4) |
|
||||
| Legacy engine labels and values, GGUF in the Ollama slot, server default instructions | D84 (rulings T1/T2) |
|
||||
| Suggestions count field | D91 (ruling T3-bulk) |
|
||||
| No prototype note, "Suggestions from …", no " in this tab" | D29 |
|
||||
| Image tags first in the inspector and viewer chip row | D88 (ruling T7/C2) |
|
||||
| Auto-tag images `aria-disabled` without a selected image | D115 |
|
||||
| Retry leads the actions when the controller allows it | D116 (ruling T5) |
|
||||
| States the concept lacks (busy, conflict, changed storage or file, unconfirmed save, unavailable model, video) | Global Constraints: `#tag-generation-status` text only |
|
||||
|
||||
Native image tagging (#249 PR B routes, PR C UI): `POST
|
||||
/prompt_manager/image-annotations/observe` (capture one listed file, its saved set and
|
||||
`tag_revision`), `GET`/`DELETE /prompt_manager/image-annotations/capture/<token>`
|
||||
(the preview and its release), `POST /prompt_manager/image-annotations/suggest` (the
|
||||
gated top-N suggestions, at most the requested count) and `PUT
|
||||
/prompt_manager/image-annotations/tags` (re-hash, then save one byte record's set).
|
||||
Cheap reads follow spec amendment S1 (the verified `(size, mtime_ns)` fingerprint;
|
||||
residual risk above, under "Image annotations schema"). No automated test downloads
|
||||
or loads a model: the browser server injects `TestModel`, whose `download_model`
|
||||
raises, and every tagging spec asserts no `/autotag/download/` request. Capture
|
||||
limits are those of the capture store above (256 MiB per file, 8 completed captures,
|
||||
512 MiB reserved, 900 s idle, 1800 s hard). The browser fixtures release every capture
|
||||
on reset. PR C is HTML, CSS and JS only (no restart); the PR B routes it calls need a
|
||||
maintainer ComfyUI restart once.
|
||||
|
||||
Test files: `tests/browser/workspace-tagging.spec.mjs` (the fixture is
|
||||
`tests/browser/workspace_tagging_fixture.py`: two files sharing one prompt and
|
||||
pair, an equal-byte copy, a video, an unlinked file, the fake model with `hf`, and
|
||||
no model files; every test asserts no download request),
|
||||
`tests/js/workspace-image-tagging.test.js`,
|
||||
`tests/js/workspace-image-tagging-state.test.js`,
|
||||
`tests/js/workspace-images-lifecycle.test.js`, and for D88
|
||||
`tests/js/workspace-file-inspector.test.js`,
|
||||
`tests/js/workspace-inspection-state.test.js` and `tests/browser/inspector-concept.spec.mjs`
|
||||
("image tags saved on the concept image keep the inspector equal to the concept"), and
|
||||
for the comparison `tests/browser/tagging-concept.spec.mjs` and
|
||||
`tests/js/workspace-images-state.test.js` (the in-place refresh), and on all four
|
||||
projects `tests/browser/workspace-tagging-lifecycle.spec.mjs` (leaving the page with
|
||||
the dialog open releases the capture with no console error; closing during a save
|
||||
releases it after the save and still reports it).
|
||||
|
||||
```sh
|
||||
node --test tests/js/workspace-image-tagging*.test.js tests/js/workspace-images-lifecycle.test.js
|
||||
npm run --prefix tools/vendor test:workflows -- workspace-tagging.spec.mjs tagging-concept.spec.mjs \
|
||||
workspace-vocabulary.spec.mjs images-concept.spec.mjs --project chromium --project touch
|
||||
npm run --prefix tools/vendor test:workflows -- workspace-tagging-lifecycle.spec.mjs tagging-concept.spec.mjs \
|
||||
--project firefox --project webkit
|
||||
```
|
||||
|
||||
### Prompt library cards and recorded deviations (#249 PR E)
|
||||
|
||||
The shelf uses the shared concept toolbar and caption (E1) and the concept's
|
||||
@@ -1286,6 +1558,83 @@ concept fixture stars the concept's favorites (each other prompt's first image).
|
||||
restore. Old backups without the table restore, and backup plus restore keeps
|
||||
WAL-resident favorites and closes every handle (`tests/test_database_backup.py`).
|
||||
|
||||
Image annotations schema (#249, `database/annotation_schema.py`): the group
|
||||
`media_annotation_metadata` (one row: schema version, `image_index_revision`),
|
||||
`media_annotation_files` (one private locator per physical file,
|
||||
`normcase(join(realpath(root), relative))`, server-side only),
|
||||
`media_annotation_bytes` (one record per observed whole-file SHA-256 with the
|
||||
`size`/`mtime_ns` verified when those bytes were last seen and the set's
|
||||
`tag_revision`) and `media_image_tags` (the ordered tag set of one byte record,
|
||||
joined into the global `tags` registry). It installs like favorites: optional in
|
||||
old backups, required once current (`annotation_schema_error`, the single
|
||||
`require_library_state` flag), validated read-only before and after a SAVEPOINT
|
||||
install and on restore, never backfilled and never stat-ing media
|
||||
(`tests/test_annotation_schema.py`). Writers (`database/annotations.py`) always
|
||||
supply the UUID ids, run in `_pair_transaction` fenced by the storage epoch, and
|
||||
replace or union one byte record's set (`tag-conflict` / `tag-limit` with the
|
||||
current set, identical retries `replayed`); vocabulary rename, merge and delete
|
||||
carry `media_image_tags` through the one `_move_tag_links` loop, and tag rows,
|
||||
impact and command receipts report `image_count` / `images` /
|
||||
`images_changed` as files (`tests/test_annotations.py`,
|
||||
`tests/test_pair_tag_maintenance.py`, `tests/test_vocabulary.py`). Cheap reads
|
||||
(spec amendment S1) use a byte record only while the file's current
|
||||
`(size, mtime_ns)` equals its verified fingerprint; an in-place rewrite that
|
||||
preserves both keeps the old set matching until the file is next observed. The
|
||||
Images Tags filter (a file matches when every tag is on its linked positive or
|
||||
its current image tag set) and `q` search stream the tagged records once and
|
||||
stat only the listed files whose records carry a filter tag or a `q` hit; the
|
||||
inspector's `image_tags` facet (`current` / `none` / `unavailable`) uses the
|
||||
inspection's own stable stat (`tests/test_api_workspace_images.py`
|
||||
`TestWorkspaceImageTags`, `tests/test_api_prompt_inspector.py`). The
|
||||
browser fixtures clear `media_image_tags`, `media_annotation_bytes` and
|
||||
`media_annotation_files` on reset and never the metadata row.
|
||||
|
||||
Capture store (#249, `utils/media_capture.py`, `tests/test_media_capture.py`):
|
||||
one explicit action captures one rooted media file into an owner-only
|
||||
`prompt-manager-captures-*` temp directory, streaming it in 1 MiB chunks while
|
||||
hashing. Identity is compared like with like: the opened descriptor against
|
||||
the path only on the API-stable facts (`size`, `mtime_ns`); the full identity
|
||||
(`size`, `mtime_ns`, `ctime_ns`, `dev`/`ino` where meaningful) between the
|
||||
`fstat` before and after the copy and between the path stats before and after
|
||||
(native Windows CI with Python 3.12/3.13 showed `stat` and `fstat` disagreeing
|
||||
on `st_ctime_ns`: change time versus creation time; 3.10/3.11 agree). Any
|
||||
disagreement is `CaptureChanged` and the copy is removed. Error messages carry
|
||||
`strerror` only, never a path. Aliases (a symlinked
|
||||
file or directory, even inside the root), escapes, non-media names and
|
||||
directories are `CaptureDenied`; the locator is
|
||||
`normcase(join(realpath(root), relative))`, so a symlinked root shares the
|
||||
listing's identity. Fixed limits: 256 MiB per file (stat and stream), 8
|
||||
completed captures, 512 MiB reserved, 2 capture/verify workers, 900 s idle,
|
||||
1800 s hard; admission is nonblocking (`CaptureCapacity`) and lifetimes are
|
||||
tested with an injected clock. An acquired capture outlives expiry and release
|
||||
until its last use ends. Header-only dimensions (4096 px per side,
|
||||
16,777,216 px) and video mark Generate unsupported; `verify` re-hashes the
|
||||
current file without a copy. Every handle closes before unlink (a patched
|
||||
`os.unlink` asserts it, plus `assert_closed`), and files are unlinked outside
|
||||
the store lock. Known limit: on Windows before Python 3.13 `fstat` reports the
|
||||
creation time, so a same-size rewrite that restores `mtime_ns` during the copy
|
||||
window completes there with a digest of the copied bytes; POSIX and Windows on
|
||||
3.13+ detect it through the descriptor's change time (native CI, 3.13 only).
|
||||
Vocabulary rename, merge and delete bump the `tag_revision` of every image set
|
||||
they change (and `image_index_revision`), so a Tag dialog opened before the
|
||||
change gets `tag-conflict` on Save (`tests/test_pair_tag_maintenance.py`).
|
||||
A case or Unicode spelling of a listed name (`A.PNG` for `a.png`, an NFD name)
|
||||
is an alias too: each path part must be spelled as its directory lists it, so a
|
||||
case- or normalization-insensitive filesystem (macOS, Windows) cannot store a
|
||||
second locator. The capture preview answers one byte range with 206 (416 for
|
||||
an unsatisfiable or multi-range header) inside the capture's pin, and a
|
||||
cancelled preview, observe or Generate still closes its handle and ends its pin
|
||||
or capture (`tests/test_api_annotations.py` `TestCancellation`,
|
||||
`tests/test_model_gate.py`).
|
||||
|
||||
Follow-ups (tracked, not accepted): the legacy Auto-tag batch's cleanup
|
||||
`unload_model()` in `py/api/autotag_routes.py` is not gated against the
|
||||
batch's own in-flight inference when the batch coroutine is cancelled (other
|
||||
owners are kept out; the thread may log an error); and `py/autotag.py`,
|
||||
`py/api/images.py` and `database/operations.py` are over the 800-line ceiling
|
||||
(all three were before #249 PR B), with a split of `py/autotag.py` into
|
||||
backends and service planned first.
|
||||
|
||||
Budgets: `test_filter_work_is_bounded` always runs. It asserts that a plain first
|
||||
page makes 1 link SELECT and a plain cursor page none, that every filter shape
|
||||
makes exactly 1, the covering-index plan, and that the per-file values are built
|
||||
@@ -1556,7 +1905,7 @@ bytes at the same path without changing the immutable receipt.
|
||||
The focused acceptance command retains all four projects:
|
||||
|
||||
```sh
|
||||
PM_TEST_PORT=8766 npm run --prefix tools/vendor test:workflows -- \
|
||||
npm run --prefix tools/vendor test:workflows -- \
|
||||
workspace-history.spec.mjs workspace-visual.spec.mjs \
|
||||
--project chromium --project touch --project firefox --project webkit
|
||||
npm run --prefix tools/vendor test:executions
|
||||
@@ -1589,8 +1938,8 @@ page lifecycle recovery retain their separate behavioral tests.
|
||||
Initial production acceptance requires maintainer inspection of paired PNGs and
|
||||
geometry; never update snapshots automatically or mask content to bless a gap.
|
||||
Data-dependent text/images/counts and the clearly labeled saved/frozen/current
|
||||
distinctions are documented in the comparison helper. The Images card tag
|
||||
button (#249 D9) remains outside #209; every prompt block now renders the concept's
|
||||
distinctions are documented in the comparison helper. The gallery cards carry the
|
||||
concept's Tag button beside Info (#249 C2 retired D9); every prompt block now renders the concept's
|
||||
`section.prompt-section` (D22), so the shared role-block spacing, padding and glyph
|
||||
gap is closed, and only the production-only surfaces above keep their own rules
|
||||
(D48). The
|
||||
@@ -1598,6 +1947,47 @@ responsive production Viewer keeps its toolbar in the media panel and prompt det
|
||||
mobile toolbar collision. These are explicit remaining visual differences, not
|
||||
a whole-application or full-gallery/Viewer pixel-parity claim.
|
||||
|
||||
Follow-up: monitor video support. The live image monitor (`utils/image_monitor.py`,
|
||||
`GalleryConfig.SUPPORTED_EXTENSIONS`) processes images only; videos written by
|
||||
ComfyUI savers reach the Library through the admin scan, which reads the shared video
|
||||
carrier. Accepting `VIDEO_EXTENSIONS` in the monitor and routing them through
|
||||
`read_video_metadata` is a separate, issue-linked change.
|
||||
|
||||
Follow-up: local Inspect parity (#249 PR L1). The browser readers
|
||||
(`web/js/png-metadata.js`, `web/js/workspace/local-video.mjs`,
|
||||
`web/js/workspace/local-projection.mjs`) answer as the server does for the same bytes
|
||||
and a matching extension (`tests/test_local_inspect_parity.py`; the real-ffprobe cases
|
||||
skip without ffmpeg). The remaining known divergences, checked against ffprobe 9.0.2,
|
||||
`read_inspection_file` and `_metadata`, are tracked here, not accepted as parity:
|
||||
|
||||
- Media kind comes from the bytes locally and from the extension on the server: a PNG
|
||||
named `.mp4` is read as a PNG locally (format and size shown) while the server probes
|
||||
it with ffprobe (no format, no size); MP4 bytes named `.png` read locally as video
|
||||
while Pillow fails them on the server (`unreadable`). Parity is stated for the same
|
||||
bytes with a matching extension.
|
||||
- JPEG dimensions: the local header read stops at 64 KiB, so a JPEG whose SOF follows
|
||||
more than 64 KiB of APP segments (camera, Photoshop and EXIF/XMP workflow savers)
|
||||
shows no dimensions, while Pillow reads them within the server's 8 MiB header bound.
|
||||
The fix is a segment-header walk up to 8 MiB, which needs a plan amendment.
|
||||
- Classic QuickTime `moov/udta` text atoms (`©cmt`/`desc` with a 16-bit size and
|
||||
language, outside `meta`) are not read locally; ffprobe reports them as
|
||||
`comment`/`description`, so the server can select that envelope while the local
|
||||
read is `absent`. No ComfyUI saver writes them.
|
||||
- Tag-size bounds count raw bytes locally and ffprobe's escaped JSON on the server
|
||||
(first probe 12 MiB + 64 KiB, role-only re-probe 4 MiB + 64 KiB): only tag sets near
|
||||
those bounds and dense with quotes or control characters can make one side re-probe
|
||||
or fail while the other reads.
|
||||
- More than 10,000 container elements stop the local read (`limited`, metadata
|
||||
`unreadable`); ffprobe reads such files.
|
||||
- An EBML DocType other than `matroska`/`webm` is an invalid container locally; ffmpeg
|
||||
only warns and reads it.
|
||||
- Envelope JSON deeper than 1,000 levels is refused locally as Python's default
|
||||
recursion limit refuses it; the server's real limit depends on the interpreter and
|
||||
call stack. A duplicate key in an object enclosing a nest deeper than the running
|
||||
Python's json limit is `malformed` locally and `limited` on that Python.
|
||||
- Decimal exponent limits assume CPython's C `_decimal`; on a `_pydecimal` interpreter
|
||||
the server would read the over-limit exponents the local projection refuses.
|
||||
|
||||
Preserve raw logs, failed traces, paired PNGs, geometry, runtime versions and
|
||||
source hashes before later managed commands clear their output directories.
|
||||
Frontend baseline updates are additive through `coverage:update` only after
|
||||
@@ -1649,7 +2039,7 @@ acceptance; a baseline is added only after the maintainer accepts them.
|
||||
Focused commands:
|
||||
|
||||
```sh
|
||||
PM_TEST_PORT=8766 npm run --prefix tools/vendor test:workflows -- \
|
||||
npm run --prefix tools/vendor test:workflows -- \
|
||||
workspace-library-editor.spec.mjs library-editor-visual.spec.mjs \
|
||||
--project chromium --project touch --project firefox --project webkit
|
||||
npm run --prefix tools/vendor test:comfyui # library-sender.spec.mjs host regression
|
||||
@@ -1713,7 +2103,7 @@ legacy admin page. The screen consumes only the `/prompt_manager/vocabulary/*` r
|
||||
above and adds no route, so PR B needs no ComfyUI restart: a browser refresh is enough.
|
||||
|
||||
Screen: a `+ Create tag` heading action, a Tag vocabulary table (columns Tag, Prompts,
|
||||
Images shown as "—" with hidden "Not available yet", and Actions with Browse and
|
||||
Images counting the files that carry the tag (`image_count`, #249 C2), and Actions with Browse and
|
||||
Rename / merge), and a Categories panel whose category rows end with the Manage
|
||||
category row (Review). Lists load 100 rows at a time, append when scrolled into view
|
||||
and keep `Load more tags` / `Load more categories` while more remain. Prompts counts
|
||||
@@ -1749,13 +2139,12 @@ mobile, the concept geometry/token/control comparison and its mutation check),
|
||||
|
||||
```sh
|
||||
node --test tests/js/workspace-vocabulary-state.test.js tests/js/workspace-state.test.js
|
||||
PM_TEST_PORT=8766 npm run --prefix tools/vendor test:workflows -- \
|
||||
npm run --prefix tools/vendor test:workflows -- \
|
||||
workspace-vocabulary.spec.mjs vocabulary-visual.spec.mjs focused-node.spec.mjs \
|
||||
--project chromium --project touch --project firefox --project webkit
|
||||
```
|
||||
|
||||
Recorded deviations from the concept (maintainer acceptance): no Auto-tag images
|
||||
button and an Images "—" cell until the image-annotation slice; bounded lists with
|
||||
Recorded deviations from the concept (maintainer acceptance): bounded lists with
|
||||
Load more; title "Tags"; Manage categories adds a Category select and Preview before
|
||||
Apply; "Apply" instead of "Apply in prototype"; Delete tag… inside Rename or merge;
|
||||
Browse sets the exact filter; singular "1 positive prompt"; the production list appends
|
||||
|
||||
@@ -90,6 +90,7 @@ try:
|
||||
host_app = getattr(config.server_instance, "app", None)
|
||||
if host_app is not None and hasattr(host_app, "on_cleanup"):
|
||||
host_app.on_cleanup.append(api.cleanup_history_reads)
|
||||
host_app.on_cleanup.append(api.cleanup_annotation_captures)
|
||||
|
||||
except Exception as e:
|
||||
# Log to internal logging system without console spam
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
"""Image annotation schema group (#249): file locators, byte records, image tags.
|
||||
|
||||
The group installs like ``image_favorites`` and ``saved_views``: optional in old
|
||||
backups, required once a database is current. It owns
|
||||
|
||||
- ``media_annotation_metadata``: one row, the group's schema version and the
|
||||
image index revision;
|
||||
- ``media_annotation_files``: one private canonical locator per physical file
|
||||
(``normcase(join(realpath(root), relative))``, server-side only);
|
||||
- ``media_annotation_bytes``: one record per observed whole-file SHA-256 of a
|
||||
file, with the size and ``mtime_ns`` verified when those bytes were last seen
|
||||
and the tag set's revision;
|
||||
- ``media_image_tags``: the ordered image tag set of one byte record, joined
|
||||
into the global ``tags`` registry so vocabulary maintenance carries image
|
||||
links like prompt and pair links.
|
||||
|
||||
Installation never backfills anything from prompt or pair tags and never
|
||||
touches the filesystem. Validation reads only: the catalog objects are compared
|
||||
after whitespace compaction, columns, foreign keys and indexes through the
|
||||
PRAGMAs, and persisted values in single bounded SQL passes.
|
||||
"""
|
||||
|
||||
import re
|
||||
import sqlite3
|
||||
from typing import Optional
|
||||
|
||||
from .image_favorites import MAX_KEY_LENGTH
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
METADATA_TABLE = "media_annotation_metadata"
|
||||
FILES_TABLE = "media_annotation_files"
|
||||
BYTES_TABLE = "media_annotation_bytes"
|
||||
IMAGE_TAGS_TABLE = "media_image_tags"
|
||||
TABLES = (METADATA_TABLE, FILES_TABLE, BYTES_TABLE, IMAGE_TAGS_TABLE)
|
||||
MAX_LOCATOR_LENGTH = MAX_KEY_LENGTH
|
||||
_TIMESTAMP_BYTES = 64
|
||||
|
||||
_TABLE_DDL = {
|
||||
METADATA_TABLE: (
|
||||
f"CREATE TABLE {METADATA_TABLE} (singleton_id INTEGER PRIMARY KEY "
|
||||
"CHECK(singleton_id=1), schema_version INTEGER NOT NULL "
|
||||
f"CHECK(schema_version={SCHEMA_VERSION}), "
|
||||
"image_index_revision INTEGER NOT NULL DEFAULT 0 "
|
||||
"CHECK(image_index_revision>=0))"
|
||||
),
|
||||
FILES_TABLE: (
|
||||
f"CREATE TABLE {FILES_TABLE} (id TEXT PRIMARY KEY CHECK(length(id)=36), "
|
||||
"locator TEXT NOT NULL UNIQUE, created_at TEXT NOT NULL)"
|
||||
),
|
||||
BYTES_TABLE: (
|
||||
f"CREATE TABLE {BYTES_TABLE} (id TEXT PRIMARY KEY CHECK(length(id)=36), "
|
||||
f"file_id TEXT NOT NULL REFERENCES {FILES_TABLE}(id) ON DELETE CASCADE, "
|
||||
"sha256 TEXT NOT NULL CHECK(length(sha256)=64 AND sha256 NOT GLOB "
|
||||
"'*[^0-9a-f]*'), size INTEGER NOT NULL CHECK(size>=0), "
|
||||
"mtime_ns INTEGER NOT NULL, verified_at TEXT NOT NULL, "
|
||||
"first_seen_at TEXT NOT NULL, tag_revision INTEGER NOT NULL DEFAULT 0 "
|
||||
"CHECK(tag_revision>=0), UNIQUE(file_id, sha256))"
|
||||
),
|
||||
IMAGE_TAGS_TABLE: (
|
||||
f"CREATE TABLE {IMAGE_TAGS_TABLE} (byte_id TEXT NOT NULL REFERENCES "
|
||||
f"{BYTES_TABLE}(id) ON DELETE CASCADE, tag_id INTEGER NOT NULL "
|
||||
"REFERENCES tags(id) ON DELETE CASCADE, position INTEGER NOT NULL "
|
||||
"CHECK(position>=0), PRIMARY KEY(byte_id, tag_id))"
|
||||
),
|
||||
}
|
||||
_INDEX_DDL = {
|
||||
"idx_media_annotation_bytes_fingerprint": (
|
||||
"CREATE INDEX idx_media_annotation_bytes_fingerprint ON "
|
||||
f"{BYTES_TABLE}(file_id, size, mtime_ns)"
|
||||
),
|
||||
"idx_media_image_tags_tag": (
|
||||
f"CREATE INDEX idx_media_image_tags_tag ON {IMAGE_TAGS_TABLE}(tag_id)"
|
||||
),
|
||||
"idx_media_image_tags_position": (
|
||||
"CREATE UNIQUE INDEX idx_media_image_tags_position ON "
|
||||
f"{IMAGE_TAGS_TABLE}(byte_id, position)"
|
||||
),
|
||||
}
|
||||
# SQLite names the indexes behind TEXT primary keys and UNIQUE constraints
|
||||
# ``sqlite_autoindex_<table>_<n>``; their catalog rows carry no SQL.
|
||||
_AUTOINDEXES = {FILES_TABLE: 2, BYTES_TABLE: 2, IMAGE_TAGS_TABLE: 1}
|
||||
# (name, declared type, not null, default, primary key position)
|
||||
_COLUMNS = {
|
||||
METADATA_TABLE: (
|
||||
("singleton_id", "INTEGER", 0, None, 1),
|
||||
("schema_version", "INTEGER", 1, None, 0),
|
||||
("image_index_revision", "INTEGER", 1, "0", 0),
|
||||
),
|
||||
FILES_TABLE: (
|
||||
("id", "TEXT", 0, None, 1),
|
||||
("locator", "TEXT", 1, None, 0),
|
||||
("created_at", "TEXT", 1, None, 0),
|
||||
),
|
||||
BYTES_TABLE: (
|
||||
("id", "TEXT", 0, None, 1),
|
||||
("file_id", "TEXT", 1, None, 0),
|
||||
("sha256", "TEXT", 1, None, 0),
|
||||
("size", "INTEGER", 1, None, 0),
|
||||
("mtime_ns", "INTEGER", 1, None, 0),
|
||||
("verified_at", "TEXT", 1, None, 0),
|
||||
("first_seen_at", "TEXT", 1, None, 0),
|
||||
("tag_revision", "INTEGER", 1, "0", 0),
|
||||
),
|
||||
IMAGE_TAGS_TABLE: (
|
||||
("byte_id", "TEXT", 1, None, 1),
|
||||
("tag_id", "INTEGER", 1, None, 2),
|
||||
("position", "INTEGER", 1, None, 0),
|
||||
),
|
||||
}
|
||||
# (column, parent table, parent column, on delete)
|
||||
_FOREIGN_KEYS = {
|
||||
METADATA_TABLE: set(),
|
||||
FILES_TABLE: set(),
|
||||
BYTES_TABLE: {("file_id", FILES_TABLE, "id", "CASCADE")},
|
||||
IMAGE_TAGS_TABLE: {
|
||||
("byte_id", BYTES_TABLE, "id", "CASCADE"),
|
||||
("tag_id", "tags", "id", "CASCADE"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _compact(sql: str) -> str:
|
||||
return re.sub(r"\s+", "", sql).lower()
|
||||
|
||||
|
||||
def _uuid(column: str) -> str:
|
||||
"""SQL: *column* is a lowercase hyphenated 36-character UUID string."""
|
||||
return (
|
||||
f"typeof({column}) = 'text' AND length({column}) = 36 "
|
||||
f"AND substr({column}, 9, 1) = '-' AND substr({column}, 14, 1) = '-' "
|
||||
f"AND substr({column}, 19, 1) = '-' AND substr({column}, 24, 1) = '-' "
|
||||
f"AND length(replace({column}, '-', '')) = 32 "
|
||||
f"AND replace({column}, '-', '') NOT GLOB '*[^0-9a-f]*'"
|
||||
)
|
||||
|
||||
|
||||
def _timestamp(column: str) -> str:
|
||||
return (
|
||||
f"typeof({column}) = 'text' "
|
||||
f"AND length(CAST({column} AS BLOB)) <= {_TIMESTAMP_BYTES}"
|
||||
)
|
||||
|
||||
|
||||
def _non_negative(column: str) -> str:
|
||||
return f"typeof({column}) = 'integer' AND {column} >= 0"
|
||||
|
||||
|
||||
# Every row of a table must satisfy its predicate; one pass each, LIMIT 1.
|
||||
_ROW_PREDICATES = {
|
||||
FILES_TABLE: " AND ".join(
|
||||
(
|
||||
_uuid("id"),
|
||||
"typeof(locator) = 'text'",
|
||||
# UTF-8 is at most 4 bytes per character; the decoded length of
|
||||
# the few candidates above the byte bound is checked in Python.
|
||||
f"length(CAST(locator AS BLOB)) <= {MAX_LOCATOR_LENGTH * 4}",
|
||||
_timestamp("created_at"),
|
||||
)
|
||||
),
|
||||
BYTES_TABLE: " AND ".join(
|
||||
(
|
||||
_uuid("id"),
|
||||
_uuid("file_id"),
|
||||
"typeof(sha256) = 'text' AND length(sha256) = 64 "
|
||||
"AND sha256 NOT GLOB '*[^0-9a-f]*'",
|
||||
_non_negative("size"),
|
||||
"typeof(mtime_ns) = 'integer'",
|
||||
_timestamp("verified_at"),
|
||||
_timestamp("first_seen_at"),
|
||||
_non_negative("tag_revision"),
|
||||
)
|
||||
),
|
||||
IMAGE_TAGS_TABLE: " AND ".join(
|
||||
(_uuid("byte_id"), "typeof(tag_id) = 'integer'", _non_negative("position"))
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _expected_catalog() -> set:
|
||||
objects = {("table", name, _compact(sql)) for name, sql in _TABLE_DDL.items()}
|
||||
objects |= {("index", name, _compact(sql)) for name, sql in _INDEX_DDL.items()}
|
||||
objects |= {
|
||||
("index", f"sqlite_autoindex_{table}_{n}", "")
|
||||
for table, count in _AUTOINDEXES.items()
|
||||
for n in range(1, count + 1)
|
||||
}
|
||||
return objects
|
||||
|
||||
|
||||
def _catalog(conn: sqlite3.Connection) -> list:
|
||||
names = TABLES + tuple(_INDEX_DDL)
|
||||
marks = ",".join("?" * len(names))
|
||||
table_marks = ",".join("?" * len(TABLES))
|
||||
return conn.execute( # only "?" placeholders are interpolated
|
||||
"SELECT type,name,sql FROM sqlite_master "
|
||||
f"WHERE name IN ({marks}) OR tbl_name IN ({table_marks})", # nosec B608
|
||||
names + TABLES,
|
||||
).fetchall()
|
||||
|
||||
|
||||
def _structure_error(conn: sqlite3.Connection) -> Optional[str]:
|
||||
for table, expected in _COLUMNS.items():
|
||||
columns = tuple(
|
||||
(row[1], row[2].upper(), row[3], row[4], row[5])
|
||||
for row in conn.execute(f"PRAGMA table_info({table})")
|
||||
)
|
||||
if columns != expected:
|
||||
return f"Image annotations table {table} has invalid columns"
|
||||
foreign_keys = {
|
||||
(row[3], row[2], row[4], row[6])
|
||||
for row in conn.execute(f"PRAGMA foreign_key_list({table})")
|
||||
}
|
||||
if foreign_keys != _FOREIGN_KEYS[table]:
|
||||
return f"Image annotations table {table} has invalid foreign keys"
|
||||
if conn.execute(f"PRAGMA foreign_key_check({table})").fetchone():
|
||||
return f"Image annotations table {table} contains foreign key violations"
|
||||
return None
|
||||
|
||||
|
||||
def _value_error(conn: sqlite3.Connection) -> Optional[str]:
|
||||
# Only this module's constants are interpolated into the SQL below.
|
||||
rows = conn.execute(
|
||||
"SELECT COUNT(*), MIN(typeof(singleton_id) = 'integer' " # nosec B608
|
||||
"AND singleton_id = 1 AND typeof(schema_version) = 'integer' "
|
||||
f"AND schema_version = {SCHEMA_VERSION} "
|
||||
f"AND {_non_negative('image_index_revision')}) FROM {METADATA_TABLE}"
|
||||
).fetchone()
|
||||
if rows[0] != 1 or rows[1] != 1:
|
||||
return "Image annotations metadata is invalid"
|
||||
for table, predicate in _ROW_PREDICATES.items():
|
||||
if conn.execute(
|
||||
f"SELECT 1 FROM {table} WHERE NOT ({predicate}) LIMIT 1" # nosec B608
|
||||
).fetchone():
|
||||
return f"Image annotations table {table} contains invalid values"
|
||||
# length() stops at an embedded NUL, so the SQL pass bounds bytes and the
|
||||
# few candidates over the character bound are measured decoded here.
|
||||
for (raw,) in conn.execute(
|
||||
f"SELECT CAST(locator AS BLOB) FROM {FILES_TABLE} " # nosec B608
|
||||
f"WHERE length(CAST(locator AS BLOB)) > {MAX_LOCATOR_LENGTH}"
|
||||
):
|
||||
if len(bytes(raw).decode("utf-8", "replace")) > MAX_LOCATOR_LENGTH:
|
||||
return f"Image annotations table {FILES_TABLE} contains invalid values"
|
||||
return None
|
||||
|
||||
|
||||
def annotation_schema_error(
|
||||
conn: sqlite3.Connection, *, allow_missing: bool = True
|
||||
) -> Optional[str]:
|
||||
"""Reject partial or unsupported annotation schemas and invalid rows.
|
||||
|
||||
Reads only: nothing is ever repaired. A database without any of the
|
||||
group's objects is acceptable for backups that predate it unless
|
||||
``allow_missing`` is false.
|
||||
"""
|
||||
objects = _catalog(conn)
|
||||
if not objects:
|
||||
return None if allow_missing else "Missing image annotation schema"
|
||||
present = {name for kind, name, _sql in objects if kind == "table"}
|
||||
if present != set(TABLES):
|
||||
return "Incomplete image annotation schema"
|
||||
actual = {(kind, name, _compact(sql or "")) for kind, name, sql in objects}
|
||||
if actual != _expected_catalog():
|
||||
return "Image annotations has an invalid schema definition"
|
||||
return _structure_error(conn) or _value_error(conn)
|
||||
|
||||
|
||||
def migrate_annotation_state(conn: sqlite3.Connection) -> None:
|
||||
"""Install the group atomically; validate before and after creating it."""
|
||||
conn.execute("SAVEPOINT media_annotation_schema")
|
||||
try:
|
||||
error = annotation_schema_error(conn)
|
||||
if error:
|
||||
raise sqlite3.DatabaseError(error)
|
||||
for sql in _TABLE_DDL.values():
|
||||
conn.execute(sql.replace("CREATE TABLE", "CREATE TABLE IF NOT EXISTS", 1))
|
||||
for sql in _INDEX_DDL.values():
|
||||
conn.execute(sql.replace("INDEX", "INDEX IF NOT EXISTS", 1))
|
||||
conn.execute(
|
||||
f"INSERT OR IGNORE INTO {METADATA_TABLE} "
|
||||
"(singleton_id, schema_version, image_index_revision) VALUES (1, ?, 0)",
|
||||
(SCHEMA_VERSION,),
|
||||
)
|
||||
error = annotation_schema_error(conn, allow_missing=False)
|
||||
if error:
|
||||
raise sqlite3.DatabaseError(error)
|
||||
conn.execute("RELEASE media_annotation_schema")
|
||||
except BaseException:
|
||||
conn.execute("ROLLBACK TO media_annotation_schema")
|
||||
conn.execute("RELEASE media_annotation_schema")
|
||||
raise
|
||||
@@ -0,0 +1,461 @@
|
||||
"""Per-image tag sets (#249): byte-exact storage in the shared vocabulary.
|
||||
|
||||
A tag set belongs to one byte record (``media_annotation_bytes``): the
|
||||
whole-file SHA-256 of one physical file, owned by the file's private locator
|
||||
(``media_annotation_files``). Callers pass verified facts (digest, size,
|
||||
``mtime_ns``); nothing here reads the filesystem or resolves an owner through
|
||||
prompt links, so tagging one image never changes another image, a prompt or a
|
||||
pair.
|
||||
|
||||
Commands run in one ``_pair_transaction`` fenced by the storage epoch, like the
|
||||
native Library and vocabulary commands. A save is a full replacement guarded
|
||||
by the set's ``tag_revision``; the identical set sent again (a lost-response
|
||||
retry, or the current set at its own revision) answers ``replayed: true`` and
|
||||
writes nothing. ``add_image_tags`` unions into the existing set, keeping the
|
||||
stored spelling. Names enter the global ``tags`` registry exactly as spelled,
|
||||
so vocabulary rename, merge and delete carry image links with prompt and pair
|
||||
links (``PromptDatabase._move_tag_links``).
|
||||
|
||||
Cheap reads (spec amendment S1) treat a byte record as *current* only while
|
||||
the file's present ``(size, mtime_ns)`` equals the record's verified
|
||||
fingerprint; a file without a current record shows no image tags.
|
||||
"""
|
||||
|
||||
import re
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
from typing import Iterator
|
||||
from uuid import uuid4
|
||||
|
||||
from .annotation_schema import (
|
||||
BYTES_TABLE,
|
||||
FILES_TABLE,
|
||||
IMAGE_TAGS_TABLE,
|
||||
MAX_LOCATOR_LENGTH,
|
||||
METADATA_TABLE,
|
||||
)
|
||||
from .library_commands import _UUID, LibraryCommandError, _validate_epoch
|
||||
from .models import utc_now_iso
|
||||
from .vocabulary import validate_vocabulary_tag_name
|
||||
|
||||
MAX_IMAGE_TAGS = 100
|
||||
_LOOKUP_CHUNK = 500
|
||||
_DIGEST = re.compile(r"[0-9a-f]{64}")
|
||||
_MAX_INT = 2**63 - 1
|
||||
_MIN_INT = -(2**63)
|
||||
# Only this module's table constants are interpolated into the SQL below.
|
||||
_OWNER = f"SELECT id FROM {FILES_TABLE} WHERE locator=?" # nosec B608
|
||||
_INSERT_OWNER = (
|
||||
f"INSERT INTO {FILES_TABLE}(id,locator,created_at) VALUES(?,?,?)" # nosec B608
|
||||
)
|
||||
_BYTE = (
|
||||
f"SELECT id,tag_revision,size,mtime_ns FROM {BYTES_TABLE} " # nosec B608
|
||||
"WHERE file_id=? AND sha256=?"
|
||||
)
|
||||
_INSERT_BYTE = (
|
||||
f"INSERT INTO {BYTES_TABLE}(id,file_id,sha256,size,mtime_ns," # nosec B608
|
||||
"verified_at,first_seen_at,tag_revision) VALUES(?,?,?,?,?,?,?,0)"
|
||||
)
|
||||
_REFRESH_BYTE = (
|
||||
f"UPDATE {BYTES_TABLE} SET size=?,mtime_ns=?,verified_at=? WHERE id=?" # nosec B608
|
||||
)
|
||||
_REFRESH_BY_LOCATOR = (
|
||||
f"UPDATE {BYTES_TABLE} SET size=?,mtime_ns=?,verified_at=? " # nosec B608
|
||||
f"WHERE sha256=? AND file_id=({_OWNER})"
|
||||
)
|
||||
_BYTE_TAGS = (
|
||||
f"SELECT t.name FROM {IMAGE_TAGS_TABLE} mit " # nosec B608
|
||||
"JOIN tags t ON t.id=mit.tag_id WHERE mit.byte_id=? ORDER BY mit.position"
|
||||
)
|
||||
_CLEAR_TAGS = f"DELETE FROM {IMAGE_TAGS_TABLE} WHERE byte_id=?" # nosec B608
|
||||
_INSERT_TAG = (
|
||||
f"INSERT INTO {IMAGE_TAGS_TABLE}(byte_id,tag_id,position) " # nosec B608
|
||||
"VALUES(?,?,?)"
|
||||
)
|
||||
_BUMP_REVISION = (
|
||||
f"UPDATE {BYTES_TABLE} SET tag_revision=tag_revision+1 WHERE id=?" # nosec B608
|
||||
)
|
||||
_REVISION = f"SELECT tag_revision FROM {BYTES_TABLE} WHERE id=?" # nosec B608
|
||||
_BUMP_INDEX = (
|
||||
f"UPDATE {METADATA_TABLE} SET " # nosec B608
|
||||
"image_index_revision=image_index_revision+1 WHERE singleton_id=1"
|
||||
)
|
||||
_READ = (
|
||||
"SELECT b.id,b.tag_revision," # nosec B608
|
||||
f"(SELECT COUNT(*) FROM {BYTES_TABLE} o WHERE o.file_id=f.id AND o.sha256!=?) "
|
||||
f"FROM {FILES_TABLE} f LEFT JOIN {BYTES_TABLE} b "
|
||||
"ON b.file_id=f.id AND b.sha256=? WHERE f.locator=?"
|
||||
)
|
||||
_FINGERPRINTS = (
|
||||
"SELECT f.locator,b.id,b.size,b.mtime_ns,b.verified_at " # nosec B608
|
||||
f"FROM {FILES_TABLE} f JOIN {BYTES_TABLE} b ON b.file_id=f.id "
|
||||
"WHERE f.locator IN ({marks})"
|
||||
)
|
||||
_TAGS_OF = (
|
||||
f"SELECT mit.byte_id,t.name FROM {IMAGE_TAGS_TABLE} mit " # nosec B608
|
||||
"JOIN tags t ON t.id=mit.tag_id WHERE mit.byte_id IN ({marks}) "
|
||||
"ORDER BY mit.byte_id,mit.position"
|
||||
)
|
||||
_RECORDS = (
|
||||
"SELECT f.locator,b.id,b.size,b.mtime_ns,t.name " # nosec B608
|
||||
f"FROM {FILES_TABLE} f JOIN {BYTES_TABLE} b ON b.file_id=f.id "
|
||||
f"LEFT JOIN {IMAGE_TAGS_TABLE} mit ON mit.byte_id=b.id "
|
||||
"LEFT JOIN tags t ON t.id=mit.tag_id "
|
||||
"ORDER BY f.locator,b.first_seen_at,b.id,mit.position"
|
||||
)
|
||||
|
||||
|
||||
class AnnotationCommandError(LibraryCommandError):
|
||||
"""An image tag command lost a race or hit the set's bound.
|
||||
|
||||
``code`` is ``tag-conflict`` (the set changed since it was read) or
|
||||
``tag-limit`` (the union would exceed ``MAX_IMAGE_TAGS``); ``current`` is
|
||||
the stored set ``{sha256, tags, tag_revision}`` read inside the
|
||||
transaction, so the caller can show it.
|
||||
"""
|
||||
|
||||
def __init__(self, code: str, epoch: str, current: dict | None = None):
|
||||
super().__init__(code, epoch)
|
||||
self.current = current
|
||||
|
||||
|
||||
class ImageTagLimitError(ValueError):
|
||||
"""More than ``MAX_IMAGE_TAGS`` distinct names in one set."""
|
||||
|
||||
|
||||
def normalize_image_tags(names: object) -> list[str]:
|
||||
"""Trim and validate each name, fold case-insensitive repeats, cap at 100.
|
||||
|
||||
The first occurrence keeps its spelling and position. Raises ``ValueError``
|
||||
for anything but a list of valid vocabulary names, and its subclass
|
||||
``ImageTagLimitError`` for more than ``MAX_IMAGE_TAGS`` distinct names.
|
||||
"""
|
||||
if not isinstance(names, list):
|
||||
raise ValueError("tags must be a list of strings")
|
||||
result: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for value in names:
|
||||
name = validate_vocabulary_tag_name(value)
|
||||
key = name.casefold()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
result.append(name)
|
||||
if len(result) > MAX_IMAGE_TAGS:
|
||||
raise ImageTagLimitError(f"An image can carry at most {MAX_IMAGE_TAGS} tags")
|
||||
return result
|
||||
|
||||
|
||||
def _validate_locator(locator: object) -> None:
|
||||
if not isinstance(locator, str) or not 0 < len(locator) <= MAX_LOCATOR_LENGTH:
|
||||
raise ValueError(
|
||||
f"locator must be a nonblank path of at most {MAX_LOCATOR_LENGTH} chars"
|
||||
)
|
||||
|
||||
|
||||
def _validate_digest(sha256: object) -> None:
|
||||
if not isinstance(sha256, str) or not _DIGEST.fullmatch(sha256):
|
||||
raise ValueError("sha256 must be 64 lowercase hexadecimal characters")
|
||||
|
||||
|
||||
def _validate_int(value: object, name: str, minimum: int) -> None:
|
||||
if type(value) is not int or not minimum <= value <= _MAX_INT:
|
||||
raise ValueError(f"{name} must be an integer not below {minimum}")
|
||||
|
||||
|
||||
def _validate_target(locator: object, sha256: object, size: object, mtime_ns: object):
|
||||
_validate_locator(locator)
|
||||
_validate_digest(sha256)
|
||||
_validate_int(size, "size", 0)
|
||||
_validate_int(mtime_ns, "mtime_ns", _MIN_INT)
|
||||
|
||||
|
||||
def _validate_fingerprint(locator: object, fingerprint: object) -> None:
|
||||
_validate_locator(locator)
|
||||
if (
|
||||
not isinstance(fingerprint, tuple)
|
||||
or len(fingerprint) != 2
|
||||
or any(type(value) is not int for value in fingerprint)
|
||||
):
|
||||
raise ValueError("stats must map locators to (size, mtime_ns) integer pairs")
|
||||
|
||||
|
||||
def _new_id() -> str:
|
||||
"""A canonical UUID; the schema's CHECK lets NULL through, this does not."""
|
||||
value = str(uuid4())
|
||||
if not _UUID.fullmatch(value):
|
||||
raise ValueError("Annotation ids must be canonical UUIDs")
|
||||
return value
|
||||
|
||||
|
||||
def _receipt(epoch: str, sha256: str, tags: list, revision: int, replayed: bool):
|
||||
return {
|
||||
"library_epoch": epoch,
|
||||
"sha256": sha256,
|
||||
"tags": list(tags),
|
||||
"tag_revision": revision,
|
||||
"replayed": replayed,
|
||||
}
|
||||
|
||||
|
||||
class AnnotationOperations:
|
||||
"""PromptDatabase mixin: image tag sets, fingerprints and cheap reads.
|
||||
|
||||
``_`` helpers take the open ``_pair_transaction`` connection.
|
||||
"""
|
||||
|
||||
@contextmanager
|
||||
def _annotation_read(self) -> Iterator[sqlite3.Connection]:
|
||||
"""One deferred read transaction, so paired SELECTs see one snapshot."""
|
||||
conn = self.model.get_connection()
|
||||
if conn.in_transaction:
|
||||
raise sqlite3.ProgrammingError(
|
||||
"Annotation reads require their own snapshot"
|
||||
)
|
||||
with conn:
|
||||
conn.execute("BEGIN")
|
||||
yield conn
|
||||
|
||||
@staticmethod
|
||||
def _annotation_owner(conn: sqlite3.Connection, locator: str, now: str) -> str:
|
||||
row = conn.execute(_OWNER, (locator,)).fetchone()
|
||||
if row is not None:
|
||||
return row[0]
|
||||
file_id = _new_id()
|
||||
conn.execute(_INSERT_OWNER, (file_id, locator, now))
|
||||
return file_id
|
||||
|
||||
@staticmethod
|
||||
def _annotation_byte(
|
||||
conn: sqlite3.Connection,
|
||||
file_id: str,
|
||||
sha256: str,
|
||||
size: int,
|
||||
mtime_ns: int,
|
||||
now: str,
|
||||
) -> tuple[str, int, bool, bool]:
|
||||
"""The byte record for these bytes, refreshed or created.
|
||||
|
||||
Returns ``(id, tag_revision, created, refingerprinted)``; the last is
|
||||
True when an existing record's verified ``(size, mtime_ns)`` changed,
|
||||
which moves its currency for cheap reads (the caller bumps the index
|
||||
revision when nothing else does).
|
||||
"""
|
||||
row = conn.execute(_BYTE, (file_id, sha256)).fetchone()
|
||||
if row is None:
|
||||
byte_id = _new_id()
|
||||
conn.execute(
|
||||
_INSERT_BYTE, (byte_id, file_id, sha256, size, mtime_ns, now, now)
|
||||
)
|
||||
return byte_id, 0, True, False
|
||||
conn.execute(_REFRESH_BYTE, (size, mtime_ns, now, row[0]))
|
||||
return row[0], row[1], False, (row[2], row[3]) != (size, mtime_ns)
|
||||
|
||||
@staticmethod
|
||||
def _byte_tags(conn: sqlite3.Connection, byte_id: str) -> list[str]:
|
||||
return [row[0] for row in conn.execute(_BYTE_TAGS, (byte_id,))]
|
||||
|
||||
def _write_image_tags(
|
||||
self, conn: sqlite3.Connection, byte_id: str, tags: list[str]
|
||||
) -> int:
|
||||
"""Rewrite the junction in order, bump both revisions; return the new one."""
|
||||
tag_ids = self._ensure_tags(conn, tags)
|
||||
conn.execute(_CLEAR_TAGS, (byte_id,))
|
||||
conn.executemany(
|
||||
_INSERT_TAG,
|
||||
[(byte_id, tag_ids[name], position) for position, name in enumerate(tags)],
|
||||
)
|
||||
conn.execute(_BUMP_REVISION, (byte_id,))
|
||||
conn.execute(_BUMP_INDEX)
|
||||
return conn.execute(_REVISION, (byte_id,)).fetchone()[0]
|
||||
|
||||
def _current_set(self, conn, sha256, byte_id, revision) -> dict:
|
||||
return {
|
||||
"sha256": sha256,
|
||||
"tags": self._byte_tags(conn, byte_id),
|
||||
"tag_revision": revision,
|
||||
}
|
||||
|
||||
def read_image_tags(self, locator: str, sha256: str) -> dict:
|
||||
"""The stored set of these exact bytes, or an empty set at revision 0.
|
||||
|
||||
``prior_count`` is the number of other byte records at the locator
|
||||
(earlier or later bytes of the same file).
|
||||
"""
|
||||
_validate_locator(locator)
|
||||
_validate_digest(sha256)
|
||||
with self._annotation_read() as conn:
|
||||
row = conn.execute(_READ, (sha256, sha256, locator)).fetchone()
|
||||
if row is None:
|
||||
return {"tags": [], "tag_revision": 0, "prior_count": 0}
|
||||
tags = self._byte_tags(conn, row[0]) if row[0] is not None else []
|
||||
return {"tags": tags, "tag_revision": row[1] or 0, "prior_count": row[2]}
|
||||
|
||||
def replace_image_tags(
|
||||
self,
|
||||
*,
|
||||
locator: str,
|
||||
sha256: str,
|
||||
size: int,
|
||||
mtime_ns: int,
|
||||
expected_epoch: str,
|
||||
expected_tag_revision: int,
|
||||
tags: list,
|
||||
) -> dict:
|
||||
"""Replace the set of these exact bytes, guarded by epoch and revision.
|
||||
|
||||
Creates the owner and byte record when the bytes are new (an explicit
|
||||
empty set is a real save). The identical set at the current revision,
|
||||
or one behind it (a lost-response retry), is ``replayed`` and writes
|
||||
nothing; any other revision mismatch is ``tag-conflict`` with the
|
||||
current set and no writes.
|
||||
"""
|
||||
_validate_target(locator, sha256, size, mtime_ns)
|
||||
_validate_epoch(expected_epoch)
|
||||
_validate_int(expected_tag_revision, "expected_tag_revision", 0)
|
||||
tags = normalize_image_tags(tags)
|
||||
with self._pair_transaction() as conn:
|
||||
epoch = self._library_epoch(conn, expected_epoch)
|
||||
now = utc_now_iso()
|
||||
file_id = self._annotation_owner(conn, locator, now)
|
||||
byte_id, revision, created, refingerprinted = self._annotation_byte(
|
||||
conn, file_id, sha256, size, mtime_ns, now
|
||||
)
|
||||
current = self._byte_tags(conn, byte_id)
|
||||
if (
|
||||
not created
|
||||
and current == tags
|
||||
and expected_tag_revision in (revision, revision - 1)
|
||||
):
|
||||
if refingerprinted:
|
||||
conn.execute(_BUMP_INDEX)
|
||||
return _receipt(epoch, sha256, current, revision, True)
|
||||
if expected_tag_revision != revision:
|
||||
raise AnnotationCommandError(
|
||||
"tag-conflict",
|
||||
epoch,
|
||||
{"sha256": sha256, "tags": current, "tag_revision": revision},
|
||||
)
|
||||
revision = self._write_image_tags(conn, byte_id, tags)
|
||||
return _receipt(epoch, sha256, tags, revision, False)
|
||||
|
||||
def add_image_tags(
|
||||
self,
|
||||
*,
|
||||
locator: str,
|
||||
sha256: str,
|
||||
size: int,
|
||||
mtime_ns: int,
|
||||
expected_epoch: str,
|
||||
tags: list,
|
||||
) -> dict:
|
||||
"""Union ``tags`` into the set of these exact bytes, keeping stored spelling.
|
||||
|
||||
``added`` counts the names that were new (case-insensitively). A union
|
||||
over ``MAX_IMAGE_TAGS`` is ``tag-limit`` with the current set and no
|
||||
writes; a union that adds nothing to an existing record is ``replayed``.
|
||||
"""
|
||||
_validate_target(locator, sha256, size, mtime_ns)
|
||||
_validate_epoch(expected_epoch)
|
||||
tags = normalize_image_tags(tags)
|
||||
with self._pair_transaction() as conn:
|
||||
epoch = self._library_epoch(conn, expected_epoch)
|
||||
now = utc_now_iso()
|
||||
file_id = self._annotation_owner(conn, locator, now)
|
||||
byte_id, revision, created, refingerprinted = self._annotation_byte(
|
||||
conn, file_id, sha256, size, mtime_ns, now
|
||||
)
|
||||
current = self._byte_tags(conn, byte_id)
|
||||
present = {name.casefold() for name in current}
|
||||
added = [name for name in tags if name.casefold() not in present]
|
||||
merged = current + added
|
||||
if len(merged) > MAX_IMAGE_TAGS:
|
||||
raise AnnotationCommandError(
|
||||
"tag-limit",
|
||||
epoch,
|
||||
{"sha256": sha256, "tags": current, "tag_revision": revision},
|
||||
)
|
||||
if not created and not added:
|
||||
if refingerprinted:
|
||||
conn.execute(_BUMP_INDEX)
|
||||
return {**_receipt(epoch, sha256, current, revision, True), "added": 0}
|
||||
revision = self._write_image_tags(conn, byte_id, merged)
|
||||
return {
|
||||
**_receipt(epoch, sha256, merged, revision, False),
|
||||
"added": len(added),
|
||||
}
|
||||
|
||||
def refresh_image_fingerprint(
|
||||
self, locator: str, sha256: str, size: int, mtime_ns: int
|
||||
) -> bool:
|
||||
"""Record that these exact bytes were just seen with this stat.
|
||||
|
||||
True when a byte record was refreshed; nothing is created and no tag
|
||||
or tag revision changes. The image index revision moves because the
|
||||
record's currency for cheap reads may have changed.
|
||||
"""
|
||||
_validate_target(locator, sha256, size, mtime_ns)
|
||||
with self.model.get_connection() as conn:
|
||||
cursor = conn.execute(
|
||||
_REFRESH_BY_LOCATOR, (size, mtime_ns, utc_now_iso(), sha256, locator)
|
||||
)
|
||||
if cursor.rowcount > 0:
|
||||
conn.execute(_BUMP_INDEX)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def current_image_tags(self, stats: dict) -> dict:
|
||||
"""Image tags of the files whose current ``(size, mtime_ns)`` is verified.
|
||||
|
||||
``stats`` maps locators to their present fingerprint. A locator is
|
||||
answered (possibly with an empty set) only when a byte record carries
|
||||
exactly that verified fingerprint; the most recently verified record
|
||||
wins when several do. Lookups go in chunks of 500 locators.
|
||||
"""
|
||||
if not isinstance(stats, dict):
|
||||
raise ValueError(
|
||||
"stats must map locators to (size, mtime_ns) integer pairs"
|
||||
)
|
||||
for locator, fingerprint in stats.items():
|
||||
_validate_fingerprint(locator, fingerprint)
|
||||
locators = list(stats)
|
||||
chosen: dict[str, tuple[tuple[str, str], str]] = {}
|
||||
result: dict[str, list[str]] = {}
|
||||
with self._annotation_read() as conn:
|
||||
for start in range(0, len(locators), _LOOKUP_CHUNK):
|
||||
chunk = locators[start : start + _LOOKUP_CHUNK]
|
||||
marks = ",".join("?" * len(chunk))
|
||||
for locator, byte_id, size, mtime_ns, verified_at in conn.execute(
|
||||
_FINGERPRINTS.format(marks=marks), chunk
|
||||
):
|
||||
if (size, mtime_ns) != stats[locator]:
|
||||
continue
|
||||
key = (verified_at, byte_id)
|
||||
if locator not in chosen or key > chosen[locator][0]:
|
||||
chosen[locator] = (key, byte_id)
|
||||
owners = {byte_id: locator for locator, (_key, byte_id) in chosen.items()}
|
||||
result = {locator: [] for locator in chosen}
|
||||
byte_ids = list(owners)
|
||||
for start in range(0, len(byte_ids), _LOOKUP_CHUNK):
|
||||
chunk = byte_ids[start : start + _LOOKUP_CHUNK]
|
||||
marks = ",".join("?" * len(chunk))
|
||||
for byte_id, name in conn.execute(_TAGS_OF.format(marks=marks), chunk):
|
||||
result[owners[byte_id]].append(name)
|
||||
return result
|
||||
|
||||
def iter_image_tag_records(self) -> Iterator[tuple[str, int, int, list[str]]]:
|
||||
"""Stream every byte record as ``(locator, size, mtime_ns, tags)``.
|
||||
|
||||
One SELECT ordered by locator, then by the bytes' first sighting, with
|
||||
each set's names in position order.
|
||||
"""
|
||||
conn = self.model.get_connection()
|
||||
current: tuple | None = None
|
||||
for locator, byte_id, size, mtime_ns, name in conn.execute(_RECORDS):
|
||||
if current is None or current[0] != byte_id:
|
||||
if current is not None:
|
||||
yield current[1]
|
||||
current = (byte_id, (locator, size, mtime_ns, []))
|
||||
if name is not None:
|
||||
current[1][3].append(name)
|
||||
if current is not None:
|
||||
yield current[1]
|
||||
+10
-3
@@ -17,6 +17,7 @@ from typing import Optional, Set, Tuple
|
||||
|
||||
from .library_schema import library_schema_error, migrate_library_state
|
||||
from .image_favorites import image_favorites_schema_error, migrate_image_favorites
|
||||
from .annotation_schema import annotation_schema_error, migrate_annotation_state
|
||||
from .saved_views import migrate_saved_views, saved_views_schema_error
|
||||
from .execution_history_schema import (
|
||||
history_schema_error,
|
||||
@@ -302,6 +303,7 @@ class PromptModel:
|
||||
migrate_library_state(conn)
|
||||
migrate_image_favorites(conn)
|
||||
migrate_saved_views(conn)
|
||||
migrate_annotation_state(conn)
|
||||
|
||||
def _migrate_prompt_pairs(self, conn: sqlite3.Connection) -> None:
|
||||
conn.execute("SAVEPOINT prompt_pair_schema")
|
||||
@@ -1021,9 +1023,9 @@ class PromptModel:
|
||||
Args:
|
||||
path: File to inspect
|
||||
require_library_state: Also require the tables every current
|
||||
database has (Library state, image favorites and saved
|
||||
views), as a migrated restore stage must; old backups may
|
||||
lack them.
|
||||
database has (Library state, image favorites, saved views
|
||||
and image annotations), as a migrated restore stage must;
|
||||
old backups may lack them.
|
||||
|
||||
Returns:
|
||||
(ok, reason): reason is "ok" when the file is acceptable, otherwise
|
||||
@@ -1088,6 +1090,11 @@ class PromptModel:
|
||||
)
|
||||
if views_error:
|
||||
return False, views_error
|
||||
annotation_error = annotation_schema_error(
|
||||
conn, allow_missing=not require_library_state
|
||||
)
|
||||
if annotation_error:
|
||||
return False, annotation_error
|
||||
return True, "ok"
|
||||
except sqlite3.Error as e:
|
||||
return False, f"Invalid SQLite database: {e}"
|
||||
|
||||
+93
-6
@@ -12,6 +12,7 @@ from .models import PromptModel, normalize_image_path, utc_now_iso
|
||||
from .pairs import PairDependencyError, PairOperations
|
||||
from .library import LibraryOperations, titled
|
||||
from .image_favorites import ImageFavoriteOperations
|
||||
from .annotations import AnnotationOperations
|
||||
from .saved_views import SavedViewOperations
|
||||
from .library_commands import LibraryCommandOperations
|
||||
from .vocabulary import VocabularyOperations
|
||||
@@ -231,6 +232,23 @@ JOIN prompts p ON p.id = COALESCE(pp.positive_prompt_id, gi.prompt_id)
|
||||
WHERE gi.file_path IS NOT NULL AND CASE WHEN {admit} THEN NOT EXISTS (
|
||||
SELECT 1 FROM generated_images other
|
||||
WHERE other.file_path = gi.file_path AND other.id <> gi.id) ELSE 0 END"""
|
||||
# The linked positive of each given path (one unambiguous link, pair scope as
|
||||
# in _LINK_FILTER_SQL) with its category; {hit} as in link_filter_query. Used
|
||||
# for the few files whose current image tags meet a Tags filter (#249 B3).
|
||||
_LINK_FACTS_SQL = """SELECT gi.file_path, p.rating, {hit}, p.id, p.category
|
||||
FROM generated_images gi
|
||||
LEFT JOIN prompt_pairs pp ON pp.id = (
|
||||
SELECT CASE WHEN COUNT(DISTINCT ip.pair_id) = 1 THEN MIN(ip.pair_id) END
|
||||
FROM image_prompt_pairs ip WHERE ip.image_id = gi.id)
|
||||
JOIN prompts p ON p.id = COALESCE(pp.positive_prompt_id, gi.prompt_id)
|
||||
WHERE gi.file_path IN ({marks}) AND NOT EXISTS (
|
||||
SELECT 1 FROM generated_images other
|
||||
WHERE other.file_path = gi.file_path AND other.id <> gi.id)"""
|
||||
_POSITIVE_TAG_NAMES_SQL = (
|
||||
"SELECT pt.prompt_id, t.name FROM prompt_tags pt JOIN tags t "
|
||||
"ON t.id = pt.tag_id WHERE pt.prompt_id IN ({marks})"
|
||||
)
|
||||
_FACTS_CHUNK = 500
|
||||
# The positive's text, its Library title or one of its tag names contains
|
||||
# :pattern (LIKE, so ASCII case is ignored as in Library search). A pair's own
|
||||
# title names its negative variant and is never searched (#249 D12).
|
||||
@@ -322,6 +340,7 @@ class PromptDatabase(
|
||||
ExecutionOutputOperations,
|
||||
HistoryReadOperations,
|
||||
ImageFavoriteOperations,
|
||||
AnnotationOperations,
|
||||
SavedViewOperations,
|
||||
):
|
||||
"""Database operations class for managing prompts."""
|
||||
@@ -1084,16 +1103,39 @@ class PromptDatabase(
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _bump_image_tag_bytes(conn: sqlite3.Connection, tag_id: int) -> int:
|
||||
"""Move the tag revision of every image set that carries *tag_id*.
|
||||
|
||||
Vocabulary rename, merge and delete change what an image's set reads
|
||||
as, so a Tag dialog opened before the change must see ``tag-conflict``
|
||||
on Save instead of recreating the old name. Bumps the image index
|
||||
revision once when any set was affected; returns that count.
|
||||
"""
|
||||
cursor = conn.execute(
|
||||
"UPDATE media_annotation_bytes SET tag_revision = tag_revision + 1 "
|
||||
"WHERE id IN (SELECT byte_id FROM media_image_tags WHERE tag_id = ?)",
|
||||
(tag_id,),
|
||||
)
|
||||
if cursor.rowcount > 0:
|
||||
conn.execute(
|
||||
"UPDATE media_annotation_metadata SET "
|
||||
"image_index_revision = image_index_revision + 1 WHERE singleton_id = 1"
|
||||
)
|
||||
return cursor.rowcount
|
||||
|
||||
@classmethod
|
||||
def _move_tag_links(
|
||||
conn: sqlite3.Connection, source_id: int, target_id: int
|
||||
cls, conn: sqlite3.Connection, source_id: int, target_id: int
|
||||
) -> None:
|
||||
"""Repoint component and pair links from source to target.
|
||||
"""Repoint component, pair and image links from source to target.
|
||||
|
||||
Owners that already carry the target keep that one link; the source's
|
||||
leftover duplicates are removed. The source registry row is left for the
|
||||
caller to delete.
|
||||
leftover duplicates are removed (an image set keeps the target's
|
||||
position). The source registry row is left for the caller to delete.
|
||||
Image sets that carried the source get a new tag revision first.
|
||||
"""
|
||||
for junction in ("prompt_tags", "prompt_pair_tags"):
|
||||
cls._bump_image_tag_bytes(conn, source_id)
|
||||
for junction in ("prompt_tags", "prompt_pair_tags", "media_image_tags"):
|
||||
conn.execute(
|
||||
f"UPDATE OR IGNORE {junction} SET tag_id = ? WHERE tag_id = ?",
|
||||
(target_id, source_id),
|
||||
@@ -1146,6 +1188,7 @@ class PromptDatabase(
|
||||
conn.execute("DELETE FROM tags WHERE id = ?", (old_tag_id,))
|
||||
else:
|
||||
# Simple rename
|
||||
self._bump_image_tag_bytes(conn, old_tag_id)
|
||||
conn.execute(
|
||||
"UPDATE tags SET name = ? WHERE id = ?", (new_name, old_tag_id)
|
||||
)
|
||||
@@ -1187,6 +1230,7 @@ class PromptDatabase(
|
||||
affected = cursor.fetchone()["c"]
|
||||
|
||||
# CASCADE delete handles prompt_tags entries
|
||||
self._bump_image_tag_bytes(conn, tag_id)
|
||||
conn.execute("DELETE FROM tags WHERE id = ?", (tag_id,))
|
||||
conn.commit()
|
||||
|
||||
@@ -1239,7 +1283,10 @@ class PromptDatabase(
|
||||
pair_count = conn.execute(
|
||||
"SELECT COUNT(*) FROM prompt_pair_tags WHERE tag_id = ?", (src_id,)
|
||||
).fetchone()[0]
|
||||
if src_count or pair_count:
|
||||
image_count = conn.execute(
|
||||
"SELECT COUNT(*) FROM media_image_tags WHERE tag_id = ?", (src_id,)
|
||||
).fetchone()[0]
|
||||
if src_count or pair_count or image_count:
|
||||
self._move_tag_links(conn, src_id, target_id)
|
||||
tags_merged += 1
|
||||
affected += src_count
|
||||
@@ -2420,6 +2467,46 @@ class PromptDatabase(
|
||||
matches[path] = (bool(matched), rating, positive)
|
||||
return matches
|
||||
|
||||
def get_image_link_facts(
|
||||
self, paths: List[str], q: str = ""
|
||||
) -> Dict[str, Tuple[bool, Optional[int], int, Optional[str], Tuple[str, ...]]]:
|
||||
"""Map each normalized path with one unambiguous link to its positive's
|
||||
(hit, rating, positive id, category, tag names).
|
||||
|
||||
The link is resolved as in get_image_link_matches, and ``hit`` says
|
||||
whether *q* occurs in the positive's text, Library title or a tag
|
||||
name (always true without *q*). Paths go in chunks of 500 through the
|
||||
``file_path`` index; the tag names are read for those positives only.
|
||||
"""
|
||||
hit, params = "1", {}
|
||||
if q:
|
||||
hit = _LINK_FILTER_HIT
|
||||
params["pattern"] = f"%{escape_like(q)}%"
|
||||
rows: Dict[str, Tuple[bool, Optional[int], int, Optional[str]]] = {}
|
||||
names: Dict[int, List[str]] = {}
|
||||
unique = list(dict.fromkeys(paths))
|
||||
with self.model.get_connection() as conn:
|
||||
for start in range(0, len(unique), _FACTS_CHUNK):
|
||||
chunk = unique[start : start + _FACTS_CHUNK]
|
||||
bound = {f"path{i}": path for i, path in enumerate(chunk)}
|
||||
marks = ",".join(f":{key}" for key in bound)
|
||||
sql = _LINK_FACTS_SQL.format(hit=hit, marks=marks)
|
||||
for path, rating, matched, positive, category in conn.execute(
|
||||
sql, {**params, **bound}
|
||||
):
|
||||
rows[path] = (bool(matched), rating, positive, category)
|
||||
positives = sorted({row[2] for row in rows.values()})
|
||||
for start in range(0, len(positives), _FACTS_CHUNK):
|
||||
chunk = positives[start : start + _FACTS_CHUNK]
|
||||
marks = ",".join("?" * len(chunk))
|
||||
for positive, name in conn.execute(
|
||||
_POSITIVE_TAG_NAMES_SQL.format(marks=marks), chunk
|
||||
):
|
||||
names.setdefault(positive, []).append(name)
|
||||
return {
|
||||
path: (*row, tuple(names.get(row[2], ()))) for path, row in rows.items()
|
||||
}
|
||||
|
||||
def get_image_paths_matching(
|
||||
self, q: str, category: Optional[str], tags: List[str]
|
||||
) -> Set[str]:
|
||||
|
||||
+30
-4
@@ -3,6 +3,9 @@
|
||||
A count is the number of eligible Library parents that carry the value
|
||||
directly or through one active saved pair, which is exactly what
|
||||
``list_library(tags=[name])`` / ``list_library(category=value)`` return.
|
||||
A tag row's ``image_count`` is the number of files (private locators) with any
|
||||
byte record carrying the tag (#249), so a file counts once however many of its
|
||||
byte versions carry it, and equal bytes at two paths count twice.
|
||||
|
||||
Tag commands run in one ``_pair_transaction``: the storage epoch is checked
|
||||
first, then the exact ``{id, name}`` identity, and the receipt is read on the
|
||||
@@ -26,6 +29,11 @@ except ImportError:
|
||||
from utils.validators import validate_category, validate_tags
|
||||
|
||||
_CATEGORY_DOMAIN = "prompt-manager-vocabulary-category:v1\n"
|
||||
# Files carrying a tag through any of their byte records (#249 image tags).
|
||||
_IMAGE_FILES = (
|
||||
"SELECT mit.tag_id,COUNT(DISTINCT b.file_id) FROM media_image_tags mit "
|
||||
"JOIN media_annotation_bytes b ON b.id=mit.byte_id"
|
||||
)
|
||||
|
||||
|
||||
def _tag_rows(page: str) -> str:
|
||||
@@ -40,9 +48,13 @@ def _tag_rows(page: str) -> str:
|
||||
"WHERE pt.tag_id IN (SELECT id FROM page) AND " + _ELIGIBLE + " "
|
||||
"UNION SELECT ppt.tag_id,pp.positive_prompt_id FROM prompt_pairs pp "
|
||||
"JOIN prompt_pair_tags ppt ON ppt.pair_id=pp.id "
|
||||
"WHERE pp.archived=0 AND ppt.tag_id IN (SELECT id FROM page)) "
|
||||
"SELECT page.id,page.name,COUNT(DISTINCT owners.pid) AS prompt_count "
|
||||
"WHERE pp.archived=0 AND ppt.tag_id IN (SELECT id FROM page)),"
|
||||
"images(tag_id,n) AS (" + _IMAGE_FILES + " "
|
||||
"WHERE mit.tag_id IN (SELECT id FROM page) GROUP BY mit.tag_id) "
|
||||
"SELECT page.id,page.name,COUNT(DISTINCT owners.pid) AS prompt_count,"
|
||||
"COALESCE(MAX(images.n),0) AS image_count "
|
||||
"FROM page LEFT JOIN owners ON owners.tag_id=page.id "
|
||||
"LEFT JOIN images ON images.tag_id=page.id "
|
||||
"GROUP BY page.id ORDER BY page.id"
|
||||
)
|
||||
|
||||
@@ -61,7 +73,10 @@ _TAG_IMPACT = (
|
||||
"AS negative_variants,"
|
||||
"(SELECT COUNT(*) FROM prompt_pairs pp JOIN prompt_pair_tags ppt "
|
||||
"ON ppt.pair_id=pp.id WHERE ppt.tag_id=:tag AND pp.archived!=0) "
|
||||
"AS archived_negative_variants"
|
||||
"AS archived_negative_variants,"
|
||||
"(SELECT COUNT(DISTINCT b.file_id) FROM media_image_tags mit "
|
||||
"JOIN media_annotation_bytes b ON b.id=mit.byte_id WHERE mit.tag_id=:tag) "
|
||||
"AS images"
|
||||
)
|
||||
_MAX_ID = 2**63 - 1
|
||||
|
||||
@@ -205,7 +220,16 @@ class VocabularyOperations:
|
||||
"SELECT COUNT(DISTINCT pair_id) FROM prompt_pair_tags WHERE tag_id=?",
|
||||
(tag_id,),
|
||||
).fetchone()[0]
|
||||
return {"components_changed": components, "pairs_changed": pairs}
|
||||
images = conn.execute(
|
||||
"SELECT COUNT(DISTINCT b.file_id) FROM media_image_tags mit "
|
||||
"JOIN media_annotation_bytes b ON b.id=mit.byte_id WHERE mit.tag_id=?",
|
||||
(tag_id,),
|
||||
).fetchone()[0]
|
||||
return {
|
||||
"components_changed": components,
|
||||
"pairs_changed": pairs,
|
||||
"images_changed": images,
|
||||
}
|
||||
|
||||
def create_vocabulary_tag(self, name: str, *, expected_epoch: str) -> dict:
|
||||
"""Insert an unused registry row, or reuse the exact existing name."""
|
||||
@@ -250,6 +274,7 @@ class VocabularyOperations:
|
||||
"SELECT id FROM tags WHERE name=?", (new_name,)
|
||||
).fetchone()
|
||||
if target is None:
|
||||
self._bump_image_tag_bytes(conn, tag_id)
|
||||
conn.execute("UPDATE tags SET name=? WHERE id=?", (new_name, tag_id))
|
||||
result_id = tag_id
|
||||
else:
|
||||
@@ -275,6 +300,7 @@ class VocabularyOperations:
|
||||
epoch = self._library_epoch(conn, expected_epoch)
|
||||
self._vocabulary_identity(conn, epoch, tag_id, name)
|
||||
counts = self._tag_owner_counts(conn, tag_id)
|
||||
self._bump_image_tag_bytes(conn, tag_id)
|
||||
conn.execute("DELETE FROM tags WHERE id=?", (tag_id,))
|
||||
return {"library_epoch": epoch, "deleted": True, **counts}
|
||||
|
||||
|
||||
@@ -29,6 +29,12 @@ Each role has `status` (`resolved`, `absent`, `unresolved`, or `ambiguous`), `te
|
||||
(string only when resolved, otherwise null), and `source`. Inspection does not
|
||||
index files, alter saved prompts, run scans, or decode image pixels. PNG text and
|
||||
video probe output are bounded; unsupported or malformed evidence remains
|
||||
unresolved rather than becoming sendable placeholder text.
|
||||
unresolved rather than becoming sendable placeholder text. Videos are read from
|
||||
one container carrier: the per-key tags written by core SaveVideo/SaveWEBM and
|
||||
current VideoHelperSuite (`prompt`, `workflow`, `parameters`,
|
||||
`comfyassets_prompt_pairs`), else the old VideoHelperSuite `comment` envelope,
|
||||
else `description`. The admin scan reads the same carrier, so Images and
|
||||
ingestion agree on a video's prompts (see TESTING.md for the order and budgets;
|
||||
the live monitor processes images only).
|
||||
|
||||
Python route changes require restarting ComfyUI. Browser assets require a refresh.
|
||||
|
||||
@@ -26,6 +26,7 @@ from .prompts import PromptRoutesMixin
|
||||
from .pairs import PairRoutesMixin
|
||||
from .library import LibraryRoutesMixin
|
||||
from .vocabulary import VocabularyRoutesMixin
|
||||
from .annotations import AnnotationRoutesMixin
|
||||
from .saved_views import SavedViewRoutesMixin
|
||||
from .history import HistoryRoutesMixin
|
||||
from .images import ImageRoutesMixin
|
||||
@@ -252,6 +253,7 @@ class PromptManagerAPI(
|
||||
PairRoutesMixin,
|
||||
LibraryRoutesMixin,
|
||||
VocabularyRoutesMixin,
|
||||
AnnotationRoutesMixin,
|
||||
SavedViewRoutesMixin,
|
||||
HistoryRoutesMixin,
|
||||
PromptRoutesMixin,
|
||||
@@ -285,6 +287,10 @@ class PromptManagerAPI(
|
||||
self.db = db if db is not None else PromptDatabase()
|
||||
self._history_read_fence = None
|
||||
self._history_reads_closed = False
|
||||
# Private media captures of the annotation routes (py/api/annotations.py),
|
||||
# created on first use with their restore subscription.
|
||||
self._captures = None
|
||||
self._captures_unsubscribe = None
|
||||
self._cached_output_dir = None # Lazy-cached by _find_comfyui_output_dir()
|
||||
self._html_cache = {} # Cached HTML file contents keyed by path
|
||||
self._gallery_cache = {} # dict: path_str -> (files, timestamp)
|
||||
@@ -441,6 +447,7 @@ class PromptManagerAPI(
|
||||
self._register_pair_routes(routes)
|
||||
self._register_library_routes(routes)
|
||||
self._register_vocabulary_routes(routes)
|
||||
self._register_annotation_routes(routes)
|
||||
self._register_saved_view_routes(routes)
|
||||
self._register_history_routes(routes)
|
||||
self._register_prompt_routes(routes)
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Request validation and the worker of the Tag dialog's Generate (#249 B2).
|
||||
|
||||
``POST /prompt_manager/image-annotations/suggest`` asks one of legacy's four
|
||||
engines for the top N tags of a captured image. Options are validated
|
||||
strictly, per engine: WD14 classifiers take two thresholds, the language
|
||||
models (GGUF and HuggingFace JoyCaption) take the instructions; every engine
|
||||
takes the suggestion count and the GPU switch. Nothing here touches the
|
||||
filesystem or the database.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
WD14_MODELS = frozenset({"wd14-swinv2", "wd14-vit"})
|
||||
LLM_MODELS = frozenset({"gguf", "hf"})
|
||||
MODEL_TYPES = WD14_MODELS | LLM_MODELS
|
||||
|
||||
MAX_INSTRUCTION_CHARS = 4000
|
||||
MAX_INSTRUCTION_BYTES = 16 * 1024
|
||||
|
||||
SUGGEST_FIELDS = frozenset(
|
||||
{"target", "capture_token", "expected_epoch", "model_type", "options"}
|
||||
)
|
||||
_COMMON = frozenset({"suggestion_limit", "use_gpu"})
|
||||
_WD14_REQUIRED = frozenset({"general_threshold", "character_threshold"})
|
||||
_LLM_REQUIRED = frozenset({"instructions"})
|
||||
|
||||
UNAVAILABLE_HINT = "Download it from the legacy Auto-tag page."
|
||||
|
||||
|
||||
class OptionError(ValueError):
|
||||
"""A suggest request whose model type or options are not acceptable."""
|
||||
|
||||
|
||||
def _threshold(name: str, value) -> float:
|
||||
if type(value) not in (int, float) or not math.isfinite(value):
|
||||
raise OptionError(f"{name} must be a finite number")
|
||||
if not 0 <= value <= 1:
|
||||
raise OptionError(f"{name} must be between 0 and 1")
|
||||
return float(value)
|
||||
|
||||
|
||||
def _instructions(value) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise OptionError("instructions must be non-empty text")
|
||||
if len(value) > MAX_INSTRUCTION_CHARS:
|
||||
raise OptionError(
|
||||
f"instructions must be at most {MAX_INSTRUCTION_CHARS} characters"
|
||||
)
|
||||
try:
|
||||
encoded = value.encode("utf-8")
|
||||
except UnicodeEncodeError:
|
||||
raise OptionError("instructions must be valid Unicode text") from None
|
||||
if len(encoded) > MAX_INSTRUCTION_BYTES:
|
||||
raise OptionError("instructions are too long")
|
||||
return value
|
||||
|
||||
|
||||
def validate_options(model_type, options, *, default_limit: int, max_limit: int):
|
||||
"""Normalised ``(model_type, options)`` or :class:`OptionError`.
|
||||
|
||||
The answer echoes every option the request will run with, the defaults
|
||||
(suggestion count from the settings, GPU on) filled in.
|
||||
"""
|
||||
if not isinstance(model_type, str) or model_type not in MODEL_TYPES:
|
||||
raise OptionError("model_type must be one of " + ", ".join(sorted(MODEL_TYPES)))
|
||||
if not isinstance(options, dict):
|
||||
raise OptionError("options must be an object")
|
||||
required = _WD14_REQUIRED if model_type in WD14_MODELS else _LLM_REQUIRED
|
||||
keys = set(options)
|
||||
if not required <= keys or not keys <= required | _COMMON:
|
||||
raise OptionError(
|
||||
f"options for {model_type} take exactly "
|
||||
+ ", ".join(sorted(required))
|
||||
+ " and optionally "
|
||||
+ ", ".join(sorted(_COMMON))
|
||||
)
|
||||
limit = options.get("suggestion_limit", default_limit)
|
||||
if type(limit) is not int or not 1 <= limit <= max_limit:
|
||||
raise OptionError(f"suggestion_limit must be an integer from 1 to {max_limit}")
|
||||
use_gpu = options.get("use_gpu", True)
|
||||
if not isinstance(use_gpu, bool):
|
||||
raise OptionError("use_gpu must be true or false")
|
||||
normalised = {"use_gpu": use_gpu, "suggestion_limit": limit}
|
||||
if model_type in WD14_MODELS:
|
||||
for name in sorted(_WD14_REQUIRED):
|
||||
normalised[name] = _threshold(name, options[name])
|
||||
else:
|
||||
normalised["instructions"] = _instructions(options["instructions"])
|
||||
return model_type, normalised
|
||||
|
||||
|
||||
def unavailable_message(model_name: str) -> str:
|
||||
return f"{model_name} is not downloaded. {UNAVAILABLE_HINT}"
|
||||
|
||||
|
||||
def run_suggestions(service, capture_path: str, model_type: str, options: dict):
|
||||
"""Blocking: load (or swap to) *model_type*, then suggest for the capture.
|
||||
|
||||
The caller holds the model gate for the whole call; nothing is ever
|
||||
downloaded (``load_model`` refuses a model that is not on disk).
|
||||
"""
|
||||
if not service.is_model_loaded() or service.get_loaded_model_type() != model_type:
|
||||
service.load_model(model_type, options["use_gpu"])
|
||||
return service.generate_tag_suggestions(
|
||||
capture_path,
|
||||
limit=options["suggestion_limit"],
|
||||
prompt=options.get("instructions"),
|
||||
general_threshold=options.get("general_threshold"),
|
||||
character_threshold=options.get("character_threshold"),
|
||||
)
|
||||
|
||||
|
||||
def suggestion_rows(pairs) -> list:
|
||||
"""``[{name, confidence}]``; a non-finite score is reported as unknown."""
|
||||
rows = []
|
||||
for name, score in pairs:
|
||||
confidence = None
|
||||
if score is not None and math.isfinite(score):
|
||||
confidence = float(score)
|
||||
rows.append({"name": str(name), "confidence": confidence})
|
||||
return rows
|
||||
@@ -0,0 +1,759 @@
|
||||
"""Image annotation routes: observe, capture preview and byte-exact tag saves.
|
||||
|
||||
Every route names its file through the listing identity ``{root_index,
|
||||
root_scope, path}`` and checks the scope against the current gallery roots
|
||||
before any file access. Observe copies the file into a private capture (the
|
||||
token the preview and Save refer to), Save re-hashes the current file and
|
||||
writes only when the bytes are still the captured ones. Responses carry no
|
||||
server path and no locator.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import functools
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from .annotation_suggest import (
|
||||
SUGGEST_FIELDS,
|
||||
OptionError,
|
||||
run_suggestions,
|
||||
suggestion_rows,
|
||||
unavailable_message,
|
||||
validate_options,
|
||||
)
|
||||
from .autotag_routes import model_busy_response
|
||||
from .images import _forbidden, _has_traversal, _unavailable_link, _verified_title
|
||||
from .prompts import bad_request
|
||||
from .. import model_gate
|
||||
|
||||
try:
|
||||
from ...database.annotations import (
|
||||
MAX_IMAGE_TAGS,
|
||||
AnnotationCommandError,
|
||||
ImageTagLimitError,
|
||||
normalize_image_tags,
|
||||
)
|
||||
from ...database.execution_history import subscribe_history_restore
|
||||
from ...database.library_commands import LibraryCommandError
|
||||
from ...utils.media_capture import CAPTURE_EXTENSIONS, CaptureError, CaptureStore
|
||||
except ImportError:
|
||||
from database.annotations import (
|
||||
MAX_IMAGE_TAGS,
|
||||
AnnotationCommandError,
|
||||
ImageTagLimitError,
|
||||
normalize_image_tags,
|
||||
)
|
||||
from database.execution_history import subscribe_history_restore
|
||||
from database.library_commands import LibraryCommandError
|
||||
from utils.media_capture import CAPTURE_EXTENSIONS, CaptureError, CaptureStore
|
||||
|
||||
# Request bodies of the annotation routes are small (a target plus a tag set).
|
||||
MAX_BODY_BYTES = 64 * 1024
|
||||
MAX_TARGET_PATH = 1024
|
||||
CAPTURE_URL = "/prompt_manager/image-annotations/capture/"
|
||||
_PREVIEW_CHUNK = 64 * 1024
|
||||
|
||||
_TARGET_FIELDS = frozenset({"root_index", "root_scope", "path"})
|
||||
_OBSERVE_FIELDS = frozenset({"target"})
|
||||
_SAVE_FIELDS = frozenset(
|
||||
{
|
||||
"target",
|
||||
"capture_token",
|
||||
"sha256",
|
||||
"expected_epoch",
|
||||
"expected_tag_revision",
|
||||
"tags",
|
||||
}
|
||||
)
|
||||
_HEX64 = re.compile(r"[0-9a-f]{64}")
|
||||
_UUID = re.compile(r"[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}")
|
||||
# secrets.token_urlsafe(32) is 43 characters; the bound only rejects junk.
|
||||
_TOKEN = re.compile(r"[A-Za-z0-9_-]{1,128}")
|
||||
|
||||
_PREVIEW_TYPES = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".webp": "image/webp",
|
||||
".gif": "image/gif",
|
||||
".bmp": "image/bmp",
|
||||
".tiff": "image/tiff",
|
||||
".tif": "image/tiff",
|
||||
".mp4": "video/mp4",
|
||||
".m4v": "video/mp4",
|
||||
".webm": "video/webm",
|
||||
".mov": "video/quicktime",
|
||||
".mkv": "video/x-matroska",
|
||||
".avi": "video/x-msvideo",
|
||||
".wmv": "video/x-ms-wmv",
|
||||
}
|
||||
|
||||
_STATUS = {
|
||||
"review-capacity": 429,
|
||||
"unsupported-media": 415,
|
||||
"file-changed": 409,
|
||||
"source-changed": 409,
|
||||
"storage-changed": 409,
|
||||
"tag-conflict": 409,
|
||||
"capture-expired": 409,
|
||||
"model-unavailable": 409,
|
||||
"tag-limit": 400,
|
||||
"metadata-conflict": 409,
|
||||
"vocabulary-conflict": 409,
|
||||
}
|
||||
_MESSAGES = {
|
||||
"source-changed": (
|
||||
"The source changed. Refresh the image collection before tagging this file."
|
||||
),
|
||||
"file-changed": "The file changed since it was previewed. Reopen the Tag dialog.",
|
||||
"review-capacity": "Too many previews are open. Close a Tag dialog and try again.",
|
||||
"storage-changed": "Prompt storage was restored; reopen the Tag dialog.",
|
||||
"tag-conflict": (
|
||||
"The tags of this image changed since the dialog opened; review them first."
|
||||
),
|
||||
"capture-expired": "The preview expired. Open the Tag dialog again.",
|
||||
"tag-limit": f"An image can carry at most {MAX_IMAGE_TAGS} tags.",
|
||||
}
|
||||
# Any other storage code (a conflict a later command adds) still answers a
|
||||
# coded envelope rather than a bare 500.
|
||||
_DEFAULT_STATUS = 409
|
||||
_DEFAULT_MESSAGE = "The tags could not be saved. Reopen the Tag dialog and try again."
|
||||
_OTHER_FILE = "The preview belongs to another file. Open the Tag dialog again."
|
||||
|
||||
|
||||
def preview_content_type(extension: str) -> str:
|
||||
"""The media type the preview is served as; never sniffed from bytes."""
|
||||
return _PREVIEW_TYPES.get(extension.lower(), "application/octet-stream")
|
||||
|
||||
|
||||
def _error(status: int, code: str, message: str, **extra) -> web.Response:
|
||||
return web.json_response(
|
||||
{"success": False, "code": code, "error": message, **extra},
|
||||
status=status,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
def _coded(code: str, message: str | None = None, **extra) -> web.Response:
|
||||
status = _STATUS.get(code, _DEFAULT_STATUS)
|
||||
return _error(
|
||||
status, code, message or _MESSAGES.get(code, _DEFAULT_MESSAGE), **extra
|
||||
)
|
||||
|
||||
|
||||
def _open_capture(path: str):
|
||||
"""Worker thread: the capture file opened for reading."""
|
||||
return open(path, "rb")
|
||||
|
||||
|
||||
async def _settled(future, cleanup):
|
||||
"""Await an executor *future*; a cancelled caller still cleans up after it.
|
||||
|
||||
The future is shielded, so the worker call always runs to its end. When the
|
||||
caller is cancelled first, ``cleanup(result)`` runs once the call is done
|
||||
(``cleanup(None)`` when it failed): a handle is closed, a capture released.
|
||||
"""
|
||||
try:
|
||||
return await asyncio.shield(future)
|
||||
except asyncio.CancelledError:
|
||||
future.add_done_callback(functools.partial(_cleanup_result, cleanup))
|
||||
raise
|
||||
|
||||
|
||||
def _cleanup_result(cleanup, future) -> None:
|
||||
if future.cancelled() or future.exception() is not None:
|
||||
cleanup(None)
|
||||
else:
|
||||
cleanup(future.result())
|
||||
|
||||
|
||||
def _submit(func, *args, **kwargs):
|
||||
return asyncio.get_running_loop().run_in_executor(
|
||||
None, functools.partial(func, *args, **kwargs)
|
||||
)
|
||||
|
||||
|
||||
def _capture_error(exc: CaptureError) -> web.Response:
|
||||
"""The envelope for a capture outcome; messages never name a path."""
|
||||
if exc.code not in _STATUS:
|
||||
return _error(500, exc.code, "The file could not be captured.")
|
||||
return _coded(exc.code, _MESSAGES.get(exc.code) or str(exc))
|
||||
|
||||
|
||||
def _target(data: object):
|
||||
"""``((index, scope, relative), None)`` for a target, else ``(None, error)``.
|
||||
|
||||
Only the listing identity is accepted: an int root index, the root's
|
||||
64-hex scope and a bounded root-relative POSIX media path, never an
|
||||
absolute, drive-qualified, backslash or ``..`` path. Nothing here touches
|
||||
the filesystem or the database.
|
||||
"""
|
||||
if not isinstance(data, dict) or set(data) != _TARGET_FIELDS:
|
||||
return None, bad_request("target must name root_index, root_scope and path")
|
||||
index, scope, relative = data["root_index"], data["root_scope"], data["path"]
|
||||
if type(index) is not int or index < 0:
|
||||
return None, bad_request("Invalid gallery root")
|
||||
if not isinstance(scope, str) or _HEX64.fullmatch(scope) is None:
|
||||
return None, bad_request("Invalid gallery root scope")
|
||||
if not isinstance(relative, str) or not relative or len(relative) > MAX_TARGET_PATH:
|
||||
return None, bad_request("path must be a relative media path")
|
||||
parts = relative.split("/")
|
||||
if (
|
||||
"\0" in relative
|
||||
or "\\" in relative
|
||||
or relative.startswith("/")
|
||||
or _has_traversal(relative)
|
||||
or any(part in ("", ".", "..") or ":" in part for part in parts)
|
||||
):
|
||||
return None, _forbidden()
|
||||
if os.path.splitext(parts[-1])[1].lower() not in CAPTURE_EXTENSIONS:
|
||||
return None, _forbidden("Only media files can be tagged")
|
||||
return (index, scope, relative), None
|
||||
|
||||
|
||||
def _expires_at(seconds: float | None) -> str:
|
||||
moment = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(
|
||||
seconds=max(0.0, seconds or 0.0)
|
||||
)
|
||||
return moment.replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _capture_info(store: CaptureStore, capture) -> dict:
|
||||
return {
|
||||
"token": capture.token,
|
||||
"expires_at": _expires_at(store.expires_in(capture.token)),
|
||||
"preview_url": CAPTURE_URL + capture.token,
|
||||
"sha256": capture.sha256,
|
||||
"size": capture.size,
|
||||
"width": capture.width,
|
||||
"height": capture.height,
|
||||
"media_type": capture.media_type,
|
||||
"generate": {
|
||||
"supported": capture.inferable,
|
||||
"reason": capture.inferable_reason,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class AnnotationRoutesMixin:
|
||||
"""Observe, capture preview and tag save routes over PR A's storage."""
|
||||
|
||||
def _register_annotation_routes(self, routes: web.RouteTableDef) -> None:
|
||||
routes.post("/prompt_manager/image-annotations/observe")(self.observe_image)
|
||||
routes.get(CAPTURE_URL + "{token}")(self.get_capture_preview)
|
||||
routes.delete(CAPTURE_URL + "{token}")(self.release_capture)
|
||||
routes.put("/prompt_manager/image-annotations/tags")(self.save_image_tags)
|
||||
routes.post("/prompt_manager/image-annotations/suggest")(
|
||||
self.suggest_image_tags
|
||||
)
|
||||
|
||||
# -- capture store lifetime ------------------------------------------------
|
||||
|
||||
def _capture_store(self) -> CaptureStore:
|
||||
"""The process's capture store, created on first use.
|
||||
|
||||
Created lazily so an API object that never observes a file (most tests,
|
||||
a server that never opens the Tag dialog) owns no temp directory. A
|
||||
restore of the database dooms every capture (``invalidate_all``).
|
||||
"""
|
||||
if self._captures is None:
|
||||
self._captures = CaptureStore()
|
||||
if self._captures_unsubscribe is None:
|
||||
self._captures_unsubscribe = subscribe_history_restore(
|
||||
self.db.model.db_path, self._invalidate_captures
|
||||
)
|
||||
return self._captures
|
||||
|
||||
def _invalidate_captures(self) -> None:
|
||||
store = self._captures
|
||||
if store is not None:
|
||||
store.invalidate_all()
|
||||
|
||||
def close_annotation_captures(self) -> None:
|
||||
"""Remove every capture file and the owned directory. Idempotent."""
|
||||
unsubscribe, self._captures_unsubscribe = self._captures_unsubscribe, None
|
||||
if unsubscribe is not None:
|
||||
unsubscribe()
|
||||
store, self._captures = self._captures, None
|
||||
if store is not None:
|
||||
store.close()
|
||||
|
||||
async def cleanup_annotation_captures(self, app: web.Application) -> None:
|
||||
self.close_annotation_captures()
|
||||
|
||||
# -- shared request steps --------------------------------------------------
|
||||
|
||||
async def _annotation_body(self, request: web.Request, fields: frozenset):
|
||||
data, error = await self._read_json_body(request, max_bytes=MAX_BODY_BYTES)
|
||||
if error is not None:
|
||||
return None, error
|
||||
if set(data) != fields:
|
||||
return None, bad_request(
|
||||
"Request must contain exactly " + ", ".join(sorted(fields))
|
||||
)
|
||||
return data, None
|
||||
|
||||
def _scoped_root(self, index: int, scope: str):
|
||||
"""``(root, None)`` when the scope names a current root, else an error."""
|
||||
roots = list(self._get_all_output_dirs())
|
||||
error = self._gallery_scope_error(
|
||||
{"root_scope": scope, "root": str(index)}, roots
|
||||
)
|
||||
if error is not None:
|
||||
if error.status == 409:
|
||||
return None, _coded("source-changed")
|
||||
return None, error
|
||||
return roots[index], None
|
||||
|
||||
# -- observe ---------------------------------------------------------------
|
||||
|
||||
async def observe_image(self, request: web.Request) -> web.Response:
|
||||
"""POST ``{target}``: capture the file and answer its saved tag set.
|
||||
|
||||
The capture is the byte-exact copy the preview and Save refer to; the
|
||||
tags are those of exactly these bytes (an empty set at revision 0 for
|
||||
unseen bytes). Observing never writes tags; equal bytes refresh the
|
||||
record's verified fingerprint (S1).
|
||||
"""
|
||||
data, error = await self._annotation_body(request, _OBSERVE_FIELDS)
|
||||
if error is not None:
|
||||
return error
|
||||
target, error = _target(data["target"])
|
||||
if error is not None:
|
||||
return error
|
||||
index, scope, relative = target
|
||||
root, error = self._scoped_root(index, scope)
|
||||
if error is not None:
|
||||
return error
|
||||
store = self._capture_store()
|
||||
try:
|
||||
epoch = await self._run_in_executor(self.db.get_execution_epoch)
|
||||
capture = await _settled(
|
||||
_submit(
|
||||
store.capture,
|
||||
root,
|
||||
relative,
|
||||
epoch=epoch,
|
||||
scope=scope,
|
||||
root_index=index,
|
||||
),
|
||||
# A cancelled observe never hands its capture out: release it.
|
||||
lambda made: made is not None and store.release(made.token),
|
||||
)
|
||||
except CaptureError as exc:
|
||||
return _capture_error(exc)
|
||||
except Exception:
|
||||
self.logger.exception("Image observation failed")
|
||||
return _error(500, "observe-failed", "The image could not be observed.")
|
||||
try:
|
||||
stored, title = await self._run_in_executor(
|
||||
self._observe_capture, store, capture, root, relative
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
store.release(capture.token)
|
||||
raise
|
||||
except Exception:
|
||||
store.release(capture.token)
|
||||
self.logger.exception("Image observation failed")
|
||||
return _error(500, "observe-failed", "The image could not be observed.")
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"library_epoch": capture.epoch,
|
||||
"target": {"root_index": index, "root_scope": scope, "path": relative},
|
||||
"capture": _capture_info(store, capture),
|
||||
"title": title,
|
||||
"filename": relative.rsplit("/", 1)[-1],
|
||||
"tags": stored["tags"],
|
||||
"tag_revision": stored["tag_revision"],
|
||||
},
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
def _observe_capture(self, store: CaptureStore, capture, root, relative: str):
|
||||
"""Blocking part of observe: fingerprint refresh, tag read, title."""
|
||||
self.db.refresh_image_fingerprint(
|
||||
capture.locator, capture.sha256, capture.size, capture.mtime_ns
|
||||
)
|
||||
stored = self.db.read_image_tags(capture.locator, capture.sha256)
|
||||
return stored, self._observed_title(store, capture, root, relative)
|
||||
|
||||
def _observed_title(self, store: CaptureStore, capture, root, relative: str):
|
||||
"""The dialog's title under the card and inspector rule (``_verified_title``).
|
||||
|
||||
A stored Library title always shows; a first-line title only when the
|
||||
captured bytes' own records confirm it. An ambiguous association has no
|
||||
title. Videos read no card metadata, so only a stored title shows.
|
||||
"""
|
||||
path = str(Path(root) / relative)
|
||||
summary = self._workspace_link_summaries([path]).get(path)
|
||||
if summary is None:
|
||||
summary = _unavailable_link()
|
||||
positive_title = None
|
||||
if (
|
||||
capture.media_type != "video"
|
||||
and summary.get("title_source") == "text"
|
||||
and store.acquire(capture.token) is not None
|
||||
):
|
||||
try:
|
||||
facts = self._read_card_facts(Path(capture.path), parse=True)
|
||||
finally:
|
||||
store.release_use(capture.token)
|
||||
positive_title = facts.positive_title if facts is not None else None
|
||||
return _verified_title(summary, positive_title)
|
||||
|
||||
# -- capture preview -------------------------------------------------------
|
||||
|
||||
async def get_capture_preview(self, request: web.Request) -> web.StreamResponse:
|
||||
"""GET the captured bytes; the token is pinned while they stream."""
|
||||
token = request.match_info["token"]
|
||||
store = self._captures
|
||||
if store is None or _TOKEN.fullmatch(token) is None:
|
||||
return _error(404, "capture-expired", _MESSAGES["capture-expired"])
|
||||
capture = store.acquire(token)
|
||||
if capture is None:
|
||||
return _error(404, "capture-expired", _MESSAGES["capture-expired"])
|
||||
pinned = True
|
||||
try:
|
||||
_root, error = self._scoped_root(capture.root_index, capture.scope)
|
||||
if error is not None:
|
||||
# The roots changed under the token: it is no longer usable.
|
||||
store.release(token)
|
||||
return error
|
||||
try:
|
||||
handle = await _settled(
|
||||
_submit(_open_capture, capture.path),
|
||||
functools.partial(_close_then_release, store, token),
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
# Cancelled while the file opens: the pin ends only after the
|
||||
# handle closes (Windows cannot unlink an open file).
|
||||
pinned = False
|
||||
raise
|
||||
except OSError:
|
||||
store.release(token)
|
||||
return _error(404, "capture-expired", _MESSAGES["capture-expired"])
|
||||
try:
|
||||
return await self._stream_capture(request, capture, handle)
|
||||
finally:
|
||||
handle.close()
|
||||
finally:
|
||||
if pinned:
|
||||
store.release_use(token)
|
||||
|
||||
@staticmethod
|
||||
async def _stream_capture(request, capture, handle) -> web.StreamResponse:
|
||||
"""The capture's bytes, or the one byte range a ``Range`` header asks for.
|
||||
|
||||
The response is written here, inside the caller's pin, so the handle
|
||||
is closed and the pin ended only after the last byte is sent.
|
||||
"""
|
||||
size = capture.size
|
||||
headers = {
|
||||
"Content-Type": preview_content_type(Path(capture.path).suffix),
|
||||
"Accept-Ranges": "bytes",
|
||||
"Cache-Control": "no-store",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
}
|
||||
status, start, end = 200, 0, size
|
||||
if "Range" in request.headers:
|
||||
wanted = _byte_range(request, size)
|
||||
if wanted is None:
|
||||
return web.Response(
|
||||
status=416,
|
||||
headers={**headers, "Content-Range": f"bytes */{size}"},
|
||||
)
|
||||
status, (start, end) = 206, wanted
|
||||
headers["Content-Range"] = f"bytes {start}-{end - 1}/{size}"
|
||||
headers["Content-Length"] = str(end - start)
|
||||
response = web.StreamResponse(status=status, headers=headers)
|
||||
await response.prepare(request)
|
||||
loop = asyncio.get_running_loop()
|
||||
if start:
|
||||
await loop.run_in_executor(None, handle.seek, start)
|
||||
remaining = end - start
|
||||
while remaining > 0 and request.method != "HEAD":
|
||||
chunk = await loop.run_in_executor(
|
||||
None, handle.read, min(_PREVIEW_CHUNK, remaining)
|
||||
)
|
||||
if not chunk:
|
||||
break
|
||||
remaining -= len(chunk)
|
||||
await response.write(chunk)
|
||||
await response.write_eof()
|
||||
return response
|
||||
|
||||
async def release_capture(self, request: web.Request) -> web.Response:
|
||||
"""DELETE a capture token; releasing an unknown token is a success."""
|
||||
token = request.match_info["token"]
|
||||
store = self._captures
|
||||
if store is not None and _TOKEN.fullmatch(token) is not None:
|
||||
store.release(token)
|
||||
return web.json_response(
|
||||
{"success": True}, headers={"Cache-Control": "no-store"}
|
||||
)
|
||||
|
||||
# -- save ------------------------------------------------------------------
|
||||
|
||||
async def save_image_tags(self, request: web.Request) -> web.Response:
|
||||
"""PUT the tag set of the previewed bytes, re-hashing the current file.
|
||||
|
||||
Writes only when the file still holds the captured bytes
|
||||
(``file-changed`` with zero writes otherwise), under the storage epoch
|
||||
and the tag revision the dialog read; an identical retry is
|
||||
``replayed``. The hash never runs inside a database transaction.
|
||||
"""
|
||||
data, error = await self._annotation_body(request, _SAVE_FIELDS)
|
||||
if error is not None:
|
||||
return error
|
||||
target, error = _target(data["target"])
|
||||
if error is not None:
|
||||
return error
|
||||
guards, error = _save_guards(data)
|
||||
if error is not None:
|
||||
return error
|
||||
token, sha256, expected_epoch, revision, tags = guards
|
||||
index, scope, relative = target
|
||||
root, error = self._scoped_root(index, scope)
|
||||
if error is not None:
|
||||
return error
|
||||
store = self._captures
|
||||
capture = store.get(token) if store is not None else None
|
||||
if capture is None:
|
||||
return _coded("capture-expired")
|
||||
if capture.root_index != index or capture.scope != scope:
|
||||
return _coded("capture-expired", _OTHER_FILE)
|
||||
if sha256 != capture.sha256:
|
||||
return bad_request("sha256 does not match the preview")
|
||||
if expected_epoch != capture.epoch:
|
||||
current = await self._run_in_executor(self.db.get_execution_epoch)
|
||||
return _coded("storage-changed", library_epoch=current)
|
||||
try:
|
||||
facts = await self._run_in_executor(
|
||||
_verify_captured_file, store, capture, root, relative
|
||||
)
|
||||
except CaptureError as exc:
|
||||
return _capture_error(exc)
|
||||
if facts is None or facts.locator != capture.locator:
|
||||
return _coded("capture-expired", _OTHER_FILE)
|
||||
if facts.sha256 != capture.sha256:
|
||||
return _coded("file-changed")
|
||||
try:
|
||||
receipt = await self._run_in_executor(
|
||||
self.db.replace_image_tags,
|
||||
locator=facts.locator,
|
||||
sha256=facts.sha256,
|
||||
size=facts.size,
|
||||
mtime_ns=facts.mtime_ns,
|
||||
expected_epoch=expected_epoch,
|
||||
expected_tag_revision=revision,
|
||||
tags=tags,
|
||||
)
|
||||
except AnnotationCommandError as exc:
|
||||
current = exc.current or {}
|
||||
return _coded(
|
||||
exc.code,
|
||||
library_epoch=exc.epoch,
|
||||
tags=current.get("tags", []),
|
||||
tag_revision=current.get("tag_revision", 0),
|
||||
)
|
||||
except LibraryCommandError as exc:
|
||||
return _coded(exc.code, library_epoch=exc.epoch)
|
||||
except ValueError as exc:
|
||||
return bad_request(str(exc))
|
||||
except Exception:
|
||||
self.logger.exception("Image tag save failed")
|
||||
return _error(500, "save-failed", "The tags could not be saved.")
|
||||
return web.json_response(
|
||||
{"success": True, **receipt}, headers={"Cache-Control": "no-store"}
|
||||
)
|
||||
|
||||
# -- suggest ---------------------------------------------------------------
|
||||
|
||||
async def suggest_image_tags(self, request: web.Request) -> web.Response:
|
||||
"""POST: the top N tags of the captured bytes from one legacy engine.
|
||||
|
||||
Runs only on the private capture (pinned for the whole inference),
|
||||
under the shared model gate (``model-busy`` while anything else holds
|
||||
it, including a legacy batch), never downloads (``model-unavailable``)
|
||||
and never writes.
|
||||
"""
|
||||
from ..autotag import MODELS, get_autotag_service
|
||||
from ..config import MAX_SUGGESTION_LIMIT, AutotagConfig
|
||||
|
||||
data, error = await self._annotation_body(request, SUGGEST_FIELDS)
|
||||
if error is not None:
|
||||
return error
|
||||
target, error = _target(data["target"])
|
||||
if error is not None:
|
||||
return error
|
||||
try:
|
||||
model_type, options = validate_options(
|
||||
data["model_type"],
|
||||
data["options"],
|
||||
default_limit=AutotagConfig.suggestion_limit(),
|
||||
max_limit=MAX_SUGGESTION_LIMIT,
|
||||
)
|
||||
except OptionError as exc:
|
||||
return bad_request(str(exc))
|
||||
token, epoch = data["capture_token"], data["expected_epoch"]
|
||||
if not isinstance(token, str) or _TOKEN.fullmatch(token) is None:
|
||||
return bad_request("Invalid capture token")
|
||||
if not isinstance(epoch, str) or _UUID.fullmatch(epoch) is None:
|
||||
return bad_request("expected_epoch must be a canonical UUID")
|
||||
index, scope, relative = target
|
||||
root, error = self._scoped_root(index, scope)
|
||||
if error is not None:
|
||||
return error
|
||||
store = self._captures
|
||||
capture = store.acquire(token) if store is not None else None
|
||||
if capture is None:
|
||||
return _coded("capture-expired")
|
||||
started = False
|
||||
try:
|
||||
error = await self._suggest_guards(capture, target, root, epoch)
|
||||
if error is not None:
|
||||
return error
|
||||
service = get_autotag_service()
|
||||
status = await self._run_in_executor(service.get_models_status)
|
||||
if not status.get(model_type, {}).get("downloaded"):
|
||||
name = MODELS.get(model_type, {}).get("name", model_type)
|
||||
return _coded("model-unavailable", unavailable_message(name))
|
||||
owner = model_gate.new_owner("image-annotations")
|
||||
if not model_gate.try_acquire(owner):
|
||||
return model_busy_response()
|
||||
try:
|
||||
# From here the pin is the gate's to end: the worker ends it,
|
||||
# or ``on_skipped`` does when the worker never starts.
|
||||
started = True
|
||||
pairs = await model_gate.run(
|
||||
owner,
|
||||
_pinned_suggestions,
|
||||
store,
|
||||
token,
|
||||
service,
|
||||
capture,
|
||||
model_type,
|
||||
options,
|
||||
on_skipped=functools.partial(store.release_use, token),
|
||||
)
|
||||
except Exception:
|
||||
self.logger.exception("Tag suggestion failed")
|
||||
return _error(
|
||||
500,
|
||||
"suggest-failed",
|
||||
"Auto-tagging failed. Check server logs for details.",
|
||||
)
|
||||
finally:
|
||||
model_gate.release(owner)
|
||||
finally:
|
||||
if not started:
|
||||
store.release_use(token)
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"model_type": model_type,
|
||||
"options": options,
|
||||
"suggestions": suggestion_rows(pairs),
|
||||
},
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
async def _suggest_guards(self, capture, target, root, epoch):
|
||||
"""The capture must be this target's, of this epoch, and inferable."""
|
||||
index, scope, relative = target
|
||||
expected = os.path.normcase(
|
||||
os.path.join(os.path.realpath(root), *relative.split("/"))
|
||||
)
|
||||
if (
|
||||
capture.root_index != index
|
||||
or capture.scope != scope
|
||||
or capture.locator != expected
|
||||
):
|
||||
return _coded("capture-expired", _OTHER_FILE)
|
||||
if epoch != capture.epoch:
|
||||
current = await self._run_in_executor(self.db.get_execution_epoch)
|
||||
return _coded("storage-changed", library_epoch=current)
|
||||
if not capture.inferable:
|
||||
return _coded("unsupported-media", capture.inferable_reason)
|
||||
return None
|
||||
|
||||
|
||||
def _close_then_release(store: CaptureStore, token: str, handle) -> None:
|
||||
"""Close a preview handle a cancelled request opened, then end its pin."""
|
||||
try:
|
||||
if handle is not None:
|
||||
handle.close()
|
||||
finally:
|
||||
store.release_use(token)
|
||||
|
||||
|
||||
def _byte_range(request: web.Request, size: int):
|
||||
"""``(start, end)`` of the one satisfiable byte range asked for, else None.
|
||||
|
||||
A malformed, multi-range or unsatisfiable header is None (416), as
|
||||
aiohttp's own file responses answer it.
|
||||
"""
|
||||
try:
|
||||
wanted = request.http_range
|
||||
except ValueError:
|
||||
return None
|
||||
start, stop = wanted.start, wanted.stop
|
||||
if start is None:
|
||||
return None
|
||||
if start < 0: # a suffix: the last -start bytes
|
||||
start = max(0, size + start)
|
||||
stop = size
|
||||
stop = size if stop is None else min(stop, size)
|
||||
if start >= size or stop <= start:
|
||||
return None
|
||||
return start, stop
|
||||
|
||||
|
||||
def _pinned_suggestions(store, token, service, capture, model_type, options):
|
||||
"""Worker thread: infer on the capture, then end its pin (even on error)."""
|
||||
try:
|
||||
return run_suggestions(service, capture.path, model_type, options)
|
||||
finally:
|
||||
store.release_use(token)
|
||||
|
||||
|
||||
def _verify_captured_file(store: CaptureStore, capture, root, relative: str):
|
||||
"""Re-hash the current file, or None when the token names another file.
|
||||
|
||||
The cheap locator comparison runs first, so a token sent for another
|
||||
target never costs a whole-file hash.
|
||||
"""
|
||||
expected = os.path.normcase(
|
||||
os.path.join(os.path.realpath(root), *relative.split("/"))
|
||||
)
|
||||
if expected != capture.locator:
|
||||
return None
|
||||
return store.verify(root, relative)
|
||||
|
||||
|
||||
def _save_guards(data: dict):
|
||||
"""Validated ``(token, sha256, epoch, revision, tags)`` or ``(None, error)``."""
|
||||
token = data["capture_token"]
|
||||
if not isinstance(token, str) or _TOKEN.fullmatch(token) is None:
|
||||
return None, bad_request("Invalid capture token")
|
||||
sha256 = data["sha256"]
|
||||
if not isinstance(sha256, str) or _HEX64.fullmatch(sha256) is None:
|
||||
return None, bad_request("sha256 must be 64 lowercase hexadecimal characters")
|
||||
epoch = data["expected_epoch"]
|
||||
if not isinstance(epoch, str) or _UUID.fullmatch(epoch) is None:
|
||||
return None, bad_request("expected_epoch must be a canonical UUID")
|
||||
revision = data["expected_tag_revision"]
|
||||
if type(revision) is not int or revision < 0:
|
||||
return None, bad_request("expected_tag_revision must be a non-negative integer")
|
||||
try:
|
||||
tags = normalize_image_tags(data["tags"])
|
||||
except ImageTagLimitError as exc:
|
||||
return None, _error(400, "tag-limit", str(exc))
|
||||
except ValueError as exc:
|
||||
return None, bad_request(str(exc))
|
||||
return (token, sha256, epoch, revision, tags), None
|
||||
+76
-27
@@ -9,11 +9,24 @@ from typing import Optional
|
||||
from urllib.parse import quote
|
||||
|
||||
from .prompts import thumbnail_url_for
|
||||
from .. import model_gate
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
_INTERNAL_ERROR = "An internal error occurred. Check server logs for details."
|
||||
_BUSY_ERROR = "Auto-tagging is already running"
|
||||
MODEL_BUSY_ERROR = (
|
||||
"A tagging model is busy with another operation. Try again when it finishes."
|
||||
)
|
||||
|
||||
|
||||
def model_busy_response() -> web.Response:
|
||||
"""``409 model-busy``: another operation holds the shared model gate."""
|
||||
return web.json_response(
|
||||
{"success": False, "code": "model-busy", "error": MODEL_BUSY_ERROR},
|
||||
status=409,
|
||||
)
|
||||
|
||||
|
||||
# Only these may be handed to the tagging engine or listed by the scanner.
|
||||
_MEDIA_EXTENSIONS = frozenset(
|
||||
@@ -86,6 +99,7 @@ class AutotagRoutesMixin:
|
||||
"""Get status of available AutoTag models."""
|
||||
try:
|
||||
from ..autotag import get_autotag_service
|
||||
from ..config import AutotagConfig
|
||||
|
||||
service = get_autotag_service()
|
||||
models_status = service.get_models_status()
|
||||
@@ -99,6 +113,7 @@ class AutotagRoutesMixin:
|
||||
"loaded_model_type": service.get_loaded_model_type(),
|
||||
"wd14_general_threshold": service.wd14_general_threshold,
|
||||
"wd14_character_threshold": service.wd14_character_threshold,
|
||||
"suggestion_limit": AutotagConfig.suggestion_limit(),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -138,6 +153,7 @@ class AutotagRoutesMixin:
|
||||
async def download_autotag_model(self, request):
|
||||
"""Download an AutoTag model with streaming progress (POST only)."""
|
||||
model_type = request.match_info.get("model_type")
|
||||
owner = model_gate.new_owner("legacy-download")
|
||||
|
||||
async def events():
|
||||
try:
|
||||
@@ -160,8 +176,8 @@ class AutotagRoutesMixin:
|
||||
def progress_callback(status: str, progress: float):
|
||||
pass
|
||||
|
||||
success = await self._run_in_executor(
|
||||
service.download_model, model_type, progress_callback
|
||||
success = await model_gate.run(
|
||||
owner, service.download_model, model_type, progress_callback
|
||||
)
|
||||
if success:
|
||||
yield {
|
||||
@@ -178,7 +194,12 @@ class AutotagRoutesMixin:
|
||||
"message": _INTERNAL_ERROR,
|
||||
}
|
||||
|
||||
return await self._stream_sse(request, events())
|
||||
if not model_gate.try_acquire(owner):
|
||||
return model_busy_response()
|
||||
try:
|
||||
return await self._stream_sse(request, events())
|
||||
finally:
|
||||
model_gate.release(owner)
|
||||
|
||||
async def _read_start_params(self, request) -> Optional[dict]:
|
||||
"""Merge query-string and optional JSON-body parameters for autotag/start.
|
||||
@@ -239,8 +260,9 @@ class AutotagRoutesMixin:
|
||||
return
|
||||
|
||||
yield {"type": "progress", "progress": 0, "status": "Loading model..."}
|
||||
run = self._gated_runner(opts.get("gate_owner"))
|
||||
try:
|
||||
await self._run_in_executor(service.load_model, model_type, True)
|
||||
await run(service.load_model, model_type, True)
|
||||
except Exception:
|
||||
self.logger.exception("Failed to load model")
|
||||
yield {
|
||||
@@ -338,7 +360,7 @@ class AutotagRoutesMixin:
|
||||
continue
|
||||
|
||||
try:
|
||||
tags = await self._run_in_executor(
|
||||
tags = await run(
|
||||
service.generate_tags,
|
||||
allowed_path,
|
||||
general_threshold=opts["general_threshold"],
|
||||
@@ -423,10 +445,26 @@ class AutotagRoutesMixin:
|
||||
{"success": False, "error": _BUSY_ERROR}, status=409
|
||||
)
|
||||
try:
|
||||
return await self._stream_sse(request, events())
|
||||
opts["gate_owner"] = model_gate.new_owner("legacy-batch")
|
||||
if not model_gate.try_acquire(opts["gate_owner"]):
|
||||
return model_busy_response()
|
||||
try:
|
||||
return await self._stream_sse(request, events())
|
||||
finally:
|
||||
model_gate.release(opts["gate_owner"])
|
||||
finally:
|
||||
_autotag_batch_lock.release()
|
||||
|
||||
def _gated_runner(self, owner):
|
||||
"""Executor calls under the model gate *owner* holds (plain when None)."""
|
||||
if owner is None:
|
||||
return self._run_in_executor
|
||||
|
||||
async def run(func, *args, **kwargs):
|
||||
return await model_gate.run(owner, func, *args, **kwargs)
|
||||
|
||||
return run
|
||||
|
||||
def _resolve_allowed_image_path(self, raw_path: str) -> Optional[str]:
|
||||
"""Return the real path when it is a media file inside an output directory."""
|
||||
if Path(str(raw_path)).suffix.lower() not in _MEDIA_EXTENSIONS:
|
||||
@@ -549,22 +587,28 @@ class AutotagRoutesMixin:
|
||||
from ..autotag import get_autotag_service
|
||||
|
||||
service = get_autotag_service()
|
||||
owner = model_gate.new_owner("legacy-single")
|
||||
if not model_gate.try_acquire(owner):
|
||||
return model_busy_response()
|
||||
try:
|
||||
if (
|
||||
not service.is_model_loaded()
|
||||
or service.get_loaded_model_type() != model_type
|
||||
):
|
||||
await model_gate.run(owner, service.load_model, model_type, use_gpu)
|
||||
|
||||
if (
|
||||
not service.is_model_loaded()
|
||||
or service.get_loaded_model_type() != model_type
|
||||
):
|
||||
await self._run_in_executor(service.load_model, model_type, use_gpu)
|
||||
if custom_prompt:
|
||||
service.custom_prompt = custom_prompt
|
||||
|
||||
if custom_prompt:
|
||||
service.custom_prompt = custom_prompt
|
||||
|
||||
tags = await self._run_in_executor(
|
||||
service.generate_tags,
|
||||
image_path,
|
||||
general_threshold=general_threshold,
|
||||
character_threshold=character_threshold,
|
||||
)
|
||||
tags = await model_gate.run(
|
||||
owner,
|
||||
service.generate_tags,
|
||||
image_path,
|
||||
general_threshold=general_threshold,
|
||||
character_threshold=character_threshold,
|
||||
)
|
||||
finally:
|
||||
model_gate.release(owner)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
@@ -636,14 +680,19 @@ class AutotagRoutesMixin:
|
||||
from ..autotag import get_autotag_service
|
||||
|
||||
service = get_autotag_service()
|
||||
owner = model_gate.new_owner("legacy-unload")
|
||||
if not model_gate.try_acquire(owner):
|
||||
return model_busy_response()
|
||||
try:
|
||||
if not service.is_model_loaded():
|
||||
return web.json_response(
|
||||
{"success": True, "message": "No model was loaded"}
|
||||
)
|
||||
|
||||
if not service.is_model_loaded():
|
||||
return web.json_response(
|
||||
{"success": True, "message": "No model was loaded"}
|
||||
)
|
||||
|
||||
model_type = service.get_loaded_model_type()
|
||||
service.unload_model()
|
||||
model_type = service.get_loaded_model_type()
|
||||
service.unload_model()
|
||||
finally:
|
||||
model_gate.release(owner)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
|
||||
+184
-9
@@ -14,7 +14,7 @@ import urllib.parse
|
||||
from bisect import bisect_left, bisect_right
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
from typing import Callable, NamedTuple
|
||||
|
||||
from aiohttp import web
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
@@ -74,6 +74,7 @@ IMAGE_EXTENSIONS = _IMAGE_EXTENSIONS
|
||||
# The header records the card parses (see utils.card_seed.header_params).
|
||||
_CARD_RECORDS = ("prompt", "workflow")
|
||||
_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
||||
_LISTING_FAILED = "The image list could not be filtered. Check server logs for details."
|
||||
|
||||
|
||||
def _card_header(path: Path) -> tuple:
|
||||
@@ -418,6 +419,29 @@ def _listed(path: Path, mtime: float, root: Path, index: int) -> _Listed:
|
||||
)
|
||||
|
||||
|
||||
class _ImageTagMatch(NamedTuple):
|
||||
"""How one listed file's current image tags meet the filters (#249 B3)."""
|
||||
|
||||
tags: bool # every filter tag is in its image tags or linked positive tags
|
||||
query: bool # ``q`` is a case-folded substring of one image tag name
|
||||
link: tuple | None # (hit, rating, positive id) of its unambiguous link
|
||||
|
||||
|
||||
def _current_fingerprint(path: Path) -> tuple[int, int] | None:
|
||||
"""The file's present ``(st_size, st_mtime_ns)``; None when it is gone."""
|
||||
try:
|
||||
observed = os.stat(path)
|
||||
except OSError:
|
||||
return None
|
||||
return observed.st_size, observed.st_mtime_ns
|
||||
|
||||
|
||||
def _image_tags_relevant(names: list, wanted: set, query: str) -> bool:
|
||||
if wanted and not wanted.isdisjoint(names):
|
||||
return True
|
||||
return bool(query) and any(query in name.casefold() for name in names)
|
||||
|
||||
|
||||
def _workspace_key(entry: tuple, order: str) -> tuple:
|
||||
"""The keyset sort key of *entry* (a _Listed or a 4-tuple entry)."""
|
||||
if not isinstance(entry, _Listed):
|
||||
@@ -752,6 +776,7 @@ def _filtered_page(
|
||||
filters: dict,
|
||||
links: dict | None,
|
||||
favorites: frozenset | None = None,
|
||||
image_tags: dict | None = None,
|
||||
) -> tuple[int, list, int | None]:
|
||||
"""Count the *entries* the filters admit and take *size* from *start* on.
|
||||
|
||||
@@ -761,6 +786,9 @@ def _filtered_page(
|
||||
or a linked-positive hit. Returns the total, the page and the number of
|
||||
distinct linked positives among all admitted entries (None without links).
|
||||
*favorites*, when given, admits only those listed paths (``favorites=1``).
|
||||
*image_tags* maps normalized paths to their _ImageTagMatch: a tag filter
|
||||
also admits a file whose current image tags (with its linked positive's
|
||||
tags) carry every tag, and ``q`` also admits an image tag name hit.
|
||||
"""
|
||||
query, media, folder = filters["query"], filters["media"], filters["folder"]
|
||||
members_only = filters["category"] is not None or bool(filters["tags"])
|
||||
@@ -780,9 +808,15 @@ def _filtered_page(
|
||||
if favorites is not None and entry.path not in favorites:
|
||||
continue
|
||||
match = None if links is None else links.get(entry.normalized)
|
||||
if members_only and match is None:
|
||||
image = None if image_tags is None else image_tags.get(entry.normalized)
|
||||
if members_only and match is None and not (image and image.tags):
|
||||
continue
|
||||
if query and query not in entry.folded and not (match and match[0]):
|
||||
if (
|
||||
query
|
||||
and query not in entry.folded
|
||||
and not (match and match[0])
|
||||
and not (image and image.query)
|
||||
):
|
||||
continue
|
||||
total += 1
|
||||
if match is not None:
|
||||
@@ -1140,7 +1174,7 @@ class ImageRoutesMixin:
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Output images error: {e}")
|
||||
self.logger.error(f"Output images error: {e}", exc_info=True)
|
||||
return web.json_response(
|
||||
{"success": False, "error": _safe_error(e)}, status=500
|
||||
)
|
||||
@@ -1184,11 +1218,27 @@ class ImageRoutesMixin:
|
||||
for index, root in enumerate(output_dirs)
|
||||
if root_index is None or index == root_index
|
||||
]
|
||||
page, total, has_more, next_cursor, positive_count = (
|
||||
await self._run_in_executor(
|
||||
self._select_workspace_page, listings, filters, after, limit, scope
|
||||
try:
|
||||
page, total, has_more, next_cursor, positive_count = (
|
||||
await self._run_in_executor(
|
||||
self._select_workspace_page, listings, filters, after, limit, scope
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
# A filter that needs the prompt links or the image tags cannot be
|
||||
# served without them. Never echo the exception: it can carry SQL
|
||||
# or database text. The traceback goes to the server log.
|
||||
self.logger.error(
|
||||
"Workspace image list could not be filtered", exc_info=True
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"code": "listing-failed",
|
||||
"error": _LISTING_FAILED,
|
||||
},
|
||||
status=500,
|
||||
)
|
||||
)
|
||||
images = await self._run_in_executor(self._format_workspace_page, page)
|
||||
result = {
|
||||
"success": True,
|
||||
@@ -1276,6 +1326,80 @@ class ImageRoutesMixin:
|
||||
)
|
||||
return orders[order]
|
||||
|
||||
def _workspace_locators(self, listings: list[tuple]) -> dict:
|
||||
"""The selected listing's private locators -> _Listed (cached).
|
||||
|
||||
A locator is ``normcase(join(realpath(root), relative))``, the key
|
||||
image tags are stored under (database/annotations.py); the listing
|
||||
never follows symlinks, so it equals the listed file's own locator.
|
||||
"""
|
||||
orders = self._workspace_orders(listings)
|
||||
reals = tuple(os.path.realpath(root) for _index, root, _files in listings)
|
||||
key = ("locators", reals)
|
||||
if key not in orders:
|
||||
located = {}
|
||||
for (index, root, files), real in zip(listings, reals):
|
||||
prefix = os.path.join(str(root), "")
|
||||
for entry in self._workspace_listed(index, root, files):
|
||||
text = str(entry.path)
|
||||
relative = (
|
||||
text[len(prefix) :]
|
||||
if text.startswith(prefix)
|
||||
else str(entry.path.relative_to(root))
|
||||
)
|
||||
located[os.path.normcase(os.path.join(real, relative))] = entry
|
||||
orders[key] = located
|
||||
return orders[key]
|
||||
|
||||
def _image_tag_matches(self, listings: list[tuple], filters: dict) -> dict:
|
||||
"""Normalized path -> _ImageTagMatch for files whose image tags count.
|
||||
|
||||
Streams the tagged byte records once and keeps only listed locators
|
||||
carrying a filter tag or a ``q`` hit; only those candidates are
|
||||
stat'ed (never every listed file), and a candidate's tags count only
|
||||
while a byte record is verified at its present ``(size, mtime_ns)``
|
||||
(spec amendment S1). Under a Tags filter the candidates' linked
|
||||
positives are read too, so their tags and category combine.
|
||||
"""
|
||||
tags, query, category = filters["tags"], filters["query"], filters["category"]
|
||||
wanted = set(tags)
|
||||
located = self._workspace_locators(listings)
|
||||
candidates = {}
|
||||
records = self.db.iter_image_tag_records()
|
||||
try:
|
||||
for locator, _size, _mtime_ns, names in records:
|
||||
entry = located.get(locator)
|
||||
if entry is not None and locator not in candidates:
|
||||
if _image_tags_relevant(names, wanted, query):
|
||||
candidates[locator] = entry
|
||||
finally:
|
||||
records.close()
|
||||
stats = {}
|
||||
for locator, entry in candidates.items():
|
||||
fingerprint = _current_fingerprint(entry.path)
|
||||
if fingerprint is not None:
|
||||
stats[locator] = fingerprint
|
||||
current = self.db.current_image_tags(stats) if stats else {}
|
||||
facts = {}
|
||||
if wanted and current:
|
||||
facts = self.db.get_image_link_facts(
|
||||
[candidates[locator].normalized for locator in current],
|
||||
filters["text"],
|
||||
)
|
||||
matches = {}
|
||||
for locator, names in current.items():
|
||||
entry = candidates[locator]
|
||||
link = facts.get(entry.normalized)
|
||||
carried = set(names).union(link[4] if link else ())
|
||||
matches[entry.normalized] = _ImageTagMatch(
|
||||
bool(wanted)
|
||||
and wanted <= carried
|
||||
and (category is None or (link is not None and link[3] == category)),
|
||||
bool(query) and any(query in name.casefold() for name in names),
|
||||
link[:3] if link else None,
|
||||
)
|
||||
return matches
|
||||
|
||||
def _select_workspace_page(
|
||||
self,
|
||||
listings: list[tuple],
|
||||
@@ -1328,6 +1452,30 @@ class ImageRoutesMixin:
|
||||
),
|
||||
exc_info=True,
|
||||
)
|
||||
image_tags = None
|
||||
if filters["query"] or tags:
|
||||
try:
|
||||
image_tags = self._image_tag_matches(listings, filters)
|
||||
except Exception:
|
||||
# A Tags filter cannot be served without them (as without the
|
||||
# links); a search then matches without image tags.
|
||||
if tags:
|
||||
raise
|
||||
self.logger.warning(
|
||||
"Workspace image tags could not be read; searching without "
|
||||
"image tags",
|
||||
exc_info=True,
|
||||
)
|
||||
if image_tags and links is not None:
|
||||
# A linked file admitted through its image tags keeps its rating
|
||||
# and counts its positive.
|
||||
admitted = {
|
||||
path: match.link
|
||||
for path, match in image_tags.items()
|
||||
if match.tags and match.link is not None and path not in links
|
||||
}
|
||||
if admitted:
|
||||
links = {**links, **admitted}
|
||||
if order == "rating":
|
||||
|
||||
def rank(entry):
|
||||
@@ -1360,7 +1508,7 @@ class ImageRoutesMixin:
|
||||
self._workspace_favorite_paths(listings) if filters["favorites"] else None
|
||||
)
|
||||
total, page, positive_count = _filtered_page(
|
||||
entries, start, limit + 1, filters, links, favorites
|
||||
entries, start, limit + 1, filters, links, favorites, image_tags
|
||||
)
|
||||
if not needed and after is not None:
|
||||
positive_count = _COUNT_OMITTED
|
||||
@@ -2467,6 +2615,9 @@ class ImageRoutesMixin:
|
||||
library=self._inspection_library(
|
||||
saved, unavailable=unavailable
|
||||
),
|
||||
image_tags=self._inspection_image_tags(
|
||||
roots[index], Path(relative).parts
|
||||
),
|
||||
)
|
||||
except CurrentFileChanged:
|
||||
return None
|
||||
@@ -2505,6 +2656,30 @@ class ImageRoutesMixin:
|
||||
{"success": False, "error": "Prompt inspection failed"}, status=500
|
||||
)
|
||||
|
||||
def _inspection_image_tags(self, root: Path, parts: tuple) -> Callable:
|
||||
"""The inspector's ``image_tags`` reader for one rooted file (S1).
|
||||
|
||||
The returned function takes the inspection's own stable stat and
|
||||
answers ``current`` only for a byte record verified at exactly that
|
||||
``(size, mtime_ns)``; a failed read is ``unavailable``, never an error.
|
||||
"""
|
||||
locator = os.path.normcase(os.path.join(os.path.realpath(root), *parts))
|
||||
|
||||
def read(size: int, mtime_ns: int) -> dict:
|
||||
try:
|
||||
found = self.db.current_image_tags({locator: (size, mtime_ns)})
|
||||
except Exception:
|
||||
self.logger.warning(
|
||||
"Inspection image tags could not be read", exc_info=True
|
||||
)
|
||||
return {"state": "unavailable", "tags": []}
|
||||
tags = found.get(locator)
|
||||
if tags is None:
|
||||
return {"state": "none", "tags": []}
|
||||
return {"state": "current", "tags": list(tags)}
|
||||
|
||||
return read
|
||||
|
||||
def _inspection_library(self, saved: dict | None, *, unavailable: bool) -> dict:
|
||||
"""The inspector's Library facts; the route sets the title afterwards."""
|
||||
result = {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import os
|
||||
import stat
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
try:
|
||||
from ...utils.prompt_inspector import (
|
||||
@@ -93,8 +94,19 @@ def _in_host_temp(path: Path) -> bool:
|
||||
|
||||
|
||||
def inspect_current_file(
|
||||
path: Path, *, source_label: str, saved: dict | None, library: dict
|
||||
path: Path,
|
||||
*,
|
||||
source_label: str,
|
||||
saved: dict | None,
|
||||
library: dict,
|
||||
image_tags: Callable[[int, int], dict] | None = None,
|
||||
) -> dict:
|
||||
"""Current-file evidence read between two equal stats of *path*.
|
||||
|
||||
*image_tags*, when given, is called once with that stable
|
||||
``(st_size, st_mtime_ns)`` and its facet is returned as ``image_tags``
|
||||
(spec amendment S1: tags of a byte record verified at exactly that stat).
|
||||
"""
|
||||
before = _current_file_facts(path)
|
||||
try:
|
||||
inspection = read_inspection_file(path)
|
||||
@@ -104,6 +116,9 @@ def inspect_current_file(
|
||||
fields = ("st_dev", "st_ino", "st_mode", "st_size", "st_mtime_ns", "st_ctime_ns")
|
||||
if any(getattr(before, key) != getattr(after, key) for key in fields):
|
||||
raise CurrentFileChanged
|
||||
facets = {}
|
||||
if image_tags is not None:
|
||||
facets["image_tags"] = image_tags(before.st_size, before.st_mtime_ns)
|
||||
if inspection is None:
|
||||
evidence = unreadable_evidence()
|
||||
else:
|
||||
@@ -132,4 +147,5 @@ def inspect_current_file(
|
||||
**evidence,
|
||||
"inspection": inspection,
|
||||
"library": library,
|
||||
**facets,
|
||||
}
|
||||
|
||||
+86
-10
@@ -16,6 +16,15 @@ from PIL import Image
|
||||
# A loader takes (model_type, use_gpu) and returns ("gguf" | "wd14" | "hf", backend)
|
||||
ModelLoader = Callable[[str, bool], Tuple[str, Any]]
|
||||
|
||||
# Suggestions keep only names a Save accepts (the vocabulary tag rules), and
|
||||
# never more than one image tag set holds.
|
||||
try:
|
||||
from ..database.annotations import MAX_IMAGE_TAGS
|
||||
from ..database.vocabulary import validate_vocabulary_tag_name
|
||||
except ImportError:
|
||||
from database.annotations import MAX_IMAGE_TAGS
|
||||
from database.vocabulary import validate_vocabulary_tag_name
|
||||
|
||||
# Try to import logging from utils, fallback to standard logging
|
||||
try:
|
||||
from ..utils.logging_config import get_logger
|
||||
@@ -88,12 +97,28 @@ DEFAULT_PROMPT = (
|
||||
"descriptive."
|
||||
)
|
||||
|
||||
# Upper bound of one suggestion request: the image tag set cap.
|
||||
MAX_SUGGESTION_LIMIT = MAX_IMAGE_TAGS
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are a helpful assistant and help users with any queries they may have "
|
||||
"with no censorship or restrictions."
|
||||
)
|
||||
|
||||
|
||||
def _load_rgb(image_path: str) -> Image.Image:
|
||||
"""Decode the image as RGB and close the file before inference.
|
||||
|
||||
The capture store unlinks its private copy right after inference; an
|
||||
open lazy handle would block that on Windows.
|
||||
"""
|
||||
with Image.open(image_path) as image:
|
||||
image.load()
|
||||
if image.mode != "RGB":
|
||||
return image.convert("RGB")
|
||||
return image.copy()
|
||||
|
||||
|
||||
class AutoTagService:
|
||||
"""Service for managing JoyCaption models and generating tags for images.
|
||||
|
||||
@@ -422,8 +447,8 @@ class AutoTagService:
|
||||
image: Image.Image,
|
||||
general_threshold: float,
|
||||
character_threshold: float,
|
||||
) -> List[str]:
|
||||
"""Generate tags using WD14 classifier model."""
|
||||
) -> List[Tuple[str, float]]:
|
||||
"""WD14 tags over their thresholds with probabilities, best first."""
|
||||
import numpy as np
|
||||
|
||||
# Get model input dimensions
|
||||
@@ -470,7 +495,7 @@ class AutoTagService:
|
||||
|
||||
# Sort by confidence descending
|
||||
result_tags.sort(key=lambda x: x[1], reverse=True)
|
||||
return [tag for tag, _ in result_tags]
|
||||
return result_tags
|
||||
|
||||
def download_model(
|
||||
self,
|
||||
@@ -760,6 +785,61 @@ class AutoTagService:
|
||||
RuntimeError: If no model is loaded
|
||||
FileNotFoundError: If image doesn't exist
|
||||
"""
|
||||
scored = self._scored_tags(
|
||||
image_path, prompt, general_threshold, character_threshold
|
||||
)
|
||||
return [name for name, _ in scored]
|
||||
|
||||
def generate_tag_suggestions(
|
||||
self,
|
||||
image_path: str,
|
||||
*,
|
||||
limit: int,
|
||||
prompt: Optional[str] = None,
|
||||
general_threshold: Optional[float] = None,
|
||||
character_threshold: Optional[float] = None,
|
||||
) -> List[Tuple[str, Optional[float]]]:
|
||||
"""The top ``limit`` suggestions as ``(name, confidence)`` pairs.
|
||||
|
||||
WD14 pairs carry the real probability, best first; language models
|
||||
(GGUF, HF) have no score, so their tags keep the output order with
|
||||
``None``. Names are distinct case-insensitively (first one wins).
|
||||
A name Save would reject (over 50 characters, a control character
|
||||
such as a newline, a comma) is skipped before the dedupe and the
|
||||
``limit`` count. ``prompt`` is used for this call only;
|
||||
``custom_prompt`` is never changed.
|
||||
"""
|
||||
if type(limit) is not int or not 1 <= limit <= MAX_SUGGESTION_LIMIT:
|
||||
raise ValueError(
|
||||
f"limit must be an integer from 1 to {MAX_SUGGESTION_LIMIT}"
|
||||
)
|
||||
scored = self._scored_tags(
|
||||
image_path, prompt, general_threshold, character_threshold
|
||||
)
|
||||
suggestions: List[Tuple[str, Optional[float]]] = []
|
||||
seen = set()
|
||||
for raw, score in scored:
|
||||
try:
|
||||
name = validate_vocabulary_tag_name(raw)
|
||||
except ValueError:
|
||||
continue
|
||||
key = name.casefold()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
suggestions.append((name, score))
|
||||
if len(suggestions) == limit:
|
||||
break
|
||||
return suggestions
|
||||
|
||||
def _scored_tags(
|
||||
self,
|
||||
image_path: str,
|
||||
prompt: Optional[str],
|
||||
general_threshold: Optional[float],
|
||||
character_threshold: Optional[float],
|
||||
) -> List[Tuple[str, Optional[float]]]:
|
||||
"""Run the loaded model: WD14 ``(name, probability)``, LLM ``(name, None)``."""
|
||||
if self._tagger is None:
|
||||
raise RuntimeError("No model loaded. Call load_model() first.")
|
||||
|
||||
@@ -767,18 +847,14 @@ class AutoTagService:
|
||||
raise FileNotFoundError(errno.ENOENT, "Image not found", str(image_path))
|
||||
|
||||
use_prompt = prompt or self._custom_prompt
|
||||
|
||||
# Load image
|
||||
image = Image.open(image_path)
|
||||
if image.mode != "RGB":
|
||||
image = image.convert("RGB")
|
||||
image = _load_rgb(image_path)
|
||||
|
||||
# Generate based on model type
|
||||
model_type, tagger_obj = self._tagger
|
||||
|
||||
if model_type == "gguf":
|
||||
raw_tags = self._generate_gguf(tagger_obj, image, use_prompt)
|
||||
return self._parse_tags(raw_tags)
|
||||
return [(tag, None) for tag in self._parse_tags(raw_tags)]
|
||||
elif model_type == "wd14":
|
||||
session, tags_list = tagger_obj
|
||||
gen_thresh = (
|
||||
@@ -799,7 +875,7 @@ class AutoTagService:
|
||||
raw_tags = self._generate_hf(
|
||||
model, processor, device, compute_dtype, image, use_prompt
|
||||
)
|
||||
return self._parse_tags(raw_tags)
|
||||
return [(tag, None) for tag in self._parse_tags(raw_tags)]
|
||||
|
||||
def _generate_gguf(self, model, image: Image.Image, prompt: str) -> str:
|
||||
"""Generate tags using GGUF model."""
|
||||
|
||||
@@ -465,6 +465,59 @@ class IntegrationConfig:
|
||||
cls.CIVITAI_API_KEY = lora["civitai_api_key"]
|
||||
|
||||
|
||||
# Number of tag suggestions the Tag dialogs request by default (D68). The cap
|
||||
# is the image tag set limit: more suggestions could never all be kept.
|
||||
DEFAULT_SUGGESTION_LIMIT = 10
|
||||
MAX_SUGGESTION_LIMIT = 100 # pinned to MAX_IMAGE_TAGS by tests/test_config.py
|
||||
|
||||
|
||||
class AutotagConfig:
|
||||
"""Persisted tagging defaults under ``autotag.defaults`` in config.json.
|
||||
|
||||
``suggestion_limit`` is how many suggestions Generate returns (the top N
|
||||
by score), an integer in ``1..MAX_SUGGESTION_LIMIT``. A hand-edited value
|
||||
outside that range, or of another type, falls back to the default with a
|
||||
warning rather than failing the load.
|
||||
"""
|
||||
|
||||
SUGGESTION_LIMIT = DEFAULT_SUGGESTION_LIMIT
|
||||
|
||||
@classmethod
|
||||
def suggestion_limit(cls) -> int:
|
||||
return cls.SUGGESTION_LIMIT
|
||||
|
||||
@staticmethod
|
||||
def valid_suggestion_limit(value: Any) -> bool:
|
||||
return (
|
||||
type(value) is int and 1 <= value <= MAX_SUGGESTION_LIMIT
|
||||
) # bool, float and str are rejected
|
||||
|
||||
@classmethod
|
||||
def get_config(cls) -> Dict[str, Any]:
|
||||
return {"defaults": {"suggestion_limit": cls.SUGGESTION_LIMIT}}
|
||||
|
||||
@classmethod
|
||||
def update_config(cls, new_config: Any) -> None:
|
||||
if not isinstance(new_config, dict):
|
||||
config_logger.warning("Ignoring autotag settings that are not an object")
|
||||
return
|
||||
defaults = new_config.get("defaults", {})
|
||||
if not isinstance(defaults, dict):
|
||||
config_logger.warning("Ignoring autotag.defaults that is not an object")
|
||||
return
|
||||
if "suggestion_limit" not in defaults:
|
||||
return
|
||||
value = defaults["suggestion_limit"]
|
||||
if cls.valid_suggestion_limit(value):
|
||||
cls.SUGGESTION_LIMIT = value
|
||||
return
|
||||
config_logger.warning(
|
||||
"autotag.defaults.suggestion_limit must be an integer from 1 to "
|
||||
f"{MAX_SUGGESTION_LIMIT}; using {DEFAULT_SUGGESTION_LIMIT}"
|
||||
)
|
||||
cls.SUGGESTION_LIMIT = DEFAULT_SUGGESTION_LIMIT
|
||||
|
||||
|
||||
class PromptManagerConfig:
|
||||
"""Main configuration class for PromptManager core functionality.
|
||||
|
||||
@@ -566,6 +619,7 @@ class PromptManagerConfig:
|
||||
},
|
||||
"gallery": GalleryConfig.get_config(),
|
||||
"integrations": IntegrationConfig.get_config(),
|
||||
"autotag": AutotagConfig.get_config(),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -706,6 +760,9 @@ class PromptManagerConfig:
|
||||
if "integrations" in new_config:
|
||||
IntegrationConfig.update_config(new_config["integrations"])
|
||||
|
||||
if "autotag" in new_config:
|
||||
AutotagConfig.update_config(new_config["autotag"])
|
||||
|
||||
|
||||
# Load configuration on import
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
"""One process-wide, non-blocking gate around the tagging models (#249 B2).
|
||||
|
||||
Loading, swapping, running, downloading and unloading a tagging model all
|
||||
happen under this gate, whether the caller is a legacy Auto-tag route, the
|
||||
Tag dialog's Generate or (later) a bulk job. A caller that cannot take the
|
||||
gate is refused at once (``409 model-busy``), never queued.
|
||||
|
||||
Work that runs in a worker thread is admitted with :meth:`ModelGate.run`. A
|
||||
release requested while such work still runs (a client disconnect cancels
|
||||
the waiting coroutine, not the thread) takes effect only when the last
|
||||
admitted call really exits, so nobody can swap the model under a running
|
||||
inference.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
import uuid
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
|
||||
class GateNotHeld(RuntimeError):
|
||||
"""Raised when work is admitted for an owner that does not hold the gate."""
|
||||
|
||||
|
||||
class ModelGate:
|
||||
"""A single-owner gate whose release waits for admitted worker calls."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._holder: Optional[str] = None
|
||||
self._inflight = 0
|
||||
self._releasing = False
|
||||
|
||||
def try_acquire(self, owner: str) -> bool:
|
||||
"""Take the gate for *owner*; False at once when anyone holds it."""
|
||||
with self._lock:
|
||||
if self._holder is not None:
|
||||
return False
|
||||
self._holder = owner
|
||||
self._inflight = 0
|
||||
self._releasing = False
|
||||
return True
|
||||
|
||||
def release(self, owner: str) -> None:
|
||||
"""Let go; deferred until admitted work of *owner* has exited."""
|
||||
with self._lock:
|
||||
if self._holder != owner:
|
||||
return
|
||||
if self._inflight:
|
||||
self._releasing = True
|
||||
else:
|
||||
self._clear_locked()
|
||||
|
||||
def holder(self) -> Optional[str]:
|
||||
with self._lock:
|
||||
return self._holder
|
||||
|
||||
def begin(self, owner: str) -> None:
|
||||
"""Admit one worker call; the holder must still hold the gate."""
|
||||
with self._lock:
|
||||
if self._holder != owner or self._releasing:
|
||||
raise GateNotHeld(f"{owner} does not hold the model gate")
|
||||
self._inflight += 1
|
||||
|
||||
def end(self, owner: str) -> None:
|
||||
"""One admitted call exited; completes a deferred release."""
|
||||
with self._lock:
|
||||
self._end_locked(owner)
|
||||
|
||||
async def run(
|
||||
self,
|
||||
owner: str,
|
||||
func: Callable[..., Any],
|
||||
*args,
|
||||
on_skipped: Optional[Callable[[], None]] = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""Run ``func`` in the default executor while *owner* holds the gate.
|
||||
|
||||
The executor future is shielded: cancelling the caller never cancels
|
||||
a call that has not started yet (which would leave it admitted
|
||||
forever) and never ends the admission of a call that is running.
|
||||
``on_skipped`` runs once when ``func`` never starts (the gate is not
|
||||
held, the executor refuses the call, or a loop shutdown drops it), so
|
||||
a caller can free what ``func`` would have freed.
|
||||
"""
|
||||
try:
|
||||
self.begin(owner)
|
||||
except GateNotHeld:
|
||||
if on_skipped is not None:
|
||||
on_skipped()
|
||||
raise
|
||||
state = {"started": False, "finished": False}
|
||||
|
||||
def finish() -> None:
|
||||
with self._lock:
|
||||
if not state["finished"]:
|
||||
state["finished"] = True
|
||||
self._end_locked(owner)
|
||||
|
||||
def work():
|
||||
with self._lock:
|
||||
if state["finished"]:
|
||||
# Already declared skipped (the future was cancelled as
|
||||
# the thread picked the call up): never run it twice over.
|
||||
return None
|
||||
state["started"] = True
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
finally:
|
||||
finish()
|
||||
|
||||
def never_ran(future) -> None:
|
||||
# Only a loop shutdown cancels the shielded future; a call that
|
||||
# never started must still give its admission back.
|
||||
if future.cancelled():
|
||||
with self._lock:
|
||||
skipped = not state["started"] and not state["finished"]
|
||||
if skipped:
|
||||
state["finished"] = True
|
||||
self._end_locked(owner)
|
||||
if skipped and on_skipped is not None:
|
||||
on_skipped()
|
||||
|
||||
try:
|
||||
future = asyncio.get_running_loop().run_in_executor(None, work)
|
||||
except BaseException:
|
||||
finish()
|
||||
if on_skipped is not None:
|
||||
on_skipped()
|
||||
raise
|
||||
future.add_done_callback(never_ran)
|
||||
return await asyncio.shield(future)
|
||||
|
||||
def _end_locked(self, owner: str) -> None:
|
||||
if self._holder != owner or self._inflight == 0:
|
||||
return
|
||||
self._inflight -= 1
|
||||
if self._releasing and self._inflight == 0:
|
||||
self._clear_locked()
|
||||
|
||||
def _clear_locked(self) -> None:
|
||||
self._holder = None
|
||||
self._inflight = 0
|
||||
self._releasing = False
|
||||
|
||||
|
||||
_GATE = ModelGate()
|
||||
|
||||
|
||||
def new_owner(kind: str) -> str:
|
||||
"""A unique owner name such as ``legacy-single:<uuid>``."""
|
||||
return f"{kind}:{uuid.uuid4()}"
|
||||
|
||||
|
||||
def try_acquire(owner: str) -> bool:
|
||||
return _GATE.try_acquire(owner)
|
||||
|
||||
|
||||
def release(owner: str) -> None:
|
||||
_GATE.release(owner)
|
||||
|
||||
|
||||
def holder() -> Optional[str]:
|
||||
return _GATE.holder()
|
||||
|
||||
|
||||
async def run(
|
||||
owner: str,
|
||||
func: Callable[..., Any],
|
||||
*args,
|
||||
on_skipped: Optional[Callable[[], None]] = None,
|
||||
**kwargs,
|
||||
):
|
||||
return await _GATE.run(owner, func, *args, on_skipped=on_skipped, **kwargs)
|
||||
@@ -1,5 +1,5 @@
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
import { THEMES, seedConcept, openConcept, openProduction, diffInventory, mutate } from './concept-inventory.mjs';
|
||||
|
||||
// Activity-1 Task 3. The review dialog is compared with the concept's own `scan-review` ("Review metadata scan",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
|
||||
test.beforeEach(async ({page, browserName}) => {
|
||||
@@ -57,7 +57,8 @@ test('Classic keeps immediate selection, search query and metadata serialization
|
||||
});
|
||||
|
||||
test('Classic detaches native text synchronization on removal',async({page})=>{
|
||||
await page.waitForTimeout(60);
|
||||
// The page installs the text sync (serializeValue and its listeners) in a 50 ms timer; wait for it, not for time.
|
||||
await expect.poll(()=>page.evaluate(()=>typeof node.widgets.find(item=>item.name==='text').serializeValue)).toBe('function');
|
||||
const values=await page.evaluate(()=>{
|
||||
const widget=node.widgets.find(item=>item.name==='text');
|
||||
widget.inputEl.value='Before removal';widget.inputEl.dispatchEvent(new Event('input'));
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// same concept selectors in the immutable concept (/__test__/canonical/workspace) and in production, which now renders
|
||||
// the concept's classes. The concept is never rewritten: each page's map<Page>Concept(reference) applies only the
|
||||
// recorded spec-§8 deviations, each commented with its D-number, and diffInventory must then report nothing.
|
||||
import {expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import {expect} from './fixtures.mjs';
|
||||
export {diffInventory} from './library-editor-canonical-comparison.mjs';
|
||||
|
||||
export const CONCEPT_URL='/__test__/canonical/workspace';
|
||||
@@ -78,6 +78,23 @@ export async function seedConcept(request,data={}) {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Save `tags` as the current image tag set of the listed file at `path` (root 0) through the real observe and tag
|
||||
* routes (#249 PR C), releasing the capture afterwards; returns the receipt's saved names.
|
||||
*/
|
||||
export async function saveImageTags(request,path,tags) {
|
||||
const listing=await (await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json();
|
||||
const target={root_index:0,root_scope:listing.images.find(item=>item.root_index===0).root_scope,path};
|
||||
const observed=await (await request.post('/prompt_manager/image-annotations/observe',{data:{target}})).json();
|
||||
expect(observed.success).toBe(true);
|
||||
try {
|
||||
const receipt=await (await request.put('/prompt_manager/image-annotations/tags',{data:{target,capture_token:observed.capture.token,
|
||||
sha256:observed.capture.sha256,expected_epoch:observed.library_epoch,expected_tag_revision:observed.tag_revision,tags}})).json();
|
||||
expect(receipt.success).toBe(true);
|
||||
return receipt.tags;
|
||||
} finally {await request.delete(`/prompt_manager/image-annotations/capture/${observed.capture.token}`);}
|
||||
}
|
||||
|
||||
async function selectTheme(page,theme) {
|
||||
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// Shared `test` for the page workflows: every Playwright worker owns one
|
||||
// tests/browser/server.py process on a port the OS chose for it, with its own temp root
|
||||
// and database. `baseURL` (and so `page`, `request` and `browser.newContext`)
|
||||
// points at that worker's server. A server that dies fails the running test
|
||||
// with its stderr tail, and the next test in the worker gets a fresh server.
|
||||
// A test that fails gets the stderr its server wrote during it attached as
|
||||
// `server-stderr`.
|
||||
import { test as base, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import { attachServerStderr, createServerSupervisor, startBrowserServer } from '../../tools/vendor/start-browser-server.mjs';
|
||||
|
||||
const SERVER_FIXTURE_TIMEOUT_MS = 90_000;
|
||||
|
||||
export const test = base.extend({
|
||||
testServer: [async ({}, use) => {
|
||||
const supervisor = createServerSupervisor({ start: () => startBrowserServer() });
|
||||
try {
|
||||
await use(supervisor);
|
||||
} finally {
|
||||
await supervisor.stop();
|
||||
}
|
||||
}, { scope: 'worker', timeout: SERVER_FIXTURE_TIMEOUT_MS }],
|
||||
workerServer: [async ({ testServer }, use, testInfo) => {
|
||||
const server = await testServer.ensure();
|
||||
const mark = server.stderrMark();
|
||||
await use(server);
|
||||
await attachServerStderr(testInfo, server, mark);
|
||||
const crash = testServer.crash(server);
|
||||
if (crash) throw crash;
|
||||
}, { auto: true, timeout: SERVER_FIXTURE_TIMEOUT_MS }],
|
||||
baseURL: async ({ workerServer }, use) => use(workerServer.url),
|
||||
});
|
||||
|
||||
export { expect };
|
||||
@@ -1,5 +1,5 @@
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
|
||||
|
||||
for (const variant of ['Focused', 'Split']) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
import { seedConcept, openConcept, openProduction, SIZES } from './concept-inventory.mjs';
|
||||
// D10/D20: the heading is the linked positive's title (its confirmed first line here), never the pair's
|
||||
// "Inspector recipe", which names the negative variant.
|
||||
@@ -92,7 +92,7 @@ test('inspection wire preserves exact rooted envelope', async ({ request }) => {
|
||||
const inspector=page=>page.locator('.detail-panel');
|
||||
/** D19: the selected card's image opens the viewer (the side panel has no full-preview cover). */
|
||||
const openSelected=page=>page.locator('.media-card.selected .image-open').click();
|
||||
const selectFile=async(page,name,root=0)=>page.locator(`article[data-image-key="${root}:inspector/${name}"] .image-card-actions button`).click();
|
||||
const selectFile=async(page,name,root=0)=>page.locator(`article[data-image-key="${root}:inspector/${name}"] .image-card-actions button:first-child`).click();
|
||||
async function fullFixture(page,request) {
|
||||
await expect(await request.post('/__test__/reset',{data:{full_inspector:true}})).toBeOK();
|
||||
await page.goto('/prompt_manager/workspace#/images');await selectFile(page,'complete.png');
|
||||
@@ -460,7 +460,7 @@ test('full inspector labels container representations and releases action URLs',
|
||||
test('full inspector distinguishes missing, limited and legacy unknown generation facts',async({page,request})=>{
|
||||
await fullFixture(page,request);const panel=inspector(page);
|
||||
await selectFile(page,'empty.png');await expect(panel.locator('.meta-grid dd')).toHaveText(Array(6).fill('Not recorded'));
|
||||
await page.locator('article[data-image-key="0:architecture/study-01.png"] .image-card-actions button').click();await expect(panel.locator('.meta-grid dd')).toHaveText(Array(6).fill('Not recorded'));
|
||||
await page.locator('article[data-image-key="0:architecture/study-01.png"] .image-card-actions button:first-child').click();await expect(panel.locator('.meta-grid dd')).toHaveText(Array(6).fill('Not recorded'));
|
||||
await selectFile(page,'control-heavy.png');
|
||||
const explanation='Configuration exceeds inspection limits; download the original file to inspect it locally.';
|
||||
await expect(panel.getByText(explanation,{exact:true})).toHaveCount(1);await expect(panel.getByText(explanation,{exact:true})).toBeVisible();
|
||||
@@ -637,7 +637,7 @@ test('collection viewer follows filtered loaded order beyond mounted cards',asyn
|
||||
// #249 D26: the whole positive's images are in the All images dialog's own embedded gallery.
|
||||
await page.getByRole('button',{name:'All images for this positive',exact:true}).click();
|
||||
const listing=page.getByRole('dialog',{name:'All images for this positive',exact:true});
|
||||
await listing.locator('article[data-image-key="0:inspector/complete.png"] .image-card-actions button').click();
|
||||
await listing.locator('article[data-image-key="0:inspector/complete.png"] .image-card-actions button:first-child').click();
|
||||
await listing.locator('.media-card.selected .image-open').click();await expect(mediaDialog(page).locator('.viewer-canvas img')).toBeVisible();
|
||||
await expect(position(page)).toContainText('of 2 loaded:');
|
||||
});
|
||||
@@ -804,6 +804,7 @@ test('slideshow preference failures leave native playback usable',async({page,re
|
||||
});
|
||||
|
||||
test('full inspector attaches matched theme and mobile evidence with reachable content',async({page,request},testInfo)=>{
|
||||
test.setTimeout(120_000); // 32 evidence captures across four viewports, like the concept comparisons
|
||||
await expect(await request.post('/__test__/reset',{data:{full_inspector:true,include_video:true}})).toBeOK();
|
||||
await page.goto('/prompt_manager/workspace#/images');
|
||||
const measurements=[];
|
||||
@@ -929,7 +930,7 @@ test('collection viewer matches approved responsive frame',async({page,request},
|
||||
await page.screenshot({path:testInfo.outputPath('viewer-mobile-wider-font-2x.png')});
|
||||
await modal.getByRole('button',{name:'Close image viewer',exact:true}).click();await page.evaluate(()=>{document.documentElement.style.zoom='1';});await settleViewport(390);
|
||||
const longFiles=await(await request.get('/prompt_manager/images/output?mode=workspace&q=a-very-long&limit=40')).json();const long=longFiles.images[0];
|
||||
await page.locator(`article[data-image-key="${long.root_index}:${long.path}"] .image-card-actions button`).click();await openCollection(page);
|
||||
await page.locator(`article[data-image-key="${long.root_index}:${long.path}"] .image-card-actions button:first-child`).click();await openCollection(page);
|
||||
await page.evaluate(()=>{document.documentElement.style.zoom='2';});await settleViewport(390);await modal.evaluate(node=>{node.scrollTop=0;});
|
||||
await expect(modal.getByRole('button',{name:'Close image viewer',exact:true})).toBeInViewport();
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import { expect } from './fixtures.mjs';
|
||||
|
||||
// Content, row counts, and image bytes come from independent synthetic datasets.
|
||||
// Saved choices and actual executions are separate production sections. The concept's
|
||||
@@ -17,7 +17,6 @@ export const canonicalDifferences = {
|
||||
// Closed by #249: the gallery's checkbox is Images-only (#259) and the role blocks are the concept's (PR D, F); the
|
||||
// exact history comparison is tests/browser/history-concept.spec.mjs.
|
||||
remainingApplicationPolish: [
|
||||
'Gallery card tag button (#249 D9, image-annotations slice) remains outside #209',
|
||||
'Production keeps mobile toolbar inside media panel and readable scrolling details; the concept toolbar collision is not reproduced',
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {writeFile} from 'node:fs/promises';
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||||
import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,mutate,stripExact} from './concept-inventory.mjs';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {writeFile} from 'node:fs/promises';
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||||
import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,mutate,stripExact,countCopy,reflow,withoutItems} from './concept-inventory.mjs';
|
||||
|
||||
@@ -59,12 +59,12 @@ function mapBulkbar(bulkbar,rise) {
|
||||
}
|
||||
|
||||
/**
|
||||
* A list row's info once D9 narrows its actions to `actionsWidth`, from the concept's boxes:
|
||||
* A list row's info with its actions `actionsWidth` wide, from the concept's boxes:
|
||||
* - D3 (the measurable list row, `.list-grid .media-info` in the overlay): the info fills the row beside the art and may
|
||||
* shrink below a long nowrap title (`min-width: 0`), so it is the card's inner width less the art, and the actions
|
||||
* end at its right padding; it keeps its own height (text or actions plus padding), centred in the row the concept
|
||||
* stretches it across, so the text, centred in either, moves only by half the lines it lost;
|
||||
* - D9: the wider text column fits a subtitle the concept wrapped when its one-line width (`maxContent`) now fits.
|
||||
* - the text column fits a subtitle the concept wrapped when its one-line width (`maxContent`) now fits.
|
||||
*/
|
||||
function listInfo(card,actionsWidth) {
|
||||
const {info}=card,{copy,p}=info;
|
||||
@@ -109,21 +109,16 @@ export function mapImagesConcept(reference,{manifest,now=new Date()}) {
|
||||
const cards=reference.cards.map(card=>{
|
||||
const filename=card.open.label.replace(/^Open /,'');
|
||||
const actions=card.info.actions;
|
||||
const tag=actions.buttons.find(button=>button.label?.startsWith('Tag or auto-tag '));
|
||||
// D9: no Tag button until the image-annotations slice; the actions group narrows by its box and gap.
|
||||
const freed=tag?tag.width+parseFloat(actions.columnGap):0;
|
||||
// D3: a list row's info keeps its own height (text or actions plus its padding) so bounded rendering can measure
|
||||
// it, centred in the row the concept stretches it across; the text and the actions keep their place.
|
||||
const actionsWidth=actions.width-freed;
|
||||
const listRow=list?listInfo(card,actionsWidth):{};
|
||||
const listRow=list?listInfo(card,actions.width):{};
|
||||
return {...card,
|
||||
// D29: the concept's alt adds its demo-only " · sample illustration N" to the card title.
|
||||
img:{...card.img,alt:card.img.alt.replace(/ · sample illustration \d+$/,'')},
|
||||
info:{...card.info,...listRow.info,copy:{...card.info.copy,...listRow.copy},p:{...card.info.p,...listRow.p},
|
||||
// D10: the title keeps the file's relative path (the fixture manifest's) in `title`.
|
||||
h3:{...card.info.h3,title:paths.get(filename)??null},
|
||||
actions:{...actions,width:actionsWidth,left:listRow.actionsLeft??actions.left+freed,
|
||||
buttons:actions.buttons.filter(button=>button!==tag)}}};
|
||||
actions:{...actions,left:listRow.actionsLeft??actions.left}}};
|
||||
});
|
||||
// D17: the right-hand date is the newest result's day under Recently added, here the fixture's newest mtime.
|
||||
// D35: the count line uses the singular for one.
|
||||
@@ -538,8 +533,9 @@ test('one image appended by scrolling reads in the singular',async({page,request
|
||||
});
|
||||
|
||||
// D3 (one footer height for the collection): bounded rendering cannot measure unrendered cards, so every row reserves
|
||||
// the tallest measured footer. Two seedless files (D11 "W × H · PNG", one line) form the first row at 390px, where
|
||||
// every concept subtitle wraps: their row is as tall as the wrapped rows, and every concept row moves down by one.
|
||||
// the tallest measured footer. Two seedless files (D11 "W × H · PNG", one line: a short "96 × 72" beside Info and Tag)
|
||||
// form the first row at 390px, where every concept subtitle wraps: their row is as tall as the wrapped rows, and every
|
||||
// concept row moves down by one.
|
||||
test('one footer height for the collection (D3)',async({page,request})=>{
|
||||
// A second fixture seed (dozens of PNGs and their thumbnails) runs inside the test.
|
||||
test.setTimeout(120_000);
|
||||
@@ -548,7 +544,7 @@ test('one footer height for the collection (D3)',async({page,request})=>{
|
||||
const reference=await inventoryOf(page,'#main');
|
||||
expect(reference.grid.columns).toBe(2);
|
||||
expect(reference.cards.every(card=>card.info.p.height>parseFloat(card.info.p.lineHeight))).toBe(true);
|
||||
const manifest=await seedConcept(request,{seedless_images:2});
|
||||
const manifest=await seedConcept(request,{seedless_images:2,short_seedless:true});
|
||||
await openProductionLoaded(page,'dark','no-preference',FIXTURE_IMAGES+2);
|
||||
const actual=await inventoryOf(page,'#workspace-main');
|
||||
const box=card=>({left:card.left,top:card.top,width:card.width,height:card.height});
|
||||
@@ -557,7 +553,10 @@ test('one footer height for the collection (D3)',async({page,request})=>{
|
||||
...reference.cards.slice(0,4).map(card=>({...box(card),top:card.top+pitch}))]);
|
||||
const seedless=actual.cards.slice(0,2);
|
||||
expect(seedless.map(card=>card.open.label)).toEqual(manifest.seedless.map(image=>`Open ${image.filename}`));
|
||||
expect(seedless.map(card=>card.info.p.text)).toEqual(['1536 × 1152 · PNG','1536 × 1152 · PNG']);
|
||||
expect(seedless.map(card=>card.info.p.text)).toEqual(['96 × 72 · PNG','96 × 72 · PNG']);
|
||||
// Their own footer is one line shorter than the row it reserves.
|
||||
for(const card of seedless)expect(card.info.height).toBeLessThan(reference.cards[0].info.height);
|
||||
for(const card of seedless) {
|
||||
expect(card.info.p.height).toBeLessThanOrEqual(parseFloat(card.info.p.lineHeight)+0.5);
|
||||
expect(card.info.height).toBeLessThan(reference.cards[0].info.height);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {writeFile} from 'node:fs/promises';
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||||
import {SIZES,THEMES,seedConcept,openConcept,openProduction,inspectorOf,diffInventory,mutate,countCopy} from './concept-inventory.mjs';
|
||||
import {SIZES,THEMES,seedConcept,openConcept,openProduction,inspectorOf,diffInventory,mutate,countCopy,saveImageTags} from './concept-inventory.mjs';
|
||||
|
||||
// Task D4 of #249: the Images side panel (app.js imageDetails a400–414) and the viewer's full details (imageDetails(i,
|
||||
// true)) against the immutable concept, on the concept-shaped real fixture, at 1600x1100 and 390x844 in both themes.
|
||||
@@ -319,6 +319,27 @@ test('a recipe-linked image without recipe metadata shows its positive prompt me
|
||||
await expect(panelOf(page).locator('.detail-footer button')).toHaveText(['Send to ComfyUI','Prompt history']);
|
||||
});
|
||||
|
||||
// Task C3 (D88, #249 PR C): the concept's chip row is the image's own tags (`i.tags`, app.js a402). With those names
|
||||
// saved as the file's current image tags, production draws the row from them (Tags filter buttons in the panel, plain
|
||||
// in the viewer, the linked positive's equal tags not repeated) and still equals the concept, in both sizes.
|
||||
for(const [width,height] of SIZES)test(`image tags saved on the concept image keep the inspector equal to the concept ${width}x${height} (D88)`,async({page,request},testInfo)=>{
|
||||
test.setTimeout(120_000);
|
||||
const viewport={width,height};await page.setViewportSize(viewport);
|
||||
await openConceptPanel(page,'dark');
|
||||
const names=await panelOf(page).locator('.detail-inner > .tags').last().locator('button.chip').allTextContents();
|
||||
expect(names).toEqual(['cinematic','blue_hour','architecture']);
|
||||
await captureCanonicalState(page,testInfo,`concept-image-tags-${width}`,'#image-inspector');
|
||||
const reference=await inventory(page,true);
|
||||
const {path}=testInfo.manifest.images.find(item=>item.filename==='alpine_00001.png');
|
||||
expect(await saveImageTags(request,path,names)).toEqual(names);
|
||||
const read=page.waitForResponse(item=>new URL(item.url()).pathname===`/prompt_manager/images/inspect/${path}`);
|
||||
await openProductionPanel(page,'dark');
|
||||
expect((await (await read).json()).image_tags).toEqual({state:'current',tags:names});
|
||||
await expect(panelOf(page).locator('.detail-inner > .tags').last().locator('.chip')).toHaveText(names);
|
||||
await captureCanonicalState(page,testInfo,`production-image-tags-${width}`,'#image-inspector');
|
||||
assertInspectorMatch(reference,await inventory(page),viewport);
|
||||
});
|
||||
|
||||
// The comparison is only worth something if it notices a change (spec §7 mutation check): on the panel one CSS
|
||||
// mutation (the role label's letter spacing) and one DOM mutation (the footer removed); in the viewer (final review m5)
|
||||
// its pair actions removed and D38's Show in folder removed; each applied after an exact match.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {writeFile} from 'node:fs/promises';
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||||
import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,mutate,stripExact,countCopy,reflow} from './concept-inventory.mjs';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||||
|
||||
// Artifact-only captures of the editor states that have no concept counterpart (archive confirmation, Show archived,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
|
||||
|
||||
const pair={id:4,positive_text:'Library positive',negative_text:'Library negative',category:'Portrait',tags:['studio']};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
|
||||
const moduleUrl = '/prompt_manager/js/shared/toolkit/prompt-bridge.js';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
|
||||
const workspace='/prompt_manager/workspace#/images';
|
||||
const details=page=>page.getByRole('complementary',{name:'Selected image details',exact:true});
|
||||
|
||||
+111
-9
@@ -1,16 +1,20 @@
|
||||
"""Serve production pages and API routes with disposable state for browser tests."""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import types
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.web_runner import GracefulExit
|
||||
from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
@@ -31,6 +35,7 @@ from tests.browser.workspace_library_editor_fixture import (
|
||||
register_library_editor_commands,
|
||||
)
|
||||
from tests.browser.workspace_vocabulary_fixture import register_vocabulary_commands
|
||||
from tests.browser.workspace_tagging_fixture import register_tagging_commands
|
||||
|
||||
CANONICAL_ROOT = Path(__file__).resolve().parents[2]
|
||||
CANONICAL_ASSETS = {
|
||||
@@ -53,11 +58,19 @@ class TestModel:
|
||||
wd14_character_threshold = 0.85
|
||||
custom_prompt = ""
|
||||
loaded = None
|
||||
# Models the Tag image dialog workflows mark as not downloaded.
|
||||
unavailable: set = set()
|
||||
# Scored names the concept comparisons answer instead of the fixed list
|
||||
# (``/__test__/tagging/suggestions``); None keeps the fixed list.
|
||||
suggestions: list | None = None
|
||||
|
||||
def get_models_status(self) -> dict:
|
||||
return {
|
||||
name: {"downloaded": True, "loaded": self.loaded == name}
|
||||
for name in ("gguf", "wd14-vit", "wd14-convnext", "wd14-swinv2")
|
||||
name: {
|
||||
"downloaded": name not in self.unavailable,
|
||||
"loaded": self.loaded == name,
|
||||
}
|
||||
for name in ("gguf", "hf", "wd14-vit", "wd14-convnext", "wd14-swinv2")
|
||||
}
|
||||
|
||||
def is_model_loaded(self) -> bool:
|
||||
@@ -76,6 +89,26 @@ class TestModel:
|
||||
def generate_tags(self, image_path: str, **kwargs: object) -> list[str]:
|
||||
return ["generated", Path(image_path).parent.parent.name]
|
||||
|
||||
def generate_tag_suggestions(
|
||||
self, image_path: str, *, limit: int, **kwargs: object
|
||||
) -> list[tuple[str, float | None]]:
|
||||
"""Fixed scored suggestions (WD14) or unscored ones (language models)."""
|
||||
scored = self.loaded is not None and self.loaded.startswith("wd14")
|
||||
if self.suggestions is not None:
|
||||
return [
|
||||
(name, confidence if scored else None)
|
||||
for name, confidence in self.suggestions[:limit]
|
||||
]
|
||||
names = ["sandstone", "desert", "arch", "sky", "rock", "dune", "canyon"]
|
||||
names += [f"tag_{index:02d}" for index in range(100)]
|
||||
return [
|
||||
(name, round(0.98 - index * 0.008, 3) if scored else None)
|
||||
for index, name in enumerate(names[:limit])
|
||||
]
|
||||
|
||||
def download_model(self, model_type: str, progress_callback=None) -> bool:
|
||||
raise AssertionError("browser tests never download a model")
|
||||
|
||||
|
||||
REVEAL_OUTCOMES = ("opened", "unavailable", "fail")
|
||||
|
||||
@@ -159,6 +192,7 @@ def create_app(root: Path) -> web.Application:
|
||||
history_commands = register_workspace_history_commands(routes, api, root)
|
||||
library_editor_commands = register_library_editor_commands(routes, api, root)
|
||||
register_vocabulary_commands(routes, api, root)
|
||||
register_tagging_commands(routes, api, root, model)
|
||||
reveal_stub = register_reveal_stub(routes, api, root)
|
||||
register_activity_commands(app, routes, api)
|
||||
|
||||
@@ -185,6 +219,9 @@ def create_app(root: Path) -> web.Application:
|
||||
for table in (
|
||||
"saved_views",
|
||||
"image_favorites",
|
||||
"media_image_tags",
|
||||
"media_annotation_bytes",
|
||||
"media_annotation_files",
|
||||
"image_prompt_pairs",
|
||||
"prompt_pair_tags",
|
||||
"prompt_pairs",
|
||||
@@ -449,7 +486,8 @@ def create_app(root: Path) -> web.Application:
|
||||
if (
|
||||
request.query
|
||||
or not isinstance(data, dict)
|
||||
or set(data) - set(limits)
|
||||
or set(data) - set(limits) - {"short_seedless"}
|
||||
or not isinstance(data.get("short_seedless", False), bool)
|
||||
or any(
|
||||
isinstance(data.get(key, 0), bool)
|
||||
or not isinstance(data.get(key, 0), int)
|
||||
@@ -468,6 +506,7 @@ def create_app(root: Path) -> web.Application:
|
||||
root,
|
||||
extra_images=data.get("extra_images", 0),
|
||||
seedless_images=data.get("seedless_images", 0),
|
||||
short_seedless=data.get("short_seedless", False),
|
||||
)
|
||||
)
|
||||
return web.json_response({"success": True, **manifest})
|
||||
@@ -515,14 +554,77 @@ def create_app(root: Path) -> web.Application:
|
||||
return app
|
||||
|
||||
|
||||
def main() -> None:
|
||||
HOST = "127.0.0.1"
|
||||
PORT_ANNOUNCEMENT = "PM_TEST_SERVER_PORT="
|
||||
|
||||
|
||||
def bind_listener(port: int) -> socket.socket:
|
||||
"""Bind the server's socket before the app is built.
|
||||
|
||||
``port`` 0 lets the OS choose a free port, which this process then holds,
|
||||
so parallel launchers never race for one between choosing and binding.
|
||||
"""
|
||||
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
try:
|
||||
if port and os.name != "nt":
|
||||
# aiohttp's own default on POSIX; on Windows it would let a
|
||||
# second process share the port.
|
||||
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
listener.bind((HOST, port))
|
||||
listener.listen(128)
|
||||
except OSError:
|
||||
listener.close()
|
||||
raise
|
||||
return listener
|
||||
|
||||
|
||||
def _graceful_exit() -> None:
|
||||
raise GracefulExit()
|
||||
|
||||
|
||||
def exit_when_stdin_closes(loop: asyncio.AbstractEventLoop) -> threading.Thread:
|
||||
"""Stop the app gracefully once stdin reaches end of file.
|
||||
|
||||
The launcher keeps the write end of this process's stdin pipe, and the OS
|
||||
closes it however the launcher dies, SIGKILL included. GracefulExit runs
|
||||
aiohttp's cleanup (closing the database) and lets the temp root be removed,
|
||||
on every OS, where a signal from a dead parent would not.
|
||||
"""
|
||||
|
||||
def watch() -> None:
|
||||
while sys.stdin.buffer.read(65536):
|
||||
pass
|
||||
loop.call_soon_threadsafe(_graceful_exit)
|
||||
|
||||
thread = threading.Thread(target=watch, name="stdin-eof", daemon=True)
|
||||
thread.start()
|
||||
return thread
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--port", type=int, default=8765)
|
||||
args = parser.parse_args()
|
||||
parser.add_argument(
|
||||
"--port", type=int, default=8765, help="0 binds an OS-chosen free port"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--exit-on-stdin-eof",
|
||||
action="store_true",
|
||||
help="shut down when stdin closes (the launcher's parent-death guard)",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
listener = bind_listener(args.port)
|
||||
# One machine-readable line tells the launcher which port this process holds.
|
||||
print(f"{PORT_ANNOUNCEMENT}{listener.getsockname()[1]}", flush=True)
|
||||
loop = asyncio.new_event_loop()
|
||||
if args.exit_on_stdin_eof:
|
||||
exit_when_stdin_closes(loop)
|
||||
original_cwd = os.getcwd()
|
||||
with tempfile.TemporaryDirectory(prefix="pm-browser-") as directory:
|
||||
web.run_app(
|
||||
create_app(Path(directory).resolve()), host="127.0.0.1", port=args.port
|
||||
)
|
||||
try:
|
||||
web.run_app(create_app(Path(directory).resolve()), sock=listener, loop=loop)
|
||||
finally:
|
||||
# create_app works inside the root; Windows cannot remove a process's cwd.
|
||||
os.chdir(original_cwd)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {writeFile} from 'node:fs/promises';
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||||
import {SIZES,THEMES,MOTIONS,seedConcept,openConcept as openConceptPage,openProduction,inventoryOf,diffInventory,focusRingOf,hoverStateOf,mutate} from './concept-inventory.mjs';
|
||||
|
||||
@@ -119,10 +119,7 @@ function mapShellConcept(reference) {
|
||||
const spans=chrome.footer.spans.map(span=>({...span,text:'ComfyAssets · Local tools. Your library.'}));
|
||||
return {...chrome.footer,spans,buttons:[],height:remainingFooter(chrome.footer,spans)};
|
||||
})(),
|
||||
},
|
||||
heading:reference.heading&&{...reference.heading,actions:reference.heading.actions&&{...reference.heading.actions,
|
||||
// #245 vocabulary ruling D2 (mapVocabularyConcept): Auto-tag images is omitted until the image-annotation slice.
|
||||
buttons:reference.heading.actions.buttons.filter(item=>item.text!=='Auto-tag images')}}};
|
||||
}};
|
||||
}
|
||||
|
||||
/** The heading without its page-owned description copy and actions; the h1 text is the shell's and stays compared. */
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
// Concept-vs-production inventory of the Tag image dialog (plan Mock inventory M2: docs/concepts/workspace/app.js
|
||||
// tagReview a1037–1061, renderTagModelOptions a1062–1076, generateTags a1077–1135; style.css s1598–1694) and of the
|
||||
// card's Info and Tag group (M1: app.js mediaCard a398, style.css s1589–1597). The *Of functions run in the page.
|
||||
// The concept is never rewritten: mapConceptTagging applies only the recorded deviations, each named with its D-number
|
||||
// or ruling; everything else must be equal.
|
||||
export {diffInventory as diffTaggingInventory} from './library-editor-canonical-comparison.mjs';
|
||||
|
||||
/** The open Tag image dialog (`dialog#modal`), its controls in order with their concept tokens. */
|
||||
export function taggingInventory(dialog) {
|
||||
const css=(node,...keys)=>{const style=getComputedStyle(node);return Object.fromEntries(keys.map(key=>[key,style[key]]));};
|
||||
const px=value=>Math.round(value*2)/2;
|
||||
const box=node=>{const rect=node.getBoundingClientRect();return {width:px(rect.width),height:px(rect.height)};};
|
||||
const font=node=>css(node,'fontSize','fontWeight','lineHeight','color','marginTop','marginBottom');
|
||||
const edges=node=>css(node,'paddingTop','paddingRight','paddingBottom','paddingLeft','borderTopWidth','borderTopStyle','borderTopColor',
|
||||
'borderTopLeftRadius','backgroundColor');
|
||||
const buttonOf=node=>({text:node.textContent.trim(),label:node.getAttribute('aria-label'),primary:node.classList.contains('ca-primary'),
|
||||
icon:node.querySelector('svg')!==null,disabled:node.getAttribute('aria-disabled')==='true'||node.disabled,
|
||||
...css(node,'minHeight','fontSize','color','backgroundColor','borderTopColor','borderTopLeftRadius')});
|
||||
const controlOf=node=>({tag:node.tagName.toLowerCase(),id:node.id,type:node.type??null,value:node.value,
|
||||
min:node.getAttribute('min'),max:node.getAttribute('max'),step:node.getAttribute('step'),
|
||||
describedBy:node.getAttribute('aria-describedby'),
|
||||
options:node.tagName==='SELECT'?[...node.options].map(option=>[option.value,option.textContent]):null,
|
||||
...css(node,'fontSize','minHeight','backgroundColor','color','borderTopColor','borderTopLeftRadius','paddingLeft')});
|
||||
const fieldOf=field=>{const label=field.querySelector('label'),control=field.querySelector('input,textarea,select');
|
||||
return {label:label.textContent,labelled:label.htmlFor===control.id,...css(field,'display','rowGap','marginTop','marginBottom'),control:controlOf(control)};};
|
||||
const header=dialog.querySelector('.dialog-header'),title=header.querySelector('h2'),close=header.querySelector('button');
|
||||
const body=dialog.querySelector('#modal-body');
|
||||
const heading=body.querySelector('.tag-image-heading');
|
||||
const preview=heading.querySelector('.tag-image-preview'),copy=heading.querySelector(':scope > div');
|
||||
const sizeChip=copy.querySelector('.chip');
|
||||
const note=body.querySelector('.tag-scope-note');
|
||||
const manual=body.querySelector('#manual-image-tags'),manualField=manual.closest('.field'),help=body.querySelector('#manual-tags-help');
|
||||
const panel=body.querySelector('.tag-model-panel'),options=panel.querySelector('#image-tag-model-options');
|
||||
const generate=[...panel.querySelectorAll('button')].at(-1);
|
||||
const suggestions=body.querySelector('section.tag-suggestion-panel');
|
||||
const list=suggestions.querySelector('#tag-suggestions'),status=suggestions.querySelector('#tag-generation-status');
|
||||
const count=suggestions.querySelector('#suggestion-count');
|
||||
const actions=body.querySelector('.dialog-actions');
|
||||
// A suggestion is a concept checkbox row (`label.tag-suggestion`) or a production chip (`span.chip.tag-suggestion`).
|
||||
const rows=[...list.querySelectorAll('.tag-suggestion')].map(row=>{
|
||||
const name=row.querySelector('span:not(.tag-confidence)'),confidence=row.querySelector('.tag-confidence');
|
||||
const checkbox=row.querySelector('input[type=checkbox]'),remove=row.querySelector('button');
|
||||
return {name:name.textContent,confidence:confidence.textContent,
|
||||
control:checkbox?{kind:'checkbox',checked:checkbox.checked}:remove?{kind:'remove',label:remove.getAttribute('aria-label')}:null,
|
||||
...css(row,'display','alignItems','borderTopColor','backgroundColor','borderTopWidth','borderTopStyle','borderTopLeftRadius','fontSize','paddingTop','paddingLeft'),
|
||||
confidenceToken:css(confidence,'color','fontSize')};
|
||||
});
|
||||
return {
|
||||
dialog:{...css(dialog,'paddingTop','paddingRight','paddingBottom','paddingLeft','borderTopLeftRadius','backgroundColor','color','overflowY'),
|
||||
width:px(dialog.getBoundingClientRect().width),modal:dialog.matches(':modal'),tagging:dialog.classList.contains('tagging-modal'),
|
||||
labelledBy:document.getElementById(dialog.getAttribute('aria-labelledby'))===title,
|
||||
noHorizontalOverflow:dialog.scrollWidth<=dialog.clientWidth+1},
|
||||
header:{title:{text:title.textContent,...font(title)},close:{label:close.getAttribute('aria-label'),text:close.textContent.trim()}},
|
||||
demo:body.querySelector('.demo-note')?.textContent??null,
|
||||
order:[...body.children].map(node=>node.className),
|
||||
heading:{...css(heading,'display','columnGap','alignItems','marginBottom'),
|
||||
preview:{tag:preview.tagName.toLowerCase(),alt:preview.getAttribute('alt'),...box(preview),...css(preview,'objectFit','borderTopLeftRadius','borderTopWidth','borderTopStyle','borderTopColor','flexShrink')},
|
||||
h3:{text:copy.querySelector('h3').textContent,...font(copy.querySelector('h3'))},
|
||||
filename:{text:copy.querySelector('p.small').textContent,...font(copy.querySelector('p.small'))},
|
||||
size:sizeChip?{text:sizeChip.textContent,...css(sizeChip,'marginTop','fontSize','borderTopColor','borderTopLeftRadius','paddingLeft')}:null},
|
||||
scopeNote:note?{text:note.textContent,...edges(note),...css(note,'borderLeftWidth','borderLeftStyle','borderLeftColor','fontSize','color')}:null,
|
||||
manual:{...fieldOf(manualField),help:{text:help.textContent,...font(help)},described:help.id===manual.getAttribute('aria-describedby')},
|
||||
modelPanel:{...edges(panel),...css(panel,'marginTop'),h3:{text:panel.querySelector('h3').textContent,...font(panel.querySelector('h3'))},
|
||||
fields:[...panel.querySelectorAll('.field')].map(fieldOf),
|
||||
twoCol:options.querySelector('.two-col')?{columns:getComputedStyle(options.querySelector('.two-col')).gridTemplateColumns.split(' ').length,
|
||||
...css(options.querySelector('.two-col'),'display','columnGap')}:null,
|
||||
generate:buttonOf(generate)},
|
||||
suggestions:{labelled:suggestions.getAttribute('aria-labelledby')===suggestions.querySelector('h3').id,...css(suggestions,'marginTop'),
|
||||
title:{text:suggestions.querySelector('h3').textContent,...font(suggestions.querySelector('h3'))},
|
||||
count:{text:count.textContent,...font(count)},
|
||||
copy:list.querySelector(':scope > p.small')?.textContent??null,
|
||||
rows,
|
||||
status:{text:status.textContent,role:status.getAttribute('role'),...css(status,'marginTop','fontSize','color')}},
|
||||
actions:{...css(actions,'position','bottom','marginLeft','marginRight','marginBottom','paddingTop','paddingLeft','backgroundColor',
|
||||
'borderTopWidth','borderTopStyle','borderTopColor','zIndex','display','justifyContent','columnGap'),
|
||||
buttons:[...actions.querySelectorAll('button')].map(buttonOf)},
|
||||
noPageScroll:document.documentElement.scrollWidth<=innerWidth+1,
|
||||
};
|
||||
}
|
||||
|
||||
/** The first image card's Info and Tag group (M1), with its card's filename. */
|
||||
export function cardActionsInventory(card) {
|
||||
const css=(node,...keys)=>{const style=getComputedStyle(node);return Object.fromEntries(keys.map(key=>[key,style[key]]));};
|
||||
const info=card.querySelector('.media-info'),actions=info.querySelector(':scope > .image-card-actions');
|
||||
return {
|
||||
info:css(info,'display','justifyContent','alignItems','columnGap'),
|
||||
actions:{...css(actions,'display','alignItems','columnGap','flexShrink','minWidth'),lastInInfo:info.lastElementChild===actions},
|
||||
buttons:[...actions.querySelectorAll(':scope > button')].map(node=>{const rect=node.getBoundingClientRect();return {
|
||||
className:node.className,label:node.getAttribute('aria-label'),icon:node.querySelector('svg')?.innerHTML.replace(/\s+/g,' ').trim()??null,
|
||||
width:Math.round(rect.width),height:Math.round(rect.height),
|
||||
...css(node,'minWidth','minHeight','paddingLeft','paddingTop','borderTopLeftRadius','borderTopColor','backgroundColor','color')};}),
|
||||
};
|
||||
}
|
||||
|
||||
// D84 (plan D61): the legacy engines' values, labels and sizes, GGUF JoyCaption in the concept's Ollama slot.
|
||||
const MODEL_LABELS=Object.freeze({'wd14-swinv2':['wd14-swinv2','WD14 SwinV2 · ~400 MB'],'wd14-vit':['wd14-vit','WD14 ViT · ~400 MB'],
|
||||
ollama:['gguf','GGUF JoyCaption (Recommended) · ~4.5 GB'],huggingface:['hf','HuggingFace JoyCaption · ~16 GB']});
|
||||
const CONCEPT_LABELS=Object.freeze({'WD14 SwinV2 · fast classifier':'WD14 SwinV2 · ~400 MB','WD14 ViT · visual tags':'WD14 ViT · ~400 MB',
|
||||
'Ollama · local language model':'GGUF JoyCaption (Recommended) · ~4.5 GB','HuggingFace · language model':'HuggingFace JoyCaption · ~16 GB'});
|
||||
|
||||
/**
|
||||
* The concept inventory with the recorded deviations applied:
|
||||
* - no prototype note (Global Constraints: `Prototype · suggestions are simulated…` is not shipped);
|
||||
* - D83 (ruling T4): while suggestions exist, "Remove anything you do not want to add." leads them, and every
|
||||
* suggestion is a chip with a `Remove <name>` × instead of a checked checkbox row: the concept's own `.chip` corner
|
||||
* (`chip`: a concept `.chip` inventory, the heading's size chip) around the row's own text (12px) in the checked
|
||||
* row's colours (accent border, `--soft-blue`);
|
||||
* - D84 (plan D61): the legacy engine labels and values, and the server's default instructions (`instructions`);
|
||||
* - D91 (plan D68): a Suggestions count field after the model's own fields, on the concept's number field tokens
|
||||
* (`numberField`: the concept's General confidence field) with the configured count (`limit`);
|
||||
* - D29: the prototype's "Sample suggestions from …" status reads "Suggestions from …".
|
||||
* The opened state is compared unmapped: both show the concept's on-screen "Generate suggestions with the selected
|
||||
* model before approving." (its renderTagModelOptions, a1062–1076, replaces the markup's sentence at once).
|
||||
*/
|
||||
export function mapConceptTagging(reference,{instructions,limit=10,chip,numberField}) {
|
||||
const [select,...own]=reference.modelPanel.fields;
|
||||
const suggestionsField={...numberField,label:'Suggestions',control:{...numberField.control,id:'image-suggestion-limit',
|
||||
value:String(limit),min:'1',max:'100',step:'1'}};
|
||||
const mappedOwn=own.map(field=>field.control.id==='image-tag-instructions'?{...field,control:{...field.control,value:instructions}}:field);
|
||||
const rows=reference.suggestions.rows.map(row=>({...row,
|
||||
control:row.control?.kind==='checkbox'&&row.control.checked?{kind:'remove',label:`Remove ${row.name}`}:row.control,
|
||||
borderTopLeftRadius:chip.borderTopLeftRadius}));
|
||||
const copy=rows.length&&reference.suggestions.copy===null?'Remove anything you do not want to add.':reference.suggestions.copy;
|
||||
const status=reference.suggestions.status.text.replace(/^Sample suggestions from (.+)\. Approve tags to save\.$/,
|
||||
(_,label)=>`Suggestions from ${CONCEPT_LABELS[label]??label}. Approve tags to save.`);
|
||||
return {...reference,
|
||||
demo:null,
|
||||
order:reference.order.filter(name=>name!=='demo-note'),
|
||||
modelPanel:{...reference.modelPanel,
|
||||
fields:[{...select,control:{...select.control,value:MODEL_LABELS[select.control.value]?.[0]??select.control.value,
|
||||
options:select.control.options.map(([value])=>MODEL_LABELS[value])}},...mappedOwn,suggestionsField]},
|
||||
suggestions:{...reference.suggestions,copy,rows,status:{...reference.suggestions.status,text:status}},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Production's own chip geometry beyond the concept's `.chip` (D83): the chip's padding hugs its × button. Only the
|
||||
* leaves D83 replaces are dropped from both sides; names, confidences, colours, radius and font stay compared.
|
||||
*/
|
||||
export function withoutChipGeometry(inventory) {
|
||||
return {...inventory,suggestions:{...inventory.suggestions,rows:inventory.suggestions.rows.map(({paddingTop,paddingLeft,alignItems,...row})=>row)}};
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
// The Tag image dialog and the card's Info and Tag group against the approved concept (#249 PR C, plan Task C4, Mock
|
||||
// inventory M1 and M2). Production runs on the concept-shaped fixture with the concept image's own tags saved as its
|
||||
// image tags and the injected fake model answering the concept's own sample (alpine: mountain 98%, observatory 95%,
|
||||
// snow 88%, blue_hour 81%), so every remaining difference is a recorded deviation applied by mapConceptTagging
|
||||
// (D29, D83, D84, D91) or a test failure. No model is ever downloaded: every test checks no download request was sent.
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
import { seedConcept, openConcept, openProduction, saveImageTags, mutate, SIZES, THEMES } from './concept-inventory.mjs';
|
||||
import { taggingInventory, cardActionsInventory, mapConceptTagging, diffTaggingInventory, withoutChipGeometry } from './tagging-canonical-comparison.mjs';
|
||||
|
||||
const MODULE = '/prompt_manager/js/workspace/image-tagging.mjs';
|
||||
// The concept's first image (app.js images[0]) and its sample suggestions (generateTags, scene "alpine").
|
||||
const CONCEPT_FILE = 'alpine_00001.png';
|
||||
const CONCEPT_TAGS = ['cinematic', 'blue_hour', 'architecture'];
|
||||
const ALPINE = [['mountain', 0.98], ['observatory', 0.95], ['snow', 0.88], ['blue_hour', 0.81]];
|
||||
// The fake model's server default instructions (tests/browser/server.py TestModel.default_prompt), D84.
|
||||
const INSTRUCTIONS = 'Describe the image';
|
||||
const CONCEPT_MODEL = { gguf: 'ollama' };
|
||||
|
||||
const tagDialog = page => page.getByRole('dialog', { name: 'Tag image', exact: true });
|
||||
const firstCard = (page, side) => page.locator(side === 'concept' ? '#image-grid > article.media-card' : '#workspace-main article[data-image-key]').first();
|
||||
|
||||
test.beforeEach(async ({ page, request }) => {
|
||||
const manifest = await seedConcept(request);
|
||||
expect(manifest).toBeTruthy();
|
||||
await expect(await request.post('/__test__/tagging/suggestions', { data: { scored: ALPINE } })).toBeOK();
|
||||
page.downloads = [];
|
||||
page.on('request', item => { if (/\/autotag\/download\//.test(item.url())) page.downloads.push(item.url()); });
|
||||
page.errors = [];
|
||||
page.on('pageerror', error => page.errors.push(error.message));
|
||||
});
|
||||
test.afterEach(async ({ page, request }) => {
|
||||
await leave(page);
|
||||
expect(page.downloads).toEqual([]);
|
||||
expect(page.errors).toEqual([]);
|
||||
await request.post('/__test__/tagging/suggestions', { data: { scored: null } });
|
||||
});
|
||||
|
||||
async function conceptPath(request) {
|
||||
const listing = await (await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json();
|
||||
const image = listing.images.find(item => item.filename === CONCEPT_FILE);
|
||||
expect(image).toBeTruthy();
|
||||
return image.path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close an open Tag dialog before opening the next page, so each comparison starts from a settled page. Leaving with
|
||||
* the dialog open is covered on its own (workspace-tagging-lifecycle.spec.mjs: the keepalive release on pagehide).
|
||||
*/
|
||||
async function leave(page) {
|
||||
if (await tagDialog(page).count()) {
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(tagDialog(page)).toHaveCount(0);
|
||||
}
|
||||
}
|
||||
/** Open `side`'s Tag image dialog from its first card, settled (production: the capture preview and saved tags). */
|
||||
async function openTagDialog(page, side, theme) {
|
||||
await leave(page);
|
||||
if (side === 'concept') await openConcept(page, 'images', theme);
|
||||
else await openProduction(page, 'images', theme);
|
||||
await firstCard(page, side).getByRole('button', { name: `Tag or auto-tag ${CONCEPT_FILE}`, exact: true }).click();
|
||||
await expect(tagDialog(page)).toBeVisible();
|
||||
await settled(page, side);
|
||||
}
|
||||
async function settled(page, side) {
|
||||
if (side === 'production') {
|
||||
await expect(tagDialog(page).locator('img.tag-image-preview')).toHaveAttribute('src', /\/image-annotations\/capture\//);
|
||||
await expect(tagDialog(page).locator('#manual-image-tags')).toHaveValue(CONCEPT_TAGS.join(', '));
|
||||
}
|
||||
await page.evaluate(async () => {
|
||||
await document.fonts.ready;
|
||||
await Promise.all([...document.querySelectorAll('#modal img')].map(image => image.decode().catch(() => {})));
|
||||
});
|
||||
}
|
||||
async function generateWith(page, side, model) {
|
||||
const select = tagDialog(page).getByRole('combobox', { name: 'Tagging model', exact: true });
|
||||
if (model) await select.selectOption(side === 'concept' ? CONCEPT_MODEL[model] ?? model : model);
|
||||
await tagDialog(page).getByRole('button', { name: 'Generate suggestions', exact: true }).click();
|
||||
await expect(tagDialog(page).locator('#suggestion-count')).not.toHaveText('Not generated');
|
||||
}
|
||||
// The pointer rests outside the dialog, so no control is measured in its hover state.
|
||||
const inventory = async page => {
|
||||
await page.mouse.move(0, 0);
|
||||
return page.locator('dialog#modal').evaluate(taggingInventory);
|
||||
};
|
||||
|
||||
/** The concept and production inventories of one dialog state, the concept mapped (D29, D83, D84, D91). */
|
||||
async function compareState(page, { theme, act = async () => {} }) {
|
||||
await openTagDialog(page, 'concept', theme);
|
||||
const conceptOpened = await inventory(page);
|
||||
await act(page, 'concept');
|
||||
const concept = await inventory(page);
|
||||
await openTagDialog(page, 'production', theme);
|
||||
await act(page, 'production');
|
||||
const actual = await inventory(page);
|
||||
const numberField = conceptOpened.modelPanel.fields.find(field => field.control.id === 'image-general-threshold');
|
||||
const reference = mapConceptTagging(concept, { instructions: INSTRUCTIONS, chip: concept.heading.size, numberField });
|
||||
return { reference: withoutChipGeometry(reference), actual: withoutChipGeometry(actual) };
|
||||
}
|
||||
|
||||
for (const [width, height] of SIZES) for (const theme of THEMES) {
|
||||
test(`concept and production Tag dialog match at ${width}x${height} ${theme}`, async ({ page, request }) => {
|
||||
test.setTimeout(90_000);
|
||||
await saveImageTags(request, await conceptPath(request), CONCEPT_TAGS);
|
||||
await page.setViewportSize({ width, height });
|
||||
// Opened, generated with WD14 (the concept's default), then generated with a language model.
|
||||
for (const [state, act] of [
|
||||
['opened', async () => {}],
|
||||
['generated WD14', (page, side) => generateWith(page, side)],
|
||||
['generated GGUF', (page, side) => generateWith(page, side, 'gguf')],
|
||||
]) {
|
||||
const { reference, actual } = await compareState(page, { theme, act });
|
||||
expect(diffTaggingInventory(reference, actual), `${state} at ${width}x${height} ${theme}`).toEqual([]);
|
||||
}
|
||||
// Pinned concept geometry (M2), so a mapping mistake on both sides cannot pass unseen.
|
||||
const { actual } = await compareState(page, { theme, act: (page, side) => generateWith(page, side) });
|
||||
const small = width <= 700;
|
||||
expect([actual.heading.preview.width, actual.heading.preview.height]).toEqual(small ? [86, 86] : [112, 100]);
|
||||
expect(actual.heading.columnGap).toBe('16px');
|
||||
expect([actual.modelPanel.paddingTop, actual.modelPanel.borderTopLeftRadius]).toEqual(['16px', '6px']);
|
||||
expect(actual.actions.position).toBe('sticky');
|
||||
expect(actual.suggestions.rows.map(row => [row.name, row.confidence, row.control.label])).toEqual(
|
||||
ALPINE.map(([name, confidence]) => [name, `${Math.round(confidence * 100)}%`, `Remove ${name}`]));
|
||||
const accent = await page.evaluate(() => {
|
||||
const probe = document.createElement('i');
|
||||
probe.style.cssText = 'border-top:1px solid var(--ca-accent);background:var(--soft-blue)';
|
||||
document.body.append(probe);
|
||||
const style = getComputedStyle(probe), tokens = [style.borderTopColor, style.backgroundColor];
|
||||
probe.remove();
|
||||
return tokens;
|
||||
});
|
||||
for (const row of actual.suggestions.rows) expect([row.borderTopColor, row.backgroundColor]).toEqual(accent);
|
||||
expect(actual.dialog.noHorizontalOverflow).toBe(true);
|
||||
expect(actual.noPageScroll).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
test('an empty result matches the concept below its threshold', async ({ page, request }) => {
|
||||
await saveImageTags(request, await conceptPath(request), CONCEPT_TAGS);
|
||||
await expect(await request.post('/__test__/tagging/suggestions', { data: { scored: [] } })).toBeOK();
|
||||
const { reference, actual } = await compareState(page, { theme: 'dark', act: async (page, side) => {
|
||||
// The concept filters its sample by the General confidence; above every sample nothing is shown.
|
||||
if (side === 'concept') await tagDialog(page).getByRole('spinbutton', { name: 'General confidence', exact: true }).fill('1');
|
||||
await generateWith(page, side);
|
||||
if (side === 'concept') await tagDialog(page).getByRole('spinbutton', { name: 'General confidence', exact: true }).fill('0.35');
|
||||
} });
|
||||
expect(actual.suggestions.copy).toBe('No suggestions met this threshold. Adjust it or enter tags manually.');
|
||||
expect(diffTaggingInventory(reference, actual)).toEqual([]);
|
||||
});
|
||||
|
||||
test('review-scoped dialog shows the scope note', async ({ page, request }) => {
|
||||
const path = await conceptPath(request);
|
||||
await saveImageTags(request, path, CONCEPT_TAGS);
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
await openConcept(page, 'review', 'dark');
|
||||
await page.locator('#main section.surface').getByRole('button', { name: 'Tag image', exact: true }).click();
|
||||
await expect(tagDialog(page)).toBeVisible();
|
||||
await settled(page, 'concept');
|
||||
const opened = await inventory(page);
|
||||
// Needs review ships with PR F; until then the review source opens the same dialog from the module.
|
||||
await leave(page);
|
||||
await openProduction(page, 'images', 'dark');
|
||||
await page.evaluate(async ({ module, path }) => {
|
||||
const { openImageTagging } = await import(module);
|
||||
const { createScope } = await import('/prompt_manager/js/shared/toolkit/core.js');
|
||||
const api = window.ApiClient.createApiClient();
|
||||
const listing = await api.get('/prompt_manager/images/output?mode=workspace&limit=40');
|
||||
openImageTagging({ api, scope: createScope(), image: listing.images.find(item => item.path === path), source: 'review' });
|
||||
}, { module: MODULE, path });
|
||||
await expect(tagDialog(page)).toBeVisible();
|
||||
await settled(page, 'production');
|
||||
const actual = await inventory(page);
|
||||
expect(actual.scopeNote.text).toBe('Tags belong to this image. Resolve its prompt association separately in Needs review.');
|
||||
expect(actual.order).toEqual(['tag-image-heading', 'tag-scope-note small', 'field', 'tag-model-panel', 'tag-suggestion-panel', 'dialog-actions']);
|
||||
const numberField = opened.modelPanel.fields.find(field => field.control.id === 'image-general-threshold');
|
||||
const reference = mapConceptTagging(opened, { instructions: INSTRUCTIONS, chip: opened.heading.size, numberField });
|
||||
expect(diffTaggingInventory(reference, actual)).toEqual([]);
|
||||
});
|
||||
|
||||
for (const [width, height] of SIZES) test(`card action group matches M1 at ${width}x${height}`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height });
|
||||
await openConcept(page, 'images', 'dark');
|
||||
const reference = await firstCard(page, 'concept').evaluate(cardActionsInventory);
|
||||
await openProduction(page, 'images', 'dark');
|
||||
const actual = await firstCard(page, 'production').evaluate(cardActionsInventory);
|
||||
expect(actual.buttons.map(button => button.label)).toEqual([`Inspect ${CONCEPT_FILE}`, `Tag or auto-tag ${CONCEPT_FILE}`]);
|
||||
expect(diffTaggingInventory(reference, actual)).toEqual([]);
|
||||
});
|
||||
|
||||
test('the comparison notices a changed panel border or primary label', async ({ page, request }) => {
|
||||
await saveImageTags(request, await conceptPath(request), CONCEPT_TAGS);
|
||||
const { reference, actual } = await compareState(page, { theme: 'dark' });
|
||||
expect(diffTaggingInventory(reference, actual)).toEqual([]);
|
||||
const undo = await mutate(page, { css: '.tag-model-panel { border: 0 !important; }' });
|
||||
const borderless = withoutChipGeometry(await inventory(page));
|
||||
expect(diffTaggingInventory(reference, borderless).map(item => item.path)).toEqual(
|
||||
expect.arrayContaining(['modelPanel.borderTopWidth', 'modelPanel.borderTopStyle']));
|
||||
await undo();
|
||||
await tagDialog(page).locator('.dialog-actions .ca-primary').evaluate(node => { node.textContent = 'Save'; });
|
||||
const relabelled = withoutChipGeometry(await inventory(page));
|
||||
expect(diffTaggingInventory(reference, relabelled).map(item => item.path)).toEqual(['actions.buttons.1.text']);
|
||||
});
|
||||
|
||||
// Paired captures for the PR (no screenshot baselines): every production state beside the concept's nearest one. The
|
||||
// states the concept lacks (unavailable model, saving, conflict, file changed) pair with the concept's opened dialog.
|
||||
test('paired Tag dialog captures', async ({ page, request }, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
const path = await conceptPath(request);
|
||||
await saveImageTags(request, path, CONCEPT_TAGS);
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
const shot = async name => {
|
||||
await page.evaluate(async () => { await document.fonts.ready; await Promise.all([...document.images].map(image => image.decode().catch(() => {}))); });
|
||||
const file = testInfo.outputPath(`${name}.png`);
|
||||
await page.screenshot({ path: file });
|
||||
await testInfo.attach(name, { path: file, contentType: 'image/png' });
|
||||
};
|
||||
const both = async (name, act) => {
|
||||
for (const side of ['concept', 'production']) {
|
||||
await openTagDialog(page, side, 'dark');
|
||||
await act(page, side);
|
||||
await shot(`${side}-${name}`);
|
||||
}
|
||||
};
|
||||
await both('opened', async () => {});
|
||||
await both('generated-wd14', (page, side) => generateWith(page, side));
|
||||
await both('generated-gguf', (page, side) => generateWith(page, side, 'gguf'));
|
||||
await expect(await request.post('/__test__/tagging/suggestions', { data: { scored: [] } })).toBeOK();
|
||||
await both('empty', async (page, side) => {
|
||||
if (side === 'concept') await tagDialog(page).getByRole('spinbutton', { name: 'General confidence', exact: true }).fill('1');
|
||||
await generateWith(page, side);
|
||||
});
|
||||
await expect(await request.post('/__test__/tagging/suggestions', { data: { scored: ALPINE } })).toBeOK();
|
||||
const save = () => tagDialog(page).locator('.dialog-actions .ca-primary').click();
|
||||
const status = () => tagDialog(page).locator('#tag-generation-status');
|
||||
|
||||
// Unavailable model.
|
||||
await expect(await request.post('/__test__/tagging/models', { data: { unavailable: ['wd14-swinv2'] } })).toBeOK();
|
||||
await openTagDialog(page, 'production', 'dark');
|
||||
await generateWith(page, 'production').catch(() => {});
|
||||
await expect(status()).toHaveText('WD14 SwinV2 is not downloaded. Download it from the legacy Auto-tag page.');
|
||||
await shot('production-unavailable');
|
||||
await expect(await request.post('/__test__/tagging/models', { data: { unavailable: [] } })).toBeOK();
|
||||
|
||||
// Saving: the write is held until the capture is taken.
|
||||
let release;
|
||||
const held = new Promise(resolve => { release = resolve; });
|
||||
const writes = url => url.pathname === '/prompt_manager/image-annotations/tags';
|
||||
await page.route(writes, async route => { await held; await route.continue(); });
|
||||
await openTagDialog(page, 'production', 'dark');
|
||||
const sent = page.waitForRequest(item => writes(new URL(item.url())));
|
||||
await save();
|
||||
await sent;
|
||||
await expect(tagDialog(page).locator('.dialog-actions .ca-primary')).toHaveAttribute('aria-disabled', 'true');
|
||||
await shot('production-saving');
|
||||
release();
|
||||
await expect(tagDialog(page)).toHaveCount(0);
|
||||
await page.unroute(writes);
|
||||
|
||||
// Conflict: another writer saved this file's set after the dialog observed it.
|
||||
await openTagDialog(page, 'production', 'dark');
|
||||
await saveImageTags(request, path, ['changed_elsewhere']);
|
||||
await save();
|
||||
await expect(status()).not.toHaveText('');
|
||||
await shot('production-conflict');
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// File changed: the server's own envelope for bytes replaced after the preview (B1 `file-changed`).
|
||||
await page.route(writes, route => route.fulfill({ status: 409, contentType: 'application/json',
|
||||
body: JSON.stringify({ success: false, code: 'file-changed', error: 'The file changed since it was previewed. Reopen the Tag dialog.' }) }));
|
||||
await saveImageTags(request, path, CONCEPT_TAGS);
|
||||
await openTagDialog(page, 'production', 'dark');
|
||||
await save();
|
||||
await expect(status()).not.toHaveText('');
|
||||
await shot('production-file-changed');
|
||||
await page.unroute(writes);
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// Unconfirmed save (a 504): D116's Retry leads the actions, before Cancel and the disabled primary.
|
||||
await page.route(writes, route => route.fulfill({ status: 504, body: 'Gateway Timeout' }));
|
||||
await openTagDialog(page, 'production', 'dark');
|
||||
await save();
|
||||
await expect(status()).toHaveText('The save could not be confirmed. Retry sends the same tags again.');
|
||||
expect((await inventory(page)).actions.buttons.map(button => [button.text, button.primary, button.disabled])).toEqual(
|
||||
[['Retry', false, false], ['Cancel', false, false], ['Save manual tags', true, true]]);
|
||||
await shot('production-unknown-outcome');
|
||||
await page.unroute(writes);
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// Review scope: the concept's Needs review dialog and production's review-sourced dialog.
|
||||
await leave(page);
|
||||
await openConcept(page, 'review', 'dark');
|
||||
await page.locator('#main section.surface').getByRole('button', { name: 'Tag image', exact: true }).click();
|
||||
await expect(tagDialog(page)).toBeVisible();
|
||||
await shot('concept-review');
|
||||
await leave(page);
|
||||
await openProduction(page, 'images', 'dark');
|
||||
await page.evaluate(async ({ module, path }) => {
|
||||
const { openImageTagging } = await import(module);
|
||||
const { createScope } = await import('/prompt_manager/js/shared/toolkit/core.js');
|
||||
const api = window.ApiClient.createApiClient();
|
||||
const listing = await api.get('/prompt_manager/images/output?mode=workspace&limit=40');
|
||||
openImageTagging({ api, scope: createScope(), image: listing.images.find(item => item.path === path), source: 'review' });
|
||||
}, { module: MODULE, path });
|
||||
await expect(tagDialog(page).locator('.tag-scope-note')).toBeVisible();
|
||||
await settled(page, 'production');
|
||||
await shot('production-review');
|
||||
await leave(page);
|
||||
});
|
||||
@@ -64,12 +64,10 @@ export function pageInventoryOf(main) {
|
||||
// Recorded deviations only (plan "Mock inventory" + decisions D1-D15 + ledger rulings). Everything else must be equal.
|
||||
export function mapVocabularyConcept(reference) {
|
||||
return {...reference,
|
||||
heading:{...reference.heading,actions:{...reference.heading.actions,
|
||||
// D2: Auto-tag images is omitted until the image-annotation slice owns image tags.
|
||||
buttons:reference.heading.actions.buttons.filter(item=>item.text!=='Auto-tag images')}},
|
||||
table:{...reference.table,rows:reference.table.rows.map(row=>({...row,
|
||||
// D3: no image-tag owner yet, so the Images cell shows "—" with a visually hidden "Not available yet".
|
||||
images:'—',imagesHidden:'Not available yet'}))},
|
||||
// Data, not a deviation: the concept tags each sample image with its prompt's tags; the vocabulary fixture
|
||||
// tags no image, so every Images count is 0 (#249 C2 retired #245 D3).
|
||||
images:'0'}))},
|
||||
operations:reference.operations.map(row=>({...row,p:{...row.p,
|
||||
// Ledger ruling: "1 positive prompts" -> singular, consistent with the shared plural helper (#238/#243).
|
||||
text:row.p.text.replace(/^1 positive prompts$/,'1 positive prompt')}})),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||||
|
||||
// Artifact-only captures of Tags & categories states that have no concept counterpart: the Manage categories
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// Each Playwright worker owns its server: `request`, `page` and manual
|
||||
// `browser.newContext()` all reach it through `baseURL`, and another server
|
||||
// process never sees its database.
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
import { startBrowserServer } from '../../tools/vendor/start-browser-server.mjs';
|
||||
|
||||
const search = text => `/prompt_manager/search?text=${encodeURIComponent(text)}`;
|
||||
|
||||
test('every fixture reaches this worker\'s own server and another server keeps its own database', async ({ request, page, browser, baseURL, workerServer }, testInfo) => {
|
||||
expect(baseURL).toBe(workerServer.url);
|
||||
await expect(await request.post('/__test__/reset')).toBeOK();
|
||||
const marker = `worker ${testInfo.workerIndex} isolation ${testInfo.project.name} ${testInfo.repeatEachIndex}`;
|
||||
await expect(await request.post('/prompt_manager/save', { data: { text: marker } })).toBeOK();
|
||||
expect((await (await request.get(search(marker))).json()).count).toBe(1);
|
||||
|
||||
const viaPage = await page.request.get(search(marker));
|
||||
expect(viaPage.url().startsWith(`${baseURL}/`)).toBe(true);
|
||||
expect((await viaPage.json()).count).toBe(1);
|
||||
const context = await browser.newContext();
|
||||
try {
|
||||
const response = await (await context.newPage()).goto(search(marker));
|
||||
expect(response.url().startsWith(`${baseURL}/`)).toBe(true);
|
||||
expect((await response.json()).count).toBe(1);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
|
||||
const other = await startBrowserServer();
|
||||
try {
|
||||
expect(other.url).not.toBe(baseURL);
|
||||
expect((await (await fetch(`${other.url}${search(marker)}`)).json()).count).toBe(0);
|
||||
} finally {
|
||||
await other.stop();
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
|
||||
test.beforeEach(async ({ request, page, browserName }) => {
|
||||
await startCoverage(page, browserName);
|
||||
@@ -64,11 +64,16 @@ test('actual Tags page reloads the selected filter and opens its image viewer',
|
||||
await page.goto('/prompt_manager/admin#/tags');
|
||||
await page.locator('[data-tag-name="cats"]').click();
|
||||
await expect(page.locator('#tagPromptsGrid')).toContainText('Cat portrait');
|
||||
const dogs = page.waitForResponse(response => response.url().includes('/tags/dogs/prompts?'));
|
||||
await page.locator('[data-tag-name="dogs"]').click();
|
||||
const [shown] = (await (await dogs).json()).prompts[0].images;
|
||||
await expect(page.locator('#tagPromptsGrid')).toContainText('Dog landscape');
|
||||
await expect(page.locator('#tagPromptsGrid')).not.toContainText('Cat portrait');
|
||||
await page.locator('#tagPromptsGrid img').first().click();
|
||||
await expect(page.locator('.viewer-canvas img')).toBeVisible();
|
||||
// The viewer's title names the shown image through the page's title callback. ViewerJS renders it only once the
|
||||
// image is viewed, so closing at "visible" alone left the callback unrun on slower hosts (CI coverage missed it).
|
||||
await expect(page.locator('.viewer-title')).toHaveText(shown.filename);
|
||||
await page.locator('.viewer-container').getByRole('button', { name: 'Close', exact: true }).click();
|
||||
await expect(page.locator('.viewer-container')).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
import { seedConcept, openConcept, openProduction } from './concept-inventory.mjs';
|
||||
|
||||
// Activity (#249 Activity-1): the concept's Activity page (app.js renderActivity 732–738), its top-bar button
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
import {readFile,writeFile} from 'node:fs/promises';
|
||||
import {createHash} from 'node:crypto';
|
||||
|
||||
@@ -317,6 +317,19 @@ test('marker labels disclose exact observed dequeue and outcome including times
|
||||
}
|
||||
});
|
||||
|
||||
// A timeline scroll re-renders the markers on the next frame. On a slow runner that frame lands between the press and
|
||||
// the release of a marker tap (Playwright's own scroll-into-view scrolls the timeline first); the re-render must leave
|
||||
// the pressed marker's nodes alone, or Chromium touch drops the click. The scroll event is dispatched so the marker
|
||||
// stays under the pointer, and the two frames are the re-render itself.
|
||||
test('a timeline re-render between press and release keeps the marker tap',async({page})=>{
|
||||
const f=page.fixture;await open(page,f.detail_id);
|
||||
const target=marker(page,f.runs.shared,'z');await expect(target).toHaveAttribute('aria-disabled','false');await target.scrollIntoViewIfNeeded();
|
||||
const box=await target.boundingBox();await page.mouse.move(box.x+box.width/2,box.y+box.height/2);await page.mouse.down();
|
||||
await recorded(page).locator('.history-timeline').evaluate(timeline=>{timeline.dispatchEvent(new Event('scroll'));return new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));});
|
||||
await page.mouse.up();
|
||||
await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
|
||||
});
|
||||
|
||||
test('same-epoch refresh retains recorded context mode and exact receipt after fresh candidate reads',async({page})=>{
|
||||
const f=page.fixture;await open(page,f.detail_id);await marker(page,f.runs.shared,'z').click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
|
||||
await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();
|
||||
@@ -1006,7 +1019,7 @@ test('shared gallery uses concept cards, image opens the viewer, empty copy',asy
|
||||
await expect(star).toHaveAccessibleName(`Favorite ${filename}`);await expect(star).toHaveAttribute('aria-pressed',String(image.favorite));
|
||||
await expect(star).toHaveText(image.favorite?'★':'☆');
|
||||
await expect(first.locator('.media-info h3')).toHaveCount(1);
|
||||
await expect(first.locator('.image-card-actions > button.icon-btn')).toHaveAccessibleName(`Inspect ${filename}`);
|
||||
await expect(first.locator('.image-card-actions > button.icon-btn:first-child')).toHaveAccessibleName(`Inspect ${filename}`);
|
||||
// C3 activation: the image opens the viewer and Escape returns focus to it.
|
||||
const openButton=first.locator('.image-open');await openButton.click();
|
||||
const viewer=page.locator('dialog#viewer-dialog');await expect(viewer).toBeVisible();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
import { seedConcept, openConcept, openProduction } from './concept-inventory.mjs';
|
||||
|
||||
const workspace = '/prompt_manager/workspace#/images';
|
||||
@@ -545,7 +545,7 @@ async function staleSourceRecovery(page, request, roots) {
|
||||
await expect(await request.post('/prompt_manager/settings', { data: { gallery_root_paths: roots } })).toBeOK();
|
||||
const selected = initial.images.at(-1), key = `${selected.root_index}:${selected.path}`;
|
||||
const stale = page.waitForResponse(response => response.url().includes('/images/inspect/') && response.status() === 409);
|
||||
await page.locator(`article[data-image-key="${key}"] .image-card-actions button`).click();expect((await stale).status()).toBe(409);
|
||||
await page.locator(`article[data-image-key="${key}"] .image-card-actions button:first-child`).click();expect((await stale).status()).toBe(409);
|
||||
await expect(details(page)).toContainText('The source changed. Refresh the image collection before inspecting this file.');
|
||||
await page.locator(`article[data-image-key="${key}"] .image-open`).click();
|
||||
const viewer = page.locator('dialog#viewer-dialog');await expect(viewer).toContainText('The source changed.');
|
||||
@@ -1028,8 +1028,7 @@ async function cardInventory(page, concept) {
|
||||
const walk = new Function(`return (${walkSource})()`)();
|
||||
return nodes.map(card => {
|
||||
const copy = card.cloneNode(true);
|
||||
// D9: no tag button until image annotations. The D8 checkbox and the D7 star are compared.
|
||||
if (concept) for (const node of copy.querySelectorAll('[aria-label^="Tag or auto-tag "]')) node.remove();
|
||||
// Info then Tag (#249 C2 retired D9). The D8 checkbox and the D7 star are compared.
|
||||
const open = card.querySelector('.image-open'), img = open.querySelector('img');
|
||||
const star = card.querySelector('.image-overlays > button.star-button');
|
||||
const box = card.querySelector('.image-overlays > label > input');
|
||||
@@ -1039,7 +1038,7 @@ async function cardInventory(page, concept) {
|
||||
size: [img.getAttribute('width'), img.getAttribute('height')],
|
||||
title: card.querySelector('.media-info h3').textContent, subtitle: card.querySelector('.media-info p').textContent,
|
||||
ratio: card.style.getPropertyValue('--media-ratio').replace(/\s/g, ''),
|
||||
inspect: card.querySelector('.image-card-actions > button').getAttribute('aria-label'),
|
||||
actions: [...card.querySelectorAll('.image-card-actions > button')].map(button => button.getAttribute('aria-label')),
|
||||
star: star && { text: star.textContent, pressed: star.getAttribute('aria-pressed'), label: star.getAttribute('aria-label') },
|
||||
select: box && { type: box.type, checked: box.checked, label: box.getAttribute('aria-label') },
|
||||
paths: walk(copy, `article.${[...card.classList].filter(value => value !== 'selected').sort().join('.')}`, []) };
|
||||
@@ -1209,7 +1208,7 @@ async function zoomedGallery(page, request, { cssZoom }) {
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(page.locator(`article[aria-posinset="${columns + 1}"] .image-overlays .star-button`)).toBeFocused();
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(page.locator(`article[aria-posinset="${columns + 1}"] .image-card-actions button`)).toBeFocused();
|
||||
await expect(page.locator(`article[aria-posinset="${columns + 1}"] .image-card-actions button:first-child`)).toBeFocused();
|
||||
await page.evaluate(() => {
|
||||
const card = document.querySelector('article[aria-posinset="1"]');
|
||||
const grid = document.querySelector('.image-grid');
|
||||
@@ -1512,6 +1511,25 @@ test('Refine opened before discovery settles lists the discovered sources and fo
|
||||
});
|
||||
|
||||
// #249 final review m1: as in the old field row, a Source offers only its own folders.
|
||||
test('a search reload keeps the Filters label node, so a click landing during the reload is not lost', async ({ page, request }) => {
|
||||
// WebKit drops a click whose mousedown hit a text node that was replaced before mouseup; the reload reports the
|
||||
// (unchanged) active count, so the label must keep its node.
|
||||
await expect(await request.post('/__test__/reset', { data: { extra_images: 80 } })).toBeOK();
|
||||
await page.goto(workspace);
|
||||
const button = filtersButton(page);
|
||||
await button.click();await expect(refineDialog(page).getByLabel('Folder', { exact: true }).locator('option[value="bulk"]')).toHaveCount(1);await page.keyboard.press('Escape');
|
||||
await refine(page, { Source: '0', Folder: 'bulk' });
|
||||
await expect(button).toHaveText('Filters · 2');
|
||||
const label = await button.evaluateHandle(node => node.firstChild);
|
||||
const reloaded = page.waitForResponse(response => {
|
||||
const url = new URL(response.url());return imagePages(url) && url.searchParams.get('q') === 'png';
|
||||
});
|
||||
await search(page).fill('png');
|
||||
await reloaded;
|
||||
await expect(button).toHaveText('Filters · 2');
|
||||
expect(await button.evaluate((node, text) => node.firstChild === text && text.isConnected, label)).toBe(true);
|
||||
});
|
||||
|
||||
test('Refine offers only the chosen source folders', async ({ page, request }) => {
|
||||
await expect(await request.post('/__test__/add-nested-image')).toBeOK();
|
||||
await page.goto(workspace);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {writeFile} from 'node:fs/promises';
|
||||
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||||
import {inventoryOf,mapConcept,diffInventory} from './library-editor-canonical-comparison.mjs';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
|
||||
const route='/prompt_manager/workspace#/library';
|
||||
const cards=page=>page.locator('article[data-prompt-id]');
|
||||
@@ -25,6 +25,8 @@ async function loadShelfPages(page,more,pages) {
|
||||
await more.evaluate(node=>node.click());await response;await expect(more).toHaveAttribute('aria-disabled','false');
|
||||
}
|
||||
}
|
||||
/** Resolves in the rendering frame after every frame callback the page had already queued (callbacks run in request order). */
|
||||
const nextFrame=page=>page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>resolve())));
|
||||
async function openHistory(page,text='Cat portrait') {
|
||||
await cards(page).filter({has:page.getByText(text,{exact:true})}).getByRole('button',{name:'Explore history',exact:true}).click();
|
||||
await expect(history(page)).toBeVisible();
|
||||
@@ -103,7 +105,8 @@ test('library infinite loading keeps unique parents and keyboard-accessible hist
|
||||
const appended=await (await nextPage).json();expect(appended.items.length).toBeGreaterThan(0);hasMore=appended.has_more;
|
||||
if(hasMore)await expect(more).toHaveAttribute('aria-disabled','false');
|
||||
}
|
||||
await expect(more).toBeHidden();expect(await cards(page).count()).toBeLessThan(80);
|
||||
// The last page's render queued the window update for the next frame; count after that frame, not before it.
|
||||
await expect(more).toBeHidden();await nextFrame(page);expect(await cards(page).count()).toBeLessThan(80);
|
||||
const ids=await cards(page).evaluateAll(nodes=>nodes.map(node=>node.dataset.promptId));expect(new Set(ids).size).toBe(ids.length);
|
||||
await expect(page.getByRole('button',{name:/next page|previous page/i})).toHaveCount(0);
|
||||
const firstId=await cards(page).first().getAttribute('data-prompt-id');
|
||||
@@ -253,6 +256,19 @@ test('returning from history restores library context and mobile content stays i
|
||||
await expect(history(page)).toHaveCount(0);await expect(page.locator('dialog[open]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('leaving the shelf before its first page answers and returning reads page one without a page error',async({page})=>{
|
||||
// Hold the first shelf page so the route changes while the shelf is still loading (a slow CI runner's natural order).
|
||||
let reached;const firstRead=new Promise(resolve=>{reached=resolve;});
|
||||
await page.route(url=>url.pathname==='/prompt_manager/library'&&!url.searchParams.has('cursor'),route=>{reached();});
|
||||
await page.goto(route);await firstRead;await expect(page.locator('.library-status')).toHaveText('Loading prompt library…');
|
||||
await page.evaluate(()=>{window.location.hash='#/tags';});
|
||||
await expect(page.locator('#workspace-main > .heading h1')).toHaveText('Tags & categories');
|
||||
await page.unrouteAll({behavior:'ignoreErrors'});
|
||||
await page.evaluate(()=>{window.location.hash='#/library';});
|
||||
await expect(page.locator('#workspace-main > .heading h1')).toHaveText('Prompt library');
|
||||
await expect(cards(page)).toHaveCount(3);await expect(page.locator('.section-caption')).toContainText('3 positive prompts');
|
||||
expect(page.libraryErrors).toEqual([]);
|
||||
});
|
||||
test('empty shelf visits restore saved scroll without inventing a parent anchor',async({page})=>{
|
||||
await page.setViewportSize({width:1600,height:300});await page.goto(route);
|
||||
await page.getByRole('searchbox',{name:'Search workspace',exact:true}).fill('No matching saved positive');
|
||||
@@ -376,14 +392,18 @@ async function observeLibrary(page, hold=false) {
|
||||
holdFrames(){holdingFrames=true;},
|
||||
releaseFrames(){holdingFrames=false;const callbacks=[...heldFrames.values()];heldFrames.clear();for(const callback of callbacks)callback();},
|
||||
equal(){for(const record of records){const entries=[...record.latest.values()].filter(entry=>record.targets.has(entry.target));if(entries.length)record.callback(entries,record.native);}},
|
||||
// Resolves once two consecutive rendering frames find no Library frame queued. Each check runs inside a native
|
||||
// frame callback, so a frame the page queues later in that update (a scroll event, a ResizeObserver delivery) is
|
||||
// still pending there. A timer is not a frame: under CPU load a 60 ms wait could end before the scroll's frame.
|
||||
// A frame that never drains (a rAF loop, or settling while frames are held) fails after `limit` frames with the stats.
|
||||
settled(limit=600){return new Promise((resolve,reject)=>{let quiet=0,frames=0;const check=()=>{frames++;quiet=pending.size?0:quiet+1;
|
||||
if(quiet>=2)resolve();else if(frames>=limit)reject(new Error(`Library never settled within ${limit} frames: ${JSON.stringify(window.libraryObservation.stats())}`));else raf(check);};raf(check);});},
|
||||
stats(){return {requested,pending:pending.size,heldFrames:heldFrames.size,measurements:records.reduce((sum,record)=>sum+record.entries.size,0),mutations,unmounted,targets:records.reduce((sum,record)=>sum+record.targets.size,0)};},
|
||||
};
|
||||
},{hold});
|
||||
}
|
||||
async function settleLibrary(page) {
|
||||
await expect.poll(async()=>page.evaluate(()=>window.libraryObservation.stats().pending)).toBe(0);
|
||||
await page.evaluate(()=>new Promise(resolve=>setTimeout(resolve,60)));
|
||||
await expect.poll(async()=>page.evaluate(()=>window.libraryObservation.stats().pending)).toBe(0);
|
||||
await page.evaluate(()=>window.libraryObservation.settled());
|
||||
}
|
||||
async function firstVisibleParent(page) {
|
||||
return cards(page).evaluateAll(nodes=>{
|
||||
@@ -683,6 +703,17 @@ test('title and Explore history are buttons that open history',async({page,reque
|
||||
await page.keyboard.press('ArrowLeft');await expect(cards(page).nth(1).locator('.card-title > button')).toBeFocused();
|
||||
});
|
||||
|
||||
test('settling fails with the observation stats when a Library frame stays queued',async({page,request})=>{
|
||||
await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:200}});
|
||||
await observeLibrary(page);await page.addInitScript(()=>{window.IntersectionObserver=undefined;});
|
||||
await page.goto(route);await expect(cards(page).first()).toBeVisible();await settleLibrary(page);
|
||||
await page.evaluate(()=>{window.libraryObservation.holdFrames();dispatchEvent(new Event('scroll'));});
|
||||
await expect.poll(()=>page.evaluate(()=>window.libraryObservation.stats().heldFrames)).toBeGreaterThan(0);
|
||||
const failure=await page.evaluate(()=>window.libraryObservation.settled(30).then(()=>'settled',error=>error.message));
|
||||
expect(failure).toMatch(/^Library never settled within 30 frames: \{.*"heldFrames":[1-9]/);
|
||||
await page.evaluate(()=>window.libraryObservation.releaseFrames());await settleLibrary(page);
|
||||
});
|
||||
|
||||
test('virtualized rows keep measured heights with the clamp',async({page,request,isMobile})=>{
|
||||
await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:200}});
|
||||
await observeLibrary(page);await page.addInitScript(()=>{window.IntersectionObserver=undefined;});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
|
||||
// Saved views (#249 D16, maintainer ruling 2026-10-21): the toolbar's Save view (concept app.js a359, a1330–1346),
|
||||
// the sidebar SAVED VIEWS rows (a331–335) and `#/saved/<id>` (a1158–1162), on Images and the Prompt library.
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// The Tag image dialog's capture lifetime when the page or the dialog goes away mid-flight (#249 PR C final review I4,
|
||||
// I5), run on all four projects: leaving the page with the dialog open releases the capture (a `keepalive` DELETE sent
|
||||
// on `pagehide`, which also closes the dialog), and closing the dialog while its save is in flight keeps the capture
|
||||
// until the save settles, then still reports the save. The fixture is tests/browser/workspace_tagging_fixture.py.
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
|
||||
const images = '/prompt_manager/workspace#/images';
|
||||
const FIRST = 'tagging/dunes_00001.png';
|
||||
const TAGS_URL = '**/prompt_manager/image-annotations/tags';
|
||||
const dialog = page => page.getByRole('dialog', { name: 'Tag image', exact: true });
|
||||
const manual = page => dialog(page).getByRole('textbox', { name: 'Image tags · edit manually', exact: true });
|
||||
const tagButton = page => page.locator(`article[data-image-key="0:${FIRST}"]`).getByRole('button', { name: 'Tag or auto-tag dunes_00001.png', exact: true });
|
||||
const openCaptures = async request => (await (await request.get('/__test__/tagging/captures')).json()).captures;
|
||||
|
||||
test.beforeEach(async ({ page, request, browserName }) => {
|
||||
await startCoverage(page, browserName);
|
||||
await expect(await request.post('/__test__/tagging/reset', { data: {} })).toBeOK();
|
||||
page.problems = [];
|
||||
page.on('pageerror', error => page.problems.push(`pageerror: ${error.message}`));
|
||||
page.on('console', message => { if (message.type() === 'error') page.problems.push(`console: ${message.text()}`); });
|
||||
});
|
||||
test.afterEach(async ({ page, browserName }) => {
|
||||
await saveCoverage(page, browserName);
|
||||
expect(page.problems).toEqual([]);
|
||||
});
|
||||
|
||||
async function openTag(page) {
|
||||
await page.goto(images);
|
||||
const observed = page.waitForResponse(item => item.url().endsWith('/image-annotations/observe'));
|
||||
await tagButton(page).click();
|
||||
await expect(dialog(page)).toBeVisible();
|
||||
expect((await observed).status()).toBe(200);
|
||||
await expect(manual(page)).toBeEditable();
|
||||
}
|
||||
|
||||
test('leaving the page with the dialog open releases its capture without a console error (I4)', async ({ page, request }) => {
|
||||
await openTag(page);
|
||||
expect(await openCaptures(request)).toBe(1);
|
||||
// Another document: the old one's pagehide closes the dialog and sends the release with keepalive.
|
||||
await page.goto('/prompt_manager/workspace?left=1#/tags');
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Tags & categories');
|
||||
await expect.poll(() => openCaptures(request)).toBe(0);
|
||||
await expect(dialog(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('closing the dialog during a save releases the capture after the save and still reports it (I5)', async ({ page, request }) => {
|
||||
await openTag(page);
|
||||
await manual(page).fill('saved_after_close');
|
||||
const order = [];
|
||||
page.on('response', item => { if (item.url().endsWith('/image-annotations/tags')) order.push(`PUT ${item.status()}`); });
|
||||
page.on('request', item => { if (item.url().includes('/image-annotations/capture/') && item.method() === 'DELETE') order.push('DELETE'); });
|
||||
let release;
|
||||
const held = new Promise(resolve => { release = resolve; });
|
||||
const sent = new Promise(resolve => {
|
||||
page.route(TAGS_URL, async route => { resolve(); await held; await route.continue(); });
|
||||
});
|
||||
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
|
||||
await sent;
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(dialog(page)).toHaveCount(0);
|
||||
expect(await openCaptures(request)).toBe(1);
|
||||
expect(order).toEqual([]);
|
||||
release();
|
||||
await expect(page.locator('#workspace-toast')).toHaveText('1 tags saved for dunes_00001.png.');
|
||||
await expect.poll(() => order).toEqual(['PUT 200', 'DELETE']);
|
||||
await expect.poll(() => openCaptures(request)).toBe(0);
|
||||
// The page followed the late save: the selected file's details show the new image tag.
|
||||
await expect(page.getByRole('complementary', { name: 'Selected image details', exact: true }).getByText('saved_after_close', { exact: true })).toBeVisible();
|
||||
await page.unroute(TAGS_URL);
|
||||
});
|
||||
@@ -0,0 +1,749 @@
|
||||
// The Tag image dialog, the card Tag button and the Tags page hooks (#249 PR C, plan Task C2, Mock inventory M1, M2, M5)
|
||||
// against the real routes and the injected fake model (tests/browser/workspace_tagging_fixture.py). No model is ever
|
||||
// downloaded or loaded: the fake answers, and every test checks that no download request was sent.
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
import { saveImageTags } from './concept-inventory.mjs';
|
||||
import { taggingInventory } from './tagging-canonical-comparison.mjs';
|
||||
|
||||
const images = '/prompt_manager/workspace#/images';
|
||||
const MODULE = '/prompt_manager/js/workspace/image-tagging.mjs';
|
||||
const MODEL_LABELS = ['WD14 SwinV2 · ~400 MB', 'WD14 ViT · ~400 MB', 'GGUF JoyCaption (Recommended) · ~4.5 GB', 'HuggingFace JoyCaption · ~16 GB'];
|
||||
// The concept's on-screen opening sentence (its renderTagModelOptions replaces the markup's at once, D83).
|
||||
const MODEL_CHANGED = 'Generate suggestions with the selected model before approving.';
|
||||
const CHIP_HINT = 'Remove anything you do not want to add.';
|
||||
const ALL_REMOVED = 'All suggestions removed. Generate again or enter tags manually.';
|
||||
const card = (page, path) => page.locator(`article[data-image-key="0:${path}"]`);
|
||||
const tagButton = (page, path) => card(page, path).getByRole('button', { name: `Tag or auto-tag ${path.split('/').pop()}`, exact: true });
|
||||
const dialog = page => page.getByRole('dialog', { name: 'Tag image', exact: true });
|
||||
const manual = page => dialog(page).getByRole('textbox', { name: 'Image tags · edit manually', exact: true });
|
||||
const model = page => dialog(page).getByRole('combobox', { name: 'Tagging model', exact: true });
|
||||
const generate = page => dialog(page).getByRole('button', { name: 'Generate suggestions', exact: true });
|
||||
const chips = page => dialog(page).locator('#tag-suggestions .chip.tag-suggestion');
|
||||
const status = page => dialog(page).locator('#tag-generation-status');
|
||||
const toast = page => page.locator('#workspace-toast');
|
||||
const nav = (page, label) => page.locator('aside.sidebar nav').getByRole('link', { name: new RegExp(`^${label}(?:\\s*[\\d,]+)?$`) });
|
||||
const FIRST = 'tagging/dunes_00001.png', SECOND = 'tagging/dunes_00002.png', COPY = 'copies/dunes_00001.png';
|
||||
const VIDEO = 'tagging/clip.webm', UNLINKED = 'loose/unlinked.png';
|
||||
|
||||
let manifest;
|
||||
test.beforeEach(async ({ page, request, browserName }) => {
|
||||
await startCoverage(page, browserName);
|
||||
const response = await request.post('/__test__/tagging/reset', { data: {} });
|
||||
await expect(response).toBeOK();
|
||||
manifest = await response.json();
|
||||
page.taggingErrors = [];
|
||||
page.on('pageerror', error => page.taggingErrors.push(error.message));
|
||||
page.downloads = [];
|
||||
page.on('request', item => { if (/\/autotag\/download\//.test(item.url())) page.downloads.push(item.url()); });
|
||||
});
|
||||
test.afterEach(async ({ page, browserName }) => {
|
||||
await saveCoverage(page, browserName);
|
||||
expect(page.taggingErrors).toEqual([]);
|
||||
expect(page.downloads).toEqual([]);
|
||||
});
|
||||
|
||||
/** Images, then the Tag dialog of `path`, settled on its observation (the manual field holds the saved tags). */
|
||||
async function openTag(page, path = FIRST, { goto = true } = {}) {
|
||||
if (goto) await page.goto(images);
|
||||
const observed = page.waitForResponse(item => item.url().endsWith('/image-annotations/observe'));
|
||||
await tagButton(page, path).click();
|
||||
await expect(dialog(page)).toBeVisible();
|
||||
expect((await observed).status()).toBe(200);
|
||||
await expect(dialog(page).locator('img.tag-image-preview, video.tag-image-preview')).toHaveAttribute('src', /\/prompt_manager\/image-annotations\/capture\//);
|
||||
}
|
||||
async function generated(page) {
|
||||
const answer = page.waitForResponse(item => item.url().endsWith('/image-annotations/suggest'));
|
||||
await generate(page).click();
|
||||
return answer;
|
||||
}
|
||||
/** Read the saved tags of `path` through the real observe route, then release that capture. */
|
||||
async function savedTags(request, path) {
|
||||
const target = { root_index: 0, root_scope: await scopeOf(request), path };
|
||||
const observed = await (await request.post('/prompt_manager/image-annotations/observe', { data: { target } })).json();
|
||||
expect(observed.success).toBe(true);
|
||||
await request.delete(`/prompt_manager/image-annotations/capture/${observed.capture.token}`);
|
||||
return observed.tags;
|
||||
}
|
||||
async function scopeOf(request) {
|
||||
const listing = await (await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json();
|
||||
return listing.images[0].root_scope;
|
||||
}
|
||||
|
||||
test('tag dialog control inventory and order match M2', async ({ page }) => {
|
||||
await openTag(page);
|
||||
await expect(page.locator('dialog#modal')).toHaveClass(/(^| )tagging-modal( |$)/);
|
||||
await expect(dialog(page).locator('#modal-title')).toHaveText('Tag image');
|
||||
const body = dialog(page).locator('#modal-body');
|
||||
expect(await body.evaluate(node => [...node.children].map(child => child.className))).toEqual(
|
||||
['tag-image-heading', 'field', 'tag-model-panel', 'tag-suggestion-panel', 'dialog-actions']);
|
||||
const heading = body.locator('.tag-image-heading');
|
||||
await expect(heading.locator('h3')).toHaveText(manifest.title);
|
||||
await expect(heading.locator('p.small')).toHaveText('dunes_00001.png');
|
||||
await expect(heading.locator('span.chip')).toHaveText('96 × 64');
|
||||
await expect(heading.locator('img.tag-image-preview')).toHaveAttribute('alt', manifest.title);
|
||||
await expect(body.locator('.tag-scope-note')).toHaveCount(0);
|
||||
await expect(manual(page)).toHaveAttribute('id', 'manual-image-tags');
|
||||
await expect(manual(page)).toHaveValue('');
|
||||
await expect(manual(page)).toHaveAccessibleDescription('Comma-separated. Generated suggestions are added only after you approve them.');
|
||||
const panel = body.locator('.tag-model-panel');
|
||||
await expect(panel.locator('h3')).toHaveText('Auto-tag this image');
|
||||
await expect(model(page).locator('option')).toHaveText(MODEL_LABELS);
|
||||
expect(await model(page).locator('option').evaluateAll(nodes => nodes.map(node => node.value))).toEqual(['wd14-swinv2', 'wd14-vit', 'gguf', 'hf']);
|
||||
await expect(model(page)).toHaveValue('wd14-swinv2');
|
||||
const options = panel.locator('#image-tag-model-options');
|
||||
const general = options.getByRole('spinbutton', { name: 'General confidence', exact: true });
|
||||
const character = options.getByRole('spinbutton', { name: 'Character confidence', exact: true });
|
||||
const limit = options.getByRole('spinbutton', { name: 'Suggestions', exact: true });
|
||||
await expect(options.locator('.two-col .field')).toHaveCount(2);
|
||||
await expect(general).toHaveValue('0.35');
|
||||
await expect(character).toHaveValue('0.85');
|
||||
await expect(limit).toHaveValue('10');
|
||||
for (const [input, attributes] of [[general, ['0', '1', '0.05']], [character, ['0', '1', '0.05']], [limit, ['1', '100', '1']]]) {
|
||||
expect(await input.evaluate(node => [node.min, node.max, node.step])).toEqual(attributes);
|
||||
}
|
||||
await expect(generate(page)).toHaveClass(/ca-primary/);
|
||||
await expect(generate(page).locator('svg.icon')).toHaveCount(1);
|
||||
const suggestions = body.locator('section.tag-suggestion-panel');
|
||||
await expect(suggestions).toHaveAttribute('aria-labelledby', 'tag-suggestions-title');
|
||||
await expect(suggestions.locator('h3#tag-suggestions-title')).toHaveText('Review suggestions');
|
||||
await expect(suggestions.locator('#suggestion-count')).toHaveText('Not generated');
|
||||
await expect(suggestions.locator('#tag-suggestions > p.small')).toHaveText(MODEL_CHANGED);
|
||||
await expect(status(page)).toHaveAttribute('role', 'status');
|
||||
await expect(status(page)).toHaveText('');
|
||||
await expect(body.locator('.dialog-actions button')).toHaveText(['Cancel', 'Save manual tags']);
|
||||
await expect(body.locator('.dialog-actions button').last()).toHaveClass(/ca-primary/);
|
||||
await model(page).selectOption('gguf');
|
||||
await expect(options.getByRole('textbox', { name: 'Tagging instructions', exact: true })).toHaveValue('Describe the image');
|
||||
await expect(options.getByRole('spinbutton', { name: 'Suggestions', exact: true })).toHaveValue('10');
|
||||
await expect(options.locator('.two-col')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('generate shows real fixture percentages for WD14 and Suggested for GGUF, at most ten chips', async ({ page }) => {
|
||||
await openTag(page);
|
||||
const answer = await generated(page);
|
||||
expect(answer.request().postDataJSON()).toMatchObject({ model_type: 'wd14-swinv2', options: { general_threshold: 0.35, character_threshold: 0.85, suggestion_limit: 10 } });
|
||||
await expect(chips(page)).toHaveCount(10);
|
||||
// D83: ruling T4's instruction leads the chips, only while chips exist.
|
||||
await expect(dialog(page).locator('#tag-suggestions > *')).toHaveCount(2);
|
||||
await expect(dialog(page).locator('#tag-suggestions > p.small')).toHaveText(CHIP_HINT);
|
||||
await expect(chips(page).first().locator(':scope > span').first()).toHaveText('sandstone');
|
||||
await expect(chips(page).locator('.tag-confidence').first()).toHaveText('98%');
|
||||
await expect(chips(page).nth(1).locator('.tag-confidence')).toHaveText('97%');
|
||||
await expect(chips(page).first().getByRole('button', { name: 'Remove sandstone', exact: true })).toBeVisible();
|
||||
await expect(dialog(page).locator('#suggestion-count')).toHaveText('10 suggestions');
|
||||
await expect(status(page)).toHaveText('Suggestions from WD14 SwinV2 · ~400 MB. Approve tags to save.');
|
||||
await expect(dialog(page).locator('.dialog-actions .ca-primary')).toHaveText('Approve selected tags');
|
||||
for (const id of ['gguf', 'hf']) {
|
||||
await model(page).selectOption(id);
|
||||
await expect(chips(page)).toHaveCount(0);
|
||||
await generated(page);
|
||||
await expect(chips(page)).toHaveCount(10);
|
||||
await expect(chips(page).locator('.tag-confidence')).toHaveText(Array(10).fill('Suggested'));
|
||||
await expect(status(page)).toHaveText(`Suggestions from ${MODEL_LABELS[id === 'gguf' ? 2 : 3]}. Approve tags to save.`);
|
||||
}
|
||||
await dialog(page).getByRole('spinbutton', { name: 'Suggestions', exact: true }).fill('3');
|
||||
const fewer = await generated(page);
|
||||
expect(fewer.request().postDataJSON().options).toEqual({ instructions: 'Describe the image', suggestion_limit: 3 });
|
||||
await expect(chips(page)).toHaveCount(3);
|
||||
await expect(dialog(page).locator('#suggestion-count')).toHaveText('3 suggestions');
|
||||
});
|
||||
|
||||
test('removing chips and editing manually then approving persists across reload', async ({ page, request }) => {
|
||||
await openTag(page);
|
||||
await manual(page).fill('Minimal, sky_blue');
|
||||
await dialog(page).getByRole('spinbutton', { name: 'Suggestions', exact: true }).fill('3');
|
||||
await generated(page);
|
||||
await expect(chips(page)).toHaveCount(3);
|
||||
const remove = dialog(page).getByRole('button', { name: 'Remove desert', exact: true });
|
||||
await remove.click();
|
||||
await expect(chips(page)).toHaveCount(2);
|
||||
// Focus moves to the next chip's ×.
|
||||
await expect(dialog(page).getByRole('button', { name: 'Remove arch', exact: true })).toBeFocused();
|
||||
const saved = page.waitForResponse(item => item.url().endsWith('/image-annotations/tags') && item.request().method() === 'PUT');
|
||||
await dialog(page).getByRole('button', { name: 'Approve selected tags', exact: true }).click();
|
||||
expect((await saved).request().postDataJSON().tags).toEqual(['Minimal', 'sky_blue', 'sandstone', 'arch']);
|
||||
await expect(dialog(page)).toHaveCount(0);
|
||||
await expect(toast(page)).toHaveText('4 tags saved for dunes_00001.png.');
|
||||
await expect(tagButton(page, FIRST)).toBeFocused();
|
||||
expect(await savedTags(request, FIRST)).toEqual(['Minimal', 'sky_blue', 'sandstone', 'arch']);
|
||||
await page.reload();
|
||||
await openTag(page, FIRST, { goto: false });
|
||||
await expect(manual(page)).toHaveValue('Minimal, sky_blue, sandstone, arch');
|
||||
// Removing every chip shows the all-removed copy; the manual set still saves.
|
||||
await dialog(page).getByRole('spinbutton', { name: 'Suggestions', exact: true }).fill('2');
|
||||
await generated(page);
|
||||
for (const name of ['sandstone', 'desert']) await dialog(page).getByRole('button', { name: `Remove ${name}`, exact: true }).click();
|
||||
await expect(dialog(page).locator('#tag-suggestions > p.small')).toHaveText(ALL_REMOVED);
|
||||
await expect(generate(page)).toBeFocused();
|
||||
await dialog(page).getByRole('button', { name: 'Approve selected tags', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('4 tags saved for dunes_00001.png.');
|
||||
});
|
||||
|
||||
test('cancel escape and close write nothing', async ({ page, request }) => {
|
||||
const writes = [];
|
||||
page.on('request', item => { if (item.url().endsWith('/image-annotations/tags')) writes.push(item.method()); });
|
||||
await page.goto(images);
|
||||
for (const dismiss of [
|
||||
() => dialog(page).getByRole('button', { name: 'Cancel', exact: true }).click(),
|
||||
() => page.keyboard.press('Escape'),
|
||||
() => dialog(page).getByRole('button', { name: 'Close dialog', exact: true }).click(),
|
||||
]) {
|
||||
const released = page.waitForResponse(item => item.url().includes('/image-annotations/capture/') && item.request().method() === 'DELETE');
|
||||
await openTag(page, FIRST, { goto: false });
|
||||
await manual(page).fill('never_saved');
|
||||
await dismiss();
|
||||
await expect(dialog(page)).toHaveCount(0);
|
||||
expect((await released).ok()).toBe(true);
|
||||
await expect(tagButton(page, FIRST)).toBeFocused();
|
||||
await expect(page.locator('dialog#modal.tagging-modal')).toHaveCount(0);
|
||||
}
|
||||
expect(writes).toEqual([]);
|
||||
expect(await savedTags(request, FIRST)).toEqual([]);
|
||||
});
|
||||
|
||||
test('model change discards suggestions and relabels primary', async ({ page }) => {
|
||||
await openTag(page);
|
||||
await manual(page).fill('kept');
|
||||
await generated(page);
|
||||
await expect(dialog(page).locator('.dialog-actions .ca-primary')).toHaveText('Approve selected tags');
|
||||
await dialog(page).getByRole('spinbutton', { name: 'General confidence', exact: true }).fill('0.5');
|
||||
await model(page).selectOption('wd14-vit');
|
||||
await expect(chips(page)).toHaveCount(0);
|
||||
await expect(dialog(page).locator('#tag-suggestions > p.small')).toHaveText(MODEL_CHANGED);
|
||||
await expect(dialog(page).locator('#suggestion-count')).toHaveText('Not generated');
|
||||
await expect(status(page)).toHaveText('');
|
||||
await expect(dialog(page).locator('.dialog-actions .ca-primary')).toHaveText('Save manual tags');
|
||||
await expect(manual(page)).toHaveValue('kept');
|
||||
// Like the concept, the new model's fields start from the defaults.
|
||||
await expect(dialog(page).getByRole('spinbutton', { name: 'General confidence', exact: true })).toHaveValue('0.35');
|
||||
});
|
||||
|
||||
test('invalid threshold reports validity without request', async ({ page }) => {
|
||||
const sent = [];
|
||||
page.on('request', item => { if (item.url().endsWith('/image-annotations/suggest')) sent.push(item.url()); });
|
||||
await openTag(page);
|
||||
const general = dialog(page).getByRole('spinbutton', { name: 'General confidence', exact: true });
|
||||
await general.fill('1.5');
|
||||
await generate(page).click();
|
||||
await expect(general).toBeFocused();
|
||||
expect(await general.evaluate(node => node.validity.rangeOverflow)).toBe(true);
|
||||
await general.fill('');
|
||||
await generate(page).click();
|
||||
await expect.poll(() => general.evaluate(node => node.validationMessage)).toBe('Enter a confidence from 0 to 1.');
|
||||
await general.fill('0.35');
|
||||
expect(await general.evaluate(node => node.validity.valid)).toBe(true);
|
||||
await model(page).selectOption('gguf');
|
||||
const instructions = dialog(page).getByRole('textbox', { name: 'Tagging instructions', exact: true });
|
||||
await instructions.fill(' ');
|
||||
await generate(page).click();
|
||||
await expect.poll(() => instructions.evaluate(node => node.validationMessage)).toBe('Enter tagging instructions of at most 4000 characters.');
|
||||
await expect(status(page)).toHaveText('');
|
||||
expect(sent).toEqual([]);
|
||||
});
|
||||
|
||||
test('unavailable model shows inline status and never downloads', async ({ page, request }) => {
|
||||
await expect(await request.post('/__test__/tagging/models', { data: { unavailable: ['wd14-swinv2'] } })).toBeOK();
|
||||
await openTag(page);
|
||||
expect((await generated(page)).status()).toBe(409);
|
||||
await expect(status(page)).toHaveText('WD14 SwinV2 is not downloaded. Download it from the legacy Auto-tag page.');
|
||||
await expect(chips(page)).toHaveCount(0);
|
||||
await expect(dialog(page).locator('#suggestion-count')).toHaveText('Not generated');
|
||||
// D116: Retry leads the actions while the controller allows it, and generates again.
|
||||
await expect(dialog(page).locator('.dialog-actions button')).toHaveText(['Retry', 'Cancel', 'Save manual tags']);
|
||||
await expect(await request.post('/__test__/tagging/models', { data: { unavailable: [] } })).toBeOK();
|
||||
const again = page.waitForResponse(item => item.url().endsWith('/image-annotations/suggest'));
|
||||
await dialog(page).getByRole('button', { name: 'Retry', exact: true }).click();
|
||||
expect((await again).status()).toBe(200);
|
||||
await expect(chips(page)).toHaveCount(10);
|
||||
await expect(dialog(page).locator('.dialog-actions button')).toHaveText(['Cancel', 'Approve selected tags']);
|
||||
await expect(dialog(page).locator('.dialog-actions .ca-primary')).toBeFocused();
|
||||
});
|
||||
|
||||
test('video tag is manual only', async ({ page, request }) => {
|
||||
await openTag(page, VIDEO);
|
||||
const preview = dialog(page).locator('video.tag-image-preview');
|
||||
await expect(preview).toHaveAttribute('preload', 'metadata');
|
||||
expect(await preview.evaluate(node => node.muted)).toBe(true);
|
||||
await expect(dialog(page).locator('.tag-image-heading span.chip')).toHaveCount(0);
|
||||
await expect(generate(page)).toHaveAttribute('aria-disabled', 'true');
|
||||
await expect(status(page)).toHaveText('Auto-tagging is available for supported still images only.');
|
||||
let suggested = false;
|
||||
page.on('request', item => { if (item.url().endsWith('/image-annotations/suggest')) suggested = true; });
|
||||
// Pressing the aria-disabled Generate sends nothing.
|
||||
await generate(page).focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await manual(page).fill('clip_tag');
|
||||
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('1 tags saved for clip.webm.');
|
||||
expect(suggested).toBe(false);
|
||||
expect(await savedTags(request, VIDEO)).toEqual(['clip_tag']);
|
||||
});
|
||||
|
||||
test('sticky footer reachable at 390x844 while content scrolls', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openTag(page);
|
||||
await model(page).selectOption('gguf');
|
||||
await generated(page);
|
||||
await expect(chips(page)).toHaveCount(10);
|
||||
const shell = page.locator('dialog#modal');
|
||||
const actions = dialog(page).locator('.dialog-actions');
|
||||
expect(await actions.evaluate(node => getComputedStyle(node).position)).toBe('sticky');
|
||||
expect(await shell.evaluate(node => node.scrollHeight > node.clientHeight)).toBe(true);
|
||||
for (const top of [0, 10_000]) {
|
||||
await shell.evaluate((node, value) => { node.scrollTop = value; }, top);
|
||||
const box = await actions.boundingBox(), frame = await shell.boundingBox();
|
||||
expect(box.y + box.height).toBeLessThanOrEqual(frame.y + frame.height + 1);
|
||||
expect(box.y).toBeGreaterThanOrEqual(frame.y);
|
||||
await expect(dialog(page).getByRole('button', { name: 'Approve selected tags', exact: true })).toBeInViewport();
|
||||
}
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
|
||||
});
|
||||
|
||||
test('focus starts on close and returns to the Tag trigger', async ({ page }) => {
|
||||
await openTag(page, SECOND);
|
||||
await expect(dialog(page).getByRole('button', { name: 'Close dialog', exact: true })).toBeFocused();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(dialog(page)).toHaveCount(0);
|
||||
await expect(tagButton(page, SECOND)).toBeFocused();
|
||||
});
|
||||
|
||||
test('every Images and Library gallery card has Info then Tag with mock labels', async ({ page }) => {
|
||||
const expectCards = async locator => {
|
||||
const labels = await locator.evaluateAll(nodes => nodes.map(node => {
|
||||
const buttons = [...node.querySelectorAll('.media-info > .image-card-actions > button')];
|
||||
return { file: node.querySelector('.image-open').getAttribute('aria-label').replace(/^Open /, ''),
|
||||
buttons: buttons.map(button => [button.className, button.getAttribute('aria-label'), button.querySelector('svg.icon') !== null]) };
|
||||
}));
|
||||
expect(labels.length).toBeGreaterThan(0);
|
||||
for (const { file, buttons } of labels) {
|
||||
expect(buttons).toEqual([['icon-btn', `Inspect ${file}`, true], ['icon-btn', `Tag or auto-tag ${file}`, true]]);
|
||||
}
|
||||
return labels.map(({ file }) => file);
|
||||
};
|
||||
await page.goto(images);
|
||||
await expect(page.locator('article[data-image-key]')).toHaveCount(manifest.files.length);
|
||||
expect(await expectCards(page.locator('article[data-image-key]'))).toEqual(manifest.files.map(file => file.filename));
|
||||
await page.goto(`/prompt_manager/workspace#/library/${manifest.positive_id}`);
|
||||
const gallery = page.locator('.library-history .history-gallery article[data-image-key]');
|
||||
await expect(gallery.first()).toBeVisible();
|
||||
await expectCards(gallery);
|
||||
const observed = page.waitForResponse(item => item.url().endsWith('/image-annotations/observe'));
|
||||
await gallery.first().getByRole('button', { name: /^Tag or auto-tag / }).click();
|
||||
await expect(dialog(page)).toBeVisible();
|
||||
expect((await observed).status()).toBe(200);
|
||||
await dialog(page).getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(gallery.first().getByRole('button', { name: /^Tag or auto-tag / })).toBeFocused();
|
||||
});
|
||||
|
||||
test('Tag opens the dialog without selecting the card', async ({ page }) => {
|
||||
const panel = page.getByRole('complementary', { name: 'Selected image details', exact: true });
|
||||
await page.goto(images);
|
||||
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
|
||||
const before = await panel.innerText();
|
||||
await openTag(page, SECOND, { goto: false });
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(card(page, SECOND)).not.toHaveClass(/\bselected\b/);
|
||||
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
|
||||
expect(await panel.innerText()).toBe(before);
|
||||
});
|
||||
|
||||
test('tagging one of two cards sharing a prompt leaves the other card, the prompt and the pair unchanged', async ({ page, request }) => {
|
||||
const before = await (await request.get('/__test__/tagging/snapshot')).json();
|
||||
expect(await savedTags(request, SECOND)).toEqual([]);
|
||||
expect(await savedTags(request, COPY)).toEqual([]);
|
||||
const pairBefore = await (await request.get(`/prompt_manager/library/${manifest.positive_id}`)).json();
|
||||
await openTag(page, FIRST);
|
||||
await manual(page).fill('only_this_file');
|
||||
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('1 tags saved for dunes_00001.png.');
|
||||
expect(await savedTags(request, FIRST)).toEqual(['only_this_file']);
|
||||
expect(await savedTags(request, SECOND)).toEqual([]);
|
||||
expect(await savedTags(request, COPY)).toEqual([]);
|
||||
expect(await (await request.get('/__test__/tagging/snapshot')).json()).toEqual(before);
|
||||
const pairAfter = await (await request.get(`/prompt_manager/library/${manifest.positive_id}`)).json();
|
||||
expect(pairAfter.success).toBe(true);
|
||||
expect({ ...pairAfter, library_epoch: null }).toEqual({ ...pairBefore, library_epoch: null });
|
||||
});
|
||||
|
||||
test('Tags page Auto-tag images opens the dialog for the selected image and is disabled without one', async ({ page }) => {
|
||||
await page.goto('/prompt_manager/workspace#/tags');
|
||||
const autoTag = page.locator('#workspace-main .heading-actions').getByRole('button', { name: 'Auto-tag images', exact: true });
|
||||
await expect(page.locator('#workspace-main .heading-actions button')).toHaveText(['+ Create tag', 'Auto-tag images']);
|
||||
await expect(autoTag).toHaveClass(/ca-primary/);
|
||||
await expect(autoTag).toHaveAttribute('aria-disabled', 'true');
|
||||
// An aria-disabled control still takes a press; it only explains.
|
||||
await autoTag.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(toast(page)).toHaveText('Select an image in Images first.');
|
||||
await expect(dialog(page)).toHaveCount(0);
|
||||
await nav(page, 'Images').click();
|
||||
await card(page, SECOND).getByRole('button', { name: 'Inspect dunes_00002.png', exact: true }).click();
|
||||
await expect(card(page, SECOND)).toHaveClass(/selected/);
|
||||
await nav(page, 'Tags & categories').click();
|
||||
await expect(autoTag).not.toHaveAttribute('aria-disabled', 'true');
|
||||
const observed = page.waitForResponse(item => item.url().endsWith('/image-annotations/observe'));
|
||||
await autoTag.click();
|
||||
expect((await observed).request().postDataJSON().target.path).toBe(SECOND);
|
||||
await expect(dialog(page).locator('.tag-image-heading p.small')).toHaveText('dunes_00002.png');
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(autoTag).toBeFocused();
|
||||
});
|
||||
|
||||
test('Images column shows image_count and updates after save', async ({ page }) => {
|
||||
await page.goto(images);
|
||||
await card(page, FIRST).getByRole('button', { name: 'Inspect dunes_00001.png', exact: true }).click();
|
||||
await nav(page, 'Tags & categories').click();
|
||||
const row = name => page.locator('#workspace-main table tbody tr').filter({ has: page.locator('td:first-child').getByText(name, { exact: true }) });
|
||||
await expect(row('minimal').locator('td:nth-child(3)')).toHaveText('0');
|
||||
await expect(row('editorial').locator('td:nth-child(3)')).toHaveText('0');
|
||||
await page.locator('#workspace-main .heading-actions').getByRole('button', { name: 'Auto-tag images', exact: true }).click();
|
||||
await expect(manual(page)).toBeVisible();
|
||||
await expect(dialog(page).locator('img.tag-image-preview')).toHaveAttribute('src', /capture/);
|
||||
await manual(page).fill('minimal, editorial');
|
||||
const reread = page.waitForResponse(item => item.url().includes('/prompt_manager/vocabulary/tags'));
|
||||
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
|
||||
await reread;
|
||||
await expect(row('minimal').locator('td:nth-child(3)')).toHaveText('1');
|
||||
await expect(row('editorial').locator('td:nth-child(3)')).toHaveText('1');
|
||||
await expect(row('warm_light').locator('td:nth-child(3)')).toHaveText('0');
|
||||
});
|
||||
|
||||
test('virtualized cards release listeners on unmount', async ({ page, request }) => {
|
||||
const extra = 140;
|
||||
await expect(await request.post('/__test__/tagging/reset', { data: { extra_images: extra } })).toBeOK();
|
||||
const count = () => page.evaluate(async url => (await import(url)).imageTagsListenerCount(), MODULE);
|
||||
await page.goto(images);
|
||||
await expect(card(page, FIRST)).toBeVisible();
|
||||
const mounted = await count();
|
||||
// Leaving and returning to Images several times keeps one subscription per mounted page.
|
||||
for (let round = 0; round < 3; round += 1) {
|
||||
await nav(page, 'Tags & categories').click();
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Tags & categories');
|
||||
await nav(page, 'Images').click();
|
||||
await expect(card(page, FIRST)).toBeVisible();
|
||||
}
|
||||
expect(await count()).toBe(mounted);
|
||||
// Cards scrolled out are unmounted; the delegated Tag still works on a card rendered again.
|
||||
const continuation = page.locator('#gallery-continuation');
|
||||
await expect(async () => {
|
||||
await continuation.scrollIntoViewIfNeeded();
|
||||
await expect(continuation).toContainText('You’re all caught up', { timeout: 1000 });
|
||||
}).toPass();
|
||||
await continuation.scrollIntoViewIfNeeded();
|
||||
await expect(card(page, FIRST)).toHaveCount(0);
|
||||
expect(await count()).toBe(mounted);
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
await expect(card(page, FIRST)).toBeVisible();
|
||||
await openTag(page, FIRST, { goto: false });
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(tagButton(page, FIRST)).toBeFocused();
|
||||
expect(await count()).toBe(mounted);
|
||||
});
|
||||
|
||||
// Task C3 (D88): the side panel and the viewer details draw the file's own current image tags first, then D21's linked
|
||||
// tags not already shown (case-insensitive). Panel image tags are Tags filter buttons; the viewer's chips are plain.
|
||||
const panel = page => page.getByRole('complementary', { name: 'Selected image details', exact: true });
|
||||
const tagRow = page => panel(page).locator('.detail-inner > .tags').last();
|
||||
const inspected = (page, path) => page.waitForResponse(item => new URL(item.url()).pathname === `/prompt_manager/images/inspect/${path}`);
|
||||
const infoButton = (page, path) => card(page, path).getByRole('button', { name: `Inspect ${path.split('/').pop()}`, exact: true });
|
||||
|
||||
test('inspector shows saved image tags after Save without reselecting', async ({ page }) => {
|
||||
await page.goto(images);
|
||||
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
|
||||
// D21/D39: the linked recipe's own tag, plain.
|
||||
await expect(tagRow(page).locator('.chip')).toHaveText(manifest.pair_tags);
|
||||
await expect(tagRow(page).locator('button.chip')).toHaveCount(0);
|
||||
await openTag(page, FIRST, { goto: false });
|
||||
await manual(page).fill('dunes_tag, Editorial');
|
||||
const reread = inspected(page, FIRST);
|
||||
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
|
||||
expect((await (await reread).json()).image_tags).toEqual({ state: 'current', tags: ['dunes_tag', 'Editorial'] });
|
||||
// The image tags lead as filter buttons; the recipe's "editorial" is already shown as "Editorial".
|
||||
await expect(tagRow(page).locator('.chip')).toHaveText(['dunes_tag', 'Editorial']);
|
||||
await expect(tagRow(page).locator('button.chip')).toHaveText(['dunes_tag', 'Editorial']);
|
||||
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
|
||||
await expect(panel(page).locator('.panel-heading h2')).toHaveText(manifest.title);
|
||||
});
|
||||
|
||||
test('clicking an image tag chip filters Images to the tagged unlinked file', async ({ page, request }) => {
|
||||
expect(await saveImageTags(request, UNLINKED, ['loose_tag'])).toEqual(['loose_tag']);
|
||||
await page.goto(images);
|
||||
const read = inspected(page, UNLINKED);
|
||||
await infoButton(page, UNLINKED).click();
|
||||
await read;
|
||||
await expect(panel(page).locator('.panel-heading h2')).toHaveText('unlinked.png');
|
||||
await expect(tagRow(page).locator('.chip')).toHaveText(['loose_tag']);
|
||||
const listing = page.waitForRequest(item => item.url().includes('/images/output?') && new URL(item.url()).searchParams.get('tags') === 'loose_tag');
|
||||
await tagRow(page).getByRole('button', { name: 'loose_tag', exact: true }).click();
|
||||
await listing;
|
||||
await expect(page.locator('.gallery-body article[data-image-key]')).toHaveCount(1);
|
||||
await expect(card(page, UNLINKED)).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Filters · 1', exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('viewer details show the same chips', async ({ page, request }) => {
|
||||
await saveImageTags(request, FIRST, ['dunes_tag', 'Editorial', 'sunlit']);
|
||||
await page.goto(images);
|
||||
await expect(tagRow(page).locator('button.chip')).toHaveText(['dunes_tag', 'Editorial', 'sunlit']);
|
||||
await card(page, FIRST).locator('.image-open').click();
|
||||
const details = page.locator('#viewer-dialog[open] #viewer-details');
|
||||
await expect(details.locator('.panel-heading h2')).toHaveText(manifest.title);
|
||||
const row = details.locator('.tags').last();
|
||||
await expect(row.locator('.chip')).toHaveText(['dunes_tag', 'Editorial', 'sunlit']);
|
||||
await expect(row.locator('span.chip')).toHaveCount(3);
|
||||
await expect(row.locator('button')).toHaveCount(0);
|
||||
// A file without image tags: the viewer shows only D21's linked tags.
|
||||
await page.getByRole('button', { name: 'Next media', exact: true }).click();
|
||||
await expect(page.locator('#viewer-dialog[open] #viewer-title')).toHaveText(`${manifest.title} · dunes_00002.png`);
|
||||
await expect(details.locator('.tags').last().locator('.chip')).toHaveText(manifest.pair_tags);
|
||||
});
|
||||
|
||||
test('search finds an image by its image tag', async ({ page, request }) => {
|
||||
await saveImageTags(request, UNLINKED, ['Loose_Tag']);
|
||||
await page.goto(images);
|
||||
await expect(page.locator('.gallery-body article[data-image-key]')).toHaveCount(manifest.files.length);
|
||||
const listing = page.waitForRequest(item => item.url().includes('/images/output?') && new URL(item.url()).searchParams.get('q') === 'loose_t');
|
||||
await page.getByRole('searchbox', { name: 'Search workspace', exact: true }).fill('loose_t');
|
||||
await listing;
|
||||
await expect(page.locator('.gallery-body article[data-image-key]')).toHaveCount(1);
|
||||
await expect(card(page, UNLINKED)).toBeVisible();
|
||||
});
|
||||
|
||||
// Task C4 rulings: a save re-reads the selected file's details without a loading flash, and under a Tags filter or a
|
||||
// search the loaded listing is re-read in place (scroll and selection kept while the file still matches).
|
||||
const cards = page => page.locator('.gallery-body article[data-image-key]');
|
||||
const listingOf = (page, check) => page.waitForResponse(item => item.url().includes('/images/output?') && check(new URL(item.url()).searchParams));
|
||||
|
||||
test('the post-save re-read keeps the panel content until the new read lands', async ({ page }) => {
|
||||
await page.goto(images);
|
||||
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
|
||||
await expect(tagRow(page).locator('.chip')).toHaveText(manifest.pair_tags);
|
||||
await page.evaluate(() => {
|
||||
const node = document.querySelector('aside[aria-label="Selected image details"]');
|
||||
window.__loadingSeen = false;
|
||||
window.__loadingWatch = new MutationObserver(() => { if (node.textContent.includes('Loading file details')) window.__loadingSeen = true; });
|
||||
window.__loadingWatch.observe(node, { childList: true, subtree: true, characterData: true });
|
||||
});
|
||||
let release;
|
||||
const held = new Promise(resolve => { release = resolve; });
|
||||
const matcher = url => url.pathname === `/prompt_manager/images/inspect/${FIRST}`;
|
||||
await page.route(matcher, async route => { await held; await route.continue(); });
|
||||
await openTag(page, FIRST, { goto: false });
|
||||
await manual(page).fill('held_tag');
|
||||
const reread = page.waitForRequest(item => new URL(item.url()).pathname === `/prompt_manager/images/inspect/${FIRST}`);
|
||||
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
|
||||
await reread;
|
||||
await expect(toast(page)).toHaveText('1 tags saved for dunes_00001.png.');
|
||||
// While the re-read is held, the panel still shows the file's details.
|
||||
await expect(panel(page).locator('.panel-heading h2')).toHaveText(manifest.title);
|
||||
await expect(tagRow(page).locator('.chip')).toHaveText(manifest.pair_tags);
|
||||
expect(await page.evaluate(() => window.__loadingSeen)).toBe(false);
|
||||
const landed = inspected(page, FIRST);
|
||||
release();
|
||||
await landed;
|
||||
await expect(tagRow(page).locator('.chip')).toHaveText(['held_tag', ...manifest.pair_tags]);
|
||||
expect(await page.evaluate(() => { window.__loadingWatch.disconnect(); return window.__loadingSeen; })).toBe(false);
|
||||
await page.unroute(matcher);
|
||||
});
|
||||
|
||||
test('a save under a Tags filter re-reads the listing: the selection stays while it matches and follows when it leaves', async ({ page, request }) => {
|
||||
await saveImageTags(request, UNLINKED, ['shared']);
|
||||
await saveImageTags(request, SECOND, ['shared']);
|
||||
await page.goto(images);
|
||||
const read = inspected(page, SECOND);
|
||||
await infoButton(page, SECOND).click();
|
||||
await read;
|
||||
const filtered = listingOf(page, query => query.get('tags') === 'shared');
|
||||
await tagRow(page).getByRole('button', { name: 'shared', exact: true }).click();
|
||||
await filtered;
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
// The newest match (SECOND) is selected once the filter applies.
|
||||
await expect(card(page, SECOND)).toHaveClass(/\bselected\b/);
|
||||
// Both matches checked: the selection bar keeps only what the refreshed result still lists (I7).
|
||||
for (const path of [SECOND, UNLINKED]) await card(page, path).getByRole('checkbox', { name: `Select ${path.split('/').pop()}`, exact: true }).check();
|
||||
const bulkbar = page.locator('#bulkbar .bulkbar');
|
||||
await expect(bulkbar.locator('strong')).toHaveText('2 selected');
|
||||
// Tagging the other match keeps both cards and SECOND selected; the listing is re-read.
|
||||
await openTag(page, UNLINKED, { goto: false });
|
||||
await manual(page).fill('shared, extra');
|
||||
let reread = listingOf(page, query => query.get('tags') === 'shared' && !query.has('cursor'));
|
||||
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
|
||||
await reread;
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
await expect(card(page, SECOND)).toHaveClass(/\bselected\b/);
|
||||
await expect(tagButton(page, UNLINKED)).toBeFocused();
|
||||
// SECOND loses the filter tag: it leaves the result and the panel follows the remaining match.
|
||||
await openTag(page, SECOND, { goto: false });
|
||||
await manual(page).fill('');
|
||||
reread = listingOf(page, query => query.get('tags') === 'shared' && !query.has('cursor'));
|
||||
const next = inspected(page, UNLINKED);
|
||||
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
|
||||
await reread;
|
||||
await next;
|
||||
await expect(cards(page)).toHaveCount(1);
|
||||
await expect(card(page, SECOND)).toHaveCount(0);
|
||||
await expect(card(page, UNLINKED)).toHaveClass(/\bselected\b/);
|
||||
await expect(panel(page).locator('.panel-heading h2')).toHaveText('unlinked.png');
|
||||
await expect(page.getByText('1 of 1 image', { exact: false }).first()).toBeVisible();
|
||||
await expect(bulkbar.locator('strong')).toHaveText('1 selected');
|
||||
await expect(card(page, UNLINKED).getByRole('checkbox')).toBeChecked();
|
||||
// Export metadata acts on exactly the one file the bar counts.
|
||||
const exported = [];
|
||||
page.on('request', item => { if (item.url().includes('/prompt_manager/images/inspect/')) exported.push(new URL(item.url()).pathname); });
|
||||
const download = page.waitForEvent('download');
|
||||
await bulkbar.getByRole('button', { name: 'Export metadata', exact: true }).click();
|
||||
await download;
|
||||
expect(exported).toEqual([`/prompt_manager/images/inspect/${UNLINKED}`]);
|
||||
});
|
||||
|
||||
test('a save under a search keeps the scroll position and the loaded range', async ({ page, request }) => {
|
||||
await expect(await request.post('/__test__/tagging/reset', { data: { extra_images: 140 } })).toBeOK();
|
||||
await page.goto(images);
|
||||
const searched = listingOf(page, query => query.get('q') === 'bulk');
|
||||
await page.getByRole('searchbox', { name: 'Search workspace', exact: true }).fill('bulk');
|
||||
await searched;
|
||||
const continuation = page.locator('#gallery-continuation');
|
||||
// Load at least two pages (the search lists 40 a page), then rest a third of the way down.
|
||||
const loadedCount = async () => {
|
||||
const text = await continuation.textContent();
|
||||
return /caught up/.test(text) ? 140 : Number(/^(\d+) of 140/.exec(text)?.[1] ?? 0);
|
||||
};
|
||||
await expect.poll(async () => { await continuation.scrollIntoViewIfNeeded(); return loadedCount(); }).toBeGreaterThanOrEqual(80);
|
||||
await page.evaluate(() => window.scrollTo(0, Math.round(document.documentElement.scrollHeight / 3)));
|
||||
await expect(continuation).not.toBeInViewport();
|
||||
const loaded = await continuation.textContent();
|
||||
await expect.poll(() => continuation.textContent()).toBe(loaded);
|
||||
const y = await page.evaluate(() => window.scrollY);
|
||||
expect(y).toBeGreaterThan(0);
|
||||
const visible = await cards(page).evaluateAll(nodes => nodes.find(node => { const box = node.getBoundingClientRect(); return box.top > 80 && box.bottom < innerHeight; })?.dataset.imageKey);
|
||||
expect(visible).toBeTruthy();
|
||||
const path = visible.slice(2);
|
||||
await openTag(page, path, { goto: false });
|
||||
await manual(page).fill('kept_in_place');
|
||||
const reread = listingOf(page, query => query.get('q') === 'bulk' && !query.has('cursor'));
|
||||
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
|
||||
await reread;
|
||||
await expect(tagButton(page, path)).toBeFocused();
|
||||
await expect(continuation).toHaveText(loaded);
|
||||
expect(Math.abs(await page.evaluate(() => window.scrollY) - y)).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
// Final review round (I1, I2, I9, M5, M6).
|
||||
test('the option fields show the server defaults that Generate sends (I1)', async ({ page, request }) => {
|
||||
await expect(await request.post('/__test__/tagging/defaults', { data: { general: 0.5, character: 0.9, limit: 7 } })).toBeOK();
|
||||
await openTag(page);
|
||||
const field = name => dialog(page).getByRole('spinbutton', { name, exact: true });
|
||||
await expect(field('General confidence')).toHaveValue('0.5');
|
||||
await expect(field('Character confidence')).toHaveValue('0.9');
|
||||
await expect(field('Suggestions')).toHaveValue('7');
|
||||
const answer = await generated(page);
|
||||
expect(answer.request().postDataJSON().options).toEqual({ general_threshold: 0.5, character_threshold: 0.9, suggestion_limit: 7 });
|
||||
await expect(chips(page)).toHaveCount(7);
|
||||
// A model change re-renders the new model's fields from the server defaults, and keeps the count (D91).
|
||||
await field('Suggestions').fill('4');
|
||||
await model(page).selectOption('wd14-vit');
|
||||
await expect(field('General confidence')).toHaveValue('0.5');
|
||||
await expect(field('Suggestions')).toHaveValue('4');
|
||||
});
|
||||
|
||||
test('a tag conflict shows the other writer\'s tags before Save, and a second Save keeps them (I2)', async ({ page, request }) => {
|
||||
await openTag(page);
|
||||
await manual(page).fill('mine');
|
||||
// Another writer (a second tab, the legacy page) saves while the dialog is open.
|
||||
expect(await saveImageTags(request, FIRST, ['changed_elsewhere'])).toEqual(['changed_elsewhere']);
|
||||
const conflict = page.waitForResponse(item => item.url().endsWith('/image-annotations/tags') && item.request().method() === 'PUT');
|
||||
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
|
||||
expect((await conflict).status()).toBe(409);
|
||||
await expect(status(page)).toContainText('Saved now: changed_elsewhere.');
|
||||
await expect(manual(page)).toHaveValue('changed_elsewhere, mine');
|
||||
await expect(dialog(page)).toBeVisible();
|
||||
const second = page.waitForResponse(item => item.url().endsWith('/image-annotations/tags') && item.request().method() === 'PUT');
|
||||
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
|
||||
expect((await second).request().postDataJSON().tags).toEqual(['changed_elsewhere', 'mine']);
|
||||
await expect(toast(page)).toHaveText('2 tags saved for dunes_00001.png.');
|
||||
expect(await savedTags(request, FIRST)).toEqual(['changed_elsewhere', 'mine']);
|
||||
});
|
||||
|
||||
test('D116: Retry leads the actions after an unconfirmed save and resends the same tags', async ({ page, request }) => {
|
||||
await openTag(page);
|
||||
await manual(page).fill('retried');
|
||||
let lost = true;
|
||||
await page.route('**/prompt_manager/image-annotations/tags', route => (lost ? (lost = false, route.fulfill({ status: 504, body: 'Gateway Timeout' })) : route.continue()));
|
||||
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
|
||||
await expect(status(page)).toHaveText('The save could not be confirmed. Retry sends the same tags again.');
|
||||
const inventory = await page.locator('dialog#modal').evaluate(taggingInventory);
|
||||
expect(inventory.actions.buttons.map(button => [button.text, button.primary, button.disabled])).toEqual(
|
||||
[['Retry', false, false], ['Cancel', false, false], ['Save manual tags', true, true]]);
|
||||
expect(inventory.actions.buttons[0].minHeight).toBe(inventory.actions.buttons[1].minHeight);
|
||||
const resent = page.waitForResponse(item => item.url().endsWith('/image-annotations/tags') && item.request().method() === 'PUT');
|
||||
await dialog(page).getByRole('button', { name: 'Retry', exact: true }).click();
|
||||
expect((await resent).request().postDataJSON().tags).toEqual(['retried']);
|
||||
await expect(toast(page)).toHaveText('1 tags saved for dunes_00001.png.');
|
||||
expect(await savedTags(request, FIRST)).toEqual(['retried']);
|
||||
await page.unroute('**/prompt_manager/image-annotations/tags');
|
||||
});
|
||||
|
||||
test('D116: Retry after an expired capture observes again and keeps the typed draft (M5)', async ({ page, request }) => {
|
||||
await openTag(page);
|
||||
await manual(page).fill('kept_draft');
|
||||
// The store forgets every capture (as its idle sweep would), so Save answers capture-expired.
|
||||
await expect(await request.post('/__test__/tagging/suggestions', { data: { scored: null } })).toBeOK();
|
||||
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
|
||||
await expect(dialog(page).locator('.dialog-actions .ca-primary')).toHaveAttribute('aria-disabled', 'true');
|
||||
const observed = page.waitForResponse(item => item.url().endsWith('/image-annotations/observe'));
|
||||
await dialog(page).getByRole('button', { name: 'Retry', exact: true }).click();
|
||||
expect((await observed).status()).toBe(200);
|
||||
await expect(dialog(page).locator('.dialog-actions button')).toHaveText(['Cancel', 'Save manual tags']);
|
||||
await expect(manual(page)).toHaveValue('kept_draft');
|
||||
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('1 tags saved for dunes_00001.png.');
|
||||
expect(await savedTags(request, FIRST)).toEqual(['kept_draft']);
|
||||
});
|
||||
|
||||
test('a tag save from Tags reloads a filtered Images visit instead of restoring it (M6)', async ({ page, request }) => {
|
||||
await saveImageTags(request, SECOND, ['shared']);
|
||||
await saveImageTags(request, UNLINKED, ['shared']);
|
||||
await page.goto(images);
|
||||
await infoButton(page, SECOND).click();
|
||||
const filtered = listingOf(page, query => query.get('tags') === 'shared');
|
||||
await tagRow(page).getByRole('button', { name: 'shared', exact: true }).click();
|
||||
await filtered;
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
await expect(card(page, SECOND)).toHaveClass(/\bselected\b/);
|
||||
// Tags › Auto-tag images tags the selected SECOND without the filter tag.
|
||||
await nav(page, 'Tags & categories').click();
|
||||
await page.locator('#workspace-main .heading-actions').getByRole('button', { name: 'Auto-tag images', exact: true }).click();
|
||||
await expect(manual(page)).toHaveValue('shared');
|
||||
await manual(page).fill('other');
|
||||
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('1 tags saved for dunes_00002.png.');
|
||||
const reloaded = listingOf(page, query => query.get('tags') === 'shared' && !query.has('cursor'));
|
||||
await nav(page, 'Images').click();
|
||||
await reloaded;
|
||||
await expect(cards(page)).toHaveCount(1);
|
||||
await expect(card(page, UNLINKED)).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Filters · 1', exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('a failed listing re-read after a save says so and keeps the cards (M2)', async ({ page, request }) => {
|
||||
await saveImageTags(request, SECOND, ['shared']);
|
||||
await saveImageTags(request, UNLINKED, ['shared']);
|
||||
await page.goto(images);
|
||||
await infoButton(page, SECOND).click();
|
||||
const filtered = listingOf(page, query => query.get('tags') === 'shared');
|
||||
await tagRow(page).getByRole('button', { name: 'shared', exact: true }).click();
|
||||
await filtered;
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
await openTag(page, SECOND, { goto: false });
|
||||
await manual(page).fill('');
|
||||
const failing = url => url.pathname === '/prompt_manager/images/output' && url.searchParams.get('tags') === 'shared';
|
||||
await page.route(failing, route => route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"down"}' }));
|
||||
const reread = page.waitForResponse(item => failing(new URL(item.url())));
|
||||
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
|
||||
expect((await reread).status()).toBe(500);
|
||||
await expect(toast(page)).toHaveText('Images could not be refreshed after the tag save. Refresh image collection to retry.');
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
await expect(page.locator('#gallery-continuation [aria-disabled="true"]')).toHaveCount(0);
|
||||
await page.unroute(failing);
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { readFile } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { captureCanonicalState, historyGeometry, compareCanonicalHistory, nativeDialogState, exerciseCurrentPairSend, assertRecordedHistory } from './history-canonical-comparison.mjs';
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
|
||||
test.use({ locale: 'en-US', timezoneId: 'UTC', reducedMotion: 'reduce' });
|
||||
const workspace = '/prompt_manager/workspace';
|
||||
@@ -107,7 +107,7 @@ for (const [width, height] of [[1600, 1100], [390, 844]]) for (const theme of ['
|
||||
await expect(roles.getByRole('button', { name: 'Send prompt pair', exact: true })).toHaveCount(0);
|
||||
await expect(page.locator('#workspace-main > .heading button.send')).toBeEnabled();
|
||||
await expect(page.locator('.library-history .history-gallery article[data-image-key]')).toHaveCount(2);
|
||||
await page.locator('.library-history .history-gallery .image-card-actions button').first().click();
|
||||
await page.locator('.library-history .history-gallery .image-card-actions button:first-child').first().click();
|
||||
await expect(page.getByRole('complementary', { name: 'Selected image details', exact: true })).toContainText('canonical-alt.png');
|
||||
await page.locator('.library-history .history-gallery .media-card.selected .image-open').click();
|
||||
await expect(page.locator('dialog#viewer-dialog')).toBeVisible();await page.keyboard.press('Escape');
|
||||
@@ -296,7 +296,7 @@ async function containedImageContent(page) {
|
||||
const failures = [];
|
||||
for (const card of nodes) {
|
||||
const outer = card.getBoundingClientRect();
|
||||
for (const selector of ['.media-info', '.media-info h3', '.media-info p', '.image-card-actions button']) {
|
||||
for (const selector of ['.media-info', '.media-info h3', '.media-info p', '.image-card-actions']) {
|
||||
const node = card.querySelector(selector), box = node.getBoundingClientRect();
|
||||
if (box.width <= 0 || box.height <= 0 || box.left < outer.left - 1 || box.right > outer.right + 1 ||
|
||||
box.top < outer.top - 1 || box.bottom > outer.bottom + 1 ||
|
||||
@@ -306,7 +306,7 @@ async function containedImageContent(page) {
|
||||
content: { left: box.left, right: box.right, top: box.top, bottom: box.bottom } });
|
||||
}
|
||||
}
|
||||
const action = card.querySelector('.image-card-actions button').getBoundingClientRect();
|
||||
const action = card.querySelector('.image-card-actions').getBoundingClientRect();
|
||||
const range = document.createRange(); range.selectNodeContents(card.querySelector('.media-info p'));
|
||||
for (const text of range.getClientRects()) {
|
||||
if (Math.min(text.right, action.right) - Math.max(text.left, action.left) > .5 &&
|
||||
@@ -351,7 +351,7 @@ for (const [width, height] of [[390, 844], [1600, 1100]]) for (const layout of [
|
||||
const filename = (await longCard.locator('.image-open').getAttribute('aria-label')).replace(/^Open /, '');
|
||||
expect(filename).toMatch(/layered-context-.*\.png/);
|
||||
// D19: Info selects into the panel; the image itself opens the viewer.
|
||||
await longCard.locator('.image-card-actions button').focus(); await page.keyboard.press('Enter');
|
||||
await longCard.locator('.image-card-actions button:first-child').focus(); await page.keyboard.press('Enter');
|
||||
await expect(page.locator('.detail-panel .panel-heading h2')).toHaveText(filename);
|
||||
// D19: the card image, not the panel, opens the viewer.
|
||||
const fullPreview = longCard.locator('.image-open');
|
||||
@@ -420,7 +420,7 @@ test('supplemental extreme zoom grows real footers preserves the visible anchor
|
||||
await page.evaluate(() => { document.documentElement.style.zoom = '2'; });
|
||||
await expect.poll(() => cards.nth(1).evaluate(node => node.offsetLeft)).toBe(0);
|
||||
const selected = cards.nth(16), key = await selected.getAttribute('data-image-key');
|
||||
await selected.locator('.image-card-actions button').click(); await selected.locator('.image-open').focus();
|
||||
await selected.locator('.image-card-actions button:first-child').click(); await selected.locator('.image-open').focus();
|
||||
const filename = (await selected.locator('.image-open').getAttribute('aria-label')).replace(/^Open /, '');
|
||||
await selected.evaluate(node => scrollBy(0, node.getBoundingClientRect().top - 120));
|
||||
await page.evaluate(() => {
|
||||
@@ -497,7 +497,7 @@ test('supplemental wrapped real footer reservation survives append without movin
|
||||
await page.setViewportSize({ width: 390, height: 844 }); await page.goto(`${workspace}#/images`);
|
||||
const cards = page.locator('article[data-image-key]'), grid = page.locator('.image-grid');
|
||||
await expect(cards).toHaveCount(40);
|
||||
await cards.first().locator('.image-card-actions button').click();
|
||||
await cards.first().locator('.image-card-actions button:first-child').click();
|
||||
const selectedKey = await cards.first().getAttribute('data-image-key');
|
||||
await page.evaluate(() => { document.documentElement.style.zoom = '4'; });
|
||||
await expect.poll(() => grid.evaluate(node => Number.parseFloat(node.style.getPropertyValue('--image-footer-height')))).toBeGreaterThan(54);
|
||||
@@ -609,7 +609,7 @@ test('Images visual layouts retain real delayed media long filenames and loaded
|
||||
await page.setViewportSize({width,height});
|
||||
for (const theme of ['dark','light']) {
|
||||
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
await page.locator('.media-card .image-card-actions button').first().click();
|
||||
await page.locator('.media-card .image-card-actions button:first-child').first().click();
|
||||
await expect(page.locator('.detail-panel .panel-heading h2')).toHaveText('study-01.png');
|
||||
for (const layout of ['Grid','List','Masonry']) {
|
||||
await page.getByRole('button',{name:layout,exact:true}).click();await page.evaluate(()=>scrollTo(0,0));
|
||||
@@ -621,7 +621,7 @@ test('Images visual layouts retain real delayed media long filenames and loaded
|
||||
const longCard=page.locator('article[data-image-key]').filter({has:page.locator('[aria-label^="Open a-very-long-geometric-composition-"]')});
|
||||
const longName=(await longCard.locator('.image-open').getAttribute('aria-label')).replace(/^Open /,'');
|
||||
expect(longName).toMatch(/layered-context-.*\.png/);
|
||||
await longCard.locator('.image-card-actions button').click();
|
||||
await longCard.locator('.image-card-actions button:first-child').click();
|
||||
await expect(page.locator('.detail-panel .panel-heading h2')).toHaveText(longName);
|
||||
await orderedMedia(page);
|
||||
await attachEvidence(page,testInfo,`after-images-loaded-scroll-${width}-${theme}`,false);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
|
||||
import {writeFile} from 'node:fs/promises';
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||||
import {pageInventoryOf,inventoryOf,diffInventory,mapVocabularyConcept,mapVocabularyDialog} from './vocabulary-canonical-comparison.mjs';
|
||||
|
||||
@@ -50,9 +50,12 @@ test('tags route renders the concept composition without a legacy handoff',async
|
||||
await expect(main(page).getByRole('heading',{level:1})).toHaveText('Tags & categories');
|
||||
await expect(main(page).locator(':scope > .heading p.muted')).toHaveText('Organize once. Reuse everywhere.');
|
||||
const actions=main(page).locator(':scope > .heading > .heading-actions button');
|
||||
await expect(actions).toHaveText(['+ Create tag']);
|
||||
// The concept's heading actions (#249 C2 retired #245 D2): Auto-tag images is primary; with no image selected in
|
||||
// Images it is aria-disabled (D115).
|
||||
await expect(actions).toHaveText(['+ Create tag','Auto-tag images']);
|
||||
await expect(actions.first()).not.toHaveClass(/ca-primary/);
|
||||
await expect(main(page).getByRole('button',{name:'Auto-tag images'})).toHaveCount(0);
|
||||
await expect(actions.last()).toHaveClass(/ca-primary/);
|
||||
await expect(actions.last()).toHaveAttribute('aria-disabled','true');
|
||||
await expect(surfaces(page)).toHaveCount(2);
|
||||
await expect(surfaces(page).locator('h2')).toHaveText(['Tag vocabulary','Categories']);
|
||||
await expect(surfaces(page).locator(':scope > p:visible')).toHaveText(['Category/tag autocomplete uses this same vocabulary across nodes and dialogs.',
|
||||
@@ -65,9 +68,9 @@ test('tags route renders the concept composition without a legacy handoff',async
|
||||
expect(manifest.tags).toEqual(expected.map(([name])=>name));
|
||||
await expect(rows(page).locator('td:nth-child(1)')).toHaveText(expected.map(([name])=>name));
|
||||
await expect(rows(page).locator('td:nth-child(2)')).toHaveText(expected.map(([,count])=>String(count)));
|
||||
const images=await rows(page).locator('td:nth-child(3)').evaluateAll(cells=>cells.map(cell=>[cell.querySelector('[aria-hidden="true"]')?.textContent,cell.querySelector('.ca-visually-hidden')?.textContent]));
|
||||
expect(images).toEqual(expected.map(()=>['—','Not available yet']));
|
||||
await expect(rows(page).first().locator('td:nth-child(3)')).toHaveAccessibleName('Not available yet');
|
||||
// Images counts the files carrying each tag (#249 C2 retired #245 D3); this fixture tags no image.
|
||||
await expect(rows(page).locator('td:nth-child(3)')).toHaveText(expected.map(()=>'0'));
|
||||
await expect(rows(page).locator('td:nth-child(3) .ca-visually-hidden')).toHaveCount(0);
|
||||
const labels=await rows(page).evaluateAll(nodes=>nodes.map(row=>[...row.querySelectorAll('td:nth-child(4) .row-actions button')].map(button=>button.textContent)));
|
||||
expect(labels).toEqual(expected.map(()=>['Browse','Rename / merge']));
|
||||
const operations=surfaces(page).nth(1).locator('.operation');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
|
||||
const workspace = '/prompt_manager/workspace';
|
||||
const statistic = (page, label) => page.locator('#workspace-main dt')
|
||||
|
||||
@@ -158,6 +158,9 @@ MAX_EXTRA_IMAGES = 120
|
||||
# Unlinked, seedless images (D11 "W × H · PNG") newer than the concept's, for D3 checks.
|
||||
MAX_SEEDLESS_IMAGES = 2
|
||||
SEEDLESS_SIZE = (1536, 1152)
|
||||
# The same 4:3 shape with a short "96 × 72 · PNG" subtitle that stays on one line at
|
||||
# 390px beside the card's Info and Tag (the D3 footer check).
|
||||
SHORT_SEEDLESS_SIZE = (96, 72)
|
||||
SESSION = "5c1f9a7e-3f0b-4a55-9d0e-2b8c4f6a7d10"
|
||||
_TABLES = (
|
||||
"execution_run_outputs",
|
||||
@@ -170,6 +173,9 @@ _TABLES = (
|
||||
"execution_context_texts",
|
||||
"saved_views",
|
||||
"image_favorites",
|
||||
"media_image_tags",
|
||||
"media_annotation_bytes",
|
||||
"media_annotation_files",
|
||||
"image_prompt_pairs",
|
||||
"prompt_pair_tags",
|
||||
"prompt_pairs",
|
||||
@@ -323,9 +329,9 @@ def _record_run(
|
||||
return start.run_id
|
||||
|
||||
|
||||
def _write_seedless_png(path: Path) -> None:
|
||||
def _write_seedless_png(path: Path, size: tuple[int, int] = SEEDLESS_SIZE) -> None:
|
||||
"""A plain PNG with no metadata: no prompt, no seed, no link."""
|
||||
with Image.new("RGB", SEEDLESS_SIZE, "#2b3442") as image:
|
||||
with Image.new("RGB", size, "#2b3442") as image:
|
||||
image.save(path)
|
||||
|
||||
|
||||
@@ -342,13 +348,17 @@ def seed_workspace_concept(
|
||||
*,
|
||||
extra_images: int = 0,
|
||||
seedless_images: int = 0,
|
||||
short_seedless: bool = False,
|
||||
) -> dict:
|
||||
"""Replace this server's disposable rows and files with the concept's data.
|
||||
|
||||
``extra_images`` continues the concept's rounds (same prompts, dimensions and
|
||||
seed rule) after its 24 images, for bounded-rendering and Load more checks.
|
||||
``seedless_images`` adds unlinked PNGs without metadata, newest of all.
|
||||
``seedless_images`` adds unlinked PNGs without metadata, newest of all;
|
||||
``short_seedless`` makes them ``SHORT_SEEDLESS_SIZE`` instead.
|
||||
"""
|
||||
if not isinstance(short_seedless, bool):
|
||||
raise TypeError("short_seedless must be a boolean")
|
||||
_bounded("extra_images", extra_images, MAX_EXTRA_IMAGES)
|
||||
_bounded("seedless_images", seedless_images, MAX_SEEDLESS_IMAGES)
|
||||
_clear(api, root)
|
||||
@@ -456,7 +466,9 @@ def seed_workspace_concept(
|
||||
for number in range(seedless_images):
|
||||
name = f"seedless_{number + 1:05}.png"
|
||||
path = folder / name
|
||||
_write_seedless_png(path)
|
||||
_write_seedless_png(
|
||||
path, SHORT_SEEDLESS_SIZE if short_seedless else SEEDLESS_SIZE
|
||||
)
|
||||
stamp = now - 10 - number * 10
|
||||
os.utime(path, (stamp, stamp))
|
||||
seedless.append({"filename": name, "path": f"concepts/{name}"})
|
||||
|
||||
@@ -28,6 +28,9 @@ EDITOR_METADATA = {
|
||||
_TABLES = (
|
||||
"saved_views",
|
||||
"image_favorites",
|
||||
"media_image_tags",
|
||||
"media_annotation_bytes",
|
||||
"media_annotation_files",
|
||||
"image_prompt_pairs",
|
||||
"prompt_pair_tags",
|
||||
"prompt_pairs",
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
"""Owned, deterministic files for the Tag image dialog workflows (#249 PR C).
|
||||
|
||||
Two files share one positive prompt and one saved pair, an equal-byte copy of
|
||||
the first sits at another path, and a video and an unlinked file follow. The
|
||||
injected fake model (``tests/browser/server.py`` ``TestModel``) answers
|
||||
Generate; no model file exists and nothing is ever downloaded.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from aiohttp import web
|
||||
from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from py.api import PromptManagerAPI
|
||||
|
||||
POSITIVE = (
|
||||
"Monolithic sandstone pavilion in a vast desert, sweeping dunes, warm "
|
||||
"terracotta, late afternoon sun, architectural photography, quiet geometry"
|
||||
)
|
||||
NEGATIVE = "plastic, harsh shadows, noise, watermark"
|
||||
TITLE = "Quiet geometry"
|
||||
PROMPT_TAGS = ["minimal", "warm_light"]
|
||||
PAIR_TAGS = ["editorial"]
|
||||
# Newest first, so the Images order (Recently added) is the manifest's.
|
||||
FILES = (
|
||||
("tagging/dunes_00001.png", "linked"),
|
||||
("tagging/dunes_00002.png", "linked"),
|
||||
("copies/dunes_00001.png", "copy"),
|
||||
("tagging/clip.webm", "video"),
|
||||
("loose/unlinked.png", "unlinked"),
|
||||
)
|
||||
MODELS = ("wd14-swinv2", "wd14-vit", "gguf", "hf")
|
||||
# Unlinked copies enough to virtualize the Images grid (it renders all up to 120).
|
||||
MAX_EXTRA_IMAGES = 160
|
||||
_TABLES = (
|
||||
"saved_views",
|
||||
"image_favorites",
|
||||
"media_image_tags",
|
||||
"media_annotation_bytes",
|
||||
"media_annotation_files",
|
||||
"image_prompt_pairs",
|
||||
"prompt_pair_tags",
|
||||
"prompt_pairs",
|
||||
"generated_images",
|
||||
"prompt_tags",
|
||||
"tags",
|
||||
"prompt_library_state",
|
||||
"prompts",
|
||||
)
|
||||
# Every prompt-side owner row a Tag save could touch, for before/after
|
||||
# comparisons: tagging one image never changes a prompt, a pair or a link.
|
||||
_SNAPSHOT = {
|
||||
"prompts": "SELECT id, text, category, tags, updated_at FROM prompts ORDER BY id",
|
||||
"prompt_tags": "SELECT prompt_id, tag_id FROM prompt_tags ORDER BY 1, 2",
|
||||
"prompt_pairs": "SELECT id, positive_prompt_id, negative_prompt_id, category,"
|
||||
" archived, updated_at FROM prompt_pairs ORDER BY id",
|
||||
"prompt_pair_tags": "SELECT pair_id, tag_id FROM prompt_pair_tags ORDER BY 1, 2",
|
||||
"generated_images": "SELECT id, prompt_id, image_path FROM generated_images"
|
||||
" ORDER BY id",
|
||||
"image_prompt_pairs": "SELECT id, image_id, pair_id FROM image_prompt_pairs"
|
||||
" ORDER BY id",
|
||||
}
|
||||
|
||||
|
||||
def _png(path: Path, colour: tuple[int, int, int], size=(96, 64)) -> None:
|
||||
graph = {
|
||||
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": POSITIVE}},
|
||||
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": NEGATIVE}},
|
||||
"3": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {"positive": ["1", 0], "negative": ["2", 0], "seed": 7},
|
||||
},
|
||||
"4": {"class_type": "SaveImage", "inputs": {"images": ["3", 0]}},
|
||||
}
|
||||
info = PngInfo()
|
||||
info.add_text("prompt", json.dumps(graph))
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with Image.new("RGB", size, colour) as image:
|
||||
image.save(path, pnginfo=info)
|
||||
|
||||
|
||||
def seed_tagging(api: PromptManagerAPI, root: Path, extra_images: int = 0) -> dict:
|
||||
"""Replace this server's disposable rows and files with the tagging set."""
|
||||
db = api.db
|
||||
with db.model.get_connection() as conn:
|
||||
for table in _TABLES:
|
||||
conn.execute(f"DELETE FROM {table}")
|
||||
for name in ("output", "input"):
|
||||
shutil.rmtree(root / name)
|
||||
(root / name).mkdir()
|
||||
output = root / "output"
|
||||
_png(output / FILES[0][0], (196, 140, 92))
|
||||
_png(output / FILES[1][0], (150, 110, 80))
|
||||
(output / FILES[2][0]).parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(output / FILES[0][0], output / FILES[2][0])
|
||||
shutil.copyfile(
|
||||
Path(__file__).resolve().parents[1] / "fixtures" / "tiny.webm",
|
||||
output / FILES[3][0],
|
||||
)
|
||||
_png(output / FILES[4][0], (60, 70, 90), size=(64, 64))
|
||||
now = time.time()
|
||||
for index, (relative, _) in enumerate(FILES):
|
||||
stamp = now - 60 - index * 10
|
||||
os.utime(output / relative, (stamp, stamp))
|
||||
for number in range(extra_images):
|
||||
extra = output / "bulk" / f"bulk_{number:04}.png"
|
||||
extra.parent.mkdir(exist_ok=True)
|
||||
shutil.copyfile(output / FILES[4][0], extra)
|
||||
stamp = now - 600 - number
|
||||
os.utime(extra, (stamp, stamp))
|
||||
|
||||
receipt = db.create_library_recipe(
|
||||
POSITIVE,
|
||||
NEGATIVE,
|
||||
expected_epoch=db.get_execution_epoch(),
|
||||
parent_metadata={
|
||||
"title": TITLE,
|
||||
"category": "Architecture",
|
||||
"tags": list(PROMPT_TAGS),
|
||||
},
|
||||
)
|
||||
positive_id = receipt["positive"]["id"]
|
||||
pair_id = receipt["pair"]["id"]
|
||||
with db.model.get_connection() as conn:
|
||||
tag_ids = []
|
||||
for name in PAIR_TAGS:
|
||||
conn.execute("INSERT OR IGNORE INTO tags (name) VALUES (?)", (name,))
|
||||
tag_ids.append(
|
||||
conn.execute("SELECT id FROM tags WHERE name=?", (name,)).fetchone()[0]
|
||||
)
|
||||
conn.executemany(
|
||||
"INSERT INTO prompt_pair_tags (pair_id, tag_id) VALUES (?, ?)",
|
||||
[(pair_id, tag_id) for tag_id in tag_ids],
|
||||
)
|
||||
for relative, kind in FILES:
|
||||
if kind in {"linked", "video"}:
|
||||
image_id = db.link_image_to_prompt(positive_id, str(output / relative))
|
||||
if kind == "linked":
|
||||
db.link_image_to_pair(pair_id, image_id, {"source": "fixture"})
|
||||
api._generate_gallery_thumbnails_sync(api._get_all_output_dirs(), (150, 150))
|
||||
api.invalidate_gallery_cache()
|
||||
return {
|
||||
"positive_id": positive_id,
|
||||
"pair_id": pair_id,
|
||||
"title": TITLE,
|
||||
"prompt_tags": list(PROMPT_TAGS),
|
||||
"pair_tags": list(PAIR_TAGS),
|
||||
"files": [
|
||||
{"path": relative, "filename": Path(relative).name, "kind": kind}
|
||||
for relative, kind in FILES
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def tagging_snapshot(api: PromptManagerAPI) -> dict:
|
||||
"""Plain rows of every prompt, pair and link table."""
|
||||
with api.db.model.get_connection() as conn:
|
||||
return {
|
||||
name: [list(row) for row in conn.execute(query)]
|
||||
for name, query in _SNAPSHOT.items()
|
||||
}
|
||||
|
||||
|
||||
def register_tagging_commands(
|
||||
routes: web.RouteTableDef, api: PromptManagerAPI, root: Path, model
|
||||
) -> None:
|
||||
"""Register fixed seed, snapshot and fake-model commands; no paths or SQL input."""
|
||||
|
||||
@routes.post("/__test__/tagging/reset")
|
||||
async def reset(request: web.Request) -> web.Response:
|
||||
data = await request.json() if request.can_read_body else {}
|
||||
if request.query or not isinstance(data, dict) or set(data) - {"extra_images"}:
|
||||
raise web.HTTPBadRequest()
|
||||
extra = data.get("extra_images", 0)
|
||||
if isinstance(extra, bool) or not isinstance(extra, int):
|
||||
raise web.HTTPBadRequest()
|
||||
if not 0 <= extra <= MAX_EXTRA_IMAGES:
|
||||
raise web.HTTPBadRequest()
|
||||
model.unavailable = set()
|
||||
model.suggestions = None
|
||||
model.loaded = None
|
||||
_set_defaults(model, DEFAULTS)
|
||||
# A test that ends with the dialog open leaves its capture behind; drop
|
||||
# every capture so the next test starts below the store's limits.
|
||||
await api._run_in_executor(api.close_annotation_captures)
|
||||
manifest = await api._run_in_executor(seed_tagging, api, root, extra)
|
||||
return web.json_response({"success": True, **manifest})
|
||||
|
||||
@routes.get("/__test__/tagging/snapshot")
|
||||
async def snapshot(request: web.Request) -> web.Response:
|
||||
if request.query:
|
||||
raise web.HTTPBadRequest()
|
||||
return web.json_response(await api._run_in_executor(tagging_snapshot, api))
|
||||
|
||||
@routes.post("/__test__/tagging/models")
|
||||
async def models(request: web.Request) -> web.Response:
|
||||
"""Mark fake models as not downloaded (``{"unavailable": [...]}``)."""
|
||||
data = await request.json() if request.can_read_body else None
|
||||
if (
|
||||
request.query
|
||||
or not isinstance(data, dict)
|
||||
or set(data) != {"unavailable"}
|
||||
or not isinstance(data["unavailable"], list)
|
||||
or any(name not in MODELS for name in data["unavailable"])
|
||||
):
|
||||
raise web.HTTPBadRequest()
|
||||
model.unavailable = set(data["unavailable"])
|
||||
return web.json_response({"success": True})
|
||||
|
||||
@routes.post("/__test__/tagging/suggestions")
|
||||
async def suggestions(request: web.Request) -> web.Response:
|
||||
"""Answer Generate with fixed scored names (the concept's own sample).
|
||||
|
||||
``{"scored": [[name, confidence], ...]}`` (at most 100, confidence in
|
||||
[0, 1]) or ``{"scored": null}`` for the default list. Every capture is
|
||||
released too, so a concept comparison starts below the store's limits
|
||||
whatever the previous test left open.
|
||||
"""
|
||||
data = await request.json() if request.can_read_body else None
|
||||
scored = data.get("scored") if isinstance(data, dict) else None
|
||||
if (
|
||||
request.query
|
||||
or not isinstance(data, dict)
|
||||
or set(data) != {"scored"}
|
||||
or not (scored is None or _valid_scored(scored))
|
||||
):
|
||||
raise web.HTTPBadRequest()
|
||||
model.suggestions = (
|
||||
None if scored is None else [(name, float(value)) for name, value in scored]
|
||||
)
|
||||
model.unavailable = set()
|
||||
await api._run_in_executor(api.close_annotation_captures)
|
||||
return web.json_response({"success": True})
|
||||
|
||||
@routes.post("/__test__/tagging/defaults")
|
||||
async def defaults(request: web.Request) -> web.Response:
|
||||
"""Serve other tagging defaults from ``GET /autotag/models``.
|
||||
|
||||
``{"general": g, "character": c, "limit": n}``: the WD14 thresholds
|
||||
(in [0, 1]) and the persisted suggestion count (1..100), so a test can
|
||||
tell the server's values from the dialog's built-in ones. Reset
|
||||
restores the standard 0.35 / 0.85 / 10.
|
||||
"""
|
||||
data = await request.json() if request.can_read_body else None
|
||||
if request.query or not _valid_defaults(data):
|
||||
raise web.HTTPBadRequest()
|
||||
_set_defaults(model, (data["general"], data["character"], data["limit"]))
|
||||
return web.json_response({"success": True})
|
||||
|
||||
@routes.get("/__test__/tagging/captures")
|
||||
async def captures(request: web.Request) -> web.Response:
|
||||
"""How many captures the store holds (a released capture is gone)."""
|
||||
if request.query:
|
||||
raise web.HTTPBadRequest()
|
||||
store = api._captures
|
||||
count = store.stats()["captures"] if store is not None else 0
|
||||
return web.json_response({"captures": count})
|
||||
|
||||
|
||||
# The standard tagging defaults: WD14 general and character thresholds, and
|
||||
# the suggestion count (``AutotagConfig``).
|
||||
DEFAULTS = (0.35, 0.85, 10)
|
||||
|
||||
|
||||
def _set_defaults(model, values) -> None:
|
||||
from py.config import AutotagConfig
|
||||
|
||||
general, character, limit = values
|
||||
model.wd14_general_threshold = general
|
||||
model.wd14_character_threshold = character
|
||||
AutotagConfig.SUGGESTION_LIMIT = limit
|
||||
|
||||
|
||||
def _valid_defaults(data: object) -> bool:
|
||||
unit = (
|
||||
lambda value: isinstance(value, (int, float))
|
||||
and not isinstance(value, bool)
|
||||
and 0 <= value <= 1
|
||||
)
|
||||
return (
|
||||
isinstance(data, dict)
|
||||
and set(data) == {"general", "character", "limit"}
|
||||
and unit(data["general"])
|
||||
and unit(data["character"])
|
||||
and type(data["limit"]) is int
|
||||
and 1 <= data["limit"] <= 100
|
||||
)
|
||||
|
||||
|
||||
def _valid_scored(scored: object) -> bool:
|
||||
return (
|
||||
isinstance(scored, list)
|
||||
and len(scored) <= 100
|
||||
and all(
|
||||
isinstance(row, list)
|
||||
and len(row) == 2
|
||||
and isinstance(row[0], str)
|
||||
and 0 < len(row[0]) <= 50
|
||||
and isinstance(row[1], (int, float))
|
||||
and not isinstance(row[1], bool)
|
||||
and 0 <= row[1] <= 1
|
||||
for row in scored
|
||||
)
|
||||
)
|
||||
@@ -114,6 +114,9 @@ def seed_workspace_visual(api: PromptManagerAPI, root: Path) -> None:
|
||||
for table in (
|
||||
"saved_views",
|
||||
"image_favorites",
|
||||
"media_image_tags",
|
||||
"media_annotation_bytes",
|
||||
"media_annotation_files",
|
||||
"image_prompt_pairs",
|
||||
"prompt_pair_tags",
|
||||
"prompt_pairs",
|
||||
|
||||
@@ -81,6 +81,9 @@ MAX_EXTRA_CATEGORIES = 200
|
||||
_TABLES = (
|
||||
"saved_views",
|
||||
"image_favorites",
|
||||
"media_image_tags",
|
||||
"media_annotation_bytes",
|
||||
"media_annotation_files",
|
||||
"image_prompt_pairs",
|
||||
"prompt_pair_tags",
|
||||
"prompt_pairs",
|
||||
|
||||
Vendored
+69
@@ -13,6 +13,75 @@ ffmpeg -f lavfi -i testsrc=size=32x32:rate=4:duration=1 \
|
||||
|
||||
Browser tests use the committed file; FFmpeg is not required to run them.
|
||||
|
||||
# Local Inspect header fixtures
|
||||
|
||||
`local-inspect/tiny.jpg` (48x32) and `local-inspect/tiny.webp` (40x24) are original
|
||||
synthetic FFmpeg color test patterns, under 4 KB, dedicated to the public domain
|
||||
under CC0-1.0. `tests/js/png-local-reader.test.js` reads their dimensions with the
|
||||
browser header reader and `tests/test_local_inspect_parity.py` checks the same two
|
||||
files against Pillow.
|
||||
|
||||
Reproduce with FFmpeg:
|
||||
|
||||
```sh
|
||||
ffmpeg -f lavfi -i testsrc=size=48x32:rate=1 -frames:v 1 -q:v 10 tiny.jpg
|
||||
ffmpeg -f lavfi -i testsrc=size=40x24:rate=1 -frames:v 1 -c:v libwebp -quality 40 tiny.webp
|
||||
```
|
||||
|
||||
# Local Inspect video fixtures
|
||||
|
||||
`local-inspect/core-faststart.mp4`, `core-moov-end.mp4`, `core.mov`, `core.m4v`,
|
||||
`core.mkv`, `core-tags.webm`, `legacy-comment.mp4` and `legacy-comment.webm` are
|
||||
0.2-second 16x16 black clips (each under 4 KB) carrying an original synthetic
|
||||
ComfyUI graph in their container tags, dedicated to the public domain under
|
||||
CC0-1.0. The `core-*` files carry one tag per key as core SaveVideo/SaveWEBM write
|
||||
them (MP4/MOV/M4V through `-movflags use_metadata_tags`; Matroska upper-cases the
|
||||
names); `core-faststart.mp4` also carries `parameters` and
|
||||
`comfyassets_prompt_pairs`. The `legacy-comment.*` files carry the old
|
||||
VideoHelperSuite `comment` JSON envelope (an ilst `©cmt` atom in MP4, a `COMMENT`
|
||||
tag in WebM). `video-tags-v1.json` records `ffprobe -show_entries format_tags -of
|
||||
json` for each file (ffmpeg 9.0.2); `tests/test_utils_video_metadata.py`
|
||||
`VideoFixtureFfprobeTests` re-checks it when ffprobe is installed, and
|
||||
`tests/js/workspace-local-video.test.js` plus `tests/test_local_inspect_parity.py`
|
||||
hold the browser video reader to the same maps.
|
||||
|
||||
Reproduce with FFmpeg, from `tests/fixtures/local-inspect`:
|
||||
|
||||
```sh
|
||||
PROMPT='{"3":{"class_type":"KSampler","inputs":{"seed":7,"steps":20,"cfg":8.0,"sampler_name":"euler","scheduler":"normal","denoise":1.0,"model":["4",0],"positive":["6",0],"negative":["7",0],"latent_image":["5",0]}},"4":{"class_type":"CheckpointLoaderSimple","inputs":{"ckpt_name":"synthetic-v1.safetensors"}},"5":{"class_type":"EmptyLatentImage","inputs":{"width":512,"height":512,"batch_size":1}},"6":{"class_type":"CLIPTextEncode","inputs":{"text":"a red fox in snow, detailed fur","clip":["4",1]}},"7":{"class_type":"CLIPTextEncode","inputs":{"text":"blurry, lowres","clip":["4",1]}},"8":{"class_type":"VAEDecode","inputs":{"samples":["3",0],"vae":["4",2]}},"9":{"class_type":"SaveVideo","inputs":{"filename_prefix":"video/synthetic","video":["8",0]}}}'
|
||||
WORKFLOW='{"last_node_id":9,"nodes":[{"id":3,"type":"KSampler","widgets_values":[7,"fixed",20,8.0,"euler","normal",1.0]},{"id":6,"type":"CLIPTextEncode","widgets_values":["a red fox in snow, detailed fur"]},{"id":7,"type":"CLIPTextEncode","widgets_values":["blurry, lowres"]}],"links":[],"version":0.4}'
|
||||
PARAMETERS=$'a red fox in snow, detailed fur\nNegative prompt: blurry, lowres\nSteps: 20, Sampler: Euler, CFG scale: 8.0, Seed: 7, Size: 512x512'
|
||||
PAIRS='{"version":1,"nodes":{"6":{"records":[{"positive_text":"a red fox in snow, detailed fur","negative_text":"blurry, lowres","negative_effective_text":"blurry, lowres","prepend_text":"","append_text":""}]}}}'
|
||||
COMMENT=$(PROMPT="$PROMPT" WORKFLOW="$WORKFLOW" python3 -c 'import json,os; print(json.dumps({"prompt": os.environ["PROMPT"], "workflow": json.loads(os.environ["WORKFLOW"])}))')
|
||||
SRC=(-v quiet -y -f lavfi -i "color=c=black:s=16x16:d=0.2")
|
||||
ffmpeg "${SRC[@]}" -movflags use_metadata_tags+faststart -metadata prompt="$PROMPT" -metadata workflow="$WORKFLOW" -metadata parameters="$PARAMETERS" -metadata comfyassets_prompt_pairs="$PAIRS" core-faststart.mp4
|
||||
ffmpeg "${SRC[@]}" -movflags use_metadata_tags -metadata prompt="$PROMPT" -metadata workflow="$WORKFLOW" core-moov-end.mp4
|
||||
ffmpeg "${SRC[@]}" -movflags use_metadata_tags -metadata prompt="$PROMPT" -metadata workflow="$WORKFLOW" core.mov
|
||||
ffmpeg "${SRC[@]}" -movflags use_metadata_tags -metadata prompt="$PROMPT" -metadata workflow="$WORKFLOW" core.m4v
|
||||
ffmpeg "${SRC[@]}" -metadata prompt="$PROMPT" -metadata workflow="$WORKFLOW" core.mkv
|
||||
ffmpeg "${SRC[@]}" -c:v libvpx -metadata prompt="$PROMPT" -metadata workflow="$WORKFLOW" core-tags.webm
|
||||
ffmpeg "${SRC[@]}" -metadata comment="$COMMENT" legacy-comment.mp4
|
||||
ffmpeg "${SRC[@]}" -c:v libvpx -metadata comment="$COMMENT" legacy-comment.webm
|
||||
for f in core-faststart.mp4 core-moov-end.mp4 core.mov core.m4v core.mkv core-tags.webm legacy-comment.mp4 legacy-comment.webm; do
|
||||
ffprobe -v quiet -show_entries format_tags -of json "$f" # -> video-tags-v1.json "fixtures"[f] = .format.tags
|
||||
done
|
||||
```
|
||||
|
||||
# Local Inspect projection cases
|
||||
|
||||
`local-inspect/projection-cases.json` holds synthetic record sets (prompt, workflow,
|
||||
parameters and pair-record texts, optional `record_formats`, a video `carrier_text`
|
||||
and `limited` records) that exercise every branch of the server's inspection
|
||||
projection: Python's Decimal spellings and exact-construction limits, the 4300-digit
|
||||
integer limit, duplicate keys, NaN/Infinity, the depth-64 and node ceilings, prompt
|
||||
node key filtering and the carrier rule. `records: null` stands for an unreadable
|
||||
file. `expect` pins both graph statuses; `needs` names an interpreter feature a case
|
||||
depends on. `tests/js/workspace-local-projection.test.js` runs them through
|
||||
`web/js/workspace/local-projection.mjs`, and `tests/test_local_inspect_parity.py`
|
||||
asserts that its answer equals `utils.inspection_response._metadata` byte for byte.
|
||||
All texts are original synthetic content, dedicated to the public domain under
|
||||
CC0-1.0.
|
||||
|
||||
# Inspection parity fixtures
|
||||
|
||||
`inspection_parity/` holds small synthetic PNGs (`f01b.png` ... `f24.png`, each
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
+849
@@ -0,0 +1,849 @@
|
||||
{
|
||||
"version": 1,
|
||||
"about": "Synthetic record sets for utils/inspection_response.py _metadata parity with web/js/workspace/local-projection.mjs projectMetadata. records null = an unreadable file. record_formats default to original-text for each record. expect pins both graph statuses; needs names an interpreter feature the Python side must have.",
|
||||
"cases": [
|
||||
{
|
||||
"name": "unreadable_read",
|
||||
"records": null,
|
||||
"expect": {
|
||||
"prompt_graph": "absent",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "absent_records",
|
||||
"records": {},
|
||||
"expect": {
|
||||
"prompt_graph": "absent",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "prompt_and_workflow_parsed",
|
||||
"records": {
|
||||
"prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"x\":1}}}",
|
||||
"workflow": "{\"nodes\":[],\"version\":0.4}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "parsed",
|
||||
"workflow_graph": "parsed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "all_four_records",
|
||||
"records": {
|
||||
"prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"x\":1}}}",
|
||||
"workflow": "{\"nodes\":[],\"version\":0.4}",
|
||||
"parameters": "cat\nNegative prompt: dog\nSteps: 20, Sampler: Euler, Seed: 7",
|
||||
"comfyassets_prompt_pairs": "{\"version\":1,\"nodes\":{}}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "parsed",
|
||||
"workflow_graph": "parsed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "parameters_only",
|
||||
"records": {
|
||||
"parameters": "cat\nSteps: 20"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "absent",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "number_spellings",
|
||||
"records": {
|
||||
"workflow": "{\"nodes\":[],\"a\":[1e3,1e0,-0.0,6.50,0.0100,1.0e2,0e5,0.0000001,0.000001,1E+3,1e-0,0.5e1,0.05e1,0.0e0,1.5e+300,123e-8,2.5e-3,1.0,-1.50,0.0,100.0,1234567e-6,0.5E-6,0.5E-5,1e22,0.000000,-0.000000e-3,7.0e-6,7.0e-7,12345678901234567890.123,9007199254740992.0]}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "absent",
|
||||
"workflow_graph": "parsed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "integer_spellings",
|
||||
"records": {
|
||||
"workflow": "{\"a\":[9007199254740991,-9007199254740991,9007199254740992,-9007199254740992,10000000000000000,123456789012345678901234567890,0,-0,42,-7,100]}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "absent",
|
||||
"workflow_graph": "parsed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "nonfinite_literals_are_null",
|
||||
"records": {
|
||||
"prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"n\":NaN,\"i\":Infinity,\"m\":-Infinity},\"_meta\":{\"n\":NaN}}}",
|
||||
"workflow": "{\"a\":[NaN,Infinity,-Infinity],\"b\":NaN}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "parsed",
|
||||
"workflow_graph": "parsed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "exponent_edges_valid",
|
||||
"records": {
|
||||
"workflow": "{\"a\":[1e999999999999999999,1e-1999999999999999997,0.0e1000000000000000000,1.5e999999999999999999,0.1e-1999999999999999996]}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "absent",
|
||||
"workflow_graph": "parsed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "exponent_edge_invalid_1e1000000000000000000",
|
||||
"records": {
|
||||
"workflow": "{\"a\":1e1000000000000000000}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "absent",
|
||||
"workflow_graph": "malformed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "exponent_edge_invalid_15e999999999999999999",
|
||||
"records": {
|
||||
"workflow": "{\"a\":15e999999999999999999}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "absent",
|
||||
"workflow_graph": "malformed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "exponent_edge_invalid_1e_1999999999999999998",
|
||||
"records": {
|
||||
"workflow": "{\"a\":1e-1999999999999999998}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "absent",
|
||||
"workflow_graph": "malformed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "exponent_edge_invalid_1_5e_1999999999999999997",
|
||||
"records": {
|
||||
"workflow": "{\"a\":1.5e-1999999999999999997}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "absent",
|
||||
"workflow_graph": "malformed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "exponent_edge_invalid_0e1000000000000000000",
|
||||
"records": {
|
||||
"workflow": "{\"a\":0e1000000000000000000}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "absent",
|
||||
"workflow_graph": "malformed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "exponent_edge_invalid_1e9223372036854775808",
|
||||
"records": {
|
||||
"workflow": "{\"a\":1e9223372036854775808}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "absent",
|
||||
"workflow_graph": "malformed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "exponent_edge_invalid_0_0e1000000000000000001",
|
||||
"records": {
|
||||
"workflow": "{\"a\":0.0e1000000000000000001}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "absent",
|
||||
"workflow_graph": "malformed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "exponent_edge_invalid_1e99999999999999999999999999",
|
||||
"records": {
|
||||
"workflow": "{\"a\":1e99999999999999999999999999}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "absent",
|
||||
"workflow_graph": "malformed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "int_4300_digits_kept",
|
||||
"records": {
|
||||
"workflow": "{\"a\":7777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "absent",
|
||||
"workflow_graph": "parsed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "int_4301_digits_malformed",
|
||||
"records": {
|
||||
"workflow": "{\"a\":77777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777}"
|
||||
},
|
||||
"needs": "int-max-str-digits",
|
||||
"expect": {
|
||||
"prompt_graph": "absent",
|
||||
"workflow_graph": "malformed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "int_negative_4301_digits_malformed",
|
||||
"records": {
|
||||
"workflow": "{\"a\":-77777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777}"
|
||||
},
|
||||
"needs": "int-max-str-digits",
|
||||
"expect": {
|
||||
"prompt_graph": "absent",
|
||||
"workflow_graph": "malformed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "string_escapes",
|
||||
"records": {
|
||||
"prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"t\":\"é😀\\u0000\\u001f/\\\\\\\"\\b\\f\\n\\r\\t\",\"raw\":\"é😀 —
\"}}}",
|
||||
"workflow": "{\"s\":\"\\u00E9\\uD83D\\uDE00\\uD800\\uDC00x\\u0000\\u001f\\/\\\\\\\"\\b\\f\\n\\r\\t\\uD800\\u0041\\uDC00\"}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "parsed",
|
||||
"workflow_graph": "parsed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "lone_surrogate_in_raw_text",
|
||||
"records": {
|
||||
"prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"t\":\"\\udc00\"}}}",
|
||||
"parameters": "\ud800 lone\nSteps: 1"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "parsed",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "keys_with_escapes_proto_and_order",
|
||||
"records": {
|
||||
"prompt": "{\"10\":{\"class_type\":\"B\",\"inputs\":{}},\"3\":{\"class_type\":\"A\",\"inputs\":{}},\"__proto__\":{\"class_type\":\"P\",\"inputs\":{\"constructor\":1}},\"constructor\":{\"class_type\":\"C\",\"inputs\":{}},\"\\u0000\":5,\"\":[]}",
|
||||
"workflow": "{\"10\":1,\"3\":2,\"b\":3,\"a\":4,\"__proto__\":{\"x\":1},\"\":{}}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "parsed",
|
||||
"workflow_graph": "parsed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "duplicate_key_top_level",
|
||||
"records": {
|
||||
"workflow": "{\"a\":1,\"a\":2}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "absent",
|
||||
"workflow_graph": "malformed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "duplicate_key_nested",
|
||||
"records": {
|
||||
"workflow": "{\"a\":{\"b\":[{\"c\":1,\"c\":1}]}}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "absent",
|
||||
"workflow_graph": "malformed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "duplicate_key_in_dropped_prompt_meta",
|
||||
"records": {
|
||||
"prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"a\":1},\"_meta\":{\"t\":1,\"t\":2}}}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "malformed",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "duplicate_key_in_prompt_inputs",
|
||||
"records": {
|
||||
"prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"a\":1,\"a\":1}}}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "malformed",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "duplicate_key_after_deep_nest",
|
||||
"records": {
|
||||
"workflow": "{\"x\":[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]],\"a\":1,\"a\":2}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "absent",
|
||||
"workflow_graph": "malformed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "workflow_depth_64_kept",
|
||||
"records": {
|
||||
"workflow": "{\"a\":[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "absent",
|
||||
"workflow_graph": "parsed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "workflow_depth_65_limited",
|
||||
"records": {
|
||||
"workflow": "{\"a\":[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "absent",
|
||||
"workflow_graph": "limited"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "prompt_inputs_depth_64_kept",
|
||||
"records": {
|
||||
"prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"v\":[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]}}}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "parsed",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "prompt_inputs_depth_65_limited",
|
||||
"records": {
|
||||
"prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"v\":[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]}}}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "limited",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "prompt_dropped_meta_depth_65_limited",
|
||||
"records": {
|
||||
"prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{},\"_meta\":{\"v\":[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]}}}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "limited",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "prompt_non_object_nodes_kept",
|
||||
"records": {
|
||||
"prompt": "{\"1\":5,\"2\":[1,2.50,\"x\",null,true],\"3\":\"s\",\"4\":null,\"5\":{\"inputs\":[1],\"class_type\":null,\"other\":{\"deep\":[NaN]}}}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "parsed",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "prompt_root_array_malformed",
|
||||
"records": {
|
||||
"prompt": "[]"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "malformed",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "prompt_root_string_malformed",
|
||||
"records": {
|
||||
"prompt": "\"{}\""
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "malformed",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "prompt_root_number_malformed",
|
||||
"records": {
|
||||
"prompt": "5"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "malformed",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "prompt_root_null_malformed",
|
||||
"records": {
|
||||
"prompt": "null"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "malformed",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "workflow_root_array_malformed",
|
||||
"records": {
|
||||
"workflow": "[1,2]"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "absent",
|
||||
"workflow_graph": "malformed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "workflow_root_nan_malformed",
|
||||
"records": {
|
||||
"workflow": "NaN"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "absent",
|
||||
"workflow_graph": "malformed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "empty_text_malformed",
|
||||
"records": {
|
||||
"prompt": "",
|
||||
"workflow": " "
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "malformed",
|
||||
"workflow_graph": "malformed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "bom_malformed",
|
||||
"records": {
|
||||
"workflow": "{}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "absent",
|
||||
"workflow_graph": "malformed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "trailing_data_malformed",
|
||||
"records": {
|
||||
"workflow": "{\"a\":1} x",
|
||||
"prompt": "{}\u0000"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "malformed",
|
||||
"workflow_graph": "malformed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "python_whitespace_only",
|
||||
"records": {
|
||||
"workflow": " \t\r\n{\n\t\"a\" :\r 1 }\n",
|
||||
"prompt": "\u000b{}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "malformed",
|
||||
"workflow_graph": "parsed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "syntax_errors",
|
||||
"records": {
|
||||
"prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{},}}",
|
||||
"workflow": "[1,]"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "malformed",
|
||||
"workflow_graph": "malformed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "bad_escape_and_control_char",
|
||||
"records": {
|
||||
"prompt": "{\"1\":\"\\x\"}",
|
||||
"workflow": "{\"a\":\"\u0001\"}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "malformed",
|
||||
"workflow_graph": "malformed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "number_grammar",
|
||||
"records": {
|
||||
"prompt": "{\"1\":01}",
|
||||
"workflow": "{\"a\":1.,\"b\":.5}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "malformed",
|
||||
"workflow_graph": "malformed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "nodes_not_list_counts_zero",
|
||||
"records": {
|
||||
"workflow": "{\"nodes\":{\"a\":1},\"definitions\":{\"subgraphs\":[{\"nodes\":5},7,{\"nodes\":[1,2]}]}}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "absent",
|
||||
"workflow_graph": "parsed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "limited_workflow_record",
|
||||
"records": {
|
||||
"prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"x\":1}}}",
|
||||
"workflow": "{\"nodes\":[],\"version\":0.4}"
|
||||
},
|
||||
"limited": [
|
||||
"workflow"
|
||||
],
|
||||
"expect": {
|
||||
"prompt_graph": "parsed",
|
||||
"workflow_graph": "limited"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "limited_workflow_without_text",
|
||||
"records": {
|
||||
"prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"x\":1}}}"
|
||||
},
|
||||
"limited": [
|
||||
"workflow"
|
||||
],
|
||||
"expect": {
|
||||
"prompt_graph": "parsed",
|
||||
"workflow_graph": "limited"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "invalid_workflow_format",
|
||||
"records": {
|
||||
"prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"x\":1}}}"
|
||||
},
|
||||
"record_formats": {
|
||||
"prompt": "original-text",
|
||||
"workflow": "invalid"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "parsed",
|
||||
"workflow_graph": "malformed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "reserialized_without_carrier_is_limited",
|
||||
"records": {
|
||||
"prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"x\":1}}}",
|
||||
"workflow": "{\"nodes\":[],\"version\":0.4}"
|
||||
},
|
||||
"record_formats": {
|
||||
"prompt": "reserialized-json",
|
||||
"workflow": "reserialized-json"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "limited",
|
||||
"workflow_graph": "limited"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "unknown_format_dropped_but_projected",
|
||||
"records": {
|
||||
"prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"x\":1}}}"
|
||||
},
|
||||
"record_formats": {
|
||||
"prompt": "weird"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "parsed",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "non_text_record_dropped",
|
||||
"records": {
|
||||
"prompt": 5,
|
||||
"workflow": "{\"nodes\":[],\"version\":0.4}"
|
||||
},
|
||||
"record_formats": {
|
||||
"prompt": "original-text",
|
||||
"workflow": "original-text"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "malformed",
|
||||
"workflow_graph": "parsed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "format_without_text_is_malformed",
|
||||
"records": {},
|
||||
"record_formats": {
|
||||
"prompt": "original-text"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "malformed",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "unknown_record_keys_ignored",
|
||||
"records": {
|
||||
"prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"x\":1}}}",
|
||||
"Software": "ComfyUI",
|
||||
"WORKFLOW": "{\"nodes\":[],\"version\":0.4}"
|
||||
},
|
||||
"record_formats": {
|
||||
"prompt": "original-text",
|
||||
"Software": "original-text",
|
||||
"WORKFLOW": "original-text"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "parsed",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "carrier_prompt_string_workflow_object",
|
||||
"records": {
|
||||
"prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"x\":1}}}",
|
||||
"workflow": "{\"nodes\": [], \"v\": 1}"
|
||||
},
|
||||
"record_formats": {
|
||||
"prompt": "original-text",
|
||||
"workflow": "reserialized-json"
|
||||
},
|
||||
"carrier_text": "{\"prompt\":\"{\\\"1\\\":{\\\"class_type\\\":\\\"A\\\",\\\"inputs\\\":{\\\"x\\\":1}}}\",\"workflow\":{\"nodes\":[],\"v\":1}}",
|
||||
"expect": {
|
||||
"prompt_graph": "parsed",
|
||||
"workflow_graph": "parsed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "carrier_prompt_object_projected",
|
||||
"records": {
|
||||
"prompt": "{\"1\": {\"class_type\": \"A\", \"inputs\": {\"x\": 8.0}, \"_meta\": {\"t\": \"x\"}}}",
|
||||
"workflow": "{\"nodes\":[],\"version\":0.4}"
|
||||
},
|
||||
"record_formats": {
|
||||
"prompt": "reserialized-json",
|
||||
"workflow": "original-text"
|
||||
},
|
||||
"carrier_text": "{\"prompt\":{\"1\":{\"class_type\":\"A\",\"inputs\":{\"x\":8.0},\"_meta\":{\"t\":\"x\"}}},\"workflow\":{\"nodes\":[],\"version\":0.4},\"title\":\"n\",\"extra\":[1e3]}",
|
||||
"expect": {
|
||||
"prompt_graph": "parsed",
|
||||
"workflow_graph": "parsed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "carrier_root_array_fails_all",
|
||||
"records": {
|
||||
"prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"x\":1}}}",
|
||||
"workflow": "{\"nodes\":[],\"version\":0.4}"
|
||||
},
|
||||
"carrier_text": "[1]",
|
||||
"expect": {
|
||||
"prompt_graph": "malformed",
|
||||
"workflow_graph": "malformed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "carrier_duplicate_key_fails_all",
|
||||
"records": {
|
||||
"prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"x\":1}}}"
|
||||
},
|
||||
"carrier_text": "{\"prompt\":\"{}\",\"prompt\":\"{}\"}",
|
||||
"expect": {
|
||||
"prompt_graph": "malformed",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "carrier_other_member_too_deep_limited",
|
||||
"records": {
|
||||
"prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"x\":1}}}"
|
||||
},
|
||||
"carrier_text": "{\"title\":[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]],\"prompt\":\"{\\\"1\\\":{\\\"class_type\\\":\\\"A\\\",\\\"inputs\\\":{\\\"x\\\":1}}}\"}",
|
||||
"expect": {
|
||||
"prompt_graph": "limited",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "carrier_other_member_depth_64_kept",
|
||||
"records": {
|
||||
"prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"x\":1}}}"
|
||||
},
|
||||
"carrier_text": "{\"title\":[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]],\"prompt\":\"{\\\"1\\\":{\\\"class_type\\\":\\\"A\\\",\\\"inputs\\\":{\\\"x\\\":1}}}\"}",
|
||||
"expect": {
|
||||
"prompt_graph": "parsed",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "carrier_prompt_object_depth_shift",
|
||||
"records": {
|
||||
"prompt": "x"
|
||||
},
|
||||
"record_formats": {
|
||||
"prompt": "reserialized-json"
|
||||
},
|
||||
"carrier_text": "{\"prompt\":{\"1\":{\"class_type\":\"A\",\"inputs\":{\"v\":[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]}}}}",
|
||||
"expect": {
|
||||
"prompt_graph": "parsed",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "carrier_prompt_object_depth_shift_limited",
|
||||
"records": {
|
||||
"prompt": "x"
|
||||
},
|
||||
"record_formats": {
|
||||
"prompt": "reserialized-json"
|
||||
},
|
||||
"carrier_text": "{\"prompt\":{\"1\":{\"class_type\":\"A\",\"inputs\":{\"v\":[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]}}}}",
|
||||
"expect": {
|
||||
"prompt_graph": "limited",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "carrier_prompt_string_invalid_json",
|
||||
"records": {
|
||||
"prompt": "{",
|
||||
"workflow": "{\"nodes\":[],\"version\":0.4}"
|
||||
},
|
||||
"carrier_text": "{\"prompt\":\"{\",\"workflow\":\"{\\\"nodes\\\":[],\\\"version\\\":0.4}\"}",
|
||||
"expect": {
|
||||
"prompt_graph": "malformed",
|
||||
"workflow_graph": "parsed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "carrier_prompt_string_too_deep",
|
||||
"records": {
|
||||
"prompt": "x"
|
||||
},
|
||||
"carrier_text": "{\"prompt\":\"{\\\"1\\\":{\\\"class_type\\\":\\\"A\\\",\\\"inputs\\\":{\\\"v\\\":[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]}}}\"}",
|
||||
"expect": {
|
||||
"prompt_graph": "limited",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "carrier_workflow_number_malformed",
|
||||
"records": {
|
||||
"prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"x\":1}}}",
|
||||
"workflow": "5"
|
||||
},
|
||||
"record_formats": {
|
||||
"prompt": "original-text",
|
||||
"workflow": "original-text"
|
||||
},
|
||||
"carrier_text": "{\"prompt\":\"{\\\"1\\\":{\\\"class_type\\\":\\\"A\\\",\\\"inputs\\\":{\\\"x\\\":1}}}\",\"workflow\":5}",
|
||||
"expect": {
|
||||
"prompt_graph": "parsed",
|
||||
"workflow_graph": "malformed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "carrier_prompt_nonfinite_malformed",
|
||||
"records": {
|
||||
"prompt": "NaN"
|
||||
},
|
||||
"carrier_text": "{\"prompt\":NaN}",
|
||||
"expect": {
|
||||
"prompt_graph": "malformed",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "carrier_prompt_bigint_malformed",
|
||||
"records": {
|
||||
"prompt": "123456789012345678901"
|
||||
},
|
||||
"carrier_text": "{\"prompt\":123456789012345678901}",
|
||||
"expect": {
|
||||
"prompt_graph": "malformed",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "carrier_prompt_decimal_malformed",
|
||||
"records": {
|
||||
"prompt": "1.5"
|
||||
},
|
||||
"carrier_text": "{\"prompt\":1.5}",
|
||||
"expect": {
|
||||
"prompt_graph": "malformed",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "carrier_missing_member_malformed",
|
||||
"records": {
|
||||
"prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"x\":1}}}"
|
||||
},
|
||||
"carrier_text": "{\"workflow\":{}}",
|
||||
"expect": {
|
||||
"prompt_graph": "malformed",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "carrier_without_graph_records",
|
||||
"records": {
|
||||
"parameters": "cat"
|
||||
},
|
||||
"carrier_text": "{\"parameters\":\"cat\"}",
|
||||
"expect": {
|
||||
"prompt_graph": "absent",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "carrier_with_limited_workflow",
|
||||
"records": {
|
||||
"prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"x\":1}}}"
|
||||
},
|
||||
"limited": [
|
||||
"workflow"
|
||||
],
|
||||
"carrier_text": "{\"prompt\":\"{\\\"1\\\":{\\\"class_type\\\":\\\"A\\\",\\\"inputs\\\":{\\\"x\\\":1}}}\"}",
|
||||
"expect": {
|
||||
"prompt_graph": "parsed",
|
||||
"workflow_graph": "limited"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "carrier_invalid_workflow_format",
|
||||
"records": {
|
||||
"prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"x\":1}}}"
|
||||
},
|
||||
"record_formats": {
|
||||
"prompt": "original-text",
|
||||
"workflow": "invalid"
|
||||
},
|
||||
"carrier_text": "{\"prompt\":\"{\\\"1\\\":{\\\"class_type\\\":\\\"A\\\",\\\"inputs\\\":{\\\"x\\\":1}}}\",\"workflow\":null}",
|
||||
"expect": {
|
||||
"prompt_graph": "parsed",
|
||||
"workflow_graph": "malformed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "carrier_non_string_text_malformed",
|
||||
"records": {
|
||||
"prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"x\":1}}}"
|
||||
},
|
||||
"carrier_text": "",
|
||||
"expect": {
|
||||
"prompt_graph": "malformed",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 470 B |
+60
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"generator": "ffmpeg -show_entries format_tags -of json over tests/fixtures/local-inspect, see tests/fixtures/README.md",
|
||||
"fixtures": {
|
||||
"core-faststart.mp4": {
|
||||
"major_brand": "isom",
|
||||
"minor_version": "512",
|
||||
"compatible_brands": "isomiso2avc1mp41",
|
||||
"prompt": "{\"3\":{\"class_type\":\"KSampler\",\"inputs\":{\"seed\":7,\"steps\":20,\"cfg\":8.0,\"sampler_name\":\"euler\",\"scheduler\":\"normal\",\"denoise\":1.0,\"model\":[\"4\",0],\"positive\":[\"6\",0],\"negative\":[\"7\",0],\"latent_image\":[\"5\",0]}},\"4\":{\"class_type\":\"CheckpointLoaderSimple\",\"inputs\":{\"ckpt_name\":\"synthetic-v1.safetensors\"}},\"5\":{\"class_type\":\"EmptyLatentImage\",\"inputs\":{\"width\":512,\"height\":512,\"batch_size\":1}},\"6\":{\"class_type\":\"CLIPTextEncode\",\"inputs\":{\"text\":\"a red fox in snow, detailed fur\",\"clip\":[\"4\",1]}},\"7\":{\"class_type\":\"CLIPTextEncode\",\"inputs\":{\"text\":\"blurry, lowres\",\"clip\":[\"4\",1]}},\"8\":{\"class_type\":\"VAEDecode\",\"inputs\":{\"samples\":[\"3\",0],\"vae\":[\"4\",2]}},\"9\":{\"class_type\":\"SaveVideo\",\"inputs\":{\"filename_prefix\":\"video/synthetic\",\"video\":[\"8\",0]}}}",
|
||||
"workflow": "{\"last_node_id\":9,\"nodes\":[{\"id\":3,\"type\":\"KSampler\",\"widgets_values\":[7,\"fixed\",20,8.0,\"euler\",\"normal\",1.0]},{\"id\":6,\"type\":\"CLIPTextEncode\",\"widgets_values\":[\"a red fox in snow, detailed fur\"]},{\"id\":7,\"type\":\"CLIPTextEncode\",\"widgets_values\":[\"blurry, lowres\"]}],\"links\":[],\"version\":0.4}",
|
||||
"parameters": "a red fox in snow, detailed fur\nNegative prompt: blurry, lowres\nSteps: 20, Sampler: Euler, CFG scale: 8.0, Seed: 7, Size: 512x512",
|
||||
"comfyassets_prompt_pairs": "{\"version\":1,\"nodes\":{\"6\":{\"records\":[{\"positive_text\":\"a red fox in snow, detailed fur\",\"negative_text\":\"blurry, lowres\",\"negative_effective_text\":\"blurry, lowres\",\"prepend_text\":\"\",\"append_text\":\"\"}]}}}",
|
||||
"encoder": "Lavf63.1.102"
|
||||
},
|
||||
"core-moov-end.mp4": {
|
||||
"major_brand": "isom",
|
||||
"minor_version": "512",
|
||||
"compatible_brands": "isomiso2avc1mp41",
|
||||
"prompt": "{\"3\":{\"class_type\":\"KSampler\",\"inputs\":{\"seed\":7,\"steps\":20,\"cfg\":8.0,\"sampler_name\":\"euler\",\"scheduler\":\"normal\",\"denoise\":1.0,\"model\":[\"4\",0],\"positive\":[\"6\",0],\"negative\":[\"7\",0],\"latent_image\":[\"5\",0]}},\"4\":{\"class_type\":\"CheckpointLoaderSimple\",\"inputs\":{\"ckpt_name\":\"synthetic-v1.safetensors\"}},\"5\":{\"class_type\":\"EmptyLatentImage\",\"inputs\":{\"width\":512,\"height\":512,\"batch_size\":1}},\"6\":{\"class_type\":\"CLIPTextEncode\",\"inputs\":{\"text\":\"a red fox in snow, detailed fur\",\"clip\":[\"4\",1]}},\"7\":{\"class_type\":\"CLIPTextEncode\",\"inputs\":{\"text\":\"blurry, lowres\",\"clip\":[\"4\",1]}},\"8\":{\"class_type\":\"VAEDecode\",\"inputs\":{\"samples\":[\"3\",0],\"vae\":[\"4\",2]}},\"9\":{\"class_type\":\"SaveVideo\",\"inputs\":{\"filename_prefix\":\"video/synthetic\",\"video\":[\"8\",0]}}}",
|
||||
"workflow": "{\"last_node_id\":9,\"nodes\":[{\"id\":3,\"type\":\"KSampler\",\"widgets_values\":[7,\"fixed\",20,8.0,\"euler\",\"normal\",1.0]},{\"id\":6,\"type\":\"CLIPTextEncode\",\"widgets_values\":[\"a red fox in snow, detailed fur\"]},{\"id\":7,\"type\":\"CLIPTextEncode\",\"widgets_values\":[\"blurry, lowres\"]}],\"links\":[],\"version\":0.4}",
|
||||
"encoder": "Lavf63.1.102"
|
||||
},
|
||||
"core.mov": {
|
||||
"major_brand": "qt ",
|
||||
"minor_version": "512",
|
||||
"compatible_brands": "qt ",
|
||||
"prompt": "{\"3\":{\"class_type\":\"KSampler\",\"inputs\":{\"seed\":7,\"steps\":20,\"cfg\":8.0,\"sampler_name\":\"euler\",\"scheduler\":\"normal\",\"denoise\":1.0,\"model\":[\"4\",0],\"positive\":[\"6\",0],\"negative\":[\"7\",0],\"latent_image\":[\"5\",0]}},\"4\":{\"class_type\":\"CheckpointLoaderSimple\",\"inputs\":{\"ckpt_name\":\"synthetic-v1.safetensors\"}},\"5\":{\"class_type\":\"EmptyLatentImage\",\"inputs\":{\"width\":512,\"height\":512,\"batch_size\":1}},\"6\":{\"class_type\":\"CLIPTextEncode\",\"inputs\":{\"text\":\"a red fox in snow, detailed fur\",\"clip\":[\"4\",1]}},\"7\":{\"class_type\":\"CLIPTextEncode\",\"inputs\":{\"text\":\"blurry, lowres\",\"clip\":[\"4\",1]}},\"8\":{\"class_type\":\"VAEDecode\",\"inputs\":{\"samples\":[\"3\",0],\"vae\":[\"4\",2]}},\"9\":{\"class_type\":\"SaveVideo\",\"inputs\":{\"filename_prefix\":\"video/synthetic\",\"video\":[\"8\",0]}}}",
|
||||
"workflow": "{\"last_node_id\":9,\"nodes\":[{\"id\":3,\"type\":\"KSampler\",\"widgets_values\":[7,\"fixed\",20,8.0,\"euler\",\"normal\",1.0]},{\"id\":6,\"type\":\"CLIPTextEncode\",\"widgets_values\":[\"a red fox in snow, detailed fur\"]},{\"id\":7,\"type\":\"CLIPTextEncode\",\"widgets_values\":[\"blurry, lowres\"]}],\"links\":[],\"version\":0.4}",
|
||||
"encoder": "Lavf63.1.102"
|
||||
},
|
||||
"core.m4v": {
|
||||
"major_brand": "M4V ",
|
||||
"minor_version": "512",
|
||||
"compatible_brands": "M4V isomiso2avc1",
|
||||
"prompt": "{\"3\":{\"class_type\":\"KSampler\",\"inputs\":{\"seed\":7,\"steps\":20,\"cfg\":8.0,\"sampler_name\":\"euler\",\"scheduler\":\"normal\",\"denoise\":1.0,\"model\":[\"4\",0],\"positive\":[\"6\",0],\"negative\":[\"7\",0],\"latent_image\":[\"5\",0]}},\"4\":{\"class_type\":\"CheckpointLoaderSimple\",\"inputs\":{\"ckpt_name\":\"synthetic-v1.safetensors\"}},\"5\":{\"class_type\":\"EmptyLatentImage\",\"inputs\":{\"width\":512,\"height\":512,\"batch_size\":1}},\"6\":{\"class_type\":\"CLIPTextEncode\",\"inputs\":{\"text\":\"a red fox in snow, detailed fur\",\"clip\":[\"4\",1]}},\"7\":{\"class_type\":\"CLIPTextEncode\",\"inputs\":{\"text\":\"blurry, lowres\",\"clip\":[\"4\",1]}},\"8\":{\"class_type\":\"VAEDecode\",\"inputs\":{\"samples\":[\"3\",0],\"vae\":[\"4\",2]}},\"9\":{\"class_type\":\"SaveVideo\",\"inputs\":{\"filename_prefix\":\"video/synthetic\",\"video\":[\"8\",0]}}}",
|
||||
"workflow": "{\"last_node_id\":9,\"nodes\":[{\"id\":3,\"type\":\"KSampler\",\"widgets_values\":[7,\"fixed\",20,8.0,\"euler\",\"normal\",1.0]},{\"id\":6,\"type\":\"CLIPTextEncode\",\"widgets_values\":[\"a red fox in snow, detailed fur\"]},{\"id\":7,\"type\":\"CLIPTextEncode\",\"widgets_values\":[\"blurry, lowres\"]}],\"links\":[],\"version\":0.4}",
|
||||
"encoder": "Lavf63.1.102"
|
||||
},
|
||||
"core.mkv": {
|
||||
"WORKFLOW": "{\"last_node_id\":9,\"nodes\":[{\"id\":3,\"type\":\"KSampler\",\"widgets_values\":[7,\"fixed\",20,8.0,\"euler\",\"normal\",1.0]},{\"id\":6,\"type\":\"CLIPTextEncode\",\"widgets_values\":[\"a red fox in snow, detailed fur\"]},{\"id\":7,\"type\":\"CLIPTextEncode\",\"widgets_values\":[\"blurry, lowres\"]}],\"links\":[],\"version\":0.4}",
|
||||
"PROMPT": "{\"3\":{\"class_type\":\"KSampler\",\"inputs\":{\"seed\":7,\"steps\":20,\"cfg\":8.0,\"sampler_name\":\"euler\",\"scheduler\":\"normal\",\"denoise\":1.0,\"model\":[\"4\",0],\"positive\":[\"6\",0],\"negative\":[\"7\",0],\"latent_image\":[\"5\",0]}},\"4\":{\"class_type\":\"CheckpointLoaderSimple\",\"inputs\":{\"ckpt_name\":\"synthetic-v1.safetensors\"}},\"5\":{\"class_type\":\"EmptyLatentImage\",\"inputs\":{\"width\":512,\"height\":512,\"batch_size\":1}},\"6\":{\"class_type\":\"CLIPTextEncode\",\"inputs\":{\"text\":\"a red fox in snow, detailed fur\",\"clip\":[\"4\",1]}},\"7\":{\"class_type\":\"CLIPTextEncode\",\"inputs\":{\"text\":\"blurry, lowres\",\"clip\":[\"4\",1]}},\"8\":{\"class_type\":\"VAEDecode\",\"inputs\":{\"samples\":[\"3\",0],\"vae\":[\"4\",2]}},\"9\":{\"class_type\":\"SaveVideo\",\"inputs\":{\"filename_prefix\":\"video/synthetic\",\"video\":[\"8\",0]}}}",
|
||||
"ENCODER": "Lavf63.1.102"
|
||||
},
|
||||
"core-tags.webm": {
|
||||
"WORKFLOW": "{\"last_node_id\":9,\"nodes\":[{\"id\":3,\"type\":\"KSampler\",\"widgets_values\":[7,\"fixed\",20,8.0,\"euler\",\"normal\",1.0]},{\"id\":6,\"type\":\"CLIPTextEncode\",\"widgets_values\":[\"a red fox in snow, detailed fur\"]},{\"id\":7,\"type\":\"CLIPTextEncode\",\"widgets_values\":[\"blurry, lowres\"]}],\"links\":[],\"version\":0.4}",
|
||||
"PROMPT": "{\"3\":{\"class_type\":\"KSampler\",\"inputs\":{\"seed\":7,\"steps\":20,\"cfg\":8.0,\"sampler_name\":\"euler\",\"scheduler\":\"normal\",\"denoise\":1.0,\"model\":[\"4\",0],\"positive\":[\"6\",0],\"negative\":[\"7\",0],\"latent_image\":[\"5\",0]}},\"4\":{\"class_type\":\"CheckpointLoaderSimple\",\"inputs\":{\"ckpt_name\":\"synthetic-v1.safetensors\"}},\"5\":{\"class_type\":\"EmptyLatentImage\",\"inputs\":{\"width\":512,\"height\":512,\"batch_size\":1}},\"6\":{\"class_type\":\"CLIPTextEncode\",\"inputs\":{\"text\":\"a red fox in snow, detailed fur\",\"clip\":[\"4\",1]}},\"7\":{\"class_type\":\"CLIPTextEncode\",\"inputs\":{\"text\":\"blurry, lowres\",\"clip\":[\"4\",1]}},\"8\":{\"class_type\":\"VAEDecode\",\"inputs\":{\"samples\":[\"3\",0],\"vae\":[\"4\",2]}},\"9\":{\"class_type\":\"SaveVideo\",\"inputs\":{\"filename_prefix\":\"video/synthetic\",\"video\":[\"8\",0]}}}",
|
||||
"ENCODER": "Lavf63.1.102"
|
||||
},
|
||||
"legacy-comment.mp4": {
|
||||
"major_brand": "isom",
|
||||
"minor_version": "512",
|
||||
"compatible_brands": "isomiso2avc1mp41",
|
||||
"encoder": "Lavf63.1.102",
|
||||
"comment": "{\"prompt\": \"{\\\"3\\\":{\\\"class_type\\\":\\\"KSampler\\\",\\\"inputs\\\":{\\\"seed\\\":7,\\\"steps\\\":20,\\\"cfg\\\":8.0,\\\"sampler_name\\\":\\\"euler\\\",\\\"scheduler\\\":\\\"normal\\\",\\\"denoise\\\":1.0,\\\"model\\\":[\\\"4\\\",0],\\\"positive\\\":[\\\"6\\\",0],\\\"negative\\\":[\\\"7\\\",0],\\\"latent_image\\\":[\\\"5\\\",0]}},\\\"4\\\":{\\\"class_type\\\":\\\"CheckpointLoaderSimple\\\",\\\"inputs\\\":{\\\"ckpt_name\\\":\\\"synthetic-v1.safetensors\\\"}},\\\"5\\\":{\\\"class_type\\\":\\\"EmptyLatentImage\\\",\\\"inputs\\\":{\\\"width\\\":512,\\\"height\\\":512,\\\"batch_size\\\":1}},\\\"6\\\":{\\\"class_type\\\":\\\"CLIPTextEncode\\\",\\\"inputs\\\":{\\\"text\\\":\\\"a red fox in snow, detailed fur\\\",\\\"clip\\\":[\\\"4\\\",1]}},\\\"7\\\":{\\\"class_type\\\":\\\"CLIPTextEncode\\\",\\\"inputs\\\":{\\\"text\\\":\\\"blurry, lowres\\\",\\\"clip\\\":[\\\"4\\\",1]}},\\\"8\\\":{\\\"class_type\\\":\\\"VAEDecode\\\",\\\"inputs\\\":{\\\"samples\\\":[\\\"3\\\",0],\\\"vae\\\":[\\\"4\\\",2]}},\\\"9\\\":{\\\"class_type\\\":\\\"SaveVideo\\\",\\\"inputs\\\":{\\\"filename_prefix\\\":\\\"video/synthetic\\\",\\\"video\\\":[\\\"8\\\",0]}}}\", \"workflow\": {\"last_node_id\": 9, \"nodes\": [{\"id\": 3, \"type\": \"KSampler\", \"widgets_values\": [7, \"fixed\", 20, 8.0, \"euler\", \"normal\", 1.0]}, {\"id\": 6, \"type\": \"CLIPTextEncode\", \"widgets_values\": [\"a red fox in snow, detailed fur\"]}, {\"id\": 7, \"type\": \"CLIPTextEncode\", \"widgets_values\": [\"blurry, lowres\"]}], \"links\": [], \"version\": 0.4}}"
|
||||
},
|
||||
"legacy-comment.webm": {
|
||||
"COMMENT": "{\"prompt\": \"{\\\"3\\\":{\\\"class_type\\\":\\\"KSampler\\\",\\\"inputs\\\":{\\\"seed\\\":7,\\\"steps\\\":20,\\\"cfg\\\":8.0,\\\"sampler_name\\\":\\\"euler\\\",\\\"scheduler\\\":\\\"normal\\\",\\\"denoise\\\":1.0,\\\"model\\\":[\\\"4\\\",0],\\\"positive\\\":[\\\"6\\\",0],\\\"negative\\\":[\\\"7\\\",0],\\\"latent_image\\\":[\\\"5\\\",0]}},\\\"4\\\":{\\\"class_type\\\":\\\"CheckpointLoaderSimple\\\",\\\"inputs\\\":{\\\"ckpt_name\\\":\\\"synthetic-v1.safetensors\\\"}},\\\"5\\\":{\\\"class_type\\\":\\\"EmptyLatentImage\\\",\\\"inputs\\\":{\\\"width\\\":512,\\\"height\\\":512,\\\"batch_size\\\":1}},\\\"6\\\":{\\\"class_type\\\":\\\"CLIPTextEncode\\\",\\\"inputs\\\":{\\\"text\\\":\\\"a red fox in snow, detailed fur\\\",\\\"clip\\\":[\\\"4\\\",1]}},\\\"7\\\":{\\\"class_type\\\":\\\"CLIPTextEncode\\\",\\\"inputs\\\":{\\\"text\\\":\\\"blurry, lowres\\\",\\\"clip\\\":[\\\"4\\\",1]}},\\\"8\\\":{\\\"class_type\\\":\\\"VAEDecode\\\",\\\"inputs\\\":{\\\"samples\\\":[\\\"3\\\",0],\\\"vae\\\":[\\\"4\\\",2]}},\\\"9\\\":{\\\"class_type\\\":\\\"SaveVideo\\\",\\\"inputs\\\":{\\\"filename_prefix\\\":\\\"video/synthetic\\\",\\\"video\\\":[\\\"8\\\",0]}}}\", \"workflow\": {\"last_node_id\": 9, \"nodes\": [{\"id\": 3, \"type\": \"KSampler\", \"widgets_values\": [7, \"fixed\", 20, 8.0, \"euler\", \"normal\", 1.0]}, {\"id\": 6, \"type\": \"CLIPTextEncode\", \"widgets_values\": [\"a red fox in snow, detailed fur\"]}, {\"id\": 7, \"type\": \"CLIPTextEncode\", \"widgets_values\": [\"blurry, lowres\"]}], \"links\": [], \"version\": 0.4}}",
|
||||
"ENCODER": "Lavf63.1.102"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
"""One stand-in for ComfyUI's ``folder_paths`` module, shared by every test.
|
||||
|
||||
Test modules that import the API standalone need a ``folder_paths`` module.
|
||||
It must be a real module, not a ``MagicMock``: ``patch("folder_paths.x",
|
||||
create=True)`` then adds and removes plain attributes, while a mock would
|
||||
hand a later test an auto-created child (or lose the attribute on exit), so
|
||||
the outcome depended on which test module discovery imported first.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
|
||||
|
||||
def install_folder_paths_stub() -> types.ModuleType:
|
||||
"""Install the shared ``folder_paths`` stub unless a real module is there.
|
||||
|
||||
A test that installs its own stand-in for a while (and restores it) is
|
||||
left alone; a ``MagicMock`` left behind by older code is replaced.
|
||||
"""
|
||||
current = sys.modules.get("folder_paths")
|
||||
if isinstance(current, types.ModuleType):
|
||||
return current
|
||||
stub = types.ModuleType("folder_paths")
|
||||
stub.models_dir = tempfile.gettempdir()
|
||||
stub.base_path = tempfile.gettempdir()
|
||||
stub.get_output_directory = tempfile.gettempdir
|
||||
sys.modules["folder_paths"] = stub
|
||||
return stub
|
||||
@@ -296,3 +296,33 @@ test("get forwards an explicit cache mode and keeps the default request otherwis
|
||||
fetch.calls[2].resolve(jsonResponse(200, { success: true }));
|
||||
await writes;
|
||||
});
|
||||
|
||||
test("ApiError exposes only a validated current tag set from a tag-conflict body", async () => {
|
||||
const permitted = [{ tags: [], tag_revision: 0 }, { tags: ["warm", "Dune"], tag_revision: 7 }];
|
||||
const denied = [undefined, null, { tags: ["a"] }, { tag_revision: 1 }, { tags: "a", tag_revision: 1 },
|
||||
{ tags: [1], tag_revision: 1 }, { tags: ["a"], tag_revision: -1 }, { tags: ["a"], tag_revision: 1.5 },
|
||||
{ tags: ["a"], tag_revision: true }, { tags: Array(101).fill("a"), tag_revision: 1 }];
|
||||
assert.equal(new ApiError(409, "old caller").current, null);
|
||||
for (const current of permitted.concat(denied)) {
|
||||
const api = createApiClient(async () => jsonResponse(409, {
|
||||
success: false, error: "changed", code: "tag-conflict", ...(current || {}),
|
||||
}));
|
||||
await assert.rejects(api.put("/x", {}), error => {
|
||||
assert.ok(error instanceof ApiError);
|
||||
assert.deepEqual(error.current, permitted.includes(current) ? current : null);
|
||||
if (permitted.includes(current)) assert.notEqual(error.current.tags, current.tags, "server arrays must be copied");
|
||||
return true;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("keepalive is forwarded only when asked, so a request can outlive its page", async () => {
|
||||
const fetch = fakeFetch();
|
||||
const api = createApiClient(fetch);
|
||||
const writes = [api.del("/x", { keepalive: true }), api.put("/x", {}, { keepalive: true }), api.post("/x", {}, { keepalive: true }),
|
||||
api.del("/x"), api.put("/x", {}), api.get("/x")];
|
||||
assert.deepEqual(fetch.calls.map(call => call.init.keepalive), [true, true, true, undefined, undefined, undefined]);
|
||||
assert.equal("keepalive" in fetch.calls[3].init, false);
|
||||
for (const call of fetch.calls) call.resolve(jsonResponse(200, { success: true }));
|
||||
await Promise.all(writes);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
// Byte-exact PNG builders for the local reader tests (real CRC32 and zlib), and
|
||||
// File-like blobs: one that records every [start, end) slice over a sparse layout
|
||||
// that is never allocated, one that answers slowly, one that fails or shrinks.
|
||||
import zlib from "node:zlib";
|
||||
|
||||
export const SIGNATURE = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
|
||||
export function u32(n) {
|
||||
return Uint8Array.from([(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]);
|
||||
}
|
||||
|
||||
export function latin1(text) {
|
||||
return Buffer.from(text, "latin1");
|
||||
}
|
||||
|
||||
export function utf8(text) {
|
||||
return Buffer.from(text, "utf8");
|
||||
}
|
||||
|
||||
export function concat(...parts) {
|
||||
return Uint8Array.from(Buffer.concat(parts.map((part) => Buffer.from(part))));
|
||||
}
|
||||
|
||||
/**
|
||||
* One chunk with a real CRC32 over type + data. `crc` overrides the checksum and
|
||||
* `declaredLength` the length field (to declare more data than is present).
|
||||
*/
|
||||
export function chunk(type, data, { crc, declaredLength } = {}) {
|
||||
const typeBytes = latin1(type);
|
||||
const body = Buffer.from(data);
|
||||
const sum = crc ?? zlib.crc32(Buffer.concat([typeBytes, body]));
|
||||
return concat(u32(declaredLength ?? body.length), typeBytes, body, u32(sum));
|
||||
}
|
||||
|
||||
export function ihdr(width = 1, height = 1, options) {
|
||||
return chunk("IHDR", concat(u32(width), u32(height), [8, 6, 0, 0, 0]), options);
|
||||
}
|
||||
|
||||
export function idat(data = [0]) {
|
||||
return chunk("IDAT", data);
|
||||
}
|
||||
|
||||
export function iend(options) {
|
||||
return chunk("IEND", [], options);
|
||||
}
|
||||
|
||||
/** tEXt: keyword NUL text, Latin-1 (text may be a string or raw bytes). */
|
||||
export function tEXt(keyword, text, options) {
|
||||
const body = typeof text === "string" ? latin1(text) : Buffer.from(text);
|
||||
return chunk("tEXt", concat(latin1(keyword), [0], body), options);
|
||||
}
|
||||
|
||||
/** zTXt: keyword NUL method zlib(text). `compressed` replaces the deflated body. */
|
||||
export function zTXt(keyword, text, { method = 0, compressed, level = 6 } = {}) {
|
||||
const body = compressed ?? zlib.deflateSync(typeof text === "string" ? latin1(text) : Buffer.from(text), { level });
|
||||
return chunk("zTXt", concat(latin1(keyword), [0, method], body));
|
||||
}
|
||||
|
||||
/** iTXt: keyword NUL flag method lang NUL translated NUL text (UTF-8, deflated when flag). */
|
||||
export function iTXt(keyword, text, { flag = 0, method = 0, lang = "", translated = "", compressed } = {}) {
|
||||
const raw = typeof text === "string" ? utf8(text) : Buffer.from(text);
|
||||
const body = compressed ?? (flag ? zlib.deflateSync(raw) : raw);
|
||||
const langBytes = typeof lang === "string" ? latin1(lang) : Buffer.from(lang);
|
||||
const translatedBytes = typeof translated === "string" ? utf8(translated) : Buffer.from(translated);
|
||||
return concat(chunk("iTXt", concat(latin1(keyword), [0, flag, method], langBytes, [0], translatedBytes, [0], body)));
|
||||
}
|
||||
|
||||
/** A whole PNG: signature, IHDR, IDAT, the given chunks, IEND. */
|
||||
export function png(...chunks) {
|
||||
return concat(SIGNATURE, ihdr(), idat(), ...chunks, iend());
|
||||
}
|
||||
|
||||
/** Signature plus exactly the given chunks (no implied IHDR/IDAT/IEND). */
|
||||
export function raw(...chunks) {
|
||||
return concat(SIGNATURE, ...chunks);
|
||||
}
|
||||
|
||||
export function blobOf(bytes) {
|
||||
return new Blob([bytes]);
|
||||
}
|
||||
|
||||
/**
|
||||
* A File-like object over a sparse layout: `parts` are [{offset, bytes}] placed in a
|
||||
* file of `size` bytes, zero everywhere else; nothing of `size` is allocated. Every
|
||||
* slice is recorded in `ranges` as [start, end).
|
||||
*/
|
||||
export function sparseBlob(parts, size) {
|
||||
const ranges = [];
|
||||
function bytesOf(start, end) {
|
||||
const out = new Uint8Array(end - start);
|
||||
for (const part of parts) {
|
||||
const from = Math.max(start, part.offset);
|
||||
const to = Math.min(end, part.offset + part.bytes.length);
|
||||
if (from < to) out.set(part.bytes.subarray(from - part.offset, to - part.offset), from - start);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return {
|
||||
size,
|
||||
ranges,
|
||||
slice(start, end) {
|
||||
ranges.push([start, end]);
|
||||
return { arrayBuffer: async () => bytesOf(start, end).buffer };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A recording blob over real bytes. */
|
||||
export function recordingBlob(bytes) {
|
||||
return sparseBlob([{ offset: 0, bytes }], bytes.length);
|
||||
}
|
||||
|
||||
/** Every slice resolves after `delayMs`. */
|
||||
export function slowBlob(bytes, delayMs) {
|
||||
return {
|
||||
size: bytes.length,
|
||||
slice(start, end) {
|
||||
return {
|
||||
arrayBuffer: () => new Promise((resolve) => setTimeout(() => resolve(bytes.slice(start, end).buffer), delayMs)),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Slices fail (as a browser does when the file vanished). */
|
||||
export function failingBlob(size) {
|
||||
return {
|
||||
size,
|
||||
slice() {
|
||||
return { arrayBuffer: () => Promise.reject(new Error("NotReadableError")) };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Reports `size` but every slice beyond `actual` bytes comes back short. */
|
||||
export function shrunkBlob(bytes, size) {
|
||||
return {
|
||||
size,
|
||||
slice(start, end) {
|
||||
return { arrayBuffer: async () => bytes.slice(start, Math.min(end, bytes.length)).buffer };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** An A1111 `parameters` record with one settings footer carrying a batch index. */
|
||||
export function parametersText(positive, batchIndex, { seedField = "Seed: 1" } = {}) {
|
||||
return `${positive}\nNegative prompt: blurry\nSteps: 20, Sampler: Euler, Batch index: ${batchIndex}, ${seedField}`;
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
// Byte-exact ISO-BMFF (MP4/MOV/M4V) and Matroska (MKV/WebM) builders for the local
|
||||
// video tag reader tests, laid out as ffmpeg's muxers write them (see the dumps in
|
||||
// tests/fixtures/local-inspect). The File-like blobs come from png-bytes.mjs.
|
||||
import { concat, u32, utf8, latin1 } from "./png-bytes.mjs";
|
||||
|
||||
export { concat, u32, utf8, latin1 };
|
||||
export { sparseBlob, recordingBlob, slowBlob, failingBlob, blobOf } from "./png-bytes.mjs";
|
||||
|
||||
const toBytes = (part) => (typeof part === "string" ? utf8(part) : Buffer.from(part));
|
||||
|
||||
function u64(n) {
|
||||
const out = new Uint8Array(8);
|
||||
new DataView(out.buffer).setBigUint64(0, BigInt(n));
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- ISO-BMFF
|
||||
|
||||
/** A box: 32-bit size, 4-byte type (string or raw bytes), payload. `size` overrides the field. */
|
||||
export function box(type, ...parts) {
|
||||
const options = parts.length && parts[parts.length - 1] && parts[parts.length - 1].__options ? parts.pop() : {};
|
||||
const payload = concat(...parts.map(toBytes));
|
||||
const typeBytes = typeof type === "string" ? latin1(type) : Buffer.from(type);
|
||||
const total = 8 + payload.length;
|
||||
if (options.largesize) return concat(u32(1), typeBytes, u64(options.largesize === true ? total + 8 : options.largesize), payload);
|
||||
return concat(u32(options.size ?? total), typeBytes, payload);
|
||||
}
|
||||
|
||||
/** Marker for `box` options: box('ftyp', ..., opts({size: 3})). */
|
||||
export function opts(options) {
|
||||
return { __options: true, ...options };
|
||||
}
|
||||
|
||||
export function fullBox(type, ...parts) {
|
||||
return box(type, u32(0), ...parts);
|
||||
}
|
||||
|
||||
export function ftyp(brand = "isom", compatible = "isomiso2avc1mp41") {
|
||||
return box("ftyp", latin1(brand), u32(512), latin1(compatible));
|
||||
}
|
||||
|
||||
export function mdat(payload = [0, 0, 0, 0]) {
|
||||
return box("mdat", payload);
|
||||
}
|
||||
|
||||
export const moov = (...children) => box("moov", ...children);
|
||||
export const udta = (...children) => box("udta", ...children);
|
||||
export const free = (count = 0) => box("free", new Uint8Array(count));
|
||||
|
||||
/** `meta` as ffmpeg writes it: a FullBox (version/flags) whose first child is hdlr. */
|
||||
export function meta(...children) {
|
||||
return fullBox("meta", ...children);
|
||||
}
|
||||
|
||||
export function hdlr(handler = "mdta") {
|
||||
return fullBox("hdlr", u32(0), latin1(handler), new Uint8Array(12), [0]);
|
||||
}
|
||||
|
||||
/** `keys`: one mdta entry per name (an entry may be [name, namespace]); `count` overrides the count field. */
|
||||
export function keys(names, { count } = {}) {
|
||||
const entries = names.map((entry) => {
|
||||
const [name, namespace] = Array.isArray(entry) ? entry : [entry, "mdta"];
|
||||
const bytes = toBytes(name);
|
||||
return concat(u32(8 + bytes.length), latin1(namespace), bytes);
|
||||
});
|
||||
return fullBox("keys", u32(count ?? names.length), ...entries);
|
||||
}
|
||||
|
||||
export const ilst = (...items) => box("ilst", ...items);
|
||||
|
||||
/** A `data` atom: type indicator (1 = UTF-8), locale, payload. */
|
||||
export function data(payload, { type = 1, locale = 0, size } = {}) {
|
||||
return box("data", u32(type), u32(locale), toBytes(payload), ...(size === undefined ? [] : [opts({ size })]));
|
||||
}
|
||||
|
||||
/** An ilst item addressed by its 1-based `keys` index. */
|
||||
export function item(index, ...children) {
|
||||
return box(u32(index), ...children);
|
||||
}
|
||||
|
||||
/** An ilst item addressed by a legacy fourcc such as "©cmt" (Latin-1 bytes). */
|
||||
export function legacyItem(fourcc, ...children) {
|
||||
return box(latin1(fourcc), ...children);
|
||||
}
|
||||
|
||||
/** `moov/udta/meta` carrying per-key mdta tags, as core SaveVideo writes through ffmpeg. */
|
||||
export function mdtaUdta(tags) {
|
||||
const names = Object.keys(tags);
|
||||
return udta(meta(hdlr("mdta"), keys(names), ilst(...names.map((name, index) => item(index + 1, data(tags[name]))))));
|
||||
}
|
||||
|
||||
/** `moov/udta/meta` with legacy ilst atoms: {comment, description} → ©cmt / desc. */
|
||||
export function legacyUdta({ comment, description, tool = "Lavf" } = {}) {
|
||||
const items = [legacyItem("©too", data(tool))];
|
||||
if (comment !== undefined) items.push(legacyItem("©cmt", data(comment)));
|
||||
if (description !== undefined) items.push(legacyItem("desc", data(description)));
|
||||
return udta(meta(hdlr("mdir"), ilst(...items)));
|
||||
}
|
||||
|
||||
/** A whole MP4: ftyp, (moov first when faststart), mdat, moov with the given udta. */
|
||||
export function mp4(udtaBox, { brand = "isom", faststart = false, extraMoov = [], payload } = {}) {
|
||||
const movie = moov(...extraMoov, udtaBox);
|
||||
const media = mdat(payload);
|
||||
return faststart ? concat(ftyp(brand), movie, media) : concat(ftyp(brand), free(), media, movie);
|
||||
}
|
||||
|
||||
export function perKeyMp4(tags, options) {
|
||||
return mp4(mdtaUdta(tags), options);
|
||||
}
|
||||
|
||||
/** Top-level boxes of `bytes` as [{type, start, end}]. */
|
||||
export function topLevelBoxes(bytes) {
|
||||
const out = [];
|
||||
let offset = 0;
|
||||
while (offset + 8 <= bytes.length) {
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset + offset);
|
||||
let size = view.getUint32(0);
|
||||
if (size === 1) size = Number(view.getBigUint64(8));
|
||||
if (size === 0) size = bytes.length - offset;
|
||||
out.push({ type: latin1Of(bytes.subarray(offset + 4, offset + 8)), start: offset, end: offset + size });
|
||||
offset += size;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function latin1Of(bytes) {
|
||||
return Buffer.from(bytes).toString("latin1");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Matroska
|
||||
|
||||
export const ID = {
|
||||
EBML: 0x1a45dfa3,
|
||||
EBMLVersion: 0x4286,
|
||||
DocType: 0x4282,
|
||||
DocTypeVersion: 0x4287,
|
||||
Segment: 0x18538067,
|
||||
SeekHead: 0x114d9b74,
|
||||
Seek: 0x4dbb,
|
||||
SeekID: 0x53ab,
|
||||
SeekPosition: 0x53ac,
|
||||
Void: 0xec,
|
||||
CRC32: 0xbf,
|
||||
Info: 0x1549a966,
|
||||
Tracks: 0x1654ae6b,
|
||||
Tags: 0x1254c367,
|
||||
Tag: 0x7373,
|
||||
Targets: 0x63c0,
|
||||
TargetTypeValue: 0x68ca,
|
||||
TagTrackUID: 0x63c5,
|
||||
SimpleTag: 0x67c8,
|
||||
TagName: 0x45a3,
|
||||
TagString: 0x4487,
|
||||
TagLanguage: 0x447a,
|
||||
TagDefault: 0x4484,
|
||||
TagBinary: 0x4485,
|
||||
Cluster: 0x1f43b675,
|
||||
Timestamp: 0xe7,
|
||||
Cues: 0x1c53bb6b,
|
||||
};
|
||||
|
||||
/** The ID's bytes (EBML IDs carry their own length marker). */
|
||||
export function idBytes(id) {
|
||||
const out = [];
|
||||
let value = id;
|
||||
while (value > 0) {
|
||||
out.unshift(value & 0xff);
|
||||
value = Math.floor(value / 256);
|
||||
}
|
||||
return Uint8Array.from(out);
|
||||
}
|
||||
|
||||
/** A size VINT: minimal width unless `width` is given; `unknown` writes all ones. */
|
||||
export function vint(value, { width, unknown = false } = {}) {
|
||||
let length = width;
|
||||
if (!length) {
|
||||
length = 1;
|
||||
while (length < 8 && value >= 2 ** (7 * length) - 1) length += 1;
|
||||
}
|
||||
const out = new Uint8Array(length);
|
||||
if (unknown) {
|
||||
out.fill(0xff);
|
||||
out[0] = 0xff >> (length - 1);
|
||||
return out;
|
||||
}
|
||||
let rest = BigInt(value);
|
||||
for (let index = length - 1; index >= 0; index -= 1) {
|
||||
out[index] = Number(rest & 0xffn);
|
||||
rest >>= 8n;
|
||||
}
|
||||
out[0] |= 0x80 >> (length - 1);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** An element: ID, size VINT, payload. `size`/`width`/`unknown` shape the size field. */
|
||||
export function el(id, ...parts) {
|
||||
const options = parts.length && parts[parts.length - 1] && parts[parts.length - 1].__options ? parts.pop() : {};
|
||||
const payload = concat(...parts.map(toBytes));
|
||||
const size = vint(options.size ?? payload.length, { width: options.width, unknown: options.unknown });
|
||||
return concat(idBytes(id), size, payload);
|
||||
}
|
||||
|
||||
export function uint(value, width) {
|
||||
const out = [];
|
||||
let rest = BigInt(value);
|
||||
do {
|
||||
out.unshift(Number(rest & 0xffn));
|
||||
rest >>= 8n;
|
||||
} while (rest > 0n);
|
||||
while (width && out.length < width) out.unshift(0);
|
||||
return Uint8Array.from(out);
|
||||
}
|
||||
|
||||
export function ebmlHeader(docType = "matroska") {
|
||||
return el(ID.EBML, el(ID.EBMLVersion, [1]), el(ID.DocType, docType), el(ID.DocTypeVersion, [4]));
|
||||
}
|
||||
|
||||
/** Segment with an 8-byte size field as ffmpeg writes it; `unknown` makes it unknown-sized. */
|
||||
export function segment(children, { unknown = false } = {}) {
|
||||
return el(ID.Segment, ...children, opts({ width: 8, unknown }));
|
||||
}
|
||||
|
||||
/** SeekHead entries: [[id, position]] with positions relative to the Segment payload. */
|
||||
export function seekHead(entries) {
|
||||
return el(ID.SeekHead, ...entries.map(([id, position]) => el(ID.Seek, el(ID.SeekID, idBytes(id)), el(ID.SeekPosition, uint(position)))));
|
||||
}
|
||||
|
||||
export function voidEl(count = 4) {
|
||||
return el(ID.Void, new Uint8Array(count));
|
||||
}
|
||||
|
||||
/** A SimpleTag with TagName and TagString (`value` undefined omits the string). */
|
||||
export function simpleTag(name, value, { lang, def, binary, nested = [], extra = [] } = {}) {
|
||||
const children = [el(ID.TagName, name)];
|
||||
if (lang !== undefined) children.push(el(ID.TagLanguage, lang));
|
||||
if (def !== undefined) children.push(el(ID.TagDefault, [def]));
|
||||
if (value !== undefined) children.push(el(ID.TagString, value));
|
||||
if (binary !== undefined) children.push(el(ID.TagBinary, binary));
|
||||
return el(ID.SimpleTag, ...children, ...nested, ...extra);
|
||||
}
|
||||
|
||||
/** A Tag whose Targets carry the given track UID (none → global). */
|
||||
export function tag(simpleTags, { trackUid, targetType } = {}) {
|
||||
const targets = [];
|
||||
if (targetType !== undefined) targets.push(el(ID.TargetTypeValue, uint(targetType)));
|
||||
if (trackUid !== undefined) targets.push(el(ID.TagTrackUID, uint(trackUid, 8)));
|
||||
return el(ID.Tag, el(ID.Targets, ...targets), ...simpleTags);
|
||||
}
|
||||
|
||||
export function tagsEl(...tagElements) {
|
||||
return el(ID.Tags, ...tagElements);
|
||||
}
|
||||
|
||||
/** A Cluster of `size` payload bytes (zeros); `unknown` makes its size unknown. */
|
||||
export function cluster(size = 8, { unknown = false, payload } = {}) {
|
||||
return el(ID.Cluster, payload ?? new Uint8Array(size), opts({ unknown }));
|
||||
}
|
||||
|
||||
/** A Cluster header only, declaring `size` payload bytes (for sparse layouts). */
|
||||
export function clusterHeader(size) {
|
||||
return concat(idBytes(ID.Cluster), vint(size, { width: 8 }));
|
||||
}
|
||||
|
||||
/** A whole Matroska file: EBML header + Segment(children). */
|
||||
export function mkv(children, { docType = "matroska", unknown = false } = {}) {
|
||||
return concat(ebmlHeader(docType), segment(children, { unknown }));
|
||||
}
|
||||
|
||||
/** A global Tags element from {name: value} (ffmpeg upper-cases names at mux time). */
|
||||
export function globalTags(tags, options) {
|
||||
return tagsEl(tag(Object.entries(tags).map(([name, value]) => simpleTag(name, value)), options));
|
||||
}
|
||||
|
||||
/** A Matroska file whose global Tags hold {name: value}, ffmpeg layout. */
|
||||
export function perKeyMkv(tags, { docType = "matroska" } = {}) {
|
||||
return mkv([el(ID.Info, new Uint8Array(4)), globalTags(tags), cluster()], { docType });
|
||||
}
|
||||
@@ -0,0 +1,549 @@
|
||||
// Run with: node --test tests/js/
|
||||
// The browser PNG File reader is a port of utils/prompt_inspector.py's bounded
|
||||
// reader (_png_metadata, _PngRecords, _png_text, _inflate, _batch_normalized). Each
|
||||
// rule below names the Python branch it mirrors; tests/test_local_inspect_parity.py
|
||||
// proves the two agree on the committed parity corpus.
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const zlib = require("node:zlib");
|
||||
const { classifyPrefix, readPngMetadataFile, readImageHeader } = require("../../web/js/png-metadata.js");
|
||||
const bytes = require("./helpers/png-bytes.mjs");
|
||||
|
||||
const { png, raw, ihdr, idat, iend, tEXt, zTXt, iTXt, chunk, blobOf, sparseBlob, recordingBlob, slowBlob, failingBlob, shrunkBlob, concat, u32, latin1, parametersText } = bytes;
|
||||
|
||||
const MiB = 1024 * 1024;
|
||||
const ROLE_BUDGET = 2 * MiB;
|
||||
const WORKFLOW_BUDGET = 4 * MiB;
|
||||
const PNG_READ_CEILING = 19794389;
|
||||
const FIXTURES = path.join(__dirname, "..", "fixtures", "local-inspect");
|
||||
|
||||
async function read(fileBytes, options) {
|
||||
return readPngMetadataFile(blobOf(fileBytes), options);
|
||||
}
|
||||
|
||||
function fill(byte, count) {
|
||||
return Buffer.alloc(count, byte);
|
||||
}
|
||||
|
||||
test("contract: the reader, classifier and header reader are exported", () => {
|
||||
assert.equal(typeof readPngMetadataFile, "function");
|
||||
assert.equal(typeof classifyPrefix, "function");
|
||||
assert.equal(typeof readImageHeader, "function");
|
||||
});
|
||||
|
||||
test("reads the four retained records and the IHDR size from a core SaveImage PNG", async () => {
|
||||
const result = await read(raw(
|
||||
ihdr(640, 480),
|
||||
idat(),
|
||||
tEXt("prompt", '{"1":{"class_type":"KSampler"}}'),
|
||||
iTXt("workflow", '{"nodes":[]}'),
|
||||
tEXt("parameters", "cat\nSteps: 20, Seed: 1"),
|
||||
zTXt("comfyassets_prompt_pairs", '{"pairs":[]}'),
|
||||
tEXt("Software", "ComfyUI"),
|
||||
iend(),
|
||||
));
|
||||
assert.deepEqual(result, {
|
||||
status: "complete",
|
||||
records: {
|
||||
prompt: '{"1":{"class_type":"KSampler"}}',
|
||||
workflow: '{"nodes":[]}',
|
||||
parameters: "cat\nSteps: 20, Seed: 1",
|
||||
comfyassets_prompt_pairs: '{"pairs":[]}',
|
||||
},
|
||||
limited: [],
|
||||
width: 640,
|
||||
height: 480,
|
||||
});
|
||||
});
|
||||
|
||||
test("local PNG skips sparse pixels and reads post-IDAT text", async () => {
|
||||
// Two 1.5 GiB IDAT chunks (each under the 0x7FFFFFFF chunk ceiling) with the
|
||||
// prompt after them: nothing of the 3 GiB is read or allocated.
|
||||
const half = 1.5 * 1024 * 1024 * 1024;
|
||||
const head = concat(bytes.SIGNATURE, ihdr(8192, 8192), u32(half), latin1("IDAT"));
|
||||
const idat1 = { start: bytes.SIGNATURE.length + ihdr().length, end: bytes.SIGNATURE.length + ihdr().length + 8 + half + 4 };
|
||||
const idat2 = { start: idat1.end, end: idat1.end + 8 + half + 4 };
|
||||
const between = concat(u32(0), u32(half), latin1("IDAT"));
|
||||
const tail = concat(u32(0), tEXt("prompt", '{"3":{"class_type":"KSampler"}}'), iend());
|
||||
const size = idat2.end - 4 + tail.length;
|
||||
const file = sparseBlob([
|
||||
{ offset: 0, bytes: head },
|
||||
{ offset: idat1.end - 4, bytes: between },
|
||||
{ offset: idat2.end - 4, bytes: tail },
|
||||
], size);
|
||||
|
||||
const result = await readPngMetadataFile(file);
|
||||
|
||||
assert.equal(result.status, "complete");
|
||||
assert.deepEqual(result.records, { prompt: '{"3":{"class_type":"KSampler"}}' });
|
||||
assert.deepEqual([result.width, result.height], [8192, 8192]);
|
||||
const insideIdat = file.ranges.filter(([start, end]) => [idat1, idat2].some((region) => start >= region.start && end <= region.end));
|
||||
assert.ok(insideIdat.length >= 2, "the IDAT headers were read");
|
||||
for (const [start, end] of insideIdat) assert.ok(end - start <= 12, `range inside IDAT longer than 12: [${start}, ${end})`);
|
||||
const total = file.ranges.reduce((sum, [start, end]) => sum + (end - start), 0);
|
||||
assert.ok(total <= PNG_READ_CEILING, `read ${total} bytes`);
|
||||
});
|
||||
|
||||
test("serves the retained prefix without slicing it again", async () => {
|
||||
const whole = png(tEXt("prompt", "p"));
|
||||
const file = recordingBlob(whole);
|
||||
const result = await readPngMetadataFile(file, { prefix: whole.subarray(0, 32) });
|
||||
assert.equal(result.status, "complete");
|
||||
assert.ok(file.ranges.every(([start]) => start >= 32), JSON.stringify(file.ranges));
|
||||
assert.deepEqual(result.records, { prompt: "p" });
|
||||
});
|
||||
|
||||
test("unknown text keys are skipped by length without reading their body", async () => {
|
||||
const body = fill(0x41, 1000);
|
||||
const whole = png(tEXt("Software", body), tEXt("prompt", "p"));
|
||||
const softwareStart = bytes.SIGNATURE.length + ihdr().length + idat().length;
|
||||
const bodyStart = softwareStart + 8 + "Software".length + 1;
|
||||
const bodyEnd = bodyStart + body.length + 4;
|
||||
const file = recordingBlob(whole);
|
||||
const result = await readPngMetadataFile(file);
|
||||
assert.equal(result.status, "complete");
|
||||
const insideBody = file.ranges.filter(([start, end]) => start < bodyEnd && end > softwareStart + 8 + 80);
|
||||
assert.deepEqual(insideBody, [], "the body past the 80-byte keyword prefix was sliced");
|
||||
});
|
||||
|
||||
const WF_OVER = WORKFLOW_BUDGET + 1;
|
||||
const bigWorkflow = () => tEXt("workflow", fill(0x7b, WF_OVER - "workflow".length - 1));
|
||||
|
||||
// One row per Python branch (prompt_inspector.py:91-260).
|
||||
const RULES = [
|
||||
{ name: "bad IHDR CRC", file: () => raw(ihdr(1, 1, { crc: 1 }), idat(), iend()), expect: { status: "unreadable", reason: "invalid-png" } },
|
||||
{ name: "bad text CRC", file: () => png(tEXt("prompt", "p", { crc: 1 })), expect: { status: "unreadable", reason: "invalid-png" } },
|
||||
{ name: "bad IEND CRC", file: () => raw(ihdr(), idat(), iend({ crc: 1 })), expect: { status: "unreadable", reason: "invalid-png" } },
|
||||
{ name: "first chunk not IHDR", file: () => raw(idat(), ihdr(), iend()), expect: { status: "unreadable", reason: "invalid-png" } },
|
||||
{ name: "IHDR not 13 bytes", file: () => raw(chunk("IHDR", concat(u32(1), u32(1), [8, 6, 0, 0])), idat(), iend()), expect: { status: "unreadable", reason: "invalid-png" } },
|
||||
{ name: "second IHDR", file: () => raw(ihdr(), idat(), ihdr(), iend()), expect: { status: "unreadable", reason: "invalid-png" } },
|
||||
{ name: "zero width", file: () => raw(ihdr(0, 1), idat(), iend()), expect: { status: "unreadable", reason: "invalid-png" } },
|
||||
{ name: "zero height", file: () => raw(ihdr(1, 0), idat(), iend()), expect: { status: "unreadable", reason: "invalid-png" } },
|
||||
{ name: "length 0x80000000", file: () => raw(ihdr(), idat(), chunk("tEXt", latin1("k\0v"), { declaredLength: 0x80000000 }), iend()), expect: { status: "unreadable", reason: "invalid-png" } },
|
||||
{ name: "end past size", file: () => raw(ihdr(), idat(), chunk("tEXt", latin1("k\0v"), { declaredLength: 100 }), iend()), expect: { status: "unreadable", reason: "invalid-png" } },
|
||||
{ name: "file ends inside a chunk header", file: () => raw(ihdr(), idat(), u32(5)), expect: { status: "unreadable", reason: "invalid-png" } },
|
||||
{ name: "file ends before any chunk", file: () => raw(), expect: { status: "unreadable", reason: "invalid-png" } },
|
||||
{ name: "too short for a signature", file: () => bytes.SIGNATURE.subarray(0, 5), expect: { status: "unreadable", reason: "invalid-png" } },
|
||||
{ name: "not a PNG", file: () => latin1("GIF89a................................."), expect: { status: "unreadable", reason: "invalid-png" } },
|
||||
{ name: "IEND without IDAT", file: () => raw(ihdr(), tEXt("prompt", "p"), iend()), expect: { status: "unreadable", reason: "invalid-png" } },
|
||||
{ name: "nonzero IEND length", file: () => raw(ihdr(), idat(), chunk("IEND", [1])), expect: { status: "unreadable", reason: "invalid-png" } },
|
||||
{ name: "duplicate key with a different length", file: () => png(tEXt("prompt", "one"), tEXt("prompt", "other")), expect: { status: "unreadable", reason: "invalid-png" } },
|
||||
{ name: "duplicate key, same length, different bytes", file: () => png(tEXt("prompt", "one"), tEXt("prompt", "two")), expect: { status: "unreadable", reason: "invalid-png" } },
|
||||
{ name: "duplicate key, same bytes, different chunk type", file: () => png(tEXt("prompt", "one"), chunk("zTXt", latin1("prompt\0one"))), expect: { status: "unreadable", reason: "invalid-png" } },
|
||||
{
|
||||
name: "byte-identical duplicate kept once",
|
||||
file: () => png(tEXt("prompt", "same"), tEXt("prompt", "same"), tEXt("prompt", "same")),
|
||||
expect: { status: "complete", records: { prompt: "same" }, limited: [], width: 1, height: 1 },
|
||||
},
|
||||
{
|
||||
name: "parameters copies differing only in Batch index keep the last",
|
||||
file: () => png(tEXt("parameters", parametersText("cat", 0)), tEXt("parameters", parametersText("cat", 1)), tEXt("parameters", parametersText("cat", 12))),
|
||||
expect: { status: "complete", records: { parameters: parametersText("cat", 12) }, limited: [], width: 1, height: 1 },
|
||||
},
|
||||
{ name: "parameters copies with any other difference", file: () => png(tEXt("parameters", parametersText("cat", 0)), tEXt("parameters", parametersText("dog", 1))), expect: { status: "unreadable", reason: "invalid-png" } },
|
||||
{ name: "parameters copies without a settings footer", file: () => png(tEXt("parameters", "cat"), tEXt("parameters", "dog")), expect: { status: "unreadable", reason: "invalid-png" } },
|
||||
{ name: "parameters copies with two footers", file: () => png(tEXt("parameters", `${parametersText("cat", 0)}\nSteps: 1, x`), tEXt("parameters", `${parametersText("cat", 1)}\nSteps: 1, x`)), expect: { status: "unreadable", reason: "invalid-png" } },
|
||||
{ name: "parameters copies with two batch indexes", file: () => png(tEXt("parameters", parametersText("cat", 0, { seedField: "Batch index: 0" })), tEXt("parameters", parametersText("cat", 1, { seedField: "Batch index: 1" }))), expect: { status: "unreadable", reason: "invalid-png" } },
|
||||
{ name: "parameters copies whose batch index is not a whole settings field", file: () => png(tEXt("parameters", "Steps: 20, Batch index: 0 trailing"), tEXt("parameters", "Steps: 20, Batch index: 1 trailing")), expect: { status: "unreadable", reason: "invalid-png" } },
|
||||
{
|
||||
name: "workflow 4 MiB + 1 encoded is limited, other records intact",
|
||||
file: () => png(tEXt("prompt", "p"), bigWorkflow(), tEXt("parameters", "cat\nSteps: 1, x")),
|
||||
expect: { status: "complete", records: { prompt: "p", parameters: "cat\nSteps: 1, x" }, limited: ["workflow"], width: 1, height: 1 },
|
||||
},
|
||||
{
|
||||
name: "a duplicate of a limited workflow is skipped",
|
||||
file: () => png(bigWorkflow(), bigWorkflow(), tEXt("prompt", "p")),
|
||||
expect: { status: "complete", records: { prompt: "p" }, limited: ["workflow"], width: 1, height: 1 },
|
||||
},
|
||||
{
|
||||
name: "workflow over 4 MiB decoded (zTXt) is limited",
|
||||
file: () => png(zTXt("workflow", fill(0x7b, WORKFLOW_BUDGET + 1), { level: 9 }), tEXt("prompt", "p")),
|
||||
expect: { status: "complete", records: { prompt: "p" }, limited: ["workflow"], width: 1, height: 1 },
|
||||
},
|
||||
{
|
||||
name: "workflow at exactly 4 MiB decoded is kept",
|
||||
file: () => png(zTXt("workflow", fill(0x7b, WORKFLOW_BUDGET), { level: 9 })),
|
||||
check: (result) => {
|
||||
assert.equal(result.status, "complete");
|
||||
assert.equal(result.records.workflow.length, WORKFLOW_BUDGET);
|
||||
assert.deepEqual(result.limited, []);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "workflow under 4 MiB inflated but over 4 MiB as UTF-8 is limited",
|
||||
file: () => png(zTXt("workflow", fill(0xe9, WORKFLOW_BUDGET - 10), { level: 9 }), tEXt("prompt", "p")),
|
||||
expect: { status: "complete", records: { prompt: "p" }, limited: ["workflow"], width: 1, height: 1 },
|
||||
},
|
||||
{ name: "roles 2 MiB + 1 encoded", file: () => png(tEXt("prompt", fill(0x41, ROLE_BUDGET + 1 - "prompt".length - 1))), expect: { status: "unreadable", reason: "metadata-limit" } },
|
||||
{ name: "roles summed over 2 MiB encoded", file: () => png(tEXt("prompt", fill(0x41, 1.5 * MiB)), tEXt("parameters", fill(0x41, 0.5 * MiB))), expect: { status: "unreadable", reason: "metadata-limit" } },
|
||||
{ name: "roles 2 MiB + 1 decoded via zTXt", file: () => png(zTXt("prompt", fill(0x41, ROLE_BUDGET + 1), { level: 9 })), expect: { status: "unreadable", reason: "metadata-limit" } },
|
||||
{ name: "roles over 2 MiB as UTF-8 from Latin-1 high bytes", file: () => png(tEXt("prompt", fill(0xe9, MiB + 1))), expect: { status: "unreadable", reason: "metadata-limit" } },
|
||||
{
|
||||
name: "roles share the decoded budget across records",
|
||||
file: () => png(zTXt("prompt", fill(0x41, 1.5 * MiB), { level: 9 }), zTXt("parameters", fill(0x41, 0.5 * MiB + 1), { level: 9 })),
|
||||
expect: { status: "unreadable", reason: "metadata-limit" },
|
||||
},
|
||||
{
|
||||
name: "roles at exactly 2 MiB decoded are kept",
|
||||
file: () => png(zTXt("prompt", fill(0x41, 1.5 * MiB), { level: 9 }), zTXt("parameters", fill(0x41, 0.5 * MiB), { level: 9 })),
|
||||
check: (result) => {
|
||||
assert.equal(result.status, "complete");
|
||||
assert.equal(result.records.prompt.length + result.records.parameters.length, ROLE_BUDGET);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "duplicate workflow bytes capped at 8 MiB: the workflow becomes limited",
|
||||
file: () => {
|
||||
const workflow = tEXt("workflow", fill(0x7b, 3 * MiB));
|
||||
return png(workflow, workflow, workflow, workflow, tEXt("prompt", "p"));
|
||||
},
|
||||
expect: { status: "complete", records: { prompt: "p" }, limited: ["workflow"], width: 1, height: 1 },
|
||||
},
|
||||
{
|
||||
name: "duplicate workflow bytes within 8 MiB keep the workflow",
|
||||
file: () => {
|
||||
const workflow = tEXt("workflow", fill(0x7b, 3 * MiB));
|
||||
return png(workflow, workflow, workflow);
|
||||
},
|
||||
check: (result) => {
|
||||
assert.equal(result.status, "complete");
|
||||
assert.equal(result.records.workflow.length, 3 * MiB);
|
||||
assert.deepEqual(result.limited, []);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "duplicate role bytes capped at 4 MiB",
|
||||
file: () => {
|
||||
const prompt = tEXt("prompt", fill(0x41, 1.5 * MiB));
|
||||
return png(prompt, prompt, prompt, prompt);
|
||||
},
|
||||
expect: { status: "unreadable", reason: "metadata-limit" },
|
||||
},
|
||||
{
|
||||
name: "duplicate decoded parameters capped at 4 MiB",
|
||||
file: () => {
|
||||
const copy = (index) => zTXt("parameters", parametersText(fill(0x41, 1.5 * MiB).toString("latin1"), index), { level: 9 });
|
||||
return png(copy(0), copy(1), copy(2), copy(3));
|
||||
},
|
||||
expect: { status: "unreadable", reason: "metadata-limit" },
|
||||
},
|
||||
{
|
||||
name: "a longer batch copy draws the growth from the role budget",
|
||||
file: () => {
|
||||
const base = parametersText("", 9);
|
||||
const positive = fill(0x41, ROLE_BUDGET - base.length - 1).toString("latin1");
|
||||
return png(zTXt("parameters", parametersText(positive, 9), { level: 9 }), zTXt("parameters", parametersText(positive, 100), { level: 9 }));
|
||||
},
|
||||
expect: { status: "unreadable", reason: "metadata-limit" },
|
||||
},
|
||||
{
|
||||
name: "a longer batch copy within the role budget is kept",
|
||||
file: () => {
|
||||
const base = parametersText("", 9);
|
||||
const positive = fill(0x41, ROLE_BUDGET - base.length - 2).toString("latin1");
|
||||
return png(zTXt("parameters", parametersText(positive, 9), { level: 9 }), zTXt("parameters", parametersText(positive, 10), { level: 9 }));
|
||||
},
|
||||
check: (result) => {
|
||||
assert.equal(result.status, "complete");
|
||||
assert.ok(result.records.parameters.endsWith("Batch index: 10, Seed: 1"));
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "a batch copy inflating past the role budget",
|
||||
file: () => png(tEXt("parameters", parametersText("cat", 0)), zTXt("parameters", parametersText(fill(0x41, ROLE_BUDGET).toString("latin1"), 1), { level: 9 })),
|
||||
expect: { status: "unreadable", reason: "metadata-limit" },
|
||||
},
|
||||
{
|
||||
name: "10,000 chunks without IEND",
|
||||
file: () => concat(bytes.SIGNATURE, ihdr(), idat(), ...Array.from({ length: 9999 }, () => chunk("xxXx", [])), iend()),
|
||||
expect: { status: "unreadable", reason: "metadata-limit" },
|
||||
},
|
||||
{
|
||||
name: "IEND as the 10,000th chunk is read",
|
||||
file: () => concat(bytes.SIGNATURE, ihdr(), idat(), ...Array.from({ length: 9997 }, () => chunk("xxXx", [])), iend()),
|
||||
expect: { status: "complete", records: {}, limited: [], width: 1, height: 1 },
|
||||
},
|
||||
{ name: "zTXt method not 0", file: () => png(zTXt("prompt", "p", { method: 1 })), expect: { status: "unreadable", reason: "invalid-png" } },
|
||||
{ name: "zTXt without a method byte", file: () => png(chunk("zTXt", latin1("prompt\0"))), expect: { status: "unreadable", reason: "invalid-png" } },
|
||||
{ name: "iTXt flag 2", file: () => png(iTXt("prompt", "p", { flag: 2 })), expect: { status: "unreadable", reason: "invalid-png" } },
|
||||
{ name: "iTXt compressed with method 1", file: () => png(iTXt("prompt", "p", { flag: 1, method: 1 })), expect: { status: "unreadable", reason: "invalid-png" } },
|
||||
{
|
||||
name: "iTXt uncompressed ignores the method byte",
|
||||
file: () => png(iTXt("prompt", "p", { flag: 0, method: 7 })),
|
||||
expect: { status: "complete", records: { prompt: "p" }, limited: [], width: 1, height: 1 },
|
||||
},
|
||||
{ name: "iTXt shorter than flag and method", file: () => png(chunk("iTXt", latin1("prompt\0\0"))), expect: { status: "unreadable", reason: "invalid-png" } },
|
||||
{ name: "iTXt missing the translated keyword separator", file: () => png(chunk("iTXt", concat(latin1("prompt\0"), [0, 0], latin1("en\0text")))), expect: { status: "unreadable", reason: "invalid-png" } },
|
||||
{ name: "iTXt non-ASCII language tag", file: () => png(iTXt("prompt", "p", { lang: [0xc3, 0xa9] })), expect: { status: "unreadable", reason: "invalid-png" } },
|
||||
{ name: "iTXt invalid UTF-8 translated keyword", file: () => png(iTXt("prompt", "p", { translated: [0xff] })), expect: { status: "unreadable", reason: "invalid-png" } },
|
||||
{ name: "iTXt invalid UTF-8 text", file: () => png(iTXt("prompt", [0xc3, 0x28])), expect: { status: "unreadable", reason: "invalid-png" } },
|
||||
{ name: "iTXt encoded surrogate", file: () => png(iTXt("prompt", [0xed, 0xa0, 0x80])), expect: { status: "unreadable", reason: "invalid-png" } },
|
||||
{ name: "NUL in tEXt text", file: () => png(tEXt("prompt", "a\0b")), expect: { status: "unreadable", reason: "invalid-png" } },
|
||||
{ name: "NUL in inflated zTXt text", file: () => png(zTXt("prompt", "a\0b")), expect: { status: "unreadable", reason: "invalid-png" } },
|
||||
{ name: "NUL in iTXt text", file: () => png(iTXt("prompt", "a\0b")), expect: { status: "unreadable", reason: "invalid-png" } },
|
||||
{ name: "keyword of 0 bytes", file: () => png(chunk("tEXt", latin1("\0v"))), expect: { status: "unreadable", reason: "invalid-png" } },
|
||||
{ name: "keyword of 80 bytes", file: () => png(tEXt("k".repeat(80), "v")), expect: { status: "unreadable", reason: "invalid-png" } },
|
||||
{ name: "keyword without a NUL", file: () => png(chunk("tEXt", latin1("nonul"))), expect: { status: "unreadable", reason: "invalid-png" } },
|
||||
{ name: "empty text chunk", file: () => png(chunk("tEXt", [])), expect: { status: "unreadable", reason: "invalid-png" } },
|
||||
{ name: "malformed keyword on an unknown key still fails", file: () => png(tEXt("prompt", "p"), chunk("tEXt", latin1("x".repeat(100)))), expect: { status: "unreadable", reason: "invalid-png" } },
|
||||
{
|
||||
name: "keyword of 79 bytes is read",
|
||||
file: () => png(tEXt("k".repeat(79), "v"), tEXt("prompt", "p")),
|
||||
expect: { status: "complete", records: { prompt: "p" }, limited: [], width: 1, height: 1 },
|
||||
},
|
||||
{
|
||||
name: "Latin-1 0xE9 is an e-acute in tEXt and zTXt",
|
||||
file: () => png(tEXt("prompt", [0xe9]), zTXt("parameters", [0xe9, 0x0a, 0x53, 0x74, 0x65, 0x70, 0x73, 0x3a, 0x20, 0x31])),
|
||||
expect: { status: "complete", records: { prompt: "é", parameters: "é\nSteps: 1" }, limited: [], width: 1, height: 1 },
|
||||
},
|
||||
{
|
||||
name: "iTXt BOM is preserved, compressed and not",
|
||||
file: () => png(iTXt("prompt", "{}"), iTXt("workflow", "[]", { flag: 1, lang: "en", translated: "wörkflow" })),
|
||||
expect: { status: "complete", records: { prompt: "{}", workflow: "[]" }, limited: [], width: 1, height: 1 },
|
||||
},
|
||||
{ name: "truncated zlib data", file: () => png(zTXt("prompt", "x".repeat(500), { compressed: zlib.deflateSync(latin1("x".repeat(500))).subarray(0, 8) })), expect: { status: "unreadable", reason: "invalid-png" } },
|
||||
{ name: "trailing bytes after the zlib stream", file: () => png(zTXt("prompt", "p", { compressed: concat(zlib.deflateSync(latin1("p")), [1, 2, 3]) })), expect: { status: "unreadable", reason: "invalid-png" } },
|
||||
{ name: "empty zlib data", file: () => png(zTXt("prompt", "p", { compressed: [] })), expect: { status: "unreadable", reason: "invalid-png" } },
|
||||
{ name: "garbage zlib data", file: () => png(zTXt("prompt", "p", { compressed: [1, 2, 3, 4, 5, 6, 7, 8] })), expect: { status: "unreadable", reason: "invalid-png" } },
|
||||
{ name: "truncated compressed iTXt", file: () => png(iTXt("prompt", "p", { flag: 1, compressed: zlib.deflateSync(latin1("x".repeat(500))).subarray(0, 8) })), expect: { status: "unreadable", reason: "invalid-png" } },
|
||||
{
|
||||
name: "chunks after IEND are ignored",
|
||||
file: () => concat(png(tEXt("prompt", "p")), tEXt("workflow", "w")),
|
||||
expect: { status: "complete", records: { prompt: "p" }, limited: [], width: 1, height: 1 },
|
||||
},
|
||||
{
|
||||
name: "non-ASCII chunk types are skipped",
|
||||
file: () => png(chunk("\xff\xfe\x80\x81", [1, 2, 3]), tEXt("prompt", "p")),
|
||||
expect: { status: "complete", records: { prompt: "p" }, limited: [], width: 1, height: 1 },
|
||||
},
|
||||
];
|
||||
|
||||
for (const rule of RULES) {
|
||||
test(`server rules, one by one: ${rule.name}`, async () => {
|
||||
const result = await read(rule.file());
|
||||
if (rule.check) rule.check(result);
|
||||
else assert.deepEqual(result, rule.expect);
|
||||
});
|
||||
}
|
||||
|
||||
test("decompress: undefined with zTXt is decompression-unavailable and yields no records", async () => {
|
||||
const result = await read(png(tEXt("prompt", "p"), zTXt("workflow", "w")), { decompress: undefined });
|
||||
assert.deepEqual(result, { status: "unreadable", reason: "decompression-unavailable" });
|
||||
const plain = await read(png(tEXt("prompt", "p"), zTXt("Comment", "skipped")), { decompress: undefined });
|
||||
assert.deepEqual(plain.records, { prompt: "p" });
|
||||
});
|
||||
|
||||
function instrumentedStream(transform) {
|
||||
const stats = { writes: [], inFlight: 0, maxInFlight: 0 };
|
||||
return {
|
||||
stats,
|
||||
Stream: class {
|
||||
constructor() {
|
||||
const stream = new TransformStream({
|
||||
async transform(chunk, controller) {
|
||||
stats.writes.push(chunk.byteLength);
|
||||
stats.inFlight += 1;
|
||||
stats.maxInFlight = Math.max(stats.maxInFlight, stats.inFlight);
|
||||
await transform(chunk, controller);
|
||||
stats.inFlight -= 1;
|
||||
},
|
||||
});
|
||||
this.writable = stream.writable;
|
||||
this.readable = stream.readable;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("inflation keeps one <=256-byte write in flight", async () => {
|
||||
const body = fill(0x41, 3000);
|
||||
const { stats, Stream } = instrumentedStream(async (chunk, controller) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
controller.enqueue(chunk);
|
||||
});
|
||||
// The fake "inflates" by passing bytes through, so the stored body is the text.
|
||||
const result = await read(png(chunk("zTXt", concat(latin1("prompt\0"), [0], body))), { decompress: Stream });
|
||||
assert.equal(result.status, "complete");
|
||||
assert.equal(result.records.prompt, body.toString("latin1"));
|
||||
assert.ok(stats.writes.every((size) => size <= 256), JSON.stringify(stats.writes));
|
||||
assert.equal(stats.writes.reduce((sum, size) => sum + size, 0), body.length);
|
||||
assert.equal(stats.maxInFlight, 1);
|
||||
});
|
||||
|
||||
test("inflation settles once on abort with an AbortError and no unhandled rejection", async () => {
|
||||
const unhandled = [];
|
||||
const onUnhandled = (reason) => unhandled.push(reason);
|
||||
process.on("unhandledRejection", onUnhandled);
|
||||
try {
|
||||
const { stats, Stream } = instrumentedStream(() => new Promise(() => {}));
|
||||
const controller = new AbortController();
|
||||
const pending = readPngMetadataFile(blobOf(png(zTXt("prompt", "p".repeat(1000)))), { decompress: Stream, signal: controller.signal });
|
||||
let settled = 0;
|
||||
pending.then(() => settled++, () => settled++);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
assert.equal(settled, 0, "the read waits on the stream");
|
||||
controller.abort();
|
||||
await assert.rejects(pending, (error) => error.name === "AbortError");
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
assert.equal(settled, 1);
|
||||
assert.equal(stats.maxInFlight, 1);
|
||||
assert.deepEqual(unhandled, []);
|
||||
} finally {
|
||||
process.off("unhandledRejection", onUnhandled);
|
||||
}
|
||||
});
|
||||
|
||||
test("a stream error under a live signal is still invalid-png, not an abort", async () => {
|
||||
const controller = new AbortController();
|
||||
const result = await read(png(zTXt("prompt", "p", { compressed: [1, 2, 3, 4, 5, 6, 7, 8] })), { signal: controller.signal });
|
||||
assert.deepEqual(result, { status: "unreadable", reason: "invalid-png" });
|
||||
assert.equal(controller.signal.aborted, false);
|
||||
});
|
||||
|
||||
test("an already aborted signal rejects before any slice", async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const file = recordingBlob(png(tEXt("prompt", "p")));
|
||||
await assert.rejects(readPngMetadataFile(file, { signal: controller.signal }), (error) => error.name === "AbortError");
|
||||
assert.deepEqual(file.ranges, []);
|
||||
});
|
||||
|
||||
test("aborting between slices rejects with an AbortError", async () => {
|
||||
const controller = new AbortController();
|
||||
const whole = png(tEXt("prompt", "p"));
|
||||
let slices = 0;
|
||||
const file = {
|
||||
size: whole.length,
|
||||
slice(start, end) {
|
||||
return {
|
||||
arrayBuffer: async () => {
|
||||
if (++slices === 3) controller.abort();
|
||||
return whole.slice(start, end).buffer;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
await assert.rejects(readPngMetadataFile(file, { signal: controller.signal }), (error) => error.name === "AbortError");
|
||||
});
|
||||
|
||||
test("aborting while inflating a real deflate stream rejects with an AbortError", async () => {
|
||||
const controller = new AbortController();
|
||||
let inflating = false;
|
||||
class Tracked extends DecompressionStream {
|
||||
constructor(format) {
|
||||
super(format);
|
||||
inflating = true;
|
||||
}
|
||||
}
|
||||
// The slow slices make the pacer yield inside the inflater's feed loop; that
|
||||
// yield aborts, so the real stream is torn down mid-inflation.
|
||||
const yieldControl = async () => {
|
||||
if (inflating) controller.abort();
|
||||
};
|
||||
const slow = slowBlob(png(zTXt("prompt", "p", { compressed: require("node:crypto").randomBytes(5000) })), 10);
|
||||
await assert.rejects(readPngMetadataFile(slow, { signal: controller.signal, yieldControl, decompress: Tracked }), (error) => error.name === "AbortError");
|
||||
assert.equal(inflating, true);
|
||||
});
|
||||
|
||||
test("yields control about every 8 ms while the file answers slowly", async () => {
|
||||
let yields = 0;
|
||||
const yieldControl = async () => {
|
||||
yields += 1;
|
||||
};
|
||||
const result = await readPngMetadataFile(slowBlob(png(tEXt("prompt", "p")), 10), { yieldControl });
|
||||
assert.equal(result.status, "complete");
|
||||
assert.ok(yields >= 1, `yielded ${yields} times`);
|
||||
});
|
||||
|
||||
test("the default yield lets the event loop turn", async () => {
|
||||
const result = await readPngMetadataFile(slowBlob(png(tEXt("prompt", "p")), 10));
|
||||
assert.deepEqual(result.records, { prompt: "p" });
|
||||
});
|
||||
|
||||
test("a slice that fails is a read failure", async () => {
|
||||
assert.deepEqual(await readPngMetadataFile(failingBlob(100)), { status: "unreadable", reason: "read-failure" });
|
||||
assert.deepEqual(await readPngMetadataFile(null), { status: "unreadable", reason: "read-failure" });
|
||||
});
|
||||
|
||||
test("a file that shrank under the reader is a read failure", async () => {
|
||||
const whole = png(tEXt("prompt", "p"));
|
||||
assert.deepEqual(await readPngMetadataFile(shrunkBlob(whole.subarray(0, 40), whole.length)), { status: "unreadable", reason: "read-failure" });
|
||||
});
|
||||
|
||||
test("classifier", () => {
|
||||
assert.deepEqual(classifyPrefix(bytes.SIGNATURE), { media: "image", kind: "png" });
|
||||
assert.deepEqual(classifyPrefix(Uint8Array.from([0xff, 0xd8, 0xff, 0xe0])), { media: "image", kind: "jpeg" });
|
||||
assert.deepEqual(classifyPrefix(latin1("RIFF\0\0\0\0WEBPVP8 ")), { media: "image", kind: "webp" });
|
||||
assert.deepEqual(classifyPrefix(latin1("\0\0\0\x18ftypisom\0\0\0\0")), { media: "video", kind: "iso-bmff" });
|
||||
assert.deepEqual(classifyPrefix(latin1("\0\0\0\x14ftypqt ")), { media: "video", kind: "iso-bmff" });
|
||||
assert.deepEqual(classifyPrefix(Uint8Array.from([0x1a, 0x45, 0xdf, 0xa3, 0x01])), { media: "video", kind: "matroska" });
|
||||
assert.deepEqual(classifyPrefix(latin1("RIFF\0\0\0\0AVI LIST")), { media: "video", kind: "avi" });
|
||||
const asf = Uint8Array.from([0x30, 0x26, 0xb2, 0x75, 0x8e, 0x66, 0xcf, 0x11, 0xa6, 0xd9, 0x00, 0xaa, 0x00, 0x62, 0xce, 0x6c, 0, 0]);
|
||||
assert.deepEqual(classifyPrefix(asf), { media: "video", kind: "asf" });
|
||||
assert.deepEqual(classifyPrefix(asf.buffer), { media: "video", kind: "asf" });
|
||||
for (const text of ["plain text file", "<!DOCTYPE html><html>", '<?xml version="1.0"?><svg', "#EXTM3U\n#EXT-X-VERSION:3", "RIFF\0\0\0\0WAVEfmt ", "\0\0\0\x18moov", ""]) {
|
||||
assert.equal(classifyPrefix(latin1(text)), null, text);
|
||||
}
|
||||
assert.equal(classifyPrefix(bytes.SIGNATURE.subarray(0, 7)), null);
|
||||
assert.equal(classifyPrefix(Uint8Array.from([0xff, 0xd8])), null);
|
||||
assert.equal(classifyPrefix("not bytes"), null);
|
||||
assert.equal(classifyPrefix(null), null);
|
||||
});
|
||||
|
||||
test("image header: JPEG and WebP fixtures match Pillow's dimensions", async () => {
|
||||
// tests/test_local_inspect_parity.py asserts the same two files against Pillow.
|
||||
const jpeg = blobOf(fs.readFileSync(path.join(FIXTURES, "tiny.jpg")));
|
||||
assert.deepEqual(await readImageHeader(jpeg, "jpeg"), { format: "JPEG", width: 48, height: 32 });
|
||||
const webp = blobOf(fs.readFileSync(path.join(FIXTURES, "tiny.webp")));
|
||||
assert.deepEqual(await readImageHeader(webp, "webp"), { format: "WEBP", width: 40, height: 24 });
|
||||
});
|
||||
|
||||
test("image header: JPEG marker walk skips padding, junk, standalone markers and tables", async () => {
|
||||
const segment = (marker, body) => concat([0xff, marker], u32(body.length + 2).subarray(2), body);
|
||||
const sof = (marker, width, height) => segment(marker, concat([8], u32(height).subarray(2), u32(width).subarray(2), [3]));
|
||||
const sized = concat([0xff, 0xd8], segment(0xe1, fill(0, 20)), [0xff, 0xff, 0xff, 0x00, 0x00, 0x11], [0xff, 0xd0], [0xff, 0x01], segment(0xc4, fill(0, 4)), segment(0xc8, []), segment(0xcc, [1]), sof(0xc2, 300, 200));
|
||||
assert.deepEqual(await readImageHeader(blobOf(sized), "jpeg"), { format: "JPEG", width: 300, height: 200 });
|
||||
for (const marker of [0xc0, 0xc1, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf]) {
|
||||
assert.deepEqual(await readImageHeader(blobOf(concat([0xff, 0xd8], sof(marker, 7, 9))), "jpeg"), { format: "JPEG", width: 7, height: 9 }, marker.toString(16));
|
||||
}
|
||||
const none = { format: "JPEG", width: null, height: null };
|
||||
assert.deepEqual(await readImageHeader(blobOf(concat([0xff, 0xd8], segment(0xda, [1]))), "jpeg"), none, "SOS before SOF");
|
||||
assert.deepEqual(await readImageHeader(blobOf(concat([0xff, 0xd8], [0xff])), "jpeg"), none, "ends on a marker byte");
|
||||
assert.deepEqual(await readImageHeader(blobOf(concat([0xff, 0xd8], [0xff, 0xe1, 0x00])), "jpeg"), none, "ends inside a length");
|
||||
assert.deepEqual(await readImageHeader(blobOf(concat([0xff, 0xd8], [0xff, 0xc0, 0x00, 0x08, 8, 0])), "jpeg"), none, "ends inside SOF");
|
||||
assert.deepEqual(await readImageHeader(blobOf(concat([0xff, 0xd8], segment(0xe1, []))), "jpeg"), none, "no SOF at all");
|
||||
assert.deepEqual(await readImageHeader(blobOf(Uint8Array.from([0xff])), "jpeg"), none, "too short");
|
||||
assert.deepEqual(await readImageHeader(blobOf(latin1("RIFF")), "jpeg"), none, "not a JPEG");
|
||||
// A SOF past the 64 KiB read gives no dimensions rather than reading on.
|
||||
assert.deepEqual(await readImageHeader(blobOf(concat([0xff, 0xd8], segment(0xe1, fill(0, 65000)), segment(0xe2, fill(0, 1000)), sof(0xc0, 5, 5))), "jpeg"), none, "SOF beyond 64 KiB");
|
||||
});
|
||||
|
||||
test("image header: WebP VP8, VP8L and VP8X headers", async () => {
|
||||
const riff = (...rest) => concat(latin1("RIFF"), u32(0), latin1("WEBP"), ...rest);
|
||||
const chunkHeader = (type, size) => concat(latin1(type), Uint8Array.from([size & 0xff, (size >>> 8) & 0xff, (size >>> 16) & 0xff, 0]));
|
||||
const vp8 = riff(chunkHeader("VP8 ", 100), [0x30, 0x0b, 0x00, 0x9d, 0x01, 0x2a], [40, 0x40], [24, 0x80], fill(0, 20));
|
||||
assert.deepEqual(await readImageHeader(blobOf(vp8), "webp"), { format: "WEBP", width: 40, height: 24 }, "scale bits are masked");
|
||||
// 14-bit width-1 = 63 and height-1 = 47, little-endian bit-packed after the 0x2f signature.
|
||||
const vp8l = riff(chunkHeader("VP8L", 100), [0x2f], [0x3f, 0xc0, 0x0b, 0x00, 0x00], fill(0, 20));
|
||||
assert.deepEqual(await readImageHeader(blobOf(vp8l), "webp"), { format: "WEBP", width: 64, height: 48 });
|
||||
const vp8x = riff(chunkHeader("VP8X", 10), [0x10, 0, 0, 0], [0xff, 0x00, 0x01], [0x2f, 0x00, 0x00], fill(0, 20));
|
||||
assert.deepEqual(await readImageHeader(blobOf(vp8x), "webp"), { format: "WEBP", width: 65792, height: 48 });
|
||||
const none = { format: "WEBP", width: null, height: null };
|
||||
assert.deepEqual(await readImageHeader(blobOf(riff(chunkHeader("VP8 ", 100), [0x30, 0x0b, 0x00, 0x00, 0x01, 0x2a], fill(0, 24))), "webp"), none, "bad start code");
|
||||
assert.deepEqual(await readImageHeader(blobOf(riff(chunkHeader("VP8L", 100), [0x00], fill(0, 24))), "webp"), none, "bad VP8L signature");
|
||||
assert.deepEqual(await readImageHeader(blobOf(riff(chunkHeader("ALPH", 100), fill(0, 24))), "webp"), none, "unknown first chunk");
|
||||
assert.deepEqual(await readImageHeader(blobOf(riff(chunkHeader("VP8 ", 100))), "webp"), none, "too short");
|
||||
assert.deepEqual(await readImageHeader(blobOf(latin1("RIFF\0\0\0\0WAVEfmt ".padEnd(40, "\0"))), "webp"), none, "not WebP");
|
||||
});
|
||||
|
||||
test("image header: other kinds give no format and the read stays within 64 KiB", async () => {
|
||||
assert.deepEqual(await readImageHeader(blobOf(bytes.SIGNATURE), "png"), { format: null, width: null, height: null });
|
||||
const file = recordingBlob(concat([0xff, 0xd8], fill(0, 200000)));
|
||||
await readImageHeader(file, "jpeg");
|
||||
assert.deepEqual(file.ranges, [[0, 65536]]);
|
||||
const small = recordingBlob(concat([0xff, 0xd8, 0xff]));
|
||||
await readImageHeader(small, "jpeg");
|
||||
assert.deepEqual(small.ranges, [[0, 3]]);
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
// The Node oracles that Python drives (tests/js/support/*.mjs) import product
|
||||
// modules dynamically. A dynamic import takes a URL or a bare/relative specifier,
|
||||
// never a filesystem path: on Windows `D:\...\x.mjs` parses as the `d:` scheme
|
||||
// (ERR_UNSUPPORTED_ESM_URL_SCHEME). Every such import goes through moduleUrl, and
|
||||
// this test proves the Windows form on any host so Linux catches a regression.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const support = path.join(here, "support");
|
||||
const m = await import("./support/module-url.mjs").catch((error) => {
|
||||
if (error.code !== "ERR_MODULE_NOT_FOUND") throw error;
|
||||
return {};
|
||||
});
|
||||
|
||||
test("moduleUrl turns a filesystem path into a file: URL on every platform", async () => {
|
||||
assert.equal(typeof m.moduleUrl, "function", "moduleUrl is exported");
|
||||
assert.equal(m.moduleUrl("D:\\x\\y z.mjs", { windows: true }), "file:///D:/x/y%20z.mjs");
|
||||
assert.equal(m.moduleUrl("C:\\repo\\web\\js\\workspace\\local-video.mjs", { windows: true }), "file:///C:/repo/web/js/workspace/local-video.mjs");
|
||||
assert.equal(m.moduleUrl("/repo/web/js/a b.mjs", { windows: false }), "file:///repo/web/js/a%20b.mjs");
|
||||
const own = m.moduleUrl(path.join(here, "support", "module-url.mjs"));
|
||||
assert.ok(own.startsWith("file:///"), own);
|
||||
assert.equal((await import(own)).moduleUrl, m.moduleUrl, "the URL imports the same module instance");
|
||||
});
|
||||
|
||||
test("every dynamic import in the Node oracles is a file URL, never a path", () => {
|
||||
const scripts = fs.readdirSync(support).filter((name) => name.endsWith(".mjs"));
|
||||
assert.ok(scripts.includes("local-inspect-oracle.mjs"));
|
||||
for (const name of scripts) {
|
||||
const source = fs.readFileSync(path.join(support, name), "utf8").split("\n").filter((line) => !line.trim().startsWith("//")).join("\n");
|
||||
for (const match of source.matchAll(/\bimport\(\s*([^)]*?)[,)]/g)) {
|
||||
const argument = match[1].trim();
|
||||
assert.ok(/^(moduleUrl\(|pathToFileURL\(|["'`]\.{1,2}\/)/.test(argument), `${name}: import(${argument}) must be a file URL or a relative specifier`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
// The browser's local readers and projection run over files on disk, for
|
||||
// tests/test_local_inspect_parity.py, which compares every answer with the server's
|
||||
// utils.prompt_inspector.read_inspection_file and utils.inspection_response._metadata
|
||||
// on the same file. Each file is opened as a Blob (fs.openAsBlob) so the reader
|
||||
// slices it exactly as it slices a File.
|
||||
// stdin: [{name, path} | {name, tags, limited?} | {name, records, record_formats?, carrier_text?, limited?}]
|
||||
// stdout: {name: {classified, result, metadata}} where result is readPngMetadataFile's
|
||||
// answer for a PNG, readImageHeader's for another image, for a video
|
||||
// {read: readVideoTags(...), selection: selectVideoCarrier(read.tags, {limited})}
|
||||
// (selection null unless the read completed), else null; metadata is
|
||||
// projectMetadata(...) of the local read (the server's _metadata) for every
|
||||
// classified file, else null.
|
||||
// A {tags} entry answers {classified: null, result: {selection}, metadata} for
|
||||
// the carrier rule alone, as the server selects from ffprobe's tag map.
|
||||
// A {records} entry (records null = an unreadable file) answers
|
||||
// {classified: null, result: null, metadata} for the projection alone.
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { moduleUrl } from './module-url.mjs';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const require = createRequire(import.meta.url);
|
||||
const pngMetadata = require(path.join(here, '..', '..', '..', 'web', 'js', 'png-metadata.js'));
|
||||
const web = path.join(here, '..', '..', '..', 'web', 'js', 'workspace');
|
||||
// ES modules import by file URL (a Windows path would parse as a URL scheme).
|
||||
const { selectVideoCarrier } = await import(moduleUrl(path.join(web, 'local-video.mjs')));
|
||||
const { projectMetadata } = await import(moduleUrl(path.join(web, 'local-projection.mjs')));
|
||||
const { readLocalFile, toInspectionRead } = await import(moduleUrl(path.join(web, 'local-inspection.mjs')));
|
||||
|
||||
const selectionRead = (selection) => selection.status === 'complete' ?
|
||||
{ records: selection.records, record_formats: selection.record_formats, carrierText: selection.carrierText, limited: selection.limited } : null;
|
||||
|
||||
const chunks = [];
|
||||
for await (const chunk of process.stdin) chunks.push(chunk);
|
||||
const output = {};
|
||||
for (const entry of JSON.parse(Buffer.concat(chunks).toString('utf8'))) {
|
||||
const { name } = entry;
|
||||
if (entry.records !== undefined) {
|
||||
const read = entry.records === null ? null : {
|
||||
records: entry.records,
|
||||
record_formats: entry.record_formats ?? Object.fromEntries(Object.keys(entry.records).map((key) => [key, 'original-text'])),
|
||||
carrierText: entry.carrier_text ?? null, limited: entry.limited ?? [],
|
||||
};
|
||||
output[name] = { classified: null, result: null, metadata: projectMetadata(read) };
|
||||
continue;
|
||||
}
|
||||
if (entry.path === undefined) {
|
||||
const selection = selectVideoCarrier(entry.tags, { limited: entry.limited || [] });
|
||||
output[name] = { classified: null, result: { selection }, metadata: projectMetadata(selectionRead(selection)) };
|
||||
continue;
|
||||
}
|
||||
const file = await fs.openAsBlob(entry.path);
|
||||
const { classification, result } = await readLocalFile(file, { pngMetadata });
|
||||
const metadata = classification === null ? null : projectMetadata(toInspectionRead(classification, result).read);
|
||||
output[name] = { classified: classification, result, metadata };
|
||||
}
|
||||
process.stdout.write(JSON.stringify(output));
|
||||
@@ -0,0 +1,9 @@
|
||||
// A dynamic import() takes a URL, never a filesystem path: on Windows a path such
|
||||
// as D:\repo\x.mjs parses as the "d:" scheme (ERR_UNSUPPORTED_ESM_URL_SCHEME).
|
||||
// Every oracle in this directory imports product modules through moduleUrl.
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
/** The file: URL for a filesystem path; `windows` forces the Windows form on any host (for tests). */
|
||||
export function moduleUrl(filePath, { windows = process.platform === 'win32' } = {}) {
|
||||
return pathToFileURL(filePath, { windows }).href;
|
||||
}
|
||||
@@ -86,3 +86,34 @@ test('Show in folder outcomes use the D38 toasts',async()=>{
|
||||
for(const error of [{status:403,code:null},{status:409,code:'source-changed'},{status:415,code:'unsupported-media-type'},{status:429,code:null},{status:500},new TypeError('offline')])
|
||||
assert.equal(revealOutcome(error),'The folder could not be opened. Try again.');
|
||||
});
|
||||
|
||||
// #249 PR C Task C3 (D88): one chip row, the file's current image tags first, then D21's linked tags not already shown.
|
||||
function tagged(imageTags, library = {status:'linked', tags:['Minimal', 'warm_light', 'editorial'], tags_scope:'positive'}) {
|
||||
return {...record(), imageTags, library};
|
||||
}
|
||||
test('renders current image tags before linked tags without duplicates', async () => {
|
||||
const {inspectorTagChips} = await load();
|
||||
assert.equal(typeof inspectorTagChips, 'function', 'inspectorTagChips is exported');
|
||||
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['dunes', 'minimal']}), {filterable:true}),
|
||||
[{name:'dunes', filter:true}, {name:'minimal', filter:true}, {name:'warm_light', filter:true}, {name:'editorial', filter:true}]);
|
||||
// D21's rule still decides the linked chips: a recipe's own tags stay plain, an image tag stays a filter.
|
||||
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['Editorial']}, {status:'linked', tags:['editorial', 'cover'], tags_scope:'pair'}), {filterable:true}),
|
||||
[{name:'Editorial', filter:true}, {name:'cover', filter:false}]);
|
||||
// An unlinked file shows only its own tags; a facet-less record (history, local) only the linked ones.
|
||||
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['loose']}, {status:'none', tags:[], tags_scope:null}), {filterable:true}), [{name:'loose', filter:true}]);
|
||||
assert.deepEqual(inspectorTagChips(tagged(null), {filterable:true}).map(chip => chip.name), ['Minimal', 'warm_light', 'editorial']);
|
||||
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:[]}, {status:'ambiguous', tags:['x'], tags_scope:'positive'}), {filterable:true}), []);
|
||||
});
|
||||
test('image tag chips filter in the panel and are plain in the viewer', async () => {
|
||||
const {inspectorTagChips} = await load();
|
||||
const source = tagged({state:'current', tags:['dunes']});
|
||||
assert.deepEqual(inspectorTagChips(source, {filterable:true}).map(chip => chip.filter), [true, true, true, true]);
|
||||
assert.deepEqual(inspectorTagChips(source, {filterable:false}).map(chip => chip.filter), [false, false, false, false]);
|
||||
});
|
||||
test('none and unavailable render no image chips', async () => {
|
||||
const {inspectorTagChips} = await load();
|
||||
for (const state of ['none', 'unavailable']) {
|
||||
assert.deepEqual(inspectorTagChips(tagged({state, tags:[]}), {filterable:true}).map(chip => chip.name), ['Minimal', 'warm_light', 'editorial']);
|
||||
assert.deepEqual(inspectorTagChips(tagged({state, tags:[]}, {status:'none', tags:[], tags_scope:null}), {filterable:true}), []);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -149,3 +149,15 @@ test('a narrow gallery caps the concept columns at the smallest readable card',
|
||||
}
|
||||
assert.equal(layoutImages([{}], 990, 'list', {columns:3}).columns, 1);
|
||||
});
|
||||
|
||||
test('the card info stacks below the narrowest bounded card, a list row beside its art too (D31)', async () => {
|
||||
const {stacksCardInfo} = await source;
|
||||
assert.equal(typeof stacksCardInfo, 'function', 'stacksCardInfo is not implemented');
|
||||
assert.equal(stacksCardInfo(119, 'grid'), true);
|
||||
assert.equal(stacksCardInfo(120, 'grid'), false);
|
||||
assert.equal(stacksCardInfo(119, 'masonry'), true);
|
||||
// A list row's info is what its art (160px, at most 45%) leaves: 195px at 200% zoom on a phone leaves 107px.
|
||||
assert.equal(stacksCardInfo(195, 'list'), true);
|
||||
assert.equal(stacksCardInfo(280, 'list'), false);
|
||||
assert.equal(stacksCardInfo(358, 'list'), false);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,769 @@
|
||||
// Run with: node --test tests/js/workspace-image-tagging-state.test.js
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { createApiClient } = require('../../web/js/api-client.js');
|
||||
|
||||
// Only an absent module is tolerated, so RED is an assertion failure, not a crash.
|
||||
const source = import('../../web/js/workspace/image-tagging-state.mjs').catch(error => {
|
||||
if (error.code !== 'ERR_MODULE_NOT_FOUND') throw error;
|
||||
return {};
|
||||
});
|
||||
async function load() {
|
||||
const module = await source;
|
||||
assert.equal(typeof module.createImageTaggingController, 'function', 'image-tagging-state.mjs is not implemented');
|
||||
return module;
|
||||
}
|
||||
|
||||
const EPOCH = '11111111-1111-4111-8111-111111111111';
|
||||
const SCOPE = 'c'.repeat(64);
|
||||
const SHA = 'd'.repeat(64);
|
||||
const TOKEN = 'capture-token_1';
|
||||
const TOKEN2 = 'capture-token_2';
|
||||
const TARGET = Object.freeze({ root_index: 0, root_scope: SCOPE, path: 'dunes/dunes_00001.png' });
|
||||
const OBSERVE = '/prompt_manager/image-annotations/observe';
|
||||
const SUGGEST = '/prompt_manager/image-annotations/suggest';
|
||||
const TAGS = '/prompt_manager/image-annotations/tags';
|
||||
const MODELS = '/prompt_manager/autotag/models';
|
||||
const CAPTURE = '/prompt_manager/image-annotations/capture/';
|
||||
const DEFAULT_PROMPT = 'Generate only comma-separated Danbooru tags (lowercase_underscores, no spaces).';
|
||||
|
||||
const flush = () => new Promise(resolve => setImmediate(resolve));
|
||||
async function settle(times = 8) { for (let i = 0; i < times; i++) await flush(); }
|
||||
function deferred() {
|
||||
let resolve, reject;
|
||||
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
function response(status, body) {
|
||||
return { ok: status >= 200 && status < 300, status, statusText: `Status ${status}`, json: async () => body };
|
||||
}
|
||||
const fail = (status, code, error = `failure ${code}`, extra = {}) => response(status, { success: false, code, error, ...extra });
|
||||
|
||||
function captureInfo(overrides = {}) {
|
||||
return {
|
||||
token: TOKEN, expires_at: '2026-10-22T12:00:00+00:00', preview_url: CAPTURE + (overrides.token || TOKEN),
|
||||
sha256: SHA, size: 1234, width: 832, height: 1216, media_type: 'image',
|
||||
generate: { supported: true, reason: null }, ...overrides,
|
||||
};
|
||||
}
|
||||
function observed(overrides = {}, capture = {}) {
|
||||
return {
|
||||
success: true, library_epoch: EPOCH, target: { ...TARGET }, capture: captureInfo(capture),
|
||||
title: 'Desert pavilion', filename: 'dunes_00001.png', tags: ['Minimal', 'warm_light'], tag_revision: 3,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
const models = (overrides = {}) => ({
|
||||
success: true, models: { 'wd14-swinv2': { downloaded: true } }, default_prompt: DEFAULT_PROMPT,
|
||||
wd14_general_threshold: 0.35, wd14_character_threshold: 0.85, suggestion_limit: 10, ...overrides,
|
||||
});
|
||||
const SANDSTONE = [
|
||||
{ name: 'minimal', confidence: 0.99 }, { name: 'editorial', confidence: 0.97 },
|
||||
{ name: 'sandstone', confidence: 0.981 }, { name: 'Editorial', confidence: 0.5 },
|
||||
];
|
||||
const suggested = (rows = SANDSTONE, overrides = {}) => ({ success: true, model_type: 'wd14-swinv2', options: {}, suggestions: rows, ...overrides });
|
||||
const receipt = (tags, overrides = {}) => ({ success: true, library_epoch: EPOCH, sha256: SHA, tags, tag_revision: 4, replayed: false, ...overrides });
|
||||
|
||||
/** Real createApiClient over a fetch whose answers come from routes[METHOD URL](call). */
|
||||
function harness(routes = {}) {
|
||||
const calls = [];
|
||||
const fetch = (url, init = {}) => {
|
||||
const call = { url, method: init.method, init, body: init.body === undefined ? undefined : JSON.parse(init.body), aborted: false, signal: init.signal };
|
||||
calls.push(call);
|
||||
return new Promise((resolve, reject) => {
|
||||
init.signal?.addEventListener('abort', () => {
|
||||
call.aborted = true;
|
||||
reject(new DOMException('The operation was aborted.', 'AbortError'));
|
||||
});
|
||||
const key = `${call.method} ${url.startsWith(CAPTURE) ? CAPTURE : url}`;
|
||||
const route = routes[key];
|
||||
Promise.resolve().then(() => {
|
||||
if (!route) return response(200, { success: true });
|
||||
return route(call);
|
||||
}).then(resolve, reject);
|
||||
});
|
||||
};
|
||||
return {
|
||||
api: createApiClient(fetch), calls,
|
||||
of: (method, url) => calls.filter(call => call.method === method && (url === CAPTURE ? call.url.startsWith(CAPTURE) : call.url === url)),
|
||||
};
|
||||
}
|
||||
function defaultRoutes(extra = {}) {
|
||||
return {
|
||||
[`POST ${OBSERVE}`]: () => response(200, observed()),
|
||||
[`GET ${MODELS}`]: () => response(200, models()),
|
||||
[`POST ${SUGGEST}`]: () => response(200, suggested()),
|
||||
[`PUT ${TAGS}`]: call => response(200, receipt(call.body.tags)),
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
async function opened(routes, options = {}) {
|
||||
const { createImageTaggingController } = await load();
|
||||
const h = harness(defaultRoutes(routes));
|
||||
const c = createImageTaggingController({ api: h.api, target: TARGET, source: 'images', ...options });
|
||||
await c.open();
|
||||
await settle();
|
||||
return { c, h };
|
||||
}
|
||||
|
||||
test('MODEL_OPTIONS lists legacy engines in mock order with D84 labels', async () => {
|
||||
const { MODEL_OPTIONS } = await load();
|
||||
assert.deepEqual(MODEL_OPTIONS.map(option => [option.id, option.label]), [
|
||||
['wd14-swinv2', 'WD14 SwinV2 · ~400 MB'],
|
||||
['wd14-vit', 'WD14 ViT · ~400 MB'],
|
||||
['gguf', 'GGUF JoyCaption (Recommended) · ~4.5 GB'],
|
||||
['hf', 'HuggingFace JoyCaption · ~16 GB'],
|
||||
]);
|
||||
assert.ok(Object.isFrozen(MODEL_OPTIONS) && Object.isFrozen(MODEL_OPTIONS[0]));
|
||||
});
|
||||
|
||||
test('dedupeTags trims, drops blanks and keeps the first spelling case-insensitively', async () => {
|
||||
const { dedupeTags, parseManualTags } = await load();
|
||||
const input = [' Minimal ', 'minimal', '', 'warm_light', 'WARM_LIGHT', 7, 'dune'];
|
||||
assert.deepEqual(dedupeTags(input), ['Minimal', 'warm_light', 'dune']);
|
||||
assert.deepEqual(input[0], ' Minimal ', 'input is not mutated');
|
||||
assert.deepEqual(dedupeTags(undefined), []);
|
||||
assert.deepEqual(parseManualTags(' a, ,B , b,c '), ['a', 'B', 'c']);
|
||||
assert.deepEqual(parseManualTags(null), []);
|
||||
});
|
||||
|
||||
test('the controller rejects a malformed target, source or api', async () => {
|
||||
const { createImageTaggingController } = await load();
|
||||
const { api } = harness();
|
||||
assert.throws(() => createImageTaggingController({ api, target: { ...TARGET, root_index: true } }), TypeError);
|
||||
assert.throws(() => createImageTaggingController({ api, target: { ...TARGET, root_scope: 'x' } }), TypeError);
|
||||
assert.throws(() => createImageTaggingController({ api, target: { ...TARGET, path: '' } }), TypeError);
|
||||
assert.throws(() => createImageTaggingController({ api, target: TARGET, source: 'elsewhere' }), TypeError);
|
||||
assert.throws(() => createImageTaggingController({ api: {}, target: TARGET }), TypeError);
|
||||
assert.throws(() => createImageTaggingController(), TypeError);
|
||||
});
|
||||
|
||||
test('opens with saved tags joined by comma-space', async () => {
|
||||
const { createImageTaggingController } = await load();
|
||||
const h = harness(defaultRoutes());
|
||||
const c = createImageTaggingController({ api: h.api, target: TARGET, source: 'images' });
|
||||
const seen = [];
|
||||
const unsubscribe = c.subscribe(state => seen.push(state.phase));
|
||||
assert.equal(c.state.phase, 'observing');
|
||||
const pending = c.open();
|
||||
assert.equal(c.state.phase, 'observing');
|
||||
await pending;
|
||||
await settle();
|
||||
assert.equal(c.state.phase, 'ready');
|
||||
assert.equal(c.state.manual, 'Minimal, warm_light');
|
||||
assert.deepEqual(c.state.savedTags, ['Minimal', 'warm_light']);
|
||||
assert.equal(c.state.image.filename, 'dunes_00001.png');
|
||||
assert.equal(c.state.image.title, 'Desert pavilion');
|
||||
assert.equal(c.state.image.dimensions, '832 × 1216');
|
||||
assert.equal(c.state.image.previewUrl, CAPTURE + TOKEN);
|
||||
assert.equal(c.state.model, 'wd14-swinv2');
|
||||
assert.equal(c.state.countText, 'Not generated');
|
||||
assert.equal(c.state.primaryLabel, 'Save manual tags');
|
||||
assert.equal(c.state.suggestionsText, 'Generate suggestions with the selected model before approving.');
|
||||
assert.equal(c.state.showScopeNote, false);
|
||||
assert.equal(c.state.canSave, true);
|
||||
assert.equal(c.state.canGenerate, true);
|
||||
assert.ok(Object.isFrozen(c.state));
|
||||
assert.deepEqual(h.of('POST', OBSERVE)[0].body, { target: { ...TARGET } });
|
||||
assert.ok(seen.includes('ready'));
|
||||
unsubscribe();
|
||||
c.setManual('x');
|
||||
assert.equal(seen.at(-1), 'ready', 'an unsubscribed listener is not called');
|
||||
assert.throws(() => c.subscribe('nope'), TypeError);
|
||||
});
|
||||
|
||||
test('an image without known dimensions shows none and an unseen file starts empty', async () => {
|
||||
const { c } = await opened({
|
||||
[`POST ${OBSERVE}`]: () => response(200, observed({ tags: [], tag_revision: 0, title: null }, { width: null, height: null })),
|
||||
});
|
||||
assert.equal(c.state.image.dimensions, null);
|
||||
assert.equal(c.state.manual, '');
|
||||
assert.equal(c.state.image.title, null);
|
||||
});
|
||||
|
||||
test('instructions default to the server prompt', async () => {
|
||||
const { c } = await opened({
|
||||
[`GET ${MODELS}`]: () => response(200, models({ suggestion_limit: 7, wd14_general_threshold: 0.5 })),
|
||||
});
|
||||
assert.equal(c.state.options.instructions, DEFAULT_PROMPT);
|
||||
assert.equal(c.state.options.suggestion_limit, 7);
|
||||
assert.equal(c.state.options.general_threshold, 0.5);
|
||||
assert.equal(c.state.options.character_threshold, 0.85);
|
||||
c.setModel('gguf');
|
||||
assert.equal(c.state.options.instructions, DEFAULT_PROMPT);
|
||||
c.setOption('instructions', 'only colours');
|
||||
c.setModel('hf');
|
||||
assert.equal(c.state.options.instructions, DEFAULT_PROMPT, 'a model change re-renders the defaults like the mock');
|
||||
assert.equal(c.state.options.suggestion_limit, 7, 'the suggestion count survives a model change');
|
||||
});
|
||||
|
||||
test('an unreadable or malformed models read falls back to the documented defaults', async () => {
|
||||
for (const route of [() => fail(500, null), () => response(200, models({ default_prompt: 4, suggestion_limit: 0, wd14_general_threshold: 2, wd14_character_threshold: 'x' }))]) {
|
||||
const { c } = await opened({ [`GET ${MODELS}`]: route });
|
||||
assert.equal(c.state.phase, 'ready');
|
||||
assert.deepEqual({ ...c.state.options }, { general_threshold: 0.35, character_threshold: 0.85, instructions: '', suggestion_limit: 10 });
|
||||
}
|
||||
});
|
||||
|
||||
test('observe failures and malformed answers end in observe-error and retry observes again', async () => {
|
||||
const { createImageTaggingController } = await load();
|
||||
let answer = () => fail(409, 'source-changed', 'The source changed. Refresh the image collection before tagging this file.');
|
||||
const h = harness(defaultRoutes({ [`POST ${OBSERVE}`]: () => answer() }));
|
||||
const c = createImageTaggingController({ api: h.api, target: TARGET });
|
||||
await c.open();
|
||||
assert.equal(c.state.phase, 'observe-error');
|
||||
assert.equal(c.state.statusText, 'The source changed. Refresh the image collection before tagging this file.');
|
||||
assert.equal(c.state.canSave, false);
|
||||
assert.equal(c.state.canGenerate, false);
|
||||
assert.equal(c.state.canRetry, true);
|
||||
answer = () => response(200, observed({ tag_revision: -1 }));
|
||||
await c.retry();
|
||||
assert.equal(c.state.phase, 'observe-error');
|
||||
assert.equal(c.state.statusText, 'The image could not be opened for tagging.');
|
||||
assert.equal(h.of('DELETE', CAPTURE).length, 1, 'a malformed answer still releases its capture');
|
||||
answer = () => Promise.reject(new TypeError('Failed to fetch'));
|
||||
await c.retry();
|
||||
assert.equal(c.state.statusText, 'The image could not be opened for tagging.');
|
||||
answer = () => response(200, observed());
|
||||
await c.retry();
|
||||
await settle();
|
||||
assert.equal(c.state.phase, 'ready');
|
||||
assert.equal(h.of('POST', OBSERVE).length, 4);
|
||||
});
|
||||
|
||||
test('model change clears suggestions count and status but keeps manual', async () => {
|
||||
const { c } = await opened();
|
||||
c.setManual('Minimal, dune');
|
||||
await c.generate();
|
||||
assert.equal(c.state.countText, '3 suggestions');
|
||||
c.setModel('wd14-vit');
|
||||
assert.deepEqual(c.state.suggestions, []);
|
||||
assert.equal(c.state.countText, 'Not generated');
|
||||
assert.equal(c.state.statusText, '');
|
||||
assert.equal(c.state.suggestionsText, 'Generate suggestions with the selected model before approving.');
|
||||
assert.equal(c.state.manual, 'Minimal, dune');
|
||||
assert.equal(c.state.model, 'wd14-vit');
|
||||
c.setModel('wd14-vit');
|
||||
assert.equal(c.state.model, 'wd14-vit');
|
||||
assert.throws(() => c.setModel('ollama'), TypeError);
|
||||
assert.throws(() => c.setOption('model', 1), TypeError);
|
||||
});
|
||||
|
||||
test('generate sends the model options and stages scored chips', async () => {
|
||||
const { c, h } = await opened();
|
||||
c.setOption('general_threshold', '0.4');
|
||||
c.setOption('suggestion_limit', '3');
|
||||
assert.equal(await c.generate(), true);
|
||||
const sent = h.of('POST', SUGGEST)[0].body;
|
||||
assert.deepEqual(sent, {
|
||||
target: { ...TARGET }, capture_token: TOKEN, expected_epoch: EPOCH, model_type: 'wd14-swinv2',
|
||||
options: { general_threshold: 0.4, character_threshold: 0.85, suggestion_limit: 3 },
|
||||
});
|
||||
assert.deepEqual(c.state.suggestions.map(row => [row.name, row.label]), [['minimal', '99%'], ['editorial', '97%'], ['sandstone', '98%']]);
|
||||
assert.equal(c.state.statusText, 'Suggestions from WD14 SwinV2 · ~400 MB. Approve tags to save.');
|
||||
assert.equal(c.state.suggestionsText, '');
|
||||
c.setModel('gguf');
|
||||
await c.generate();
|
||||
assert.deepEqual(h.of('POST', SUGGEST)[1].body.options, { instructions: DEFAULT_PROMPT, suggestion_limit: 3 });
|
||||
});
|
||||
|
||||
test('language models label chips Suggested and the count is capped at the requested limit', async () => {
|
||||
const rows = Array.from({ length: 12 }, (_, i) => ({ name: `tag${i}`, confidence: null }));
|
||||
const { c } = await opened({ [`POST ${SUGGEST}`]: () => response(200, suggested(rows, { model_type: 'gguf' })) });
|
||||
c.setModel('gguf');
|
||||
await c.generate();
|
||||
assert.equal(c.state.suggestions.length, 10);
|
||||
assert.ok(c.state.suggestions.every(row => row.label === 'Suggested' && row.confidence === null));
|
||||
assert.equal(c.state.countText, '10 suggestions');
|
||||
});
|
||||
|
||||
test('an empty result shows the threshold copy and a malformed one is a generate error', async () => {
|
||||
let answer = () => response(200, suggested([]));
|
||||
const { c } = await opened({ [`POST ${SUGGEST}`]: () => answer() });
|
||||
await c.generate();
|
||||
assert.equal(c.state.suggestionsText, 'No suggestions met this threshold. Adjust it or enter tags manually.');
|
||||
assert.equal(c.state.countText, '0 suggestions');
|
||||
assert.equal(c.state.primaryLabel, 'Approve selected tags');
|
||||
answer = () => response(200, suggested([{ name: 4, confidence: 2 }]));
|
||||
await c.generate();
|
||||
assert.equal(c.state.phase, 'generate-error');
|
||||
assert.equal(c.state.statusText, 'Auto-tagging failed. Check server logs for details.');
|
||||
answer = () => response(200, suggested([{ name: 'one', confidence: 1 }]));
|
||||
assert.equal(await c.retry(), true, 'retry after a generate error generates again');
|
||||
assert.equal(c.state.countText, '1 suggestion');
|
||||
assert.equal(c.state.suggestions[0].label, '100%');
|
||||
});
|
||||
|
||||
test('invalid options send nothing', async () => {
|
||||
const { c, h } = await opened();
|
||||
const { optionError } = await load();
|
||||
for (const [key, value] of [['general_threshold', '1.5'], ['general_threshold', ''], ['character_threshold', 'abc'], ['suggestion_limit', '0'], ['suggestion_limit', '2.5'], ['suggestion_limit', '101']]) {
|
||||
c.setOption('general_threshold', 0.35);
|
||||
c.setOption('character_threshold', 0.85);
|
||||
c.setOption('suggestion_limit', 10);
|
||||
c.setOption(key, value);
|
||||
assert.equal(await c.generate(), false, `${key}=${value}`);
|
||||
assert.equal(optionError(c.state.model, c.state.options).field, key);
|
||||
}
|
||||
c.setModel('gguf');
|
||||
c.setOption('instructions', ' ');
|
||||
assert.equal(await c.generate(), false);
|
||||
c.setOption('instructions', 'x'.repeat(4001));
|
||||
assert.equal(await c.generate(), false);
|
||||
assert.equal(optionError('gguf', c.state.options).field, 'instructions');
|
||||
assert.equal(h.of('POST', SUGGEST).length, 0);
|
||||
assert.equal(optionError('wd14-vit', { general_threshold: 0, character_threshold: 1, suggestion_limit: 100 }), null);
|
||||
});
|
||||
|
||||
test('late generation for an old serial, closed or expired dialog is ignored', async () => {
|
||||
const pending = [];
|
||||
const { c, h } = await opened({ [`POST ${SUGGEST}`]: () => { const d = deferred(); pending.push(d); return d.promise; } });
|
||||
const first = c.generate();
|
||||
assert.equal(c.state.phase, 'generating');
|
||||
assert.equal(c.state.statusText, 'Generating suggestions…');
|
||||
assert.equal(c.state.canSave, false);
|
||||
c.setModel('wd14-vit');
|
||||
assert.equal(c.state.phase, 'ready');
|
||||
assert.equal(h.of('POST', SUGGEST)[0].aborted, true);
|
||||
await settle();
|
||||
pending[0].resolve(response(200, suggested()));
|
||||
assert.equal(await first, false);
|
||||
assert.equal(c.state.phase, 'ready', 'the superseded request neither stages chips nor reports its abort');
|
||||
assert.equal(c.state.statusText, '');
|
||||
assert.deepEqual(c.state.suggestions, []);
|
||||
assert.equal(c.state.countText, 'Not generated');
|
||||
|
||||
const second = c.generate();
|
||||
c.cancel();
|
||||
await settle();
|
||||
pending[1].resolve(response(200, suggested()));
|
||||
assert.equal(await second, false);
|
||||
assert.deepEqual(c.state.suggestions, []);
|
||||
assert.equal(c.state.closed, true);
|
||||
assert.equal(await c.generate(), false, 'a closed dialog never generates');
|
||||
|
||||
const expired = await opened({ [`POST ${SUGGEST}`]: () => fail(409, 'capture-expired', 'The preview expired. Open the Tag dialog again.') });
|
||||
await expired.c.generate();
|
||||
assert.equal(expired.c.state.phase, 'capture-expired');
|
||||
assert.equal(expired.c.state.canSave, false);
|
||||
assert.equal(expired.c.state.canGenerate, false);
|
||||
});
|
||||
|
||||
test('a reopen while observing ignores the older observation and releases its capture', async () => {
|
||||
const { createImageTaggingController } = await load();
|
||||
const answers = [deferred(), deferred()];
|
||||
let n = 0;
|
||||
const h = harness(defaultRoutes({ [`POST ${OBSERVE}`]: () => answers[n++].promise }));
|
||||
const c = createImageTaggingController({ api: h.api, target: TARGET });
|
||||
const one = c.open();
|
||||
const two = c.open();
|
||||
answers[1].resolve(response(200, observed({}, { token: TOKEN2 })));
|
||||
await two;
|
||||
answers[0].resolve(response(200, observed()));
|
||||
await one;
|
||||
await settle();
|
||||
assert.equal(c.state.image.previewUrl, CAPTURE + TOKEN2);
|
||||
assert.deepEqual(h.of('DELETE', CAPTURE).map(call => call.url), [CAPTURE + TOKEN]);
|
||||
c.dispose();
|
||||
assert.deepEqual(h.of('DELETE', CAPTURE).map(call => call.url), [CAPTURE + TOKEN, CAPTURE + TOKEN2]);
|
||||
});
|
||||
|
||||
test('primary label toggles Save manual tags / Approve selected tags', async () => {
|
||||
const { c } = await opened();
|
||||
assert.equal(c.state.primaryLabel, 'Save manual tags');
|
||||
await c.generate();
|
||||
assert.equal(c.state.primaryLabel, 'Approve selected tags'); c.setModel('wd14-vit'); assert.equal(c.state.primaryLabel, 'Save manual tags');
|
||||
assert.equal(c.state.countText, 'Not generated');
|
||||
});
|
||||
|
||||
test('remove drops one chip and the last removal shows the all-removed copy', async () => {
|
||||
const { c } = await opened();
|
||||
await c.generate();
|
||||
c.remove('editorial');
|
||||
assert.deepEqual(c.state.suggestions.map(row => row.name), ['minimal', 'sandstone']);
|
||||
assert.equal(c.state.countText, '2 suggestions');
|
||||
c.remove('missing');
|
||||
assert.equal(c.state.suggestions.length, 2);
|
||||
c.remove('minimal');
|
||||
c.remove('sandstone');
|
||||
assert.deepEqual(c.state.suggestions, []);
|
||||
assert.equal(c.state.suggestionsText, 'All suggestions removed. Generate again or enter tags manually.');
|
||||
assert.equal(c.state.primaryLabel, 'Approve selected tags');
|
||||
});
|
||||
|
||||
test('save sends manual then remaining chips deduplicated case-insensitively keeping the manual spelling', async () => {
|
||||
const { createImageTaggingController } = await load();
|
||||
const h = harness(defaultRoutes({
|
||||
[`POST ${SUGGEST}`]: () => response(200, suggested([
|
||||
{ name: 'minimal', confidence: 0.99 }, { name: 'editorial', confidence: 0.97 },
|
||||
{ name: 'sandstone', confidence: 0.96 }, { name: 'desert', confidence: 0.9 },
|
||||
])),
|
||||
}));
|
||||
const c = createImageTaggingController({ api: h.api, target: TARGET, source: 'review' });
|
||||
await c.open();
|
||||
await settle();
|
||||
assert.equal(c.state.showScopeNote, true);
|
||||
await c.generate();
|
||||
c.remove('desert');
|
||||
assert.equal(c.state.primaryLabel, 'Approve selected tags');
|
||||
assert.equal(await c.save(), true);
|
||||
const sent = h.of('PUT', TAGS)[0].body;
|
||||
assert.deepEqual(sent.tags, ['Minimal', 'warm_light', 'editorial', 'sandstone']); // manual "Minimal", chip "minimal" dropped
|
||||
assert.deepEqual(sent, {
|
||||
target: { ...TARGET }, capture_token: TOKEN, sha256: SHA, expected_epoch: EPOCH,
|
||||
expected_tag_revision: 3, tags: sent.tags,
|
||||
});
|
||||
assert.equal(c.state.phase, 'saved');
|
||||
assert.deepEqual(c.state.savedTags, sent.tags);
|
||||
assert.equal(c.state.tagRevision, 4);
|
||||
assert.equal(c.state.toast, '4 tags saved for dunes_00001.png. Prompt association still awaits review.');
|
||||
assert.equal(c.state.canSave, false);
|
||||
assert.equal(await c.save(), false, 'a saved dialog does not save twice');
|
||||
});
|
||||
|
||||
test('toast matches the mock with the review suffix', async () => {
|
||||
const { c } = await opened({ [`PUT ${TAGS}`]: () => response(200, receipt(['one'])) });
|
||||
c.setManual('one');
|
||||
await c.save();
|
||||
assert.equal(c.state.toast, '1 tags saved for dunes_00001.png.', 'the mock always says "tags"');
|
||||
const many = await opened({}, { source: 'library' });
|
||||
await many.c.save();
|
||||
assert.equal(many.c.state.toast, '2 tags saved for dunes_00001.png.');
|
||||
});
|
||||
|
||||
test('empty manual and no chips sends the acknowledged empty set', async () => {
|
||||
const { c, h } = await opened({ [`PUT ${TAGS}`]: () => response(200, receipt([])) });
|
||||
c.setManual(' , ');
|
||||
await c.save();
|
||||
assert.deepEqual(h.of('PUT', TAGS)[0].body.tags, []);
|
||||
assert.equal(c.state.toast, '0 tags saved for dunes_00001.png.');
|
||||
});
|
||||
|
||||
test('conflict keeps draft and exposes current saved tags', async () => {
|
||||
let answer = () => fail(409, 'tag-conflict', 'The tags of this image changed since the dialog opened; review them first.',
|
||||
{ tags: ['night', 'rain'], tag_revision: 5 });
|
||||
const { c, h } = await opened({ [`PUT ${TAGS}`]: call => answer(call) });
|
||||
await c.generate();
|
||||
c.setManual('Minimal, dune');
|
||||
await c.save();
|
||||
assert.equal(c.state.phase, 'conflict');
|
||||
assert.deepEqual(c.state.currentTags, ['night', 'rain']);
|
||||
assert.equal(c.state.manual, 'night, rain, Minimal, dune');
|
||||
assert.equal(c.state.suggestions.length, 3);
|
||||
assert.equal(c.state.statusText, 'The tags of this image changed since the dialog opened; review them first. Saved now: night, rain.');
|
||||
assert.equal(c.state.canSave, true);
|
||||
answer = call => response(200, receipt(call.body.tags, { tag_revision: 6 }));
|
||||
await c.save();
|
||||
assert.equal(h.of('PUT', TAGS)[1].body.expected_tag_revision, 5, 'the second save is against the revision the user has seen');
|
||||
assert.equal(c.state.phase, 'saved');
|
||||
assert.equal(c.state.currentTags, null);
|
||||
});
|
||||
|
||||
test('lost response keeps draft and retry resends the identical body once', async () => {
|
||||
const answers = [() => Promise.reject(new TypeError('Failed to fetch')), () => fail(504, null, 'Gateway Timeout'),
|
||||
() => response(200, { success: true, tags: 'broken' })];
|
||||
for (const lost of answers) {
|
||||
let answer = lost;
|
||||
const { c, h } = await opened({ [`PUT ${TAGS}`]: call => answer(call) });
|
||||
c.setManual('Minimal, dune');
|
||||
await c.save();
|
||||
assert.equal(c.state.phase, 'unknown-outcome');
|
||||
assert.equal(c.state.statusText, 'The save could not be confirmed. Retry sends the same tags again.');
|
||||
assert.equal(c.state.canRetry, true);
|
||||
assert.equal(c.state.canSave, false);
|
||||
assert.equal(c.state.manual, 'Minimal, dune');
|
||||
c.setManual('changed meanwhile');
|
||||
const gate = deferred();
|
||||
answer = call => gate.promise.then(() => response(200, receipt(call.body.tags, { replayed: true })));
|
||||
const retried = c.retry();
|
||||
assert.equal(await c.retry(), false, 'a retry in flight is not sent twice');
|
||||
gate.resolve();
|
||||
assert.equal(await retried, true);
|
||||
const puts = h.of('PUT', TAGS);
|
||||
assert.equal(puts.length, 2);
|
||||
assert.deepEqual(puts[1].body, puts[0].body);
|
||||
assert.equal(c.state.phase, 'saved');
|
||||
assert.equal(await c.retry(), false);
|
||||
}
|
||||
});
|
||||
|
||||
test('write errors keep the draft and allow another save', async () => {
|
||||
const { c } = await opened({ [`PUT ${TAGS}`]: () => fail(400, 'tag-limit', 'An image can carry at most 100 tags.') });
|
||||
await c.save();
|
||||
assert.equal(c.state.phase, 'write-error');
|
||||
assert.equal(c.state.statusText, 'An image can carry at most 100 tags.');
|
||||
assert.equal(c.state.canSave, true);
|
||||
assert.equal(c.state.canRetry, false);
|
||||
const generic = await opened({ [`PUT ${TAGS}`]: () => fail(500, 'save-failed', '') });
|
||||
await generic.c.save();
|
||||
assert.equal(generic.c.state.phase, 'write-error');
|
||||
assert.equal(generic.c.state.statusText, 'The tags could not be saved.');
|
||||
});
|
||||
|
||||
test('storage-changed and file-changed disable save until reopen', async () => {
|
||||
for (const [code, phase] of [['storage-changed', 'storage-changed'], ['file-changed', 'file-changed'], ['source-changed', 'file-changed'], ['capture-expired', 'capture-expired']]) {
|
||||
const message = `server says ${code}`;
|
||||
let answer = () => fail(409, code, message);
|
||||
const { c, h } = await opened({ [`PUT ${TAGS}`]: () => answer() });
|
||||
c.setManual('kept draft');
|
||||
await c.save();
|
||||
assert.equal(c.state.phase, phase);
|
||||
assert.equal(c.state.statusText, message);
|
||||
assert.equal(c.state.canSave, false);
|
||||
assert.equal(c.state.canGenerate, false);
|
||||
assert.equal(await c.save(), false);
|
||||
assert.equal(h.of('PUT', TAGS).length, 1);
|
||||
await c.open();
|
||||
await settle();
|
||||
assert.equal(c.state.phase, 'ready', 'reopen observes again');
|
||||
assert.equal(c.state.manual, 'Minimal, warm_light');
|
||||
assert.equal(h.of('DELETE', CAPTURE).length, 1, 'reopen releases the old capture');
|
||||
}
|
||||
});
|
||||
|
||||
test('video disables generate with the still-image message', async () => {
|
||||
const reason = 'Auto-tagging is available for supported still images only.';
|
||||
const { c, h } = await opened({
|
||||
[`POST ${OBSERVE}`]: () => response(200, observed({ filename: 'clip.mp4' }, { media_type: 'video', generate: { supported: false, reason } })),
|
||||
});
|
||||
assert.equal(c.state.image.mediaType, 'video');
|
||||
assert.equal(c.state.canGenerate, false);
|
||||
assert.equal(c.state.canSave, true);
|
||||
assert.equal(c.state.statusText, reason);
|
||||
c.setModel('gguf');
|
||||
assert.equal(c.state.statusText, reason, 'the reason survives a model change');
|
||||
assert.equal(await c.generate(), false);
|
||||
assert.equal(h.of('POST', SUGGEST).length, 0);
|
||||
});
|
||||
|
||||
test('model-unavailable and model-busy set the status line only', async () => {
|
||||
let answer = () => response(200, suggested());
|
||||
const { c } = await opened({ [`POST ${SUGGEST}`]: () => answer() });
|
||||
await c.generate();
|
||||
const before = c.state;
|
||||
for (const [code, message] of [
|
||||
['model-unavailable', 'WD14 SwinV2 is not downloaded. Download it from the legacy Auto-tag page.'],
|
||||
['model-busy', 'A tagging model is busy with another operation. Try again when it finishes.'],
|
||||
['unsupported-media', 'Images larger than 4096 px per side or 16,777,216 px are not supported.'],
|
||||
]) {
|
||||
answer = () => fail(409, code, message);
|
||||
assert.equal(await c.generate(), false);
|
||||
assert.equal(c.state.statusText, message);
|
||||
assert.deepEqual(c.state.suggestions, before.suggestions);
|
||||
assert.equal(c.state.countText, before.countText);
|
||||
assert.equal(c.state.primaryLabel, before.primaryLabel);
|
||||
assert.equal(c.state.manual, before.manual);
|
||||
assert.equal(c.state.canSave, true);
|
||||
assert.equal(c.state.canGenerate, true);
|
||||
}
|
||||
answer = () => Promise.reject(new TypeError('Failed to fetch'));
|
||||
await c.generate();
|
||||
assert.equal(c.state.statusText, 'Auto-tagging failed. Check server logs for details.');
|
||||
c.setModel('wd14-vit');
|
||||
assert.equal(c.state.phase, 'ready');
|
||||
});
|
||||
|
||||
test('a storage restore during generate requires a reopen', async () => {
|
||||
const { c } = await opened({ [`POST ${SUGGEST}`]: () => fail(409, 'storage-changed', 'Prompt storage was restored; reopen the Tag dialog.') });
|
||||
c.setManual('kept');
|
||||
await c.generate();
|
||||
assert.equal(c.state.phase, 'storage-changed');
|
||||
assert.equal(c.state.canSave, false);
|
||||
assert.equal(c.state.canGenerate, false);
|
||||
await c.retry();
|
||||
await settle();
|
||||
assert.equal(c.state.phase, 'ready', 'Retry observes again (D116)');
|
||||
assert.equal(c.state.manual, 'kept');
|
||||
});
|
||||
|
||||
test('dispose aborts requests and releases the capture once', async () => {
|
||||
const pending = deferred();
|
||||
const { c, h } = await opened({ [`POST ${SUGGEST}`]: () => pending.promise });
|
||||
const token = TOKEN;
|
||||
const releases = [];
|
||||
const generating = c.generate();
|
||||
let notified = 0;
|
||||
c.subscribe(() => { notified += 1; });
|
||||
c.dispose();
|
||||
c.dispose();
|
||||
c.cancel();
|
||||
for (const call of h.of('DELETE', CAPTURE)) releases.push(decodeURIComponent(call.url.slice(CAPTURE.length)));
|
||||
assert.deepEqual(releases, [token]);
|
||||
assert.equal(h.of('POST', SUGGEST)[0].aborted, true);
|
||||
pending.resolve(response(200, suggested()));
|
||||
assert.equal(await generating, false);
|
||||
assert.equal(notified, 0, 'a disposed controller never notifies');
|
||||
c.setManual('ignored');
|
||||
c.remove('x');
|
||||
assert.notEqual(c.state.manual, 'ignored');
|
||||
assert.equal(await c.save(), false);
|
||||
assert.equal(await c.open(), undefined);
|
||||
assert.equal(h.of('POST', OBSERVE).length, 1);
|
||||
});
|
||||
|
||||
test('dispose while observing aborts the models read and releases a late capture', async () => {
|
||||
const { createImageTaggingController } = await load();
|
||||
const late = deferred();
|
||||
const h = harness(defaultRoutes({ [`POST ${OBSERVE}`]: () => late.promise, [`GET ${MODELS}`]: () => new Promise(() => {}) }));
|
||||
const c = createImageTaggingController({ api: h.api, target: TARGET });
|
||||
const opening = c.open();
|
||||
c.dispose();
|
||||
assert.equal(h.of('GET', MODELS)[0].aborted, true);
|
||||
late.resolve(response(200, observed()));
|
||||
await opening;
|
||||
await settle();
|
||||
assert.deepEqual(h.of('DELETE', CAPTURE).map(call => call.url), [CAPTURE + TOKEN]);
|
||||
assert.equal(c.state.phase, 'observing');
|
||||
});
|
||||
|
||||
test('a failing capture release is not reported as a dialog error', async () => {
|
||||
const { c, h } = await opened({ [`DELETE ${CAPTURE}`]: () => Promise.reject(new TypeError('offline')) });
|
||||
c.dispose();
|
||||
await settle();
|
||||
assert.equal(h.of('DELETE', CAPTURE).length, 1);
|
||||
assert.equal(c.state.phase, 'ready');
|
||||
});
|
||||
|
||||
test('the opening sentence is the concept on-screen copy and the chip hint shows only with chips (D83)', async () => {
|
||||
const { createImageTaggingController } = await load();
|
||||
const h = harness(defaultRoutes());
|
||||
const c = createImageTaggingController({ api: h.api, target: TARGET });
|
||||
assert.equal(c.state.suggestionsText, 'Generate suggestions with the selected model before approving.');
|
||||
assert.equal(c.state.chipHint, '');
|
||||
await c.open();
|
||||
await settle();
|
||||
assert.equal(c.state.suggestionsText, 'Generate suggestions with the selected model before approving.');
|
||||
assert.equal(c.state.chipHint, '');
|
||||
await c.generate();
|
||||
assert.equal(c.state.chipHint, 'Remove anything you do not want to add.');
|
||||
for (const row of [...c.state.suggestions]) c.remove(row.name);
|
||||
assert.equal(c.state.chipHint, '');
|
||||
});
|
||||
|
||||
test('the option fields follow the server defaults: open and a model change bump the revision, typing does not', async () => {
|
||||
const { c, h } = await opened({
|
||||
[`GET ${MODELS}`]: () => response(200, models({ suggestion_limit: 7, wd14_general_threshold: 0.5, wd14_character_threshold: 0.9 })),
|
||||
});
|
||||
const opening = c.state.optionsRevision;
|
||||
assert.ok(opening > 0, 'the observation that applies the server defaults bumps the revision');
|
||||
assert.deepEqual({ ...c.state.options }, { general_threshold: 0.5, character_threshold: 0.9, instructions: DEFAULT_PROMPT, suggestion_limit: 7 });
|
||||
c.setOption('general_threshold', '0.6');
|
||||
assert.equal(c.state.optionsRevision, opening, 'typing never re-renders the fields');
|
||||
c.setModel('wd14-vit');
|
||||
assert.equal(c.state.optionsRevision, opening + 1);
|
||||
assert.equal(c.state.options.general_threshold, 0.5);
|
||||
await c.generate();
|
||||
assert.deepEqual(h.of('POST', SUGGEST)[0].body.options, { general_threshold: 0.5, character_threshold: 0.9, suggestion_limit: 7 });
|
||||
});
|
||||
|
||||
test('a conflict shows the current set and merges it ahead of the draft, so a second Save never drops it unseen', async () => {
|
||||
let answer = () => fail(409, 'tag-conflict', 'The tags of this image changed since the dialog opened; review them first.',
|
||||
{ tags: ['changed_elsewhere', 'Minimal'], tag_revision: 5 });
|
||||
const { c, h } = await opened({ [`PUT ${TAGS}`]: call => answer(call) });
|
||||
const draft = c.state.draftRevision;
|
||||
c.setManual('minimal, dune');
|
||||
await c.save();
|
||||
assert.equal(c.state.phase, 'conflict');
|
||||
assert.equal(c.state.statusText,
|
||||
'The tags of this image changed since the dialog opened; review them first. Saved now: changed_elsewhere, Minimal.');
|
||||
assert.equal(c.state.manual, 'changed_elsewhere, Minimal, dune', 'the current set leads, the draft follows, repeats dropped');
|
||||
assert.equal(c.state.draftRevision, draft + 1, 'the dialog rewrites its field');
|
||||
answer = call => response(200, receipt(call.body.tags, { tag_revision: 6 }));
|
||||
await c.save();
|
||||
const second = h.of('PUT', TAGS)[1].body;
|
||||
assert.deepEqual(second.tags, ['changed_elsewhere', 'Minimal', 'dune']);
|
||||
assert.equal(second.expected_tag_revision, 5);
|
||||
const empty = await opened({ [`PUT ${TAGS}`]: () => fail(409, 'tag-conflict', 'Changed.', { tags: [], tag_revision: 9 }) });
|
||||
empty.c.setManual('a');
|
||||
await empty.c.save();
|
||||
assert.equal(empty.c.state.statusText, 'Changed. Saved now: no tags.');
|
||||
assert.equal(empty.c.state.manual, 'a');
|
||||
});
|
||||
|
||||
test('the capture release and the save survive leaving the page (keepalive)', async () => {
|
||||
const { c, h } = await opened();
|
||||
await c.save();
|
||||
assert.equal(h.of('PUT', TAGS)[0].init.keepalive, true);
|
||||
const other = await opened();
|
||||
other.c.dispose();
|
||||
const [release] = other.h.of('DELETE', CAPTURE);
|
||||
assert.equal(release.init.keepalive, true);
|
||||
assert.equal(h.of('POST', OBSERVE)[0].init.keepalive, undefined, 'reads are not kept alive');
|
||||
});
|
||||
|
||||
test('closing during a save releases the capture only after the PUT settles and reports the outcome', async () => {
|
||||
const outcomes = [];
|
||||
const gate = deferred();
|
||||
const { c, h } = await opened({ [`PUT ${TAGS}`]: call => gate.promise.then(() => response(200, receipt(call.body.tags))) },
|
||||
{ onLateSave: outcome => outcomes.push(outcome) });
|
||||
c.setManual('dune');
|
||||
const saving = c.save();
|
||||
c.dispose();
|
||||
await settle();
|
||||
assert.equal(h.of('DELETE', CAPTURE).length, 0, 'the capture stays while its save is in flight');
|
||||
gate.resolve();
|
||||
assert.equal(await saving, true);
|
||||
await settle();
|
||||
const order = h.calls.filter(call => call.method !== 'GET').map(call => call.method);
|
||||
assert.deepEqual(order, ['POST', 'PUT', 'DELETE']);
|
||||
assert.deepEqual(outcomes, [{ saved: true, filename: 'dunes_00001.png', tags: ['dune'], toast: '1 tags saved for dunes_00001.png.' }]);
|
||||
assert.equal(c.state.closed, true);
|
||||
|
||||
for (const [route, message] of [
|
||||
[() => fail(409, 'tag-conflict', 'Changed elsewhere.', { tags: ['x'], tag_revision: 5 }), 'Changed elsewhere.'],
|
||||
[() => fail(400, 'tag-limit', 'Too many.'), 'Too many.'],
|
||||
[() => Promise.reject(new TypeError('Failed to fetch')), 'The tag save could not be confirmed. Open Tag image again to check the saved tags.'],
|
||||
]) {
|
||||
const late = [];
|
||||
const held = deferred();
|
||||
const run = await opened({ [`PUT ${TAGS}`]: () => held.promise.then(route) }, { onLateSave: outcome => late.push(outcome) });
|
||||
const pending = run.c.save();
|
||||
run.c.cancel();
|
||||
held.resolve();
|
||||
assert.equal(await pending, false);
|
||||
await settle();
|
||||
assert.deepEqual(late, [{ saved: false, message }]);
|
||||
assert.equal(run.h.of('DELETE', CAPTURE).length, 1);
|
||||
}
|
||||
const quiet = await opened();
|
||||
quiet.c.dispose();
|
||||
assert.equal(quiet.h.of('DELETE', CAPTURE).length, 1, 'with no save in flight the capture is released at once');
|
||||
const { createImageTaggingController } = await load();
|
||||
assert.throws(() => createImageTaggingController({ api: h.api, target: TARGET, onLateSave: 'x' }), TypeError);
|
||||
});
|
||||
|
||||
test('retry after a changed storage, file or expired capture observes again and keeps the draft', async () => {
|
||||
for (const code of ['storage-changed', 'file-changed', 'capture-expired']) {
|
||||
let revision = 3;
|
||||
const { c, h } = await opened({
|
||||
[`PUT ${TAGS}`]: () => fail(409, code, `server says ${code}`),
|
||||
[`POST ${OBSERVE}`]: () => response(200, observed({ tag_revision: revision, tags: revision === 3 ? ['Minimal', 'warm_light'] : ['night'] })),
|
||||
});
|
||||
c.setManual('kept draft');
|
||||
await c.save();
|
||||
assert.equal(c.state.canRetry, true);
|
||||
const draft = c.state.draftRevision;
|
||||
await c.retry();
|
||||
await settle();
|
||||
assert.equal(c.state.phase, 'ready');
|
||||
assert.equal(c.state.manual, 'kept draft', 'an unchanged tag set keeps the draft as typed');
|
||||
assert.equal(c.state.draftRevision, draft + 1);
|
||||
assert.equal(h.of('POST', OBSERVE).length, 2);
|
||||
revision = 4;
|
||||
await c.save();
|
||||
await c.retry();
|
||||
await settle();
|
||||
assert.equal(c.state.manual, 'night, kept draft', 'a set changed meanwhile is shown ahead of the draft');
|
||||
assert.equal(c.state.statusText, 'The tags of this image changed since the dialog opened. Saved now: night.');
|
||||
assert.equal(c.state.tagRevision, 4);
|
||||
}
|
||||
// A first observation that failed has no draft to keep: its retry fills the saved tags.
|
||||
const { createImageTaggingController } = await load();
|
||||
let first = true;
|
||||
const h = harness(defaultRoutes({ [`POST ${OBSERVE}`]: () => (first ? (first = false, fail(500, 'observe-failed')) : response(200, observed())) }));
|
||||
const c = createImageTaggingController({ api: h.api, target: TARGET });
|
||||
await c.open();
|
||||
await c.retry();
|
||||
await settle();
|
||||
assert.equal(c.state.manual, 'Minimal, warm_light');
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
// Guarded: before the dialog module exists every test fails on an assertion, not on the import.
|
||||
const loaded = await import('../../web/js/workspace/image-tagging.mjs').catch(() => null);
|
||||
const module = () => { assert.ok(loaded, 'image-tagging.mjs is not implemented'); return loaded; };
|
||||
const SCOPE = 'a'.repeat(64);
|
||||
|
||||
test('a listed file gives its tagging target, anything else none', () => {
|
||||
const { imageTarget } = module();
|
||||
assert.deepEqual(imageTarget({ root_index: 1, root_scope: SCOPE, path: 'a/b.png', filename: 'b.png' }),
|
||||
{ root_index: 1, root_scope: SCOPE, path: 'a/b.png' });
|
||||
for (const image of [null, {}, { root_index: -1, root_scope: SCOPE, path: 'a.png' }, { root_index: 0, path: 'a.png' },
|
||||
{ root_index: 0, root_scope: 'A'.repeat(64), path: 'a.png' }, { root_index: 0, root_scope: SCOPE, path: '' },
|
||||
{ root_index: 1.5, root_scope: SCOPE, path: 'a.png' }]) {
|
||||
assert.equal(imageTarget(image), null);
|
||||
}
|
||||
});
|
||||
|
||||
test('tag save listeners subscribe and unsubscribe once', () => {
|
||||
const { onImageTagsSaved, imageTagsListenerCount } = module();
|
||||
const before = imageTagsListenerCount();
|
||||
const off = onImageTagsSaved(() => {});
|
||||
assert.equal(imageTagsListenerCount(), before + 1);
|
||||
off(); off();
|
||||
assert.equal(imageTagsListenerCount(), before);
|
||||
assert.throws(() => onImageTagsSaved('nope'), TypeError);
|
||||
});
|
||||
@@ -21,3 +21,12 @@ test('Images mounted after its parent is aborted creates no DOM, requests or obs
|
||||
for(const [key,descriptor] of originals) {if(descriptor) Object.defineProperty(globalThis,key,descriptor);else delete globalThis[key];}
|
||||
}
|
||||
});
|
||||
|
||||
test('Images mounted after its parent is aborted follows no tag saves', async () => {
|
||||
const tagging = await import('../../web/js/workspace/image-tagging.mjs').catch(() => null);
|
||||
assert.ok(tagging, 'image-tagging.mjs is not implemented');
|
||||
const before = tagging.imageTagsListenerCount();
|
||||
const parent = createScope(); parent.dispose();
|
||||
mountImages({}, {get() { assert.fail('Request started after parent disposal'); }}, parent, {embedded: true, fetchPage() {}}).dispose();
|
||||
assert.equal(tagging.imageTagsListenerCount(), before);
|
||||
});
|
||||
|
||||
@@ -352,3 +352,129 @@ test('selectionLabel counts the selected images as the concept bar does', async
|
||||
assert.equal(selectionLabel(2), '2 selected');
|
||||
assert.equal(selectionLabel(1234), '1,234 selected');
|
||||
});
|
||||
|
||||
test('refresh re-reads the loaded range in place and publishes once, replacing the items', async () => {
|
||||
const {createImageStream,imageKey}=await source;const requests=[];const states=[];
|
||||
const stream=createImageStream({api:{get:(url,{signal})=>{const d=deferred();requests.push({...d,url,signal});return d.promise;}},onChange:(s,hint)=>states.push([s,hint]),batchSize:2});
|
||||
assert.equal(typeof stream.refresh,'function','refresh is part of the stream');
|
||||
const first=stream.reset({q:'tag'});requests[0].resolve(page([image('a'),image('b')],5,'c1'));await first;
|
||||
const second=stream.loadMore();requests[1].resolve(page([image('c'),image('d')],5,'c2'));await second;
|
||||
const before=stream.state;const published=states.length;
|
||||
const refresh=stream.refresh();
|
||||
// The current collection stays shown while the re-read runs; Load more waits for it.
|
||||
assert.equal(stream.state,before);assert.equal(states.length,published);
|
||||
assert.equal(await stream.loadMore(),0);assert.equal(requests.length,3);
|
||||
const query=new URL(requests[2].url,'http://local').searchParams;
|
||||
// The loaded range is re-read in pages as large as it (at most 200), not in the listing's batches (M2).
|
||||
assert.equal(query.get('q'),'tag');assert.equal(query.has('cursor'),false);assert.equal(query.get('limit'),'4');
|
||||
requests[2].resolve(page([image('a'),image('new'),image('c')],6,'n1'));
|
||||
await new Promise(resolve=>setImmediate(resolve));
|
||||
const next=new URL(requests[3].url,'http://local').searchParams;
|
||||
assert.equal(next.get('cursor'),'n1');assert.equal(next.get('limit'),'2','the rest, at least one batch');
|
||||
assert.equal(stream.state,before);
|
||||
requests[3].resolve(page([image('d')],6,'n2'));
|
||||
assert.equal(await refresh,4);
|
||||
assert.deepEqual(stream.state.items.map(imageKey),['0:a','0:new','0:c','0:d']);
|
||||
assert.deepEqual([stream.state.total,stream.state.cursor,stream.state.hasMore,stream.state.loaded,stream.state.loading],[6,'n2',true,4,false]);
|
||||
assert.equal(states.length,published+1);assert.deepEqual(states.at(-1)[1],{replaced:true});
|
||||
// Load more continues from the refreshed cursor.
|
||||
const more=stream.loadMore();assert.equal(new URL(requests[4].url,'http://local').searchParams.get('cursor'),'n2');
|
||||
requests[4].resolve(page([image('e')],6));await more;
|
||||
assert.deepEqual(stream.state.items.map(imageKey),['0:a','0:new','0:c','0:d','0:e']);
|
||||
});
|
||||
|
||||
test('refresh stops early when the result shrank, and a reset or failure leaves the collection as it was', async () => {
|
||||
const {createImageStream,imageKey}=await source;const requests=[];
|
||||
const stream=createImageStream({api:{get:(url,{signal})=>{const d=deferred();requests.push({...d,url,signal});return d.promise;}},batchSize:2});
|
||||
assert.equal(typeof stream.refresh,'function','refresh is part of the stream');
|
||||
const first=stream.reset({tags:['x']});requests[0].resolve(page([image('a'),image('b')],4,'c1'));await first;
|
||||
const shrink=stream.refresh();requests[1].resolve(page([image('b')],1));
|
||||
assert.equal(await shrink,1);assert.deepEqual(stream.state.items.map(imageKey),['0:b']);assert.equal(stream.state.hasMore,false);
|
||||
const before=stream.state;
|
||||
const failing=stream.refresh();requests[2].reject(new Error('offline'));
|
||||
await assert.rejects(failing,/offline/,'a failed re-read is reported to the page');assert.equal(stream.state,before);
|
||||
const stale=stream.refresh();const fresh=stream.reset({tags:['y']});
|
||||
assert.equal(requests[3].signal.aborted,true);
|
||||
requests[4].resolve(page([image('y')]));await fresh;
|
||||
requests[3].resolve(page([image('stale')]));assert.equal(await stale,0);
|
||||
assert.deepEqual(stream.state.items.map(imageKey),['0:y']);
|
||||
stream.dispose();assert.equal(await stream.refresh(),0);
|
||||
});
|
||||
|
||||
test('a refresh asked while a page loads runs once that page settles (I6)', async () => {
|
||||
const {createImageStream,imageKey}=await source;const requests=[];const states=[];
|
||||
const stream=createImageStream({api:{get:(url,{signal})=>{const d=deferred();requests.push({...d,url,signal});return d.promise;}},onChange:(s,hint)=>states.push(hint),batchSize:2});
|
||||
const first=stream.reset({tags:['x']});requests[0].resolve(page([image('a'),image('b')],4,'c1'));await first;
|
||||
for (const settle of ['resolve','reject']) {
|
||||
const more=stream.loadMore();const sent=requests.length;
|
||||
assert.equal(await stream.refresh(),0,'nothing is read beside the page in flight');
|
||||
assert.equal(await stream.refresh(),0);
|
||||
assert.equal(requests.length,sent);
|
||||
if (settle==='resolve') requests[sent-1].resolve(page([image('c'),image('d')],4,'c2'));
|
||||
else requests[sent-1].reject(new Error('offline'));
|
||||
await more;
|
||||
await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.equal(requests.length,sent+1,'exactly one pending refresh follows');
|
||||
assert.equal(new URL(requests[sent].url,'http://local').searchParams.has('cursor'),false);
|
||||
requests[sent].resolve(page([image('a'),image('b'),image('c'),image('d')],6,'r1'));
|
||||
await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.deepEqual(states.at(-1),{replaced:true});
|
||||
assert.equal(states.filter(hint=>hint?.replaced).length,settle==='resolve'?1:2);
|
||||
}
|
||||
// A reset drops a pending refresh.
|
||||
const more=stream.loadMore();const sent=requests.length;
|
||||
assert.equal(await stream.refresh(),0);
|
||||
const fresh=stream.reset({tags:['y']});
|
||||
requests[sent].resolve(page([image('y')]));await fresh;
|
||||
requests[sent-1].resolve(page([image('late')],1));await more;
|
||||
await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.equal(requests.length,sent+1);assert.deepEqual(stream.state.items.map(imageKey),['0:y']);
|
||||
assert.equal(stream.refreshing,false);
|
||||
});
|
||||
|
||||
test('refresh reads a long loaded range in at most 200-file pages and exposes refreshing (M2, M3)', async () => {
|
||||
const {createImageStream}=await source;const requests=[];
|
||||
const stream=createImageStream({api:{get:(url,{signal})=>{const d=deferred();requests.push({...d,url,signal});return d.promise;}},batchSize:150});
|
||||
const many=(prefix,n)=>Array.from({length:n},(_,i)=>image(`${prefix}${i}`));
|
||||
const first=stream.reset({q:'x'});requests[0].resolve(page(many('a',150),1000,'c1'));await first;
|
||||
const second=stream.loadMore();requests[1].resolve(page(many('b',150),1000,'c2'));await second;
|
||||
const refresh=stream.refresh();
|
||||
assert.equal(stream.refreshing,true);
|
||||
assert.equal(new URL(requests[2].url,'http://local').searchParams.get('limit'),'200');
|
||||
requests[2].resolve(page(many('r',200),1000,'n1'));
|
||||
await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.equal(new URL(requests[3].url,'http://local').searchParams.get('limit'),'150');
|
||||
requests[3].resolve(page(many('s',150),1000,'n2'));
|
||||
assert.equal(await refresh,350);assert.equal(requests.length,4);
|
||||
assert.equal(stream.refreshing,false);
|
||||
});
|
||||
|
||||
test('a second refresh supersedes the first, repeats in a re-read are dropped, and dispose abandons one in flight', async () => {
|
||||
const {createImageStream,imageKey}=await source;const requests=[];const states=[];
|
||||
const stream=createImageStream({api:{get:(url,{signal})=>{const d=deferred();requests.push({...d,url,signal});return d.promise;}},onChange:(s,hint)=>states.push(hint),batchSize:2});
|
||||
const first=stream.reset({q:'x'});requests[0].resolve(page([image('a'),image('b')],2));await first;
|
||||
const older=stream.refresh();const newer=stream.refresh();
|
||||
assert.equal(requests[1].signal.aborted,true,'the older re-read is aborted');
|
||||
requests[2].resolve(page([image('b'),image('b'),image('a')],2));
|
||||
assert.equal(await newer,2);
|
||||
requests[1].resolve(page([image('old')],1));assert.equal(await older,0);
|
||||
assert.deepEqual(stream.state.items.map(imageKey),['0:b','0:a']);
|
||||
assert.equal(states.filter(hint=>hint?.replaced).length,1);
|
||||
const pending=stream.refresh();
|
||||
stream.dispose();
|
||||
assert.equal(requests[3].signal.aborted,true);
|
||||
requests[3].resolve(page([image('late')],1));
|
||||
assert.equal(await pending,0);assert.deepEqual(stream.state.items.map(imageKey),['0:b','0:a']);
|
||||
});
|
||||
|
||||
test('a refresh deferred behind a page load is dropped when the stream is disposed meanwhile', async () => {
|
||||
const {createImageStream}=await source;const requests=[];
|
||||
const stream=createImageStream({api:{get:(url,{signal})=>{const d=deferred();requests.push({...d,url,signal});return d.promise;}},batchSize:2});
|
||||
const first=stream.reset({q:'x'});requests[0].resolve(page([image('a'),image('b')],4,'c1'));await first;
|
||||
const more=stream.loadMore();
|
||||
assert.equal(await stream.refresh(),0);
|
||||
stream.dispose();
|
||||
requests[1].resolve(page([image('c')],4,'c2'));await more;
|
||||
await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.equal(requests.length,2,'no refresh runs after dispose');
|
||||
});
|
||||
|
||||
@@ -702,3 +702,57 @@ test('a workflow the legacy parser throws on keeps the server result instead of
|
||||
assert.deepEqual(record.prompts.positive, { status: 'resolved', text: 'exact\npositive', source: 'execution' });
|
||||
assert.equal(record.prompts.negative.status, 'ambiguous');
|
||||
});
|
||||
|
||||
// #249 PR C Task C3: the B3 envelope's `image_tags` facet, the file's own current tags (S1).
|
||||
test('image_tags facet is kept exactly and absent stays null', () => {
|
||||
const entry = image(), project = contract('toInspectionRecord');
|
||||
assert.equal(project(envelope(entry), entry, extractGenerationFacts).imageTags, null);
|
||||
for (const facet of [{ state: 'current', tags: ['dunes', 'warm_light'] }, { state: 'current', tags: [] }, { state: 'none', tags: [] }, { state: 'unavailable', tags: [] }]) {
|
||||
const data = { ...envelope(entry), image_tags: facet };
|
||||
const record = project(data, entry, extractGenerationFacts);
|
||||
assert.deepEqual(record.imageTags, facet);
|
||||
assert.notEqual(record.imageTags.tags, facet.tags);
|
||||
}
|
||||
const hundred = Array.from({ length: 100 }, (_, index) => `tag_${index}`);
|
||||
assert.deepEqual(project({ ...envelope(entry), image_tags: { state: 'current', tags: hundred } }, entry, extractGenerationFacts).imageTags.tags, hundred);
|
||||
});
|
||||
|
||||
test('rejects a malformed image_tags facet', () => {
|
||||
const entry = image();
|
||||
for (const facet of [null, [], 'current', { state: 'current' }, { tags: [] }, { state: 'current', tags: ['a'], extra: 1 },
|
||||
{ state: 'stale', tags: [] }, { state: 'none', tags: ['a'] }, { state: 'unavailable', tags: ['a'] }, { state: 'current', tags: 'a' },
|
||||
{ state: 'current', tags: [1] }, { state: 'current', tags: [''] }, { state: 'current', tags: [' a'] }, { state: 'current', tags: ['a,b'] },
|
||||
{ state: 'current', tags: ['a\nb'] }, { state: 'current', tags: ['x'.repeat(51)] },
|
||||
{ state: 'current', tags: Array.from({ length: 101 }, (_, index) => `tag_${index}`) }]) {
|
||||
assert.throws(() => module.toInspectionRecord({ ...envelope(entry), image_tags: facet }, entry, extractGenerationFacts), TypeError, JSON.stringify(facet));
|
||||
}
|
||||
assert.equal(module.toInspectionRecord({ ...envelope(entry), image_tags: { state: 'current', tags: ['é'.repeat(50)] } }, entry, extractGenerationFacts).imageTags.tags[0].length, 50);
|
||||
});
|
||||
|
||||
test('refresh re-reads the selected file and keeps the shown record until the new read lands', async () => {
|
||||
const { api, requests } = pendingApi(), updates = [];
|
||||
const controller = contract('createInspectionController')({ api, extractFacts: extractGenerationFacts });
|
||||
assert.equal(typeof controller.refresh, 'function', 'refresh is part of the controller');
|
||||
controller.subscribe(state => updates.push(state));
|
||||
const load = controller.select(image()); requests[0].resolve(envelope()); await load;
|
||||
const shown = controller.state, count = updates.length;
|
||||
const refresh = controller.refresh();
|
||||
assert.equal(requests.length, 2);
|
||||
// No loading state is published: the panel keeps its content.
|
||||
assert.equal(controller.state, shown); assert.equal(updates.length, count);
|
||||
requests[1].resolve({ ...envelope(), image_tags: { state: 'current', tags: ['fresh'] } }); await refresh;
|
||||
assert.deepEqual(controller.state.record.imageTags, { state: 'current', tags: ['fresh'] });
|
||||
assert.equal(controller.state.loading, false); assert.equal(updates.length, count + 1);
|
||||
// A failed quiet re-read reports the failure as a read would.
|
||||
const failing = controller.refresh(); requests[2].reject(new Error('offline')); await failing;
|
||||
assert.equal(controller.state.error, true);
|
||||
// Without a shown record a refresh is an ordinary retry (loading published).
|
||||
const again = controller.refresh(); assert.equal(controller.state.loading, true);
|
||||
requests[3].resolve(envelope()); await again; assert.equal(controller.state.error, false);
|
||||
// A newer selection supersedes a refresh in flight.
|
||||
const stale = controller.refresh(); const other = controller.select({ ...image(), path: 'folder/b.png', filename: 'b.png', url: image().url.replace('a%20%23%25', 'b') });
|
||||
requests[5].resolve(envelope({ ...image(), path: 'folder/b.png', filename: 'b.png', url: image().url.replace('a%20%23%25', 'b') })); await other;
|
||||
requests[4].resolve(envelope()); await stale;
|
||||
assert.equal(controller.state.record.identity.path, 'folder/b.png');
|
||||
controller.dispose(); assert.equal(await controller.refresh(), undefined);
|
||||
});
|
||||
|
||||
@@ -47,6 +47,22 @@ test('restored shelves keep filters, loaded parents and continuation without a f
|
||||
const next=createLibraryStream({api:{get:async url=>{urls.push(url);return page([item(2)]);}},restore:{filters:{text:'retained',sort:'name'},state:old.state}});assert.equal(next.state.items.length,1);await next.loadMore();assert.match(urls[0],/text=retained/);assert.match(urls[0],/cursor=next/);assert.equal(next.state.items.length,2);next.dispose();
|
||||
assert.throws(()=>createLibraryStream({api:{},restore:{filters:{},state:{items:[item(1),item(1)],total:2,variantTotal:0,cursor:null,hasMore:false}}}));
|
||||
});
|
||||
test('a shelf left before its first page answered or after it failed restores and reads page one',async()=>{
|
||||
const {createLibraryStream}=await source;
|
||||
// The shelf saves whatever state it holds when the route changes; a first page still pending or failed has no variant total yet.
|
||||
let pending;const left=createLibraryStream({api:{get:()=>new Promise(resolve=>{pending=resolve;})}});const first=left.reset({text:'kept'});
|
||||
assert.equal(left.state.loading,true);assert.equal(left.state.variantTotal,null);const saved=left.state;left.dispose();pending(page([item(9)]));await first;
|
||||
const failed=createLibraryStream({api:{get:async()=>{throw Error('offline');}}});await failed.reset();assert.ok(failed.state.error);failed.dispose();
|
||||
for(const state of [saved,failed.state]) {
|
||||
const urls=[];const next=createLibraryStream({api:{get:async url=>{urls.push(url);return page([item(1)]);}},restore:{filters:{text:'kept'},state}});
|
||||
assert.deepEqual([next.state.items.length,next.state.loading,next.state.error,next.state.variantTotal,next.state.hasMore],[0,false,null,null,true]);
|
||||
assert.equal(await next.loadMore(),1);assert.equal(new URL(urls[0],'https://x').searchParams.get('text'),'kept');assert.equal(new URL(urls[0],'https://x').searchParams.has('cursor'),false);
|
||||
assert.equal(next.state.variantTotal,6);next.dispose();
|
||||
}
|
||||
// A pending first page is only the empty, cursorless state: anything else without a variant total stays invalid.
|
||||
for(const state of [{items:[item(1)],total:1,variantTotal:null,cursor:null,hasMore:false},{items:[],total:0,variantTotal:null,cursor:'next',hasMore:true},{items:[],total:3,variantTotal:null,cursor:null,hasMore:true},{items:[],total:0,variantTotal:null,cursor:null,hasMore:false}])
|
||||
assert.throws(()=>createLibraryStream({api:{},restore:{filters:{},state}}),/Invalid library page/);
|
||||
});
|
||||
test('parent shelf window bounds DOM and pins focused rows without changing logical order',async()=>{
|
||||
const {libraryWindow}=await source;const first=libraryWindow(500,2,0,900);assert.equal(first[0],0);assert.ok(first.length<24);
|
||||
const middle=libraryWindow(500,2,50000,900,0);assert.equal(middle[0],0);assert.ok(middle.some(index=>index>150));assert.ok(middle.length<24);assert.deepEqual(middle,[...middle].sort((a,b)=>a-b));assert.deepEqual(libraryWindow(3,2,0,900),[0,1,2]);
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
// web/js/workspace/local-inspection.mjs turns one chosen File's local reads into
|
||||
// the inspection wire the workspace already renders, fits it into the 8 MiB
|
||||
// response budget exactly as the server does, and plugs into the shared
|
||||
// inspection controller, inspector and viewer through localFileAdapter.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { raw, ihdr, idat, iend, tEXt, iTXt, utf8 } from "./helpers/png-bytes.mjs";
|
||||
import { createInspectionController, toInspectionRecord, projectInspectionPayload } from "../../web/js/workspace/inspection-state.mjs";
|
||||
import { inspectionDownloads } from "../../web/js/workspace/file-inspector.mjs";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const require = createRequire(import.meta.url);
|
||||
const PngMetadata = require("../../web/js/png-metadata.js");
|
||||
const ComfyMetadata = require("../../web/js/comfy-metadata.js");
|
||||
const { extractGenerationFacts } = ComfyMetadata;
|
||||
// The page loads both as scripts; the legacy fill and the PNG reader find them on the global.
|
||||
globalThis.PngMetadata = PngMetadata;
|
||||
globalThis.ComfyMetadata = ComfyMetadata;
|
||||
const m = await import("../../web/js/workspace/local-inspection.mjs").catch((error) => {
|
||||
if (error.code !== "ERR_MODULE_NOT_FOUND") throw error;
|
||||
return {};
|
||||
});
|
||||
|
||||
const FIXTURES = path.join(here, "..", "fixtures", "local-inspect");
|
||||
const PARITY = path.join(here, "..", "fixtures", "inspection_parity");
|
||||
const PROMPT = '{"3":{"class_type":"KSampler","inputs":{"seed":7,"steps":20,"cfg":8.0,"sampler_name":"euler","scheduler":"normal","denoise":1.0,"model":["4",0],"positive":["6",0],"negative":["7",0],"latent_image":["5",0]}},"4":{"class_type":"CheckpointLoaderSimple","inputs":{"ckpt_name":"synthetic-v1.safetensors"}},"5":{"class_type":"EmptyLatentImage","inputs":{"width":512,"height":512,"batch_size":1}},"6":{"class_type":"CLIPTextEncode","inputs":{"text":"a red fox in snow, detailed fur","clip":["4",1]}},"7":{"class_type":"CLIPTextEncode","inputs":{"text":"blurry, lowres","clip":["4",1]}}}';
|
||||
const WORKFLOW = '{"last_node_id":7,"nodes":[{"id":3,"type":"KSampler","widgets_values":[7,"fixed",20,8.0,"euler","normal",1.0]}],"links":[],"version":0.4}';
|
||||
const CONTAINER_WORKFLOW = "Original workflow JSON text is unavailable for this container record. Download metadata or the original file.";
|
||||
const LIMIT = 8388608;
|
||||
const plain = (value) => JSON.parse(JSON.stringify(value));
|
||||
const bytes = (value) => Buffer.byteLength(JSON.stringify(value), "utf8");
|
||||
|
||||
function pngFile(name, chunks, { lastModified = 1_700_000_000_123 } = {}) {
|
||||
return new File([raw(ihdr(640, 480), idat(), ...chunks, iend())], name, { type: "image/png", lastModified });
|
||||
}
|
||||
const fixtureFile = (name, directory = FIXTURES) => new File([fs.readFileSync(path.join(directory, name))], name, { lastModified: 1_700_000_000_000 });
|
||||
const classify = async (file) => PngMetadata.classifyPrefix(new Uint8Array(await file.slice(0, 32).arrayBuffer()));
|
||||
async function handleFor(file, { url = "blob:http://host.test/11111111-2222-3333-4444-555555555555", filename } = {}) {
|
||||
return m.createLocalFileHandle({ file, url, classification: await classify(file), filename });
|
||||
}
|
||||
async function recordFor(file, options) {
|
||||
const handle = await handleFor(file, options);
|
||||
const wire = await m.localFileAdapter.read(handle);
|
||||
return { handle, wire, record: m.localFileAdapter.toRecord(wire, handle, extractGenerationFacts) };
|
||||
}
|
||||
|
||||
test("contract", () => {
|
||||
for (const name of ["buildLocalPayload", "fitLocalPayload", "createLocalFileHandle", "readLocalFile", "toInspectionRead", "sanitizeLocalFilename"]) {
|
||||
assert.equal(typeof m[name], "function", `${name} is exported`);
|
||||
}
|
||||
assert.equal(typeof m.localFileAdapter, "object");
|
||||
for (const name of ["validate", "key", "scope", "inspectionPath", "read", "toRecord", "originalDownload", "mediaUrl"]) {
|
||||
assert.equal(typeof m.localFileAdapter[name], "function", `adapter.${name}`);
|
||||
}
|
||||
assert.ok(Object.isFrozen(m.localFileAdapter));
|
||||
});
|
||||
|
||||
test("a PNG File becomes the server-shaped wire with local provenance", async () => {
|
||||
const file = pngFile("Fox Render.PNG", [tEXt("prompt", PROMPT), tEXt("workflow", WORKFLOW)]);
|
||||
const { wire, record, handle } = await recordFor(file);
|
||||
assert.deepEqual(wire.file, { observation: "selected-local-file", source_label: "Selected local file", media_type: "image", extension: ".png", format: "PNG",
|
||||
size: file.size, width: 640, height: 480, modified_time: 1_700_000_000.123 });
|
||||
assert.equal(wire.success, true);
|
||||
assert.equal(wire.inspection_version, 1);
|
||||
assert.deepEqual(wire.prompts, { positive: { status: "unresolved" }, negative: { status: "unresolved" } });
|
||||
assert.deepEqual(wire.resolution, { state: "unresolved", sampler_id: null, reason: "Resolved in this browser from the workflow.", reason_code: null, node: null });
|
||||
assert.deepEqual(wire.library, { status: "unavailable", scope: "saved-library", tags: [], category_scope: null, tags_scope: null });
|
||||
assert.equal(wire.metadata.status, "available");
|
||||
assert.deepEqual(wire.metadata.raw, { prompt: PROMPT, workflow: WORKFLOW });
|
||||
assert.deepEqual(wire.metadata.record_formats, { prompt: "original-text", workflow: "original-text" });
|
||||
assert.equal(wire.metadata.prompt_graph.status, "parsed");
|
||||
assert.equal(plain(wire.metadata.prompt_graph.value)["3"].inputs.cfg, "8.0");
|
||||
assert.equal(wire.metadata.workflow_graph.status, "parsed");
|
||||
assert.equal(wire.metadata.parameters, null);
|
||||
for (const key of Object.keys(wire)) assert.ok(["success", "inspection_version", "file", "prompts", "resolution", "metadata", "library"].includes(key), key);
|
||||
// The existing projection renders it: facts from the API graph, legacy fill for the roles, no library link.
|
||||
assert.deepEqual(record.identity, { kind: "local-file", key: handle.key, path: "Fox Render.PNG" });
|
||||
assert.equal(record.file.observation, "selected-local-file");
|
||||
assert.equal(record.generation.seed.value, "7");
|
||||
assert.equal(record.generation.cfg.value, "8.0");
|
||||
assert.equal(record.prompts.positive.text, "a red fox in snow, detailed fur");
|
||||
assert.equal(record.prompts.positive.source, "graph-inferred");
|
||||
assert.equal(record.prompts.negative.text, "blurry, lowres");
|
||||
assert.equal(record.historyTarget, null);
|
||||
assert.deepEqual(record.download, { url: "blob:http://host.test/11111111-2222-3333-4444-555555555555", filename: "Fox Render.PNG" });
|
||||
const downloads = inspectionDownloads(record);
|
||||
assert.equal(downloads.metadata.available, true);
|
||||
assert.equal(downloads.workflow.bodyText, WORKFLOW);
|
||||
assert.equal(downloads.metadata.filename, "Fox Render.metadata.json");
|
||||
const exported = JSON.parse(downloads.metadata.bodyText);
|
||||
assert.equal(exported.identity.kind, "local-file");
|
||||
assert.equal(exported.file.source_label, "Selected local file");
|
||||
});
|
||||
|
||||
test("file facts follow the server: extension like Path.suffix, null when unknown", async () => {
|
||||
const cases = [["a.b.PnG", ".png"], [".hidden", ""], ["noext", ""], ["dot.", ""], ["x.tar.gz", ".gz"]];
|
||||
for (const [name, extension] of cases) {
|
||||
const wire = m.buildLocalPayload({ read: { status: "complete", records: {}, limited: [], width: 2, height: 3 }, file: { name, size: 10, lastModified: 0 }, classification: { media: "image", kind: "png" } });
|
||||
assert.equal(wire.file.extension, extension, name);
|
||||
assert.equal(wire.file.modified_time, 0);
|
||||
}
|
||||
const sparse = m.buildLocalPayload({ read: { status: "complete", records: {}, limited: [], width: 2, height: 3 }, file: { name: "a.png" }, classification: { media: "image", kind: "png" } });
|
||||
assert.deepEqual([sparse.file.size, sparse.file.modified_time], [null, null]);
|
||||
assert.equal(m.buildLocalPayload({ read: { status: "complete", records: {}, limited: [], width: 2, height: 3 }, file: { name: "a.png", size: -1, lastModified: NaN }, classification: { media: "image", kind: "png" } }).file.size, null);
|
||||
assert.throws(() => m.buildLocalPayload({ read: null, file: { name: "a" }, classification: null }), TypeError);
|
||||
assert.throws(() => m.buildLocalPayload({ read: null, file: { name: "a" }, classification: { media: "text", kind: "svg" } }), TypeError);
|
||||
});
|
||||
|
||||
test("sanitized filenames carry no separators or control characters", () => {
|
||||
assert.equal(m.sanitizeLocalFilename("../a/b\\c\u0000\u001f\u007f.png"), ".._a_b_c.png");
|
||||
assert.equal(m.sanitizeLocalFilename(" spaced .png "), "spaced .png");
|
||||
assert.equal(m.sanitizeLocalFilename(""), "file");
|
||||
assert.equal(m.sanitizeLocalFilename(undefined), "file");
|
||||
assert.equal(m.sanitizeLocalFilename("\u0000"), "file");
|
||||
assert.equal([...m.sanitizeLocalFilename("😀".repeat(300))].length, 255);
|
||||
});
|
||||
|
||||
test("unreadable PNG metadata is unreadable, not absent", async () => {
|
||||
const { wire, record } = await recordFor(pngFile("bad.png", [tEXt("prompt", PROMPT, { crc: 0 })]));
|
||||
assert.equal(wire.metadata.status, "unreadable");
|
||||
assert.deepEqual([wire.file.format, wire.file.width, wire.file.height], [null, null, null]);
|
||||
assert.equal(record.generation.seed.reason, "metadata-unreadable");
|
||||
assert.equal(record.prompts.positive.status, "unresolved");
|
||||
assert.equal(JSON.parse(inspectionDownloads(record).metadata.bodyText).metadata.status, "unreadable");
|
||||
const limited = await recordFor(pngFile("big.png", [tEXt("prompt", PROMPT), tEXt("workflow", "[" + "0,".repeat(2_097_153) + "]")]));
|
||||
assert.deepEqual(limited.wire.metadata.record_formats, { prompt: "original-text", workflow: "limited" });
|
||||
assert.equal(limited.wire.metadata.workflow_graph.status, "limited");
|
||||
assert.equal(limited.record.metadata.record_formats.workflow, "limited");
|
||||
});
|
||||
|
||||
test("JPEG and WebP previews have header facts and absent metadata", async () => {
|
||||
for (const [name, format, width, height] of [["tiny.jpg", "JPEG", 48, 32], ["tiny.webp", "WEBP", 40, 24]]) {
|
||||
const { wire, record } = await recordFor(fixtureFile(name));
|
||||
assert.deepEqual([wire.file.format, wire.file.width, wire.file.height, wire.file.media_type], [format, width, height, "image"], name);
|
||||
assert.equal(wire.metadata.status, "absent");
|
||||
assert.equal(record.generation.seed.status, "not-recorded");
|
||||
}
|
||||
});
|
||||
|
||||
test("video payloads", async () => {
|
||||
const legacy = await recordFor(fixtureFile("legacy-comment.mp4"));
|
||||
assert.deepEqual([legacy.wire.file.media_type, legacy.wire.file.format, legacy.wire.file.width, legacy.wire.file.extension], ["video", null, null, ".mp4"]);
|
||||
assert.equal(legacy.wire.metadata.record_formats.workflow, "reserialized-json");
|
||||
assert.equal(legacy.wire.metadata.record_formats.prompt, "original-text");
|
||||
assert.equal(legacy.wire.metadata.workflow_graph.status, "parsed");
|
||||
assert.equal(inspectionDownloads(legacy.record).workflow.available, false);
|
||||
assert.equal(inspectionDownloads(legacy.record).workflow.reason, CONTAINER_WORKFLOW);
|
||||
assert.equal(legacy.record.prompts.positive.text, "a red fox in snow, detailed fur");
|
||||
// Ruling M1: a Matroska COMMENT tag is not the exact-case `comment` envelope, so it is absent, as Images reports it.
|
||||
const webm = await recordFor(fixtureFile("legacy-comment.webm"));
|
||||
assert.equal(webm.wire.metadata.status, "absent");
|
||||
const tags = await recordFor(fixtureFile("core-tags.webm"));
|
||||
assert.deepEqual(tags.wire.metadata.record_formats, { prompt: "original-text", workflow: "original-text" });
|
||||
assert.equal(tags.record.generation.seed.value, "7");
|
||||
// AVI and ASF are recognised, previewable, and never read.
|
||||
const avi = new File([utf8("RIFF"), new Uint8Array([0, 0, 0, 0]), utf8("AVI LIST"), new Uint8Array(64)], "clip.avi");
|
||||
const aviRecord = await recordFor(avi);
|
||||
assert.equal(aviRecord.wire.metadata.status, "unreadable");
|
||||
assert.equal(aviRecord.wire.file.media_type, "video");
|
||||
assert.equal(aviRecord.record.generation.seed.reason, "metadata-unreadable");
|
||||
const asf = new File([new Uint8Array([0x30, 0x26, 0xb2, 0x75, 0x8e, 0x66, 0xcf, 0x11, 0xa6, 0xd9, 0x00, 0xaa, 0x00, 0x62, 0xce, 0x6c]), new Uint8Array(64)], "clip.wmv");
|
||||
assert.equal((await recordFor(asf)).wire.metadata.status, "unreadable");
|
||||
// A pre-classified video needs no PNG reader; an image without one is an error, not a silent absence.
|
||||
const clip = fixtureFile("core.mkv");
|
||||
assert.equal((await m.readLocalFile(clip, { classification: { media: "video", kind: "matroska" }, pngMetadata: null })).result.read.status, "complete");
|
||||
await assert.rejects(m.readLocalFile(clip, { pngMetadata: null }), /PNG reader/);
|
||||
await assert.rejects(m.readLocalFile(fixtureFile("tiny.jpg"), { classification: { media: "image", kind: "jpeg" }, pngMetadata: null }), /PNG reader/);
|
||||
await assert.rejects(m.readLocalFile("not a blob"), TypeError);
|
||||
assert.deepEqual(await m.readLocalFile(new File([utf8("<svg/>")], "x.png"), { pngMetadata: PngMetadata }), { classification: null, result: null });
|
||||
// A container that fails to parse is unreadable too.
|
||||
const broken = new File([utf8("\0\0\0\x08ftypisom"), utf8("\0\0\0\x01moov")], "broken.mp4");
|
||||
assert.equal((await recordFor(broken)).wire.metadata.status, "unreadable");
|
||||
});
|
||||
|
||||
test("local cap measures escaped UTF-8 atomically", async () => {
|
||||
// A 1 MiB Latin-1 prompt of 'é' (2 UTF-8 bytes, 1 code unit each) and a 4 MiB UTF-8 workflow of
|
||||
// emoji (4 bytes, 2 units): the UTF-16 length of the wire is well under the limit while its UTF-8
|
||||
// size is not, so a cap counting code units would keep too much.
|
||||
const prompt = `{"1":{"class_type":"A","inputs":{"t":"${"é".repeat(1_000_000)}"}}}`;
|
||||
const workflow = `{"nodes":[],"u":"${"😀".repeat(1_000_000)}"}`;
|
||||
const file = pngFile("wide.png", [iTXt("workflow", workflow), tEXt("prompt", prompt)]);
|
||||
const handle = await handleFor(file);
|
||||
const raw = await m.readLocalFile(handle.file, { classification: handle.classification, pngMetadata: PngMetadata });
|
||||
assert.equal(raw.result.status, "complete");
|
||||
const wire = m.buildLocalPayload({ read: raw.result, file: { name: file.name, size: file.size, lastModified: file.lastModified }, classification: handle.classification });
|
||||
const text = JSON.stringify(wire);
|
||||
assert.ok(text.length < LIMIT && bytes(wire) > LIMIT, `${text.length} code units, ${bytes(wire)} bytes`);
|
||||
const fitted = await m.fitLocalPayload(wire);
|
||||
assert.ok(bytes(fitted) <= LIMIT);
|
||||
assert.equal(fitted.metadata.workflow_graph.status, "limited", "the workflow graph goes first");
|
||||
assert.equal(fitted.metadata.prompt_graph.status, "parsed");
|
||||
assert.deepEqual(Object.keys(fitted.metadata.raw).sort(), ["prompt", "workflow"], "raw is kept");
|
||||
const record = m.localFileAdapter.toRecord(fitted, handle, extractGenerationFacts);
|
||||
assert.equal(inspectionDownloads(record).metadata.available, true);
|
||||
assert.equal(inspectionDownloads(record).workflow.available, false);
|
||||
// Whole-raw omission is atomic and marks the response-size limit; the inspector offers no metadata download.
|
||||
const squeezed = await m.fitLocalPayload(wire, bytes(fitted) - 1);
|
||||
assert.deepEqual([squeezed.metadata.workflow_graph.status, squeezed.metadata.prompt_graph.status], ["limited", "limited"]);
|
||||
const tiny = await m.fitLocalPayload(wire, 2000);
|
||||
assert.equal(tiny.metadata.status, "limited");
|
||||
assert.equal(tiny.metadata.omitted_reason, "response-size-limit");
|
||||
assert.deepEqual(tiny.metadata.raw, {});
|
||||
assert.equal(tiny.metadata.parameters, null);
|
||||
assert.ok(bytes(tiny) <= 2000);
|
||||
const omitted = m.localFileAdapter.toRecord(tiny, handle, extractGenerationFacts);
|
||||
assert.equal(inspectionDownloads(omitted).metadata.available, false);
|
||||
assert.equal(omitted.generation.seed.reason, "projection-limited");
|
||||
await assert.rejects(m.fitLocalPayload(wire, 100), /exceeds/);
|
||||
assert.equal(wire.metadata.status, "available", "the input wire is not mutated");
|
||||
assert.equal((await m.localFileAdapter.read(handle)).metadata.workflow_graph.status, "limited", "adapter.read returns the fitted wire");
|
||||
});
|
||||
|
||||
test("adapter rejects foreign blob URLs and never resolves owned ones", async () => {
|
||||
const file = pngFile("a.png", [tEXt("prompt", PROMPT)]);
|
||||
const classification = await classify(file);
|
||||
const owned = m.createLocalFileHandle({ file, url: "blob:http://host.test/0b0e", classification });
|
||||
assert.ok(Object.isFrozen(owned));
|
||||
assert.deepEqual(m.localFileAdapter.originalDownload(owned), { url: "blob:http://host.test/0b0e", filename: "a.png" });
|
||||
assert.equal(m.localFileAdapter.mediaUrl(owned), "blob:http://host.test/0b0e");
|
||||
// The controller and the viewer session spread-copy descriptors: a copy is the same selection.
|
||||
const copy = { ...owned, occurrence: {}, reference: {} };
|
||||
assert.equal(m.localFileAdapter.key(copy), owned.key);
|
||||
assert.equal(m.localFileAdapter.mediaUrl(copy), "blob:http://host.test/0b0e");
|
||||
// A tampered URL, key, File or name, or a descriptor this module never made, is foreign.
|
||||
const tampered = [{ ...owned, url: "blob:http://host.test/other" }, { ...owned, key: "local:1" }, { ...owned, file: new File([], "x.png") }, { ...owned, filename: "other.png" },
|
||||
{ kind: "local-file", key: owned.key, filename: owned.filename, url: owned.url, file, is_video: false, classification }, null, "local:1"];
|
||||
for (const foreign of tampered) {
|
||||
for (const method of ["validate", "originalDownload", "mediaUrl", "key", "scope", "inspectionPath"]) assert.throws(() => m.localFileAdapter[method](foreign), TypeError, method);
|
||||
await assert.rejects(m.localFileAdapter.read(foreign), TypeError);
|
||||
assert.throws(() => m.localFileAdapter.toRecord({ success: true, inspection_version: 1 }, foreign, extractGenerationFacts), TypeError);
|
||||
}
|
||||
for (const url of ["http://host.test/x.png", "/prompt_manager/images/serve/a.png", "javascript:alert(1)", "data:image/png;base64,AA==", "", 5]) {
|
||||
assert.throws(() => m.createLocalFileHandle({ file, url, classification }), TypeError, String(url));
|
||||
}
|
||||
assert.throws(() => m.createLocalFileHandle({ file: "not a blob", url: "blob:http://host.test/1", classification }), TypeError);
|
||||
assert.throws(() => m.createLocalFileHandle({ file, url: "blob:http://host.test/1", classification: null }), TypeError);
|
||||
assert.throws(() => m.createLocalFileHandle({ file, url: "blob:http://host.test/1", classification: { media: "text", kind: "svg" } }), TypeError);
|
||||
// A proxy-mounted host resolves API URLs; blob URLs are session-owned and bypass it.
|
||||
const before = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
Object.defineProperty(globalThis, "window", { configurable: true, value: { location: { href: "https://example.test/proxy/prompt_manager/workspace" }, ApiClient: { resolveUrl: (url) => `/proxy${url}` } } });
|
||||
try {
|
||||
assert.equal(m.localFileAdapter.mediaUrl(owned), "blob:http://host.test/0b0e");
|
||||
assert.equal(m.localFileAdapter.originalDownload(owned).url, "blob:http://host.test/0b0e");
|
||||
} finally {
|
||||
if (before) Object.defineProperty(globalThis, "window", before); else delete globalThis.window;
|
||||
}
|
||||
// Without an object URL the record has no download and the viewer cannot show the original.
|
||||
const previewless = m.createLocalFileHandle({ file, url: null, classification });
|
||||
assert.equal(m.localFileAdapter.mediaUrl(previewless), null);
|
||||
assert.throws(() => m.localFileAdapter.originalDownload(previewless), TypeError);
|
||||
const record = m.localFileAdapter.toRecord(await m.localFileAdapter.read(previewless), previewless, extractGenerationFacts);
|
||||
assert.equal(record.download, null);
|
||||
assert.equal(inspectionDownloads(record).metadata.filename, "a.metadata.json");
|
||||
});
|
||||
|
||||
test("select reuses the same local key", async () => {
|
||||
const file = pngFile("a.png", [tEXt("prompt", PROMPT)]);
|
||||
const classification = await classify(file);
|
||||
const first = m.createLocalFileHandle({ file, url: "blob:http://host.test/1", classification });
|
||||
const second = m.createLocalFileHandle({ file, url: "blob:http://host.test/2", classification });
|
||||
assert.notEqual(first.key, second.key, "the same bytes chosen twice are two selections");
|
||||
assert.equal(m.localFileAdapter.scope(first), "local");
|
||||
assert.equal(m.localFileAdapter.inspectionPath(first), `local-file:${first.key}`);
|
||||
assert.equal(m.localFileAdapter.key(first), first.key);
|
||||
const reads = [];
|
||||
// Until the loader seam lands (PR L2 Task 4) the controller reaches the adapter through api.get.
|
||||
const api = { get: (sentinel) => { reads.push(sentinel); return m.localFileAdapter.read(reads.length === 1 || sentinel === `local-file:${first.key}` ? first : second); } };
|
||||
const controller = createInspectionController({ api, extractFacts: extractGenerationFacts, adapter: m.localFileAdapter });
|
||||
const pending = controller.select(first);
|
||||
assert.equal(controller.select(first), pending, "the same handle returns the pending read");
|
||||
await pending;
|
||||
assert.deepEqual(reads, [`local-file:${first.key}`]);
|
||||
assert.equal(controller.state.record.identity.key, first.key);
|
||||
assert.equal(controller.state.record.prompts.positive.text, "a red fox in snow, detailed fur");
|
||||
await controller.select(second);
|
||||
assert.equal(reads.length, 2);
|
||||
assert.equal(controller.state.record.identity.key, second.key);
|
||||
await controller.select({ ...second, occurrence: {}, reference: {} });
|
||||
assert.equal(reads.length, 2, "the controller's own copy is the same selection");
|
||||
await controller.select({ ...second, url: "blob:http://host.test/evil" });
|
||||
assert.equal(controller.state.error, true, "a foreign handle is published as an error, never read");
|
||||
assert.equal(reads.length, 2);
|
||||
controller.dispose();
|
||||
});
|
||||
|
||||
test("fileFor admits selected-local-file only for a local identity", async () => {
|
||||
const file = pngFile("a.png", [tEXt("prompt", PROMPT)]);
|
||||
const { wire, handle } = await recordFor(file);
|
||||
const rooted = { root_index: 0, path: "folder/a.png", filename: "a.png", url: "/prompt_manager/images/serve/folder/a.png?root=0" };
|
||||
assert.throws(() => toInspectionRecord({ ...wire, image: { root_index: 0, path: "folder/a.png", filename: "a.png" } }, rooted, extractGenerationFacts), /Invalid inspection file/);
|
||||
assert.throws(() => projectInspectionPayload({ ...wire, file: { ...wire.file, observation: "current-file" } }, { kind: "local-file", key: handle.key, path: "a.png" }, null, extractGenerationFacts), /Invalid inspection file/);
|
||||
assert.throws(() => m.localFileAdapter.toRecord({ ...wire, success: false }, handle, extractGenerationFacts), TypeError);
|
||||
assert.throws(() => m.localFileAdapter.toRecord({ ...wire, inspection_version: 2 }, handle, extractGenerationFacts), TypeError);
|
||||
assert.throws(() => m.localFileAdapter.toRecord(null, handle, extractGenerationFacts), TypeError);
|
||||
});
|
||||
|
||||
test("the parity corpus PNGs read into renderable records", async () => {
|
||||
for (const name of ["f02.png", "f06a.png", "f23.png"]) {
|
||||
const { wire, record } = await recordFor(fixtureFile(name, PARITY));
|
||||
assert.equal(wire.metadata.status, "available", name);
|
||||
assert.equal(record.file.format, "PNG");
|
||||
assert.ok(["parsed", "limited", "malformed"].includes(wire.metadata.prompt_graph.status));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
// web/js/workspace/local-projection.mjs ports utils/inspection_response.py: the
|
||||
// strict Decimal/duplicate-key parse, the prompt/workflow/carrier projection, the
|
||||
// depth and node ceilings, _metadata and the 8 MiB omission steps. These units pin
|
||||
// the rules the plan names; tests/test_local_inspect_parity.py proves byte parity
|
||||
// with the server over the committed corpus.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const m = await import("../../web/js/workspace/local-projection.mjs").catch((error) => {
|
||||
if (error.code !== "ERR_MODULE_NOT_FOUND") throw error;
|
||||
return {};
|
||||
});
|
||||
const CASES = JSON.parse(fs.readFileSync(path.join(here, "..", "fixtures", "local-inspect", "projection-cases.json"), "utf8")).cases;
|
||||
|
||||
const plain = (value) => JSON.parse(JSON.stringify(value));
|
||||
const bytes = (value) => Buffer.byteLength(JSON.stringify(value), "utf8");
|
||||
const graphOf = (text, kind = "workflow") => plain(m.projectGraph(text, kind));
|
||||
const nested = (depth, close = "]", open = "[") => open.repeat(depth) + close.repeat(depth);
|
||||
const nodes = (count) => `{${Array.from({ length: count }, (_, i) => `"${i}":{"class_type":"A","inputs":{}}`).join(",")}}`;
|
||||
|
||||
test("contract", () => {
|
||||
for (const name of ["projectGraph", "projectMetadata", "fitInspectionPayload", "inspectionBytes", "parseStrictJson"]) {
|
||||
assert.equal(typeof m[name], "function", `${name} is exported`);
|
||||
}
|
||||
assert.equal(m.MAX_RESPONSE_BYTES, 8388608);
|
||||
assert.equal(m.MAX_PROJECTION_DEPTH, 64);
|
||||
assert.equal(m.GRAPH_NODE_CEILING, 20000);
|
||||
});
|
||||
|
||||
test("projection spells numbers like the server", () => {
|
||||
// literal -> Python str(Decimal(literal)) (or the int when it is a safe integer)
|
||||
const table = [
|
||||
["1e3", "1E+3"], ["1e0", "1"], ["-0.0", "-0.0"], ["6.50", "6.50"], ["0.0100", "0.0100"], ["1.0e2", "1.0E+2"],
|
||||
["0e5", "0E+5"], ["0.0000001", "1E-7"], ["0.000001", "0.000001"], ["1E+3", "1E+3"], ["1e-0", "1"], ["0.5e1", "5"],
|
||||
["0.05e1", "0.5"], ["0.0e0", "0.0"], ["1.5e+300", "1.5E+300"], ["123e-8", "0.00000123"], ["2.5e-3", "0.0025"],
|
||||
["1.0", "1.0"], ["-1.50", "-1.50"], ["0.0", "0.0"], ["100.0", "100.0"], ["1234567e-6", "1.234567"], ["0.5E-6", "5E-7"],
|
||||
["0.5E-5", "0.000005"], ["1e22", "1E+22"], ["0.000000", "0.000000"], ["-0.000000e-3", "-0E-9"], ["7.0e-6", "0.0000070"],
|
||||
["7.0e-7", "7.0E-7"], ["12345678901234567890.123", "12345678901234567890.123"], ["9007199254740992.0", "9007199254740992.0"],
|
||||
["1e999999999999999999", "1E+999999999999999999"], ["1e-1999999999999999997", "1E-1999999999999999997"],
|
||||
["0.0e1000000000000000000", "0E+999999999999999999"], ["1.5e999999999999999999", "1.5E+999999999999999999"],
|
||||
["0.1e-1999999999999999996", "1E-1999999999999999997"],
|
||||
["9007199254740991", 9007199254740991], ["-9007199254740991", -9007199254740991], ["9007199254740992", "9007199254740992"],
|
||||
["-9007199254740992", "-9007199254740992"], ["10000000000000000", "10000000000000000"], ["123456789012345678901234567890", "123456789012345678901234567890"],
|
||||
["0", 0], ["-0", 0], ["42", 42], ["-7", -7],
|
||||
];
|
||||
for (const [literal, expected] of table) {
|
||||
const graph = graphOf(`{"n":${literal}}`);
|
||||
assert.equal(graph.status, "parsed", literal);
|
||||
assert.deepEqual(graph.value, { n: expected }, literal);
|
||||
}
|
||||
assert.deepEqual(graphOf('{"n":[NaN,Infinity,-Infinity]}').value, { n: [null, null, null] });
|
||||
// A 4300-digit integer is Python's int() limit; one more digit fails the parse.
|
||||
assert.deepEqual(graphOf(`{"n":${"1".repeat(4300)}}`).value, { n: "1".repeat(4300) });
|
||||
assert.equal(graphOf(`{"n":${"1".repeat(4301)}}`).status, "malformed");
|
||||
assert.equal(graphOf(`{"n":-${"1".repeat(4301)}}`).status, "malformed");
|
||||
// Exact Decimal construction fails past the C library's exponent range.
|
||||
for (const literal of ["1e1000000000000000000", "15e999999999999999999", "1e-1999999999999999998", "1.5e-1999999999999999997",
|
||||
"0e1000000000000000000", "1e9223372036854775808", "0.0e1000000000000000001", "1e99999999999999999999999999"]) {
|
||||
assert.equal(graphOf(`{"n":${literal}}`).status, "malformed", literal);
|
||||
}
|
||||
// Escapes: surrogate pairs join, lone surrogates stay, control characters decode.
|
||||
const text = graphOf('{"s":"\\u00E9\\uD83D\\uDE00\\uD800\\u0000\\u001f\\/\\\\\\"\\b\\f\\n\\r\\t\\uD800\\u0041"}').value.s;
|
||||
assert.equal(text, "é\u{1f600}\ud800\u0000\u001f/\\\"\b\f\n\r\t\ud800A");
|
||||
assert.equal(JSON.stringify(m.projectGraph('{"s":"\\ud800"}', "workflow").value), '{"s":"\\ud800"}');
|
||||
});
|
||||
|
||||
test("projection rejects what Python's json rejects and nothing else", () => {
|
||||
const rejected = ["{}", "\f1", " 1", '{"a":1,}', "[1,]", "{'a':1}", '{"a"}', "[1 2]", "tru", "nul", "-NaN", "1.", ".5", "01", "+1",
|
||||
"1e", "-", '"\\x"', '"abc', "", " ", '"\u0001"', '{"a":1}x', '"\\u12"', '{"a":1,"a":2}', '{"a":{"b":1,"b":1}}', "[1,[2,{\"k\":1,\"k\":2}]]",
|
||||
'{"a":1 "b":2}', "[,1]", "{,}", '{"a":}', "[1}", "{1:2}", "
[]", "[]\u000b", "0x10", "1_000", "--1", "1e+", "1E-", '"\\uD800\\uDC"'];
|
||||
for (const text of rejected) assert.equal(graphOf(text).status, "malformed", JSON.stringify(text));
|
||||
for (const text of [" \t\r\n{}\n\t ", '{"a":[1,{"b":null}],"c":"d"}', '{"\\u0000":1}', '{"a":1,"A":1}', '{"":[]}']) {
|
||||
assert.equal(graphOf(text).status, "parsed", JSON.stringify(text));
|
||||
}
|
||||
// Top-level values that are not objects parse but are not graphs.
|
||||
for (const text of ["[]", '"x"', "1", "null", "true", "NaN", "1.5", "-Infinity"]) assert.equal(graphOf(text).status, "malformed", text);
|
||||
assert.equal(graphOf(undefined).status, "malformed");
|
||||
assert.equal(graphOf(null).status, "malformed");
|
||||
});
|
||||
|
||||
test("projection limits", () => {
|
||||
// A workflow's root is depth 1; the 64th nested container is kept, the 65th is limited.
|
||||
assert.equal(graphOf(`{"a":${nested(63)}}`).status, "parsed");
|
||||
assert.equal(graphOf(`{"a":${nested(64)}}`).status, "limited");
|
||||
// Prompt: root 1, node 2, inputs 3; a value chain reaching depth 64 is kept, 65 is limited.
|
||||
assert.equal(graphOf(`{"1":{"class_type":"A","inputs":{"v":${nested(61)}}}}`, "prompt").status, "parsed");
|
||||
assert.equal(graphOf(`{"1":{"class_type":"A","inputs":{"v":${nested(62)}}}}`, "prompt").status, "limited");
|
||||
// Dropped node keys are still validated for depth.
|
||||
assert.equal(graphOf(`{"1":{"class_type":"A","inputs":{},"_meta":{"v":${nested(61)}}}}`, "prompt").status, "parsed");
|
||||
assert.equal(graphOf(`{"1":{"class_type":"A","inputs":{},"_meta":{"v":${nested(62)}}}}`, "prompt").status, "limited");
|
||||
// A workflow is normalized before its shape is checked, so a deep non-object is limited; a
|
||||
// prompt checks its root first. Depth never overflows the parser (100,000 nested arrays).
|
||||
assert.equal(graphOf(nested(10)).status, "malformed");
|
||||
assert.equal(graphOf(nested(5000)).status, "limited");
|
||||
assert.equal(graphOf(nested(5000), "prompt").status, "malformed");
|
||||
assert.equal(graphOf(`{"a":${nested(100000)}}`).status, "limited");
|
||||
// Node ceilings: 20,000 API nodes parse, 20,001 are limited.
|
||||
assert.equal(graphOf(nodes(20000), "prompt").status, "parsed");
|
||||
assert.equal(graphOf(nodes(20001), "prompt").status, "limited");
|
||||
const list = (count) => `[${Array.from({ length: count }, () => "{}").join(",")}]`;
|
||||
assert.equal(graphOf(`{"nodes":${list(20000)}}`).status, "parsed");
|
||||
assert.equal(graphOf(`{"nodes":${list(20001)}}`).status, "limited");
|
||||
assert.equal(graphOf(`{"nodes":${list(10001)},"definitions":{"subgraphs":[{"nodes":${list(10000)}},5,{"nodes":3}]}}`).status, "limited");
|
||||
assert.equal(graphOf(`{"nodes":${list(10000)},"definitions":{"subgraphs":[{"nodes":${list(10000)}}]}}`).status, "parsed");
|
||||
assert.equal(graphOf(`{"nodes":{"${"x".repeat(5)}":1},"definitions":[]}`).status, "parsed");
|
||||
// Duplicate keys anywhere are malformed, including inside dropped node keys.
|
||||
assert.equal(graphOf('{"1":{"class_type":"A","inputs":{},"_meta":{"t":1,"t":2}}}', "prompt").status, "malformed");
|
||||
assert.equal(graphOf('{"1":{"class_type":"A","inputs":{"a":1,"a":1}}}', "prompt").status, "malformed");
|
||||
});
|
||||
|
||||
test("prompt nodes keep only class_type and inputs on null-prototype objects", () => {
|
||||
const text = '{"3":{"class_type":"KSampler","inputs":{"seed":9007199254740993,"cfg":8.0,"n":NaN},"_meta":{"title":"x"},"is_changed":[1]},"4":5,"5":[1,2.50],"__proto__":{"class_type":"P","inputs":{}},"constructor":"c"}';
|
||||
const graph = m.projectGraph(text, "prompt");
|
||||
assert.equal(graph.status, "parsed");
|
||||
assert.equal(Object.getPrototypeOf(graph.value), null);
|
||||
assert.equal(Object.getPrototypeOf(graph.value["3"]), null);
|
||||
assert.equal(Object.getPrototypeOf(graph.value["3"].inputs), null);
|
||||
// JSON.parse, like the projection, makes "__proto__" an own key; an object literal would not.
|
||||
assert.deepEqual(plain(graph.value), JSON.parse('{"3":{"class_type":"KSampler","inputs":{"seed":"9007199254740993","cfg":"8.0","n":null}},"4":5,"5":[1,"2.50"],"__proto__":{"class_type":"P","inputs":{}},"constructor":"c"}'));
|
||||
assert.ok(Object.hasOwn(graph.value, "__proto__"));
|
||||
assert.deepEqual(plain(graph.value["__proto__"]), { class_type: "P", inputs: {} });
|
||||
assert.equal(graph.value.constructor, "c");
|
||||
for (const text of ["[]", '"3"', "5", "null", '{"nodes":[]}x']) assert.equal(m.projectGraph(text, "prompt").status, text === '{"nodes":[]}x' ? "malformed" : "malformed", text);
|
||||
// A workflow keeps everything, normalized.
|
||||
assert.deepEqual(graphOf('{"nodes":[{"id":1,"widgets_values":[7,"fixed",8.0,1e3]}],"links":[],"version":0.4}').value,
|
||||
{ nodes: [{ id: 1, widgets_values: [7, "fixed", "8.0", "1E+3"] }], links: [], version: "0.4" });
|
||||
});
|
||||
|
||||
test("projectMetadata mirrors _metadata for records, formats and carriers", () => {
|
||||
const prompt = '{"1":{"class_type":"A","inputs":{"x":1.0}}}', workflow = '{"nodes":[],"version":0.4}';
|
||||
const read = (records, record_formats, carrierText = null, limited = []) => ({ records, record_formats, carrierText, limited });
|
||||
assert.deepEqual(plain(m.projectMetadata(null)), { status: "unreadable", omitted_reason: null, raw: {}, record_formats: {}, parameters: null,
|
||||
prompt_graph: { status: "absent", value: null }, workflow_graph: { status: "absent", value: null } });
|
||||
assert.deepEqual(plain(m.projectMetadata(read({}, {}))), { status: "absent", omitted_reason: null, raw: {}, record_formats: {}, parameters: null,
|
||||
prompt_graph: { status: "absent", value: null }, workflow_graph: { status: "absent", value: null } });
|
||||
const full = plain(m.projectMetadata(read({ prompt, workflow, parameters: "cat\nSteps: 1", comfyassets_prompt_pairs: "{}", other: "x" },
|
||||
{ prompt: "original-text", workflow: "original-text", parameters: "original-text", comfyassets_prompt_pairs: "original-text", other: "original-text" })));
|
||||
assert.deepEqual(full, { status: "available", omitted_reason: null,
|
||||
raw: { prompt, workflow, parameters: "cat\nSteps: 1", comfyassets_prompt_pairs: "{}" },
|
||||
record_formats: { prompt: "original-text", workflow: "original-text", parameters: "original-text", comfyassets_prompt_pairs: "original-text" },
|
||||
parameters: "cat\nSteps: 1", prompt_graph: { status: "parsed", value: { 1: { class_type: "A", inputs: { x: "1.0" } } } },
|
||||
workflow_graph: { status: "parsed", value: { nodes: [], version: "0.4" } } });
|
||||
// limited records lose their text; invalid is malformed; reserialized without a carrier is limited.
|
||||
const limited = plain(m.projectMetadata(read({ prompt, workflow }, { prompt: "original-text" }, null, ["workflow"])));
|
||||
assert.deepEqual(limited.raw, { prompt });
|
||||
assert.deepEqual(limited.record_formats, { prompt: "original-text", workflow: "limited" });
|
||||
assert.equal(limited.workflow_graph.status, "limited");
|
||||
assert.equal(plain(m.projectMetadata(read({ prompt }, { prompt: "original-text", workflow: "invalid" }))).workflow_graph.status, "malformed");
|
||||
assert.equal(plain(m.projectMetadata(read({ workflow }, { workflow: "reserialized-json" }))).workflow_graph.status, "limited");
|
||||
assert.equal(plain(m.projectMetadata(read({ prompt: "{" }, { prompt: "original-text" }))).prompt_graph.status, "malformed");
|
||||
// Unknown formats are dropped from record_formats but the raw text still projects.
|
||||
const odd = plain(m.projectMetadata(read({ prompt }, { prompt: "weird" })));
|
||||
assert.deepEqual(odd.record_formats, {});
|
||||
assert.equal(odd.prompt_graph.status, "parsed");
|
||||
// A non-text record vanishes from raw and its named graph is malformed.
|
||||
assert.equal(plain(m.projectMetadata(read({ prompt: 5 }, { prompt: "original-text" }))).prompt_graph.status, "malformed");
|
||||
// Carriers: strings re-parse by kind, objects were projected at depth 2; a bad carrier fails every present graph.
|
||||
const envelope = JSON.stringify({ prompt, workflow: { nodes: [], v: 1 } });
|
||||
const carried = plain(m.projectMetadata(read({ prompt, workflow: '{"nodes": [], "v": 1}' }, { prompt: "original-text", workflow: "reserialized-json" }, envelope)));
|
||||
assert.deepEqual(carried.prompt_graph, { status: "parsed", value: { 1: { class_type: "A", inputs: { x: "1.0" } } } });
|
||||
assert.deepEqual(carried.workflow_graph, { status: "parsed", value: { nodes: [], v: 1 } });
|
||||
const broken = plain(m.projectMetadata(read({ prompt, workflow }, { prompt: "original-text", workflow: "original-text" }, "[1]")));
|
||||
assert.deepEqual([broken.prompt_graph.status, broken.workflow_graph.status], ["malformed", "malformed"]);
|
||||
const deep = plain(m.projectMetadata(read({ prompt }, { prompt: "original-text" }, `{"title":${nested(70)},"prompt":${JSON.stringify(prompt)}}`)));
|
||||
assert.equal(deep.prompt_graph.status, "limited");
|
||||
const partial = plain(m.projectMetadata(read({ prompt, workflow }, { prompt: "original-text", workflow: "original-text" }, '{"prompt":"{","workflow":5}')));
|
||||
assert.deepEqual([partial.prompt_graph.status, partial.workflow_graph.status], ["malformed", "malformed"]);
|
||||
const missing = plain(m.projectMetadata(read({ prompt }, { prompt: "original-text" }, '{"workflow":{}}')));
|
||||
assert.deepEqual([missing.prompt_graph.status, missing.workflow_graph.status], ["malformed", "absent"]);
|
||||
// The carrier's own prompt object is projected: depth 2, node keys filtered.
|
||||
const objectPrompt = plain(m.projectMetadata(read({ prompt: "x" }, { prompt: "reserialized-json" }, '{"prompt":{"1":{"class_type":"A","inputs":{},"_meta":{}}}}')));
|
||||
assert.deepEqual(objectPrompt.prompt_graph, { status: "parsed", value: { 1: { class_type: "A", inputs: {} } } });
|
||||
});
|
||||
|
||||
test("fitInspectionPayload omits the workflow graph, then the prompt graph, then all raw atomically", async () => {
|
||||
const payload = {
|
||||
success: true, inspection_version: 1, metadata: {
|
||||
status: "available", omitted_reason: null, raw: { prompt: "é".repeat(100), workflow: "{}" }, record_formats: { prompt: "original-text", workflow: "original-text" },
|
||||
parameters: null, prompt_graph: { status: "parsed", value: { 1: { class_type: "A", inputs: { t: "\ud800".repeat(50) } } } },
|
||||
workflow_graph: { status: "parsed", value: { nodes: Array.from({ length: 50 }, (_, i) => ({ id: i })) } },
|
||||
},
|
||||
};
|
||||
const text = JSON.stringify(payload);
|
||||
// UTF-8 bytes, not UTF-16 code units: 'é' is 2 bytes, an escaped lone surrogate 6.
|
||||
assert.equal(m.inspectionBytes(payload), Buffer.byteLength(text, "utf8"));
|
||||
assert.ok(m.inspectionBytes(payload) > text.length);
|
||||
// Measured in bounded chunks: pairs and 2-byte characters across every chunk edge count once.
|
||||
for (const shift of [0, 1, 2, 3]) {
|
||||
const wide = { s: "x".repeat(shift) + "é😀".repeat(200000) + "\ud800", t: "a😀".repeat(70000) };
|
||||
assert.equal(m.inspectionBytes(wide), Buffer.byteLength(JSON.stringify(wide), "utf8"), `shift ${shift}`);
|
||||
}
|
||||
const whole = await m.fitInspectionPayload(payload, bytes(payload));
|
||||
assert.equal(whole, payload, "a payload within the limit is returned as is");
|
||||
const first = await m.fitInspectionPayload(payload, bytes(payload) - 1);
|
||||
assert.equal(first.metadata.workflow_graph.status, "limited");
|
||||
assert.equal(first.metadata.prompt_graph.status, "parsed");
|
||||
assert.deepEqual(first.metadata.raw, payload.metadata.raw);
|
||||
assert.ok(bytes(first) <= bytes(payload) - 1);
|
||||
const second = await m.fitInspectionPayload(payload, bytes(first) - 1);
|
||||
assert.deepEqual([second.metadata.workflow_graph.status, second.metadata.prompt_graph.status], ["limited", "limited"]);
|
||||
assert.equal(second.metadata.status, "available");
|
||||
assert.deepEqual(second.metadata.raw, payload.metadata.raw);
|
||||
const third = await m.fitInspectionPayload(payload, bytes(second) - 1);
|
||||
assert.deepEqual(third.metadata, { ...second.metadata, raw: {}, parameters: null, status: "limited", omitted_reason: "response-size-limit" });
|
||||
assert.ok(bytes(third) <= bytes(second) - 1);
|
||||
await assert.rejects(m.fitInspectionPayload(payload, bytes(third) - 1), /exceeds/);
|
||||
// The caller's payload is never mutated and absent graphs are left alone.
|
||||
assert.equal(JSON.stringify(payload), text);
|
||||
const absent = { ...payload, metadata: { ...payload.metadata, workflow_graph: { status: "absent", value: null } } };
|
||||
const fitted = await m.fitInspectionPayload(absent, bytes(absent) - 1);
|
||||
assert.equal(fitted.metadata.workflow_graph.status, "absent");
|
||||
assert.equal(fitted.metadata.prompt_graph.status, "limited");
|
||||
assert.equal((await m.fitInspectionPayload(payload)).metadata.status, "available", "the default limit is 8 MiB");
|
||||
// Every omission step after the first measure hands the event loop a turn, and an
|
||||
// abort between steps rejects with AbortError; the answer itself is unchanged.
|
||||
let turns = 0;
|
||||
const counted = await m.fitInspectionPayload(payload, bytes(second) - 1, { yieldControl: async () => { turns += 1; } });
|
||||
assert.equal(JSON.stringify(counted), JSON.stringify(third));
|
||||
assert.equal(turns, 3);
|
||||
assert.equal(await m.fitInspectionPayload(payload, bytes(payload), { yieldControl: async () => { turns += 1; } }), payload);
|
||||
assert.equal(turns, 3, "a payload within the limit is measured once, without a turn");
|
||||
const controller = new AbortController();
|
||||
await assert.rejects(m.fitInspectionPayload(payload, bytes(second) - 1, { signal: controller.signal, yieldControl: async () => controller.abort() }), { name: "AbortError" });
|
||||
});
|
||||
|
||||
test("the committed projection corpus has every outcome and parses with the oracle entry shape", () => {
|
||||
assert.ok(CASES.length >= 40);
|
||||
const names = new Set(), statuses = new Set(), graphs = new Set();
|
||||
for (const entry of CASES) {
|
||||
assert.equal(typeof entry.name, "string");
|
||||
assert.ok(!names.has(entry.name), `duplicate case ${entry.name}`);
|
||||
names.add(entry.name);
|
||||
const read = entry.records === null ? null : { records: entry.records, record_formats: entry.record_formats ?? Object.fromEntries(Object.keys(entry.records).map((key) => [key, "original-text"])),
|
||||
carrierText: entry.carrier_text ?? null, limited: entry.limited ?? [] };
|
||||
const metadata = m.projectMetadata(read);
|
||||
statuses.add(metadata.status);
|
||||
graphs.add(metadata.prompt_graph.status);
|
||||
graphs.add(metadata.workflow_graph.status);
|
||||
if (entry.expect) {
|
||||
for (const [field, status] of Object.entries(entry.expect)) assert.equal(metadata[field].status, status, `${entry.name} ${field}`);
|
||||
}
|
||||
}
|
||||
assert.deepEqual([...statuses].sort(), ["absent", "available", "unreadable"]);
|
||||
assert.deepEqual([...graphs].sort(), ["absent", "limited", "malformed", "parsed"]);
|
||||
});
|
||||
@@ -0,0 +1,622 @@
|
||||
// Run with: node --test tests/js/
|
||||
// The browser video tag readers mirror what the server reads through ffprobe
|
||||
// (utils/prompt_inspector.py _video_metadata) and the carrier rule of
|
||||
// utils/video_metadata.py select_video_carrier. Each rule names its server twin;
|
||||
// tests/test_local_inspect_parity.py proves the two agree on the committed fixtures
|
||||
// and on recorded tag maps.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import * as v from "./helpers/video-bytes.mjs";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const FIXTURES = path.join(here, "..", "fixtures", "local-inspect");
|
||||
const RECORDED = JSON.parse(fs.readFileSync(path.join(FIXTURES, "video-tags-v1.json"), "utf8")).fixtures;
|
||||
const m = await import("../../web/js/workspace/local-video.mjs").catch((error) => {
|
||||
if (error.code !== "ERR_MODULE_NOT_FOUND") throw error;
|
||||
return {};
|
||||
});
|
||||
const { readVideoTags, selectVideoCarrier, pythonJsonDumps, pythonJsonLoads } = m;
|
||||
|
||||
const MiB = 1024 * 1024;
|
||||
const GiB = 1024 * MiB;
|
||||
const ROLE_BUDGET = 2 * MiB;
|
||||
const WORKFLOW_BUDGET = 4 * MiB;
|
||||
const TAG_READ_BYTES = 12 * MiB + 64 * 1024;
|
||||
const PER_KEY = new Set(["prompt", "workflow", "parameters", "comfyassets_prompt_pairs"]);
|
||||
const GRAPH = '{"3":{"class_type":"KSampler","inputs":{"seed":7}}}';
|
||||
const WORKFLOW = '{"nodes":[],"version":0.4}';
|
||||
|
||||
// The tags ffprobe reports to the server: the six probed names, ASCII case folded.
|
||||
function knownTags(map) {
|
||||
return Object.fromEntries(Object.entries(map).filter(([name]) => PER_KEY.has(name.toLowerCase()) || ["comment", "description"].includes(name.toLowerCase())));
|
||||
}
|
||||
|
||||
function fixture(name) {
|
||||
return new Uint8Array(fs.readFileSync(path.join(FIXTURES, name)));
|
||||
}
|
||||
|
||||
async function read(bytes, options = {}) {
|
||||
return readVideoTags(v.blobOf(bytes), options);
|
||||
}
|
||||
|
||||
function fill(byte, count) {
|
||||
return Buffer.alloc(count, byte);
|
||||
}
|
||||
|
||||
function complete(tags, extra = {}) {
|
||||
return { status: "complete", tags, limited: [], ...extra };
|
||||
}
|
||||
|
||||
test("contract: the readers, the carrier rule and the json port are exported", () => {
|
||||
assert.equal(typeof readVideoTags, "function");
|
||||
assert.equal(typeof selectVideoCarrier, "function");
|
||||
assert.equal(typeof pythonJsonDumps, "function");
|
||||
assert.equal(typeof pythonJsonLoads, "function");
|
||||
});
|
||||
|
||||
test("iso-bmff reader finds per-key tags with moov after mdat", async () => {
|
||||
for (const [name, recorded] of Object.entries(RECORDED)) {
|
||||
const blob = v.recordingBlob(fixture(name));
|
||||
const result = await readVideoTags(blob, { prefix: fixture(name).subarray(0, 32) });
|
||||
assert.equal(result.status, "complete", name);
|
||||
assert.deepEqual(result.tags, knownTags(recorded), name);
|
||||
assert.equal(result.container, name.endsWith(".mkv") || name.endsWith(".webm") ? "matroska" : "iso-bmff", name);
|
||||
assert.ok(["isom", "qt", "M4V", "matroska", "webm"].includes(result.format), `${name}: ${result.format}`);
|
||||
assert.deepEqual(result.limited, [], name);
|
||||
assert.ok(blob.ranges.length > 0, name);
|
||||
}
|
||||
// core-moov-end.mp4 behind a 3 GiB mdat: only the box headers and the tag bytes are sliced.
|
||||
const bytes = fixture("core-moov-end.mp4");
|
||||
const boxes = v.topLevelBoxes(bytes);
|
||||
const mdat = boxes.find((box) => box.type === "mdat");
|
||||
const moov = boxes.find((box) => box.type === "moov");
|
||||
const mdatSize = 3 * GiB + 16;
|
||||
const header = v.concat(v.u32(1), v.latin1("mdat"), new Uint8Array(8));
|
||||
new DataView(header.buffer).setBigUint64(8, BigInt(mdatSize));
|
||||
const moovOffset = mdat.start + mdatSize;
|
||||
const blob = v.sparseBlob(
|
||||
[
|
||||
{ offset: 0, bytes: bytes.subarray(0, mdat.start) },
|
||||
{ offset: mdat.start, bytes: header },
|
||||
{ offset: moovOffset, bytes: bytes.subarray(moov.start, moov.end) },
|
||||
],
|
||||
moovOffset + (moov.end - moov.start),
|
||||
);
|
||||
const result = await readVideoTags(blob, { prefix: bytes.subarray(0, 32) });
|
||||
assert.deepEqual(result.tags, knownTags(RECORDED["core-moov-end.mp4"]));
|
||||
const inside = blob.ranges.filter(([start]) => start >= mdat.start && start < moovOffset);
|
||||
assert.ok(inside.every(([start, end]) => start === mdat.start && end - start <= 16), JSON.stringify(inside));
|
||||
const total = blob.ranges.reduce((sum, [start, end]) => sum + (end - start), 0);
|
||||
assert.ok(total <= m.VIDEO_READ_CEILING && total < 64 * 1024, `${total}`);
|
||||
});
|
||||
|
||||
test("iso-bmff: legacy ilst atoms, moov/meta, sticky mdta handler and data types", async () => {
|
||||
// ©cmt and desc map to comment and description, as ffprobe reports them.
|
||||
const legacy = await read(v.mp4(v.legacyUdta({ comment: '{"prompt":"{}"}', description: "note" })));
|
||||
assert.deepEqual(legacy, complete({ comment: '{"prompt":"{}"}', description: "note" }, { container: "iso-bmff", format: "isom" }));
|
||||
// moov/meta (ISO style) is read like moov/udta/meta.
|
||||
const isoMeta = await read(v.mp4(v.box("meta", v.u32(0), v.hdlr("mdta"), v.keys(["prompt"]), v.ilst(v.item(1, v.data(GRAPH))))));
|
||||
assert.deepEqual(isoMeta.tags, { prompt: GRAPH });
|
||||
// meta without the version/flags word (QuickTime) still finds hdlr.
|
||||
const qtMeta = await read(v.mp4(v.udta(v.box("meta", v.hdlr("mdta"), v.keys(["prompt"]), v.ilst(v.item(1, v.data(GRAPH)))))));
|
||||
assert.deepEqual(qtMeta.tags, { prompt: GRAPH });
|
||||
// Index items need an mdta handler and a keys box; with a legacy handler they are skipped.
|
||||
const noKeys = await read(v.mp4(v.udta(v.meta(v.hdlr("mdir"), v.ilst(v.item(1, v.data(GRAPH)))))));
|
||||
assert.deepEqual(noKeys.tags, {});
|
||||
// The mdta handler is sticky across the file, as ffmpeg's found_hdlr_mdta.
|
||||
const sticky = await read(v.mp4(v.udta(v.meta(v.hdlr("mdta"), v.keys(["prompt"])), v.meta(v.hdlr("mdir"), v.ilst(v.item(1, v.data(GRAPH)))))));
|
||||
assert.deepEqual(sticky.tags, { prompt: GRAPH });
|
||||
// As ffprobe reports them: consecutive data children join with ";", integer data
|
||||
// types print as decimals, other types are not tags, text types 0/1/4 are read, NUL
|
||||
// truncates, and the run of data children stops at the first other child.
|
||||
const mixed = await read(v.mp4(v.udta(v.meta(
|
||||
v.hdlr("mdta"),
|
||||
v.keys(["prompt", "workflow", "parameters", "comfyassets_prompt_pairs", "comment", "description"]),
|
||||
v.ilst(
|
||||
v.item(1, v.data("first"), v.data("second"), v.box("name", "x"), v.data("not read")),
|
||||
v.item(2, v.data([0, 0, 0, 7], { type: 21 }), v.data([0xff, 0xfe], { type: 21 }), v.data([0xff, 0xfe], { type: 22 })),
|
||||
v.item(3, v.data("cut\0off")),
|
||||
v.item(4, v.data("sort", { type: 4 }), v.data("mac", { type: 0 })),
|
||||
v.item(5, v.data([0, 0, 0, 0, 0, 0, 0], { type: 21 })),
|
||||
v.item(6, v.data("float", { type: 23 })),
|
||||
),
|
||||
))));
|
||||
assert.deepEqual(mixed.tags, { prompt: "first;second", workflow: "7;-2;65534", parameters: "cut", comfyassets_prompt_pairs: "sort;mac" });
|
||||
// Keys outside the mdta namespace address nothing; unknown names are skipped unread.
|
||||
const blob = v.recordingBlob(v.mp4(v.udta(v.meta(
|
||||
v.hdlr("mdta"),
|
||||
v.keys([["prompt", "udta"], "encoder", "workflow"]),
|
||||
v.ilst(v.item(1, v.data(GRAPH)), v.item(2, v.data(fill(0x41, 5000))), v.item(3, v.data(WORKFLOW))),
|
||||
))));
|
||||
const result = await readVideoTags(blob);
|
||||
assert.deepEqual(result.tags, { workflow: WORKFLOW });
|
||||
assert.ok(blob.ranges.every(([start, end]) => end - start < 5000), "the encoder value was never sliced");
|
||||
// Legacy fourccs are mapped even in mdta mode, and a repeated name joins with ";".
|
||||
const repeated = await read(v.mp4(v.udta(v.meta(
|
||||
v.hdlr("mdta"),
|
||||
v.keys(["prompt", "prompt", "comment"]),
|
||||
v.ilst(v.item(1, v.data("one")), v.item(2, v.data("two")), v.item(3, v.data("mdta")), v.legacyItem("©cmt", v.data("c"))),
|
||||
))));
|
||||
assert.deepEqual(repeated.tags, { prompt: "one;two", comment: "mdta;c" });
|
||||
// Apple's namespaced keys are reported verbatim, so they are never an envelope.
|
||||
const apple = await read(v.perKeyMp4({ "com.apple.quicktime.description": '{"prompt":"{}"}', prompt: GRAPH }));
|
||||
assert.deepEqual(apple.tags, { prompt: GRAPH });
|
||||
// An item whose first child is not a data atom, or a data atom shorter than its
|
||||
// 16-byte header, is skipped as ffmpeg skips it.
|
||||
const junk = await read(v.mp4(v.udta(v.meta(v.hdlr("mdta"), v.keys(["prompt"]), v.ilst(v.item(1, v.box("name", "x"), v.data(GRAPH)))))));
|
||||
assert.deepEqual(junk.tags, {});
|
||||
const short = await read(v.mp4(v.udta(v.meta(v.hdlr("mdta"), v.keys(["prompt"]), v.ilst(v.item(1, v.data(GRAPH, { size: 12 })))))));
|
||||
assert.deepEqual(short.tags, {});
|
||||
// A box declared with size 0 runs to the end of its parent; faststart puts moov first.
|
||||
const fast = await read(v.perKeyMp4({ prompt: GRAPH }, { faststart: true, brand: "mp42" }));
|
||||
assert.deepEqual(fast, complete({ prompt: GRAPH }, { container: "iso-bmff", format: "mp42" }));
|
||||
const toEnd = await read(v.concat(v.ftyp(), v.mdat(), v.moov(v.mdtaUdta({ prompt: GRAPH }), v.opts({ size: 0 }))));
|
||||
assert.deepEqual(toEnd.tags, { prompt: GRAPH });
|
||||
});
|
||||
|
||||
test("matroska reads global Tags and skips clusters", async () => {
|
||||
const { ID } = v;
|
||||
// ffmpeg layout: SeekHead, Info, Tracks, Tags, Cluster, Cues; names upper-cased.
|
||||
const plain = await read(v.perKeyMkv({ PROMPT: GRAPH, WORKFLOW, ENCODER: "Lavf" }, { docType: "webm" }));
|
||||
assert.deepEqual(plain, complete({ PROMPT: GRAPH, WORKFLOW }, { container: "matroska", format: "webm" }));
|
||||
// Tags after a 3 GiB Cluster, located through the SeekHead: no slice inside the cluster.
|
||||
const tags = v.globalTags({ PROMPT: GRAPH });
|
||||
const clusterSize = 3 * GiB;
|
||||
// The Tags position (relative to the Segment payload) is a 4-byte uint either way,
|
||||
// so the SeekHead's length can be measured with a placeholder first.
|
||||
const seekLength = v.seekHead([[ID.Tags, clusterSize]]).length;
|
||||
const seekHead = v.seekHead([[ID.Tags, seekLength + 12 + clusterSize]]);
|
||||
assert.equal(seekHead.length, seekLength);
|
||||
const head = v.concat(v.ebmlHeader(), v.idBytes(ID.Segment), v.vint(seekHead.length + 12 + clusterSize + tags.length, { width: 8 }), seekHead, v.clusterHeader(clusterSize));
|
||||
const clusterStart = v.ebmlHeader().length + 12 + seekHead.length;
|
||||
const tagsOffset = head.length + clusterSize;
|
||||
const blob = v.sparseBlob([{ offset: 0, bytes: head }, { offset: tagsOffset, bytes: tags }], tagsOffset + tags.length);
|
||||
const seeked = await readVideoTags(blob, { prefix: head.subarray(0, 32) });
|
||||
assert.deepEqual(seeked.tags, { PROMPT: GRAPH });
|
||||
const inside = blob.ranges.filter(([start]) => start >= clusterStart && start < tagsOffset);
|
||||
assert.ok(inside.every(([start, end]) => start === clusterStart && end - start <= 16), JSON.stringify(inside));
|
||||
assert.ok(blob.ranges.reduce((sum, [start, end]) => sum + (end - start), 0) <= m.VIDEO_READ_CEILING);
|
||||
// ffprobe reads level-1 elements up to the first Cluster: Tags behind it are found
|
||||
// only through a SeekHead, so without one they are not read (nor sliced).
|
||||
const behind = v.recordingBlob(v.mkv([v.cluster(64), v.globalTags({ PROMPT: GRAPH }), el(ID.Cues, new Uint8Array(2))]));
|
||||
const sequential = await readVideoTags(behind);
|
||||
assert.deepEqual(sequential.tags, {});
|
||||
assert.ok(behind.ranges.every(([, end]) => end <= v.ebmlHeader().length + 12 + 12 + 32), JSON.stringify(behind.ranges));
|
||||
// Track-targeted Tags are ignored; chapter and attachment targets too.
|
||||
const targeted = await read(v.mkv([
|
||||
v.tagsEl(
|
||||
v.tag([v.simpleTag("PROMPT", "track")], { trackUid: 1 }),
|
||||
v.tag([v.simpleTag("WORKFLOW", WORKFLOW)], { targetType: 50 }),
|
||||
v.tag([v.simpleTag("PROMPT", "zero uid is global")], { trackUid: 0 }),
|
||||
),
|
||||
v.cluster(),
|
||||
]));
|
||||
assert.deepEqual(targeted.tags, { WORKFLOW, PROMPT: "zero uid is global" });
|
||||
// An unknown-size Cluster (live recordings) is the same stop; a SeekHead still works.
|
||||
const hidden = await read(v.mkv([v.cluster(8, { unknown: true }), v.globalTags({ PROMPT: GRAPH })]));
|
||||
assert.deepEqual(hidden, complete({}, { container: "matroska", format: "matroska" }));
|
||||
const before = await read(v.mkv([v.globalTags({ PROMPT: GRAPH }), v.cluster(8, { unknown: true })]));
|
||||
assert.deepEqual(before.tags, { PROMPT: GRAPH });
|
||||
const info = el(ID.Info, new Uint8Array(4));
|
||||
const clusterUnknown = v.cluster(8, { unknown: true });
|
||||
const seekToTags = v.seekHead([[ID.Tags, 0x7f]]); // a one-byte position, like the real one
|
||||
const viaSeek = await read(v.mkv([v.seekHead([[ID.Tags, seekToTags.length + info.length + clusterUnknown.length]]), info, clusterUnknown, v.globalTags({ PROMPT: GRAPH })]));
|
||||
assert.deepEqual(viaSeek.tags, { PROMPT: GRAPH });
|
||||
// An unknown-size Segment is fine (live recordings); other unknown sizes are invalid.
|
||||
const liveSegment = await read(v.mkv([v.globalTags({ PROMPT: GRAPH }), v.cluster()], { unknown: true }));
|
||||
assert.deepEqual(liveSegment.tags, { PROMPT: GRAPH });
|
||||
const unknownTags = await read(v.mkv([el(ID.Tags, v.tag([v.simpleTag("PROMPT", GRAPH)]), v.opts({ unknown: true }))]));
|
||||
assert.deepEqual(unknownTags, { status: "unreadable", reason: "invalid-container" });
|
||||
// ffmpeg's tag naming (checked against ffprobe 9.0.2): a language other than und
|
||||
// files the tag under NAME-lang only, unless TagDefault is set; nested SimpleTags
|
||||
// become PARENT/CHILD (never ours); a string-less tag deletes the key; a repeated
|
||||
// name keeps the last value; NUL ends the string; Void and CRC-32 are skipped.
|
||||
const naming = await read(v.mkv([
|
||||
v.voidEl(8),
|
||||
v.tagsEl(
|
||||
el(ID.CRC32, new Uint8Array(4)),
|
||||
v.tag([
|
||||
v.simpleTag("PROMPT", "eng only", { lang: "eng" }),
|
||||
v.simpleTag("WORKFLOW", "eng only", { lang: "eng", def: 0 }),
|
||||
v.simpleTag("workflow", "default and eng", { lang: "eng", def: 1 }),
|
||||
v.simpleTag("PARAMETERS", "und", { lang: "und", nested: [v.simpleTag("prompt", "nested")] }),
|
||||
v.simpleTag("comfyassets_prompt_pairs", "cut\0here"),
|
||||
v.simpleTag("description", undefined, { binary: [1, 2, 3] }),
|
||||
v.simpleTag("comment", "note"),
|
||||
v.simpleTag("comment", undefined),
|
||||
v.simpleTag("prompt", "first"),
|
||||
v.simpleTag("prompt", "last", { binary: [1] }),
|
||||
]),
|
||||
),
|
||||
v.cluster(),
|
||||
]));
|
||||
assert.deepEqual(naming.tags, { workflow: "default and eng", PARAMETERS: "und", comfyassets_prompt_pairs: "cut", prompt: "last" });
|
||||
// Long names are never ours and are skipped without reading their strings.
|
||||
const longName = v.recordingBlob(v.mkv([v.tagsEl(v.tag([v.simpleTag("N".repeat(300), fill(0x41, 4000)), v.simpleTag("PROMPT", GRAPH)])), v.cluster()]));
|
||||
const skipped = await readVideoTags(longName);
|
||||
assert.deepEqual(skipped.tags, { PROMPT: GRAPH });
|
||||
assert.ok(longName.ranges.every(([start, end]) => end - start < 300));
|
||||
function el(id, ...parts) {
|
||||
return v.el(id, ...parts);
|
||||
}
|
||||
});
|
||||
|
||||
test("containers fail closed", async () => {
|
||||
const { ID } = v;
|
||||
const invalid = { status: "unreadable", reason: "invalid-container" };
|
||||
const rows = [
|
||||
["box size below its header", v.concat(v.ftyp(), v.box("free", v.opts({ size: 4 })), v.mdat(), v.moov(v.mdtaUdta({ prompt: GRAPH })))],
|
||||
["child box past its parent", v.concat(v.ftyp(), v.mdat(), v.moov(v.udta(v.meta(v.hdlr("mdta"), v.keys(["prompt"]), v.ilst(v.item(1, v.data(GRAPH), v.opts({ size: 5000 })))))))],
|
||||
["top-level box past the end of the file", v.concat(v.ftyp(), v.mdat(), v.box("moov", v.opts({ size: 4000 })))],
|
||||
["64-bit size above MAX_SAFE_INTEGER", v.concat(v.ftyp(), v.box("mdat", v.opts({ largesize: 2 ** 60 })), v.moov(v.mdtaUdta({ prompt: GRAPH })))],
|
||||
["64-bit size below its header", v.concat(v.ftyp(), v.box("mdat", v.opts({ largesize: 12 })), v.moov(v.mdtaUdta({ prompt: GRAPH })))],
|
||||
["keys count larger than the entries", v.mp4(v.udta(v.meta(v.hdlr("mdta"), v.keys(["prompt"], { count: 3 }), v.ilst(v.item(1, v.data(GRAPH))))))],
|
||||
["keys entry overrunning the box", v.mp4(v.udta(v.meta(v.hdlr("mdta"), v.box("keys", v.u32(0), v.u32(1), v.u32(90), v.latin1("mdta"), v.utf8("prompt")), v.ilst(v.item(1, v.data(GRAPH))))))],
|
||||
["no moov box", v.concat(v.ftyp(), v.mdat(), v.free(32))],
|
||||
["not a container at all", v.utf8("#EXTM3U\n#EXT-X-VERSION:3\n".padEnd(64, "x"))],
|
||||
["bad VINT: zero ID byte", v.concat(v.ebmlHeader(), v.idBytes(ID.Segment), v.vint(20, { width: 8 }), [0x00, 0x81, 0x00], new Uint8Array(17))],
|
||||
["bad VINT: zero size byte", v.concat(v.ebmlHeader(), v.idBytes(ID.Segment), v.vint(20, { width: 8 }), v.idBytes(ID.Info), [0x00], new Uint8Array(17))],
|
||||
["element past its parent", v.mkv([v.el(ID.Tags, v.tag([v.simpleTag("PROMPT", GRAPH)]), v.opts({ size: 5000 }))])],
|
||||
["element past the end of the file", v.concat(v.ebmlHeader(), v.idBytes(ID.Segment), v.vint(5000, { width: 8 }), v.globalTags({ PROMPT: GRAPH }))],
|
||||
["unknown DocType", v.perKeyMkv({ PROMPT: GRAPH }, { docType: "avi" })],
|
||||
["EBML header without a DocType", v.concat(v.el(ID.EBML, v.el(ID.EBMLVersion, [1])), v.segment([v.globalTags({ PROMPT: GRAPH })]))],
|
||||
["Matroska bytes after a stray size", v.concat(v.ebmlHeader(), v.idBytes(ID.Segment), v.vint(3, { width: 8 }), [0xec, 0x82, 0x00])],
|
||||
];
|
||||
for (const [name, bytes] of rows) {
|
||||
assert.deepEqual(await read(bytes), invalid, name);
|
||||
}
|
||||
// Trailing bytes shorter than a box header are ignored, as ffmpeg does.
|
||||
assert.deepEqual((await read(v.concat(v.perKeyMp4({ prompt: GRAPH }), [1, 2, 3]))).tags, { prompt: GRAPH });
|
||||
// 10,001 boxes stop the read.
|
||||
const many = v.concat(v.ftyp(), ...Array.from({ length: 10001 }, () => v.free()), v.mdat(), v.moov(v.mdtaUdta({ prompt: GRAPH })));
|
||||
assert.deepEqual(await read(many), { status: "limited", reason: "chunk-limit" });
|
||||
const manyElements = v.mkv([...Array.from({ length: 10001 }, () => v.voidEl(1)), v.globalTags({ PROMPT: GRAPH })]);
|
||||
assert.deepEqual(await read(manyElements), { status: "limited", reason: "chunk-limit" });
|
||||
// Exactly 10,000 elements still read (ftyp, mdat, moov, udta, meta, hdlr, keys, its entry, ilst, the item and its data atom).
|
||||
const exact = v.concat(v.ftyp(), ...Array.from({ length: 10000 - 11 }, () => v.free()), v.mdat(), v.moov(v.mdtaUdta({ prompt: GRAPH })));
|
||||
assert.deepEqual((await read(exact)).tags, { prompt: GRAPH });
|
||||
// A vanished file is a read failure, not a container error.
|
||||
assert.deepEqual(await readVideoTags(v.failingBlob(4096), { prefix: v.ftyp() }), { status: "unreadable", reason: "read-failure" });
|
||||
assert.deepEqual(await readVideoTags({}), { status: "unreadable", reason: "read-failure" });
|
||||
});
|
||||
|
||||
// ffprobe's string_validation=replace (av_utf8_decode): one U+FFFD per failing
|
||||
// decode step. A bad continuation or a truncated sequence fails one byte at a time;
|
||||
// an overlong form, a surrogate, a code above U+10FFFF, U+FFFE/U+FFFF and 5/6-byte
|
||||
// forms fail as one whole sequence. Each row was read through ffprobe 9.0.2.
|
||||
const FFPROBE_REPLACEMENTS = [
|
||||
["one Latin-1 byte", [0x63, 0x61, 0x66, 0xe9], "caf�"],
|
||||
["truncated 4-byte sequence", [0x61, 0xf0, 0x9f, 0x22, 0x62], 'a��"b'],
|
||||
["overlong 2-byte form", [0x61, 0xc0, 0xaf, 0x62], "a�b"],
|
||||
["C1 overlong lead", [0x61, 0xc1, 0xbf, 0x62], "a�b"],
|
||||
["overlong 3-byte form", [0x61, 0xe0, 0x80, 0x80, 0x62], "a�b"],
|
||||
["overlong 4-byte form", [0x61, 0xf0, 0x80, 0x80, 0x80, 0x62], "a�b"],
|
||||
["UTF-16 surrogate", [0x61, 0xed, 0xa0, 0x80, 0x62], "a�b"],
|
||||
["U+FFFE", [0x61, 0xef, 0xbf, 0xbe, 0x62], "a�b"],
|
||||
["U+FFFF", [0x61, 0xef, 0xbf, 0xbf, 0x62], "a�b"],
|
||||
["U+FDD0 is kept", [0x61, 0xef, 0xb7, 0x90, 0x62], "ab"],
|
||||
["5-byte form", [0x61, 0xf8, 0x88, 0x80, 0x80, 0x80, 0x62], "a�b"],
|
||||
["6-byte form", [0x61, 0xfc, 0x84, 0x80, 0x80, 0x80, 0x80, 0x62], "a�b"],
|
||||
["truncated 5-byte form", [0x61, 0xf8, 0x88, 0x62], "a��b"],
|
||||
["above U+10FFFF", [0x61, 0xf4, 0x90, 0x80, 0x80, 0x62], "a�b"],
|
||||
["0xFE", [0x61, 0xfe, 0x62], "a�b"],
|
||||
["0xFF", [0x61, 0xff, 0x62], "a�b"],
|
||||
["0xFE 0xFF", [0xfe, 0xff], "��"],
|
||||
["lone continuation byte", [0x61, 0x80, 0x62], "a�b"],
|
||||
["truncated at the end", [0x61, 0xe2, 0x82], "a��"],
|
||||
["lead byte before a whole sequence", [0x61, 0xc3, 0xc3, 0xa9, 0x62], "a�éb"],
|
||||
["valid emoji", [0x61, 0xf0, 0x9f, 0x98, 0x80, 0x62], "a\u{1f600}b"],
|
||||
["control characters", [0x61, 0x01, 0x1f, 0x62], "a\u0001\u001fb"],
|
||||
];
|
||||
|
||||
test("invalid UTF-8 reads as ffprobe replaces it; names that do not decode are not carriers", async () => {
|
||||
for (const [name, bytes, expected] of FFPROBE_REPLACEMENTS) {
|
||||
assert.deepEqual((await read(v.perKeyMp4({ prompt: bytes }))).tags, { prompt: expected }, name);
|
||||
assert.deepEqual((await read(v.perKeyMkv({ PROMPT: bytes }))).tags, { PROMPT: expected }, `${name} (Matroska)`);
|
||||
}
|
||||
// A long value with one bad byte keeps every other byte.
|
||||
const long = Buffer.concat([Buffer.from('{"a":"'), Buffer.alloc(100000, 0x78), Buffer.from([0xe9]), Buffer.from('"}')]);
|
||||
assert.equal((await read(v.perKeyMp4({ workflow: long }))).tags.workflow, `{"a":"${"x".repeat(100000)}�"}`);
|
||||
assert.deepEqual((await read(v.perKeyMp4({ prompt: [0x7b, 0xff, 0xfe, 0x7d] }))).tags, { prompt: "{��}" });
|
||||
// A keys name with a bad byte is reported as "prom�pt", which is no carrier.
|
||||
const badKey = v.mp4(v.udta(v.meta(v.hdlr("mdta"), v.keys([Uint8Array.from([0x70, 0x72, 0x6f, 0x6d, 0xff, 0x70, 0x74]), "prompt"]), v.ilst(v.item(1, v.data("junk")), v.item(2, v.data(GRAPH))))));
|
||||
assert.deepEqual(await read(badKey), complete({ prompt: GRAPH }, { container: "iso-bmff", format: "isom" }));
|
||||
const onlyBadKey = v.mp4(v.udta(v.meta(v.hdlr("mdta"), v.keys([Uint8Array.from([0xc3, 0x28])]), v.ilst(v.item(1, v.data(GRAPH))))));
|
||||
assert.deepEqual((await read(onlyBadKey)).tags, {});
|
||||
assert.deepEqual((await read(v.mkv([v.tagsEl(v.tag([v.simpleTag([0xff], GRAPH), v.simpleTag("WORKFLOW", WORKFLOW)]))]))).tags, { WORKFLOW: WORKFLOW });
|
||||
assert.deepEqual((await read(v.mkv([v.tagsEl(v.tag([v.simpleTag("PROMPT", [0xff, 0xfe])]))]))).tags, { PROMPT: "��" });
|
||||
});
|
||||
|
||||
test("ilst items with index 0 or past the keys are skipped; the first keys box wins", async () => {
|
||||
const wf = '{"nodes":[]}';
|
||||
const stray = v.mp4(v.udta(v.meta(v.hdlr("mdta"), v.keys(["prompt", "workflow"]), v.ilst(v.item(1, v.data(GRAPH)), v.item(5, v.data("junk")), v.item(2, v.data(wf))))));
|
||||
assert.deepEqual((await read(stray)).tags, { prompt: GRAPH, workflow: wf });
|
||||
const zero = v.mp4(v.udta(v.meta(v.hdlr("mdta"), v.keys(["prompt", "workflow"]), v.ilst(v.item(0, v.data("junk")), v.item(1, v.data(GRAPH)), v.item(2, v.data(wf))))));
|
||||
assert.deepEqual((await read(zero)).tags, { prompt: GRAPH, workflow: wf });
|
||||
assert.deepEqual((await read(v.mp4(v.udta(v.meta(v.hdlr("mdta"), v.keys(["prompt"]), v.ilst(v.item(2, v.data(GRAPH)))))))).tags, {});
|
||||
// ffmpeg reads one keys box: a second one (even malformed) addresses nothing.
|
||||
const twoKeys = v.mp4(v.udta(v.meta(v.hdlr("mdta"), v.keys(["prompt"]), v.keys(["workflow"]), v.ilst(v.item(1, v.data(GRAPH))))));
|
||||
assert.deepEqual((await read(twoKeys)).tags, { prompt: GRAPH });
|
||||
const brokenSecond = v.mp4(v.udta(v.meta(v.hdlr("mdta"), v.keys(["prompt"]), v.keys(["workflow"], { count: 9 }), v.ilst(v.item(1, v.data(GRAPH))))));
|
||||
assert.deepEqual((await read(brokenSecond)).tags, { prompt: GRAPH });
|
||||
const emptyFirst = v.mp4(v.udta(v.meta(v.hdlr("mdta"), v.keys([]), v.keys(["prompt"]), v.ilst(v.item(1, v.data(GRAPH))))));
|
||||
assert.deepEqual((await read(emptyFirst)).tags, {});
|
||||
// A keys box too short for its count field is not read, so the next one is.
|
||||
const shortFirst = v.mp4(v.udta(v.meta(v.hdlr("mdta"), v.fullBox("keys"), v.keys(["prompt"]), v.ilst(v.item(1, v.data(GRAPH))))));
|
||||
assert.deepEqual((await read(shortFirst)).tags, { prompt: GRAPH });
|
||||
});
|
||||
|
||||
test("one tag per ASCII-folded name and Mac Roman text, as ffmpeg's tag dictionary holds them", async () => {
|
||||
const mdta = (names, items, legacy = []) => v.mp4(v.udta(v.meta(v.hdlr("mdta"), v.keys(names), v.ilst(...items.map(([index, value, type = 1]) => v.item(index, v.data(value, { type }))), ...legacy))));
|
||||
// ISO-BMFF joins case variants with ";" under the spelling set last (checked through ffprobe).
|
||||
assert.deepEqual((await read(mdta(["workflow", "Workflow"], [[1, "A"], [2, "B"]]))).tags, { Workflow: "A;B" });
|
||||
// A legacy comment atom and a later mdta COMMENT key become one COMMENT tag: no exact-case envelope.
|
||||
const merged = await read(mdta(["COMMENT"], [[1, "y"]], [v.legacyItem("©cmt", v.data("x"))]));
|
||||
assert.deepEqual(merged.tags, { comment: "y;x" });
|
||||
const mergedUpper = await read(v.mp4(v.udta(v.meta(v.hdlr("mdta"), v.keys(["COMMENT"]), v.ilst(v.legacyItem("©cmt", v.data("x")), v.item(1, v.data("y")))))));
|
||||
assert.deepEqual(mergedUpper.tags, { COMMENT: "x;y" });
|
||||
assert.deepEqual(selectVideoCarrier(mergedUpper.tags).records, {});
|
||||
// Matroska keeps the last spelling and value; a string-less SimpleTag deletes any spelling.
|
||||
assert.deepEqual((await read(v.mkv([v.tagsEl(v.tag([v.simpleTag("PROMPT", GRAPH), v.simpleTag("Prompt", "B")]))]))).tags, { Prompt: "B" });
|
||||
assert.deepEqual((await read(v.mkv([v.tagsEl(v.tag([v.simpleTag("PROMPT", GRAPH), v.simpleTag("Prompt", undefined)]))]))).tags, {});
|
||||
// ffprobe's format_tags filter folds ASCII only: a Kelvin-sign "workflow" is never reported.
|
||||
assert.deepEqual((await read(mdta(["prompt", "worKflow"], [[1, GRAPH], [2, WORKFLOW]]))).tags, { prompt: GRAPH });
|
||||
// data types 0 and 3 are Mac Roman to ffmpeg (types 1 and 4 UTF-8, type 2 not text).
|
||||
const cafe = [0x63, 0x61, 0x66, 0x8e];
|
||||
for (const [type, expected] of [[0, "café"], [3, "café"], [1, "caf�"], [4, "caf�"]]) {
|
||||
assert.deepEqual((await read(mdta(["prompt"], [[1, cafe, type]]))).tags, { prompt: expected }, `type ${type}`);
|
||||
}
|
||||
assert.deepEqual((await read(mdta(["prompt"], [[1, cafe, 2]]))).tags, {});
|
||||
const high = Uint8Array.from({ length: 128 }, (_, index) => 0x80 + index);
|
||||
const roomy = Buffer.concat([high, Buffer.alloc(400, 0x7c)]);
|
||||
assert.equal((await read(mdta(["prompt"], [[1, roomy, 0]]))).tags.prompt, new TextDecoder("macintosh").decode(roomy));
|
||||
// ffmpeg writes the UTF-8 into twice the input size: a long non-ASCII value is cut,
|
||||
// here inside U+00DA, which ffprobe then replaces (its own output for these bytes).
|
||||
const tight = Buffer.concat([high, Buffer.from([0x7c, 0x01, 0x7f])]);
|
||||
assert.equal((await read(mdta(["prompt"], [[1, tight, 0]]))).tags.prompt, new TextDecoder("macintosh").decode(high).slice(0, 114) + "�");
|
||||
});
|
||||
|
||||
test("budgets: roles share 2 MiB, an oversized workflow is left unread, duplicates and the total are bounded", async () => {
|
||||
const limited = { status: "limited", reason: "metadata-limit" };
|
||||
// Role tags over 2 MiB together stop the read (the server fails the whole read).
|
||||
assert.deepEqual(await read(v.perKeyMp4({ prompt: fill(0x41, ROLE_BUDGET + 1) })), limited);
|
||||
assert.deepEqual(await read(v.perKeyMkv({ PROMPT: fill(0x41, ROLE_BUDGET - 10), PARAMETERS: fill(0x42, 11) })), limited);
|
||||
const atRole = await read(v.perKeyMp4({ prompt: fill(0x41, ROLE_BUDGET - 10), comfyassets_prompt_pairs: fill(0x42, 10) }));
|
||||
assert.equal(atRole.status, "complete");
|
||||
assert.equal(atRole.tags.prompt.length, ROLE_BUDGET - 10);
|
||||
// A per-key workflow over 4 MiB is not sliced; the result says the workflow is limited.
|
||||
const big = v.recordingBlob(v.perKeyMp4({ prompt: GRAPH, workflow: fill(0x7b, WORKFLOW_BUDGET + 1) }));
|
||||
const over = await readVideoTags(big);
|
||||
// It stays in the tag map as a marker, so the carrier choice still sees a per-key workflow.
|
||||
assert.deepEqual(over, { status: "complete", container: "iso-bmff", format: "isom", tags: { prompt: GRAPH, workflow: m.OVERSIZED_WORKFLOW }, limited: ["workflow"] });
|
||||
assert.ok(big.ranges.every(([start, end]) => end - start < WORKFLOW_BUDGET), "the oversized workflow was never sliced");
|
||||
const bigMkv = await read(v.perKeyMkv({ WORKFLOW: fill(0x7b, WORKFLOW_BUDGET + 1), PROMPT: GRAPH }));
|
||||
assert.deepEqual([bigMkv.limited, Object.keys(bigMkv.tags)], [["workflow"], ["WORKFLOW", "PROMPT"]]);
|
||||
const atWorkflow = await read(v.perKeyMp4({ workflow: fill(0x7b, WORKFLOW_BUDGET) }));
|
||||
assert.deepEqual([atWorkflow.limited, atWorkflow.tags.workflow.length], [[], WORKFLOW_BUDGET]);
|
||||
// Fold-duplicates draw from the duplicate budgets (2x each class), then stop.
|
||||
// (ffmpeg keeps one tag per ASCII-folded name: Matroska keeps the last spelling and value.)
|
||||
const twice = await read(v.perKeyMkv({ PROMPT: fill(0x41, ROLE_BUDGET), prompt: fill(0x42, ROLE_BUDGET) }));
|
||||
assert.deepEqual(Object.keys(twice.tags), ["prompt"]);
|
||||
assert.equal(twice.tags.prompt[0], "B");
|
||||
const thrice = await read(v.perKeyMkv({ PROMPT: fill(0x41, ROLE_BUDGET), prompt: fill(0x41, ROLE_BUDGET), Prompt: fill(0x41, ROLE_BUDGET + 1) }));
|
||||
assert.deepEqual(thrice, limited);
|
||||
const workflowTwice = await read(v.perKeyMkv({ WORKFLOW: fill(0x7b, WORKFLOW_BUDGET), workflow: fill(0x7b, WORKFLOW_BUDGET) }));
|
||||
assert.equal(workflowTwice.status, "complete");
|
||||
// Envelopes count against the total only; the total is the server's probe bound.
|
||||
const envelope = await read(v.mp4(v.legacyUdta({ comment: fill(0x41, 5 * MiB) })));
|
||||
assert.equal(envelope.tags.comment.length, 5 * MiB);
|
||||
const tooMuch = await read(v.mp4(v.legacyUdta({ comment: fill(0x41, TAG_READ_BYTES - 10), description: fill(0x42, 11) })));
|
||||
assert.deepEqual(tooMuch, limited);
|
||||
assert.equal(m.VIDEO_TAG_READ_BYTES, TAG_READ_BYTES);
|
||||
});
|
||||
|
||||
test("an oversized per-key workflow keeps its place in the carrier choice, as in the server's first probe", async () => {
|
||||
const big = fill(0x7b, WORKFLOW_BUDGET + 1);
|
||||
const envelope = '{"workflow": {"nodes": [1]}}';
|
||||
const beside = (comment) => v.mp4(v.udta(v.meta(v.hdlr("mdta"), v.keys(["workflow"]), v.ilst(v.item(1, v.data(big)), v.legacyItem("©cmt", v.data(comment))))));
|
||||
const read1 = await read(beside(envelope));
|
||||
assert.deepEqual([read1.tags, read1.limited], [{ workflow: m.OVERSIZED_WORKFLOW, comment: envelope }, ["workflow"]]);
|
||||
// The server picks the per-key carrier (no role on either side) and marks the workflow limited.
|
||||
assert.deepEqual(selectVideoCarrier(read1.tags, { limited: read1.limited }), { status: "complete", records: {}, record_formats: { workflow: "limited" }, carrierText: null, limited: ["workflow"] });
|
||||
// A role envelope still wins and clears it.
|
||||
const roleEnvelope = '{"prompt": "{}", "workflow": {"a": 1}}';
|
||||
const read2 = await read(beside(roleEnvelope));
|
||||
assert.deepEqual(selectVideoCarrier(read2.tags, { limited: read2.limited }), { status: "complete", records: { prompt: "{}", workflow: '{"a": 1}' }, record_formats: { prompt: "original-text", workflow: "reserialized-json" }, carrierText: roleEnvelope, limited: [] });
|
||||
// ISO-BMFF joins a repeat onto the oversized value, which stays over budget and unread.
|
||||
const repeat = (extra) => v.mp4(v.udta(v.meta(v.hdlr("mdta"), v.keys(["workflow", "prompt"]), v.ilst(v.item(1, v.data(big)), v.item(1, v.data(extra)), v.item(2, v.data(GRAPH))))));
|
||||
const repeatedBlob = v.recordingBlob(repeat(WORKFLOW));
|
||||
const repeated = await readVideoTags(repeatedBlob);
|
||||
assert.deepEqual([repeated.tags, repeated.limited], [{ workflow: m.OVERSIZED_WORKFLOW, prompt: GRAPH }, ["workflow"]]);
|
||||
assert.ok(repeatedBlob.ranges.every(([start, end]) => end - start < 1024), "neither workflow part was sliced");
|
||||
// When the first probe would overflow its bound, the server re-probes without the
|
||||
// per-key workflow: then it takes no part in the choice ...
|
||||
const dropped = await read(repeat(fill(0x41, TAG_READ_BYTES - WORKFLOW_BUDGET)));
|
||||
assert.deepEqual([dropped.tags, dropped.limited], [{ prompt: GRAPH }, ["workflow"]]);
|
||||
// ... and the re-probe's own bound (twice the role budget plus 64 KiB) applies to the rest.
|
||||
const wide = '{"workflow": {"n": "' + "x".repeat(TAG_READ_BYTES - WORKFLOW_BUDGET) + '"}}';
|
||||
assert.deepEqual(await read(beside(wide)), { status: "limited", reason: "metadata-limit" });
|
||||
assert.equal(m.VIDEO_REPROBE_BYTES, 2 * ROLE_BUDGET + 64 * 1024);
|
||||
// Matroska keeps the last value of a name, and a string-less SimpleTag deletes it.
|
||||
const replaced = await read(v.mkv([v.tagsEl(v.tag([v.simpleTag("WORKFLOW", big), v.simpleTag("WORKFLOW", WORKFLOW)]))]));
|
||||
assert.deepEqual([replaced.tags, replaced.limited], [{ WORKFLOW: WORKFLOW }, []]);
|
||||
const deleted = await read(v.mkv([v.tagsEl(v.tag([v.simpleTag("WORKFLOW", big), v.simpleTag("WORKFLOW", undefined), v.simpleTag("PROMPT", GRAPH)]))]));
|
||||
assert.deepEqual([deleted.tags, deleted.limited], [{ PROMPT: GRAPH }, []]);
|
||||
});
|
||||
|
||||
test("avi and asf are recognised but not read", async () => {
|
||||
const avi = v.concat(v.latin1("RIFF"), v.u32(1000), v.latin1("AVI LIST"), new Uint8Array(16));
|
||||
const asf = v.concat([0x30, 0x26, 0xb2, 0x75, 0x8e, 0x66, 0xcf, 0x11, 0xa6, 0xd9, 0x00, 0xaa, 0x00, 0x62, 0xce, 0x6c], new Uint8Array(16));
|
||||
for (const bytes of [avi, asf]) {
|
||||
const blob = v.recordingBlob(v.concat(bytes, new Uint8Array(4096)));
|
||||
assert.deepEqual(await readVideoTags(blob, { prefix: bytes }), { status: "unsupported", reason: "video-container-unsupported" });
|
||||
assert.deepEqual(blob.ranges, []);
|
||||
}
|
||||
// The classifier's kind can be handed in instead of a prefix.
|
||||
const blob = v.recordingBlob(new Uint8Array(64));
|
||||
assert.deepEqual(await readVideoTags(blob, { kind: "avi" }), { status: "unsupported", reason: "video-container-unsupported" });
|
||||
assert.deepEqual(blob.ranges, []);
|
||||
// Without a prefix the reader slices the first 32 bytes itself.
|
||||
const mp4 = v.recordingBlob(v.perKeyMp4({ prompt: GRAPH }));
|
||||
assert.deepEqual((await readVideoTags(mp4)).tags, { prompt: GRAPH });
|
||||
assert.deepEqual(mp4.ranges[0], [0, 32]);
|
||||
});
|
||||
|
||||
test("abort during a moov walk settles once", async () => {
|
||||
const unhandled = [];
|
||||
const onUnhandled = (reason) => unhandled.push(reason);
|
||||
process.on("unhandledRejection", onUnhandled);
|
||||
try {
|
||||
const bytes = v.concat(v.ftyp(), ...Array.from({ length: 50 }, () => v.free()), v.mdat(), v.moov(v.mdtaUdta({ prompt: GRAPH, workflow: WORKFLOW })));
|
||||
const controller = new AbortController();
|
||||
let slices = 0;
|
||||
const blob = {
|
||||
size: bytes.length,
|
||||
slice(start, end) {
|
||||
slices += 1;
|
||||
if (slices === 3) controller.abort();
|
||||
return { arrayBuffer: () => new Promise((resolve) => setTimeout(() => resolve(bytes.slice(start, end).buffer), 2)) };
|
||||
},
|
||||
};
|
||||
await assert.rejects(readVideoTags(blob, { signal: controller.signal }), { name: "AbortError" });
|
||||
const already = new AbortController();
|
||||
already.abort();
|
||||
await assert.rejects(readVideoTags(v.blobOf(bytes), { signal: already.signal }), { name: "AbortError" });
|
||||
const slow = v.slowBlob(bytes, 1);
|
||||
const during = new AbortController();
|
||||
const pending = readVideoTags(slow, { signal: during.signal, yieldControl: async () => during.abort() });
|
||||
await assert.rejects(pending, { name: "AbortError" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
assert.deepEqual(unhandled, []);
|
||||
} finally {
|
||||
process.off("unhandledRejection", onUnhandled);
|
||||
}
|
||||
});
|
||||
|
||||
test("yieldControl is awaited while walking many boxes", async () => {
|
||||
let yields = 0;
|
||||
const many = v.concat(v.ftyp(), ...Array.from({ length: 3000 }, () => v.free()), v.mdat(), v.moov(v.mdtaUdta({ prompt: GRAPH })));
|
||||
const result = await readVideoTags(v.slowBlob(many, 0), { yieldControl: async () => { yields += 1; } });
|
||||
assert.equal(result.status, "complete");
|
||||
assert.ok(yields > 0, "the reader handed control back");
|
||||
});
|
||||
|
||||
test("carrier selection: per-key tags, then the comment envelope, then description, never merged", () => {
|
||||
const ENVELOPE_WORKFLOW = '{"last_node_id": 9, "nodes": [{"id": 3, "type": "KSampler", "widgets_values": [7, "fixed", 20, 8.0, "euler", "normal", 1.0]}, {"id": 6, "type": "CLIPTextEncode", "widgets_values": ["a red fox in snow, detailed fur"]}, {"id": 7, "type": "CLIPTextEncode", "widgets_values": ["blurry, lowres"]}], "links": [], "version": 0.4}';
|
||||
const original = (keys) => Object.fromEntries(keys.map((key) => [key, "original-text"]));
|
||||
const text = (tags, keys) => Object.fromEntries(keys.map((key) => [key, tags[key] ?? tags[key.toUpperCase()]]));
|
||||
// The recorded fixtures.
|
||||
for (const name of ["core-faststart.mp4", "core-moov-end.mp4", "core.mov", "core.m4v", "core.mkv", "core-tags.webm"]) {
|
||||
const tags = RECORDED[name];
|
||||
const keys = Object.keys(tags).map((key) => key.toLowerCase()).filter((key) => PER_KEY.has(key));
|
||||
assert.deepEqual(selectVideoCarrier(tags), { status: "complete", records: text(tags, keys), record_formats: original(keys), carrierText: null, limited: [] }, name);
|
||||
}
|
||||
const legacy = RECORDED["legacy-comment.mp4"];
|
||||
const comment = pythonJsonLoads(legacy.comment);
|
||||
assert.deepEqual(selectVideoCarrier(legacy), {
|
||||
status: "complete",
|
||||
records: { prompt: comment.get("prompt"), workflow: ENVELOPE_WORKFLOW },
|
||||
record_formats: { prompt: "original-text", workflow: "reserialized-json" },
|
||||
carrierText: legacy.comment,
|
||||
limited: [],
|
||||
});
|
||||
assert.ok(comment.get("prompt").startsWith('{"3":{"class_type":"KSampler"'));
|
||||
// Matroska upper-cases COMMENT, and envelope names are exact-case (ruling M1): nothing is read.
|
||||
assert.deepEqual(selectVideoCarrier(RECORDED["legacy-comment.webm"]), { status: "complete", records: {}, record_formats: {}, carrierText: null, limited: [] });
|
||||
// Synthetic maps.
|
||||
const envelope = JSON.stringify({ prompt: '{"9":{}}', workflow: { v: 1 } });
|
||||
const cases = [
|
||||
["per-key wins over comment", { prompt: GRAPH, workflow: WORKFLOW, comment: envelope }, { records: { prompt: GRAPH, workflow: WORKFLOW }, record_formats: original(["prompt", "workflow"]), carrierText: null }],
|
||||
["Matroska names fold", { PROMPT: GRAPH, Workflow: WORKFLOW, ENCODER: "Lavf" }, { records: { prompt: GRAPH, workflow: WORKFLOW }, record_formats: original(["prompt", "workflow"]), carrierText: null }],
|
||||
["identical fold-duplicates are one record", { PROMPT: GRAPH, prompt: GRAPH }, { records: { prompt: GRAPH }, record_formats: original(["prompt"]), carrierText: null }],
|
||||
["differing fold-duplicates fail", { PROMPT: GRAPH, prompt: '{"9":{}}' }, { status: "unreadable", reason: "duplicate-metadata" }],
|
||||
["differing workflow duplicates fail even with a prompt", { Workflow: WORKFLOW, WORKFLOW: "{}", prompt: GRAPH }, { status: "unreadable", reason: "duplicate-metadata" }],
|
||||
["envelope names never fold", { comment: envelope, COMMENT: "a free-text note" }, { records: { prompt: '{"9":{}}', workflow: '{"v": 1}' }, record_formats: { prompt: "original-text", workflow: "reserialized-json" }, carrierText: envelope }],
|
||||
["non-JSON comment falls through to description", { comment: "not json", description: envelope }, { records: { prompt: '{"9":{}}', workflow: '{"v": 1}' }, record_formats: { prompt: "original-text", workflow: "reserialized-json" }, carrierText: envelope }],
|
||||
["a JSON array comment is not an envelope", { comment: '["prompt"]', description: '{"workflow": {}}' }, { records: { workflow: "{}" }, record_formats: { workflow: "reserialized-json" }, carrierText: '{"workflow": {}}' }],
|
||||
["a role envelope beats a workflow-only per-key carrier", { workflow: WORKFLOW, comment: envelope }, { records: { prompt: '{"9":{}}', workflow: '{"v": 1}' }, record_formats: { prompt: "original-text", workflow: "reserialized-json" }, carrierText: envelope }],
|
||||
["role description beats workflow-only comment", { comment: '{"workflow": {"a": 1}}', description: '{"parameters": "cat"}' }, { records: { parameters: "cat" }, record_formats: { parameters: "original-text" }, carrierText: '{"parameters": "cat"}' }],
|
||||
["workflow-only carriers: the first wins, never merged", { comment: '{"workflow": {"a": 1}}', description: '{"workflow": {"b": 2}}' }, { records: { workflow: '{"a": 1}' }, record_formats: { workflow: "reserialized-json" }, carrierText: '{"workflow": {"a": 1}}' }],
|
||||
["description only", { description: envelope }, { records: { prompt: '{"9":{}}', workflow: '{"v": 1}' }, record_formats: { prompt: "original-text", workflow: "reserialized-json" }, carrierText: envelope }],
|
||||
["no known key", { encoder: "Lavf", major_brand: "isom" }, { records: {}, record_formats: {}, carrierText: null }],
|
||||
["envelope with only unknown keys is no carrier", { comment: '{"title": "x"}' }, { records: {}, record_formats: {}, carrierText: null }],
|
||||
["null role marker is refused", { comment: '{"prompt": null, "workflow": {}}' }, { status: "unreadable", reason: "invalid-metadata" }],
|
||||
["numeric role is refused", { description: '{"parameters": 5}' }, { status: "unreadable", reason: "invalid-metadata" }],
|
||||
["a non-text non-object workflow is invalid but the roles stay", { comment: '{"prompt": "{}", "workflow": null}' }, { records: { prompt: "{}" }, record_formats: { prompt: "original-text", workflow: "invalid" }, carrierText: '{"prompt": "{}", "workflow": null}' }],
|
||||
["an object prompt is reserialized", { comment: '{"prompt": {"1": {"class_type": "A"}}}' }, { records: { prompt: '{"1": {"class_type": "A"}}' }, record_formats: { prompt: "reserialized-json" }, carrierText: '{"prompt": {"1": {"class_type": "A"}}}' }],
|
||||
["a non-text per-key role marker is refused", { prompt: 5, workflow: WORKFLOW }, { status: "unreadable", reason: "invalid-metadata" }],
|
||||
["a non-text per-key workflow is invalid", { prompt: GRAPH, workflow: 5 }, { records: { prompt: GRAPH }, record_formats: { prompt: "original-text", workflow: "invalid" }, carrierText: null }],
|
||||
["a workflow-only envelope with an invalid workflow is still the carrier", { comment: '{"workflow": 5}' }, { records: {}, record_formats: { workflow: "invalid" }, carrierText: '{"workflow": 5}' }],
|
||||
["not a map", null, { records: {}, record_formats: {}, carrierText: null }],
|
||||
];
|
||||
for (const [name, tags, expected] of cases) {
|
||||
const want = expected.status ? expected : { status: "complete", limited: [], ...expected };
|
||||
assert.deepEqual(selectVideoCarrier(tags), want, name);
|
||||
}
|
||||
// The server's budget loop: a 4 MiB workflow is limited, roles over 2 MiB fail, exact sizes pass.
|
||||
const bigWorkflow = "{" + "0".repeat(WORKFLOW_BUDGET);
|
||||
assert.deepEqual(selectVideoCarrier({ prompt: GRAPH, workflow: bigWorkflow }), { status: "complete", records: { prompt: GRAPH }, record_formats: { prompt: "original-text", workflow: "limited" }, carrierText: null, limited: ["workflow"] });
|
||||
assert.deepEqual(selectVideoCarrier({ workflow: bigWorkflow.slice(1) }).records, { workflow: bigWorkflow.slice(1) });
|
||||
assert.deepEqual(selectVideoCarrier({ comment: JSON.stringify({ prompt: "x", workflow: { k: "0".repeat(WORKFLOW_BUDGET) } }) }).record_formats, { prompt: "original-text", workflow: "limited" });
|
||||
assert.deepEqual(selectVideoCarrier({ prompt: "a".repeat(ROLE_BUDGET), parameters: "b" }), { status: "limited", reason: "metadata-limit" });
|
||||
assert.deepEqual(selectVideoCarrier({ prompt: "é".repeat(ROLE_BUDGET / 2) }).records.prompt.length, ROLE_BUDGET / 2);
|
||||
assert.deepEqual(selectVideoCarrier({ prompt: "é".repeat(ROLE_BUDGET / 2) + "a" }), { status: "limited", reason: "metadata-limit" });
|
||||
assert.deepEqual(selectVideoCarrier({ prompt: "\ud800".repeat(ROLE_BUDGET / 3) + "aaa" }), { status: "limited", reason: "metadata-limit" });
|
||||
assert.deepEqual(selectVideoCarrier({ prompt: "\u{1f600}".repeat(ROLE_BUDGET / 4) }).records.prompt.length, ROLE_BUDGET / 2);
|
||||
assert.deepEqual(selectVideoCarrier({ prompt: "\u{1f600}".repeat(ROLE_BUDGET / 4) + "a" }), { status: "limited", reason: "metadata-limit" });
|
||||
// The reader's limited workflow (the server's role-only re-probe): kept with the per-key
|
||||
// carrier or no carrier, cleared when an envelope wins.
|
||||
assert.deepEqual(selectVideoCarrier({ prompt: GRAPH }, { limited: ["workflow"] }).record_formats, { prompt: "original-text", workflow: "limited" });
|
||||
assert.deepEqual(selectVideoCarrier({ encoder: "x" }, { limited: ["workflow"] }), { status: "complete", records: {}, record_formats: { workflow: "limited" }, carrierText: null, limited: ["workflow"] });
|
||||
assert.deepEqual(selectVideoCarrier({ comment: envelope }, { limited: ["workflow"] }).limited, []);
|
||||
assert.deepEqual(selectVideoCarrier({ comment: envelope }, { limited: ["workflow"] }).record_formats, { prompt: "original-text", workflow: "reserialized-json" });
|
||||
});
|
||||
|
||||
test("pythonJsonLoads and pythonJsonDumps spell values as Python's json module does", () => {
|
||||
const rows = [
|
||||
["1.0", "1.0"], ["8.0", "8.0"], ["0.1", "0.1"], ["1e16", "1e+16"], ["1e15", "1000000000000000.0"],
|
||||
["123456789012345678", "123456789012345678"], ["0.0001", "0.0001"], ["0.00001", "1e-05"], ["1e-7", "1e-07"],
|
||||
["1.5e300", "1.5e+300"], ["1e400", "Infinity"], ["-0", "0"], ["-0.0", "-0.0"], ["1E2", "100.0"], ["2.5", "2.5"],
|
||||
["100.0", "100.0"], ["1e21", "1e+21"], ["123456.789", "123456.789"], ["0.000123", "0.000123"], ["-1e-05", "-1e-05"],
|
||||
["9007199254740993", "9007199254740993"], ["1.7976931348623157e308", "1.7976931348623157e+308"], ["5e-324", "5e-324"],
|
||||
["NaN", "NaN"], ["-Infinity", "-Infinity"], ["Infinity", "Infinity"], ["[1.0e+2]", "[100.0]"], ["-1e400", "-Infinity"],
|
||||
["[]", "[]"], ["{}", "{}"], ["[true, false, null]", "[true, false, null]"], ['{"k":[1,{"z":1}]}', '{"k": [1, {"z": 1}]}'],
|
||||
[' \t\n\r{"a" : 1 } ', '{"a": 1}'],
|
||||
['{"a":"é\\u0001\u007f\\"\\\\\\n\\t\\b\\f\\r\\/ 😀 \\ud800"}', '{"a": "\\u00e9\\u0001\\u007f\\"\\\\\\n\\t\\b\\f\\r/ \\ud83d\\ude00 \\ud800"}'],
|
||||
['{"a":"\\ud83d\\ude00"}', '{"a": "\\ud83d\\ude00"}'],
|
||||
['{"10":1,"3":2,"b":3,"a":4}', '{"10": 1, "3": 2, "b": 3, "a": 4}'],
|
||||
['{"a":1,"b":2,"a":3}', '{"a": 3, "b": 2}'],
|
||||
];
|
||||
for (const [input, output] of rows) {
|
||||
assert.equal(pythonJsonDumps(pythonJsonLoads(input)), output, input);
|
||||
}
|
||||
const bad = ['{"a":1} x', '{"a":"\u0001"}', '{"a":"\\x"}', "01", "1.", ".5", "+1", "[1,]", '{"a":1,}', "{a:1}", "'a'", "", " ", "[", "nul", "-", "1e", '"\\u12"', "[1 2]", "tru", "Infinit"];
|
||||
for (const input of bad) {
|
||||
assert.throws(() => pythonJsonLoads(input), { name: "PythonJsonError" }, JSON.stringify(input));
|
||||
}
|
||||
assert.throws(() => pythonJsonLoads("[".repeat(2000) + "]".repeat(2000)), { name: "PythonJsonError", recursion: true });
|
||||
// CPython's int() refuses more than 4300 digits (sign excluded); floats have no such limit.
|
||||
const digits = (count) => "1" + "0".repeat(count - 1);
|
||||
assert.equal(pythonJsonDumps(pythonJsonLoads(digits(4300))), digits(4300));
|
||||
assert.equal(pythonJsonDumps(pythonJsonLoads("-" + digits(4300))), "-" + digits(4300));
|
||||
for (const input of [digits(4301), "-" + digits(4301), `[${digits(5000)}]`]) {
|
||||
assert.throws(() => pythonJsonLoads(input), (error) => error.name === "PythonJsonError" && error.recursion === false, input.slice(0, 8));
|
||||
}
|
||||
assert.equal(pythonJsonLoads(digits(4301) + ".0").isFloat, true);
|
||||
// So an envelope holding such an integer is skipped, as the server's _carriers does.
|
||||
const hugeInt = `{"workflow": {"a": ${digits(4400)}}}`;
|
||||
assert.deepEqual(selectVideoCarrier({ comment: hugeInt, description: '{"workflow": {"b": 2}}' }).carrierText, '{"workflow": {"b": 2}}');
|
||||
assert.throws(() => pythonJsonDumps(undefined), { name: "PythonJsonError" });
|
||||
assert.throws(() => pythonJsonLoads(5), { name: "PythonJsonError" });
|
||||
assert.equal(pythonJsonDumps(pythonJsonLoads("[".repeat(500) + "]".repeat(500))), "[".repeat(500) + "]".repeat(500));
|
||||
const parsed = pythonJsonLoads('{"s":"x","n":1,"f":1.5,"b":true,"z":null,"l":[1]}');
|
||||
assert.ok(parsed instanceof Map);
|
||||
assert.deepEqual([...parsed.keys()], ["s", "n", "f", "b", "z", "l"]);
|
||||
assert.equal(parsed.get("s"), "x");
|
||||
assert.equal(parsed.get("b"), true);
|
||||
assert.equal(parsed.get("z"), null);
|
||||
assert.ok(Array.isArray(parsed.get("l")));
|
||||
});
|
||||
@@ -765,6 +765,20 @@ test('the Filters button shows the active refinement count', async () => {
|
||||
assert.equal(filtersButton.textContent, 'Filters');
|
||||
for (const bad of [-1, 1.5, '1', null, Number.NaN]) assert.throws(() => toolbar.setActiveFilters(bad), TypeError);
|
||||
assert.equal(filtersButton.textContent, 'Filters');
|
||||
// An unchanged count leaves the label's text node alone: every listing reload reports the count, and WebKit
|
||||
// drops a click whose mousedown hit a text node that was replaced before mouseup.
|
||||
let writes = 0;
|
||||
const proto = Object.getPrototypeOf(filtersButton);
|
||||
const descriptor = Object.getOwnPropertyDescriptor(proto, 'textContent');
|
||||
Object.defineProperty(filtersButton, 'textContent', {
|
||||
get() { return descriptor.get.call(this); },
|
||||
set(text) { writes += 1; descriptor.set.call(this, text); },
|
||||
configurable: true,
|
||||
});
|
||||
toolbar.setActiveFilters(0);
|
||||
assert.equal(writes, 0);
|
||||
toolbar.setActiveFilters(3);
|
||||
assert.deepEqual([writes, filtersButton.textContent], [1, 'Filters · 3']);
|
||||
assert.throws(() => mountToolbar(new dom.Element('div'), { scope, categories: [], sorts: [['newest', 'Recently added']],
|
||||
activeFilters: -1, onChange() {}, onFilters() {}, onSaveView() {} }), TypeError);
|
||||
} finally {
|
||||
|
||||
@@ -507,10 +507,49 @@ test('category preview text states the real impact and whether it renames or con
|
||||
'“Landscapes” is used by 2 other saved prompts. Apply renames it to “Scenery”.');
|
||||
});
|
||||
|
||||
test('accepts image_count and names images in the delete text', async () => {
|
||||
const {createVocabularyStream, createVocabularyCommand, readTagImpact, deleteTagText} = await helpers;
|
||||
const rows = [{...tag(1, 'mist', 2), image_count: 3}, tag(2, 'dusk', 0)];
|
||||
const stream = createVocabularyStream({api: queued([tagPage(rows)]).api, kind: 'tags'});
|
||||
assert.equal(await stream.loadMore(), 2);
|
||||
assert.deepEqual(stream.state.items.map(row => row.image_count), [3, undefined]);
|
||||
for (const bad of [{...tag(1), image_count: -1}, {...tag(1), image_count: '3'}, {...tag(1), image_count: 1.5}]) {
|
||||
const rejected = createVocabularyStream({api: queued([tagPage([bad])]).api, kind: 'tags'});
|
||||
await rejected.loadMore();
|
||||
assert.ok(rejected.state.error instanceof TypeError, JSON.stringify(bad));
|
||||
assert.deepEqual(rejected.state.items, []);
|
||||
}
|
||||
const withImages = {...impact, images: 2};
|
||||
const api = {get: async () => ({success: true, library_epoch: EPOCH, impact: withImages})};
|
||||
assert.deepEqual((await readTagImpact(api, {id: 4, name: 'mist'})).impact, withImages);
|
||||
await assert.rejects(readTagImpact({get: async () => ({success: true, library_epoch: EPOCH, impact: {...impact, images: -2}})},
|
||||
{id: 4, name: 'mist'}), TypeError);
|
||||
const command = createVocabularyCommand({api: {
|
||||
patch: async () => renameReceipt({tag: {...tag(1, 'dusk', 2), image_count: 1}, images_changed: 1}),
|
||||
post: async () => ({success: true, library_epoch: EPOCH, deleted: true, components_changed: 0, pairs_changed: 0, images_changed: 2}),
|
||||
}});
|
||||
await command.renameTag({id: 1, name: 'dawn', prompt_count: 2}, 'dusk', EPOCH);
|
||||
assert.equal(command.state.phase, 'done');
|
||||
assert.equal(command.state.receipt.images_changed, 1);
|
||||
command.reset();
|
||||
await command.deleteTag({id: 1, name: 'dusk'}, EPOCH);
|
||||
assert.equal(command.state.phase, 'done');
|
||||
const malformed = createVocabularyCommand({api: {post: async () => ({success: true, library_epoch: EPOCH, deleted: true,
|
||||
components_changed: 0, pairs_changed: 0, images_changed: 'two'})}});
|
||||
await malformed.deleteTag({id: 1, name: 'dusk'}, EPOCH);
|
||||
assert.equal(malformed.state.phase, 'unknown-outcome');
|
||||
assert.equal(deleteTagText('mist', withImages),
|
||||
'Deleting “mist” removes the name and its links from 3 positive prompts, 3 negative variants (1 archived), 1 other saved prompt and 2 images. The prompts, negative variants, images and history themselves are kept.');
|
||||
assert.equal(deleteTagText('mist', {...zero, images: 1}),
|
||||
'Deleting “mist” removes the name and its links from 1 image. The prompts, negative variants, images and history themselves are kept.');
|
||||
assert.equal(deleteTagText('unused', {...zero, images: 0}),
|
||||
'“unused” is not linked to any saved prompt or image. Deleting it removes only the name.');
|
||||
});
|
||||
|
||||
test('delete text names the tag, its real links and what stays unchanged', async () => {
|
||||
const {deleteTagText} = await helpers;
|
||||
assert.equal(deleteTagText('mist', impact),
|
||||
'Deleting “mist” removes the name and its links from 3 positive prompts, 3 negative variants (1 archived) and 1 other saved prompt. The prompts, negative variants, images and history themselves are kept.');
|
||||
assert.equal(deleteTagText('unused', zero),
|
||||
'“unused” is not linked to any saved prompt. Deleting it removes only the name.');
|
||||
'“unused” is not linked to any saved prompt or image. Deleting it removes only the name.');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,602 @@
|
||||
"""Image annotation schema group (#249): install, validation and restore safety."""
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
try:
|
||||
from tests.open_handles import assert_closed
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from open_handles import assert_closed
|
||||
|
||||
from database.models import PromptModel
|
||||
|
||||
try: # guarded so a missing module fails assertions, not test loading
|
||||
from database.annotation_schema import (
|
||||
annotation_schema_error,
|
||||
migrate_annotation_state,
|
||||
)
|
||||
except ImportError:
|
||||
annotation_schema_error = None
|
||||
migrate_annotation_state = None
|
||||
|
||||
METADATA = "media_annotation_metadata"
|
||||
FILES = "media_annotation_files"
|
||||
BYTES = "media_annotation_bytes"
|
||||
IMAGE_TAGS = "media_image_tags"
|
||||
TABLES = (METADATA, FILES, BYTES, IMAGE_TAGS)
|
||||
# The exact DDL the plan pins; the module must produce precisely these objects.
|
||||
DDL = {
|
||||
METADATA: (
|
||||
f"CREATE TABLE {METADATA}(singleton_id INTEGER PRIMARY KEY "
|
||||
"CHECK(singleton_id=1), schema_version INTEGER NOT NULL "
|
||||
"CHECK(schema_version=1), image_index_revision INTEGER NOT NULL DEFAULT 0 "
|
||||
"CHECK(image_index_revision>=0))"
|
||||
),
|
||||
FILES: (
|
||||
f"CREATE TABLE {FILES}(id TEXT PRIMARY KEY CHECK(length(id)=36), "
|
||||
"locator TEXT NOT NULL UNIQUE, created_at TEXT NOT NULL)"
|
||||
),
|
||||
BYTES: (
|
||||
f"CREATE TABLE {BYTES}(id TEXT PRIMARY KEY CHECK(length(id)=36), "
|
||||
f"file_id TEXT NOT NULL REFERENCES {FILES}(id) ON DELETE CASCADE, "
|
||||
"sha256 TEXT NOT NULL CHECK(length(sha256)=64 AND sha256 NOT GLOB "
|
||||
"'*[^0-9a-f]*'), size INTEGER NOT NULL CHECK(size>=0), "
|
||||
"mtime_ns INTEGER NOT NULL, verified_at TEXT NOT NULL, "
|
||||
"first_seen_at TEXT NOT NULL, tag_revision INTEGER NOT NULL DEFAULT 0 "
|
||||
"CHECK(tag_revision>=0), UNIQUE(file_id, sha256))"
|
||||
),
|
||||
IMAGE_TAGS: (
|
||||
f"CREATE TABLE {IMAGE_TAGS}(byte_id TEXT NOT NULL REFERENCES {BYTES}(id) "
|
||||
"ON DELETE CASCADE, tag_id INTEGER NOT NULL REFERENCES tags(id) "
|
||||
"ON DELETE CASCADE, position INTEGER NOT NULL CHECK(position>=0), "
|
||||
"PRIMARY KEY(byte_id, tag_id))"
|
||||
),
|
||||
}
|
||||
INDEXES = {
|
||||
"idx_media_annotation_bytes_fingerprint": (
|
||||
f"CREATE INDEX idx_media_annotation_bytes_fingerprint ON {BYTES}"
|
||||
"(file_id, size, mtime_ns)"
|
||||
),
|
||||
"idx_media_image_tags_tag": (
|
||||
f"CREATE INDEX idx_media_image_tags_tag ON {IMAGE_TAGS}(tag_id)"
|
||||
),
|
||||
"idx_media_image_tags_position": (
|
||||
f"CREATE UNIQUE INDEX idx_media_image_tags_position ON {IMAGE_TAGS}"
|
||||
"(byte_id, position)"
|
||||
),
|
||||
}
|
||||
AUTOINDEXES = {FILES: 2, BYTES: 2, IMAGE_TAGS: 1}
|
||||
LEGACY_TABLES = (
|
||||
"prompts",
|
||||
"generated_images",
|
||||
"tags",
|
||||
"prompt_tags",
|
||||
"prompt_pair_tags",
|
||||
"image_favorites",
|
||||
)
|
||||
FILE_ID = "0f8e4b2a-1c3d-4e5f-8a9b-0c1d2e3f4a5b"
|
||||
BYTE_ID = "6a1b2c3d-4e5f-4a6b-8c9d-0e1f2a3b4c5d"
|
||||
OTHER_ID = "7b2c3d4e-5f6a-4b7c-9d0e-1f2a3b4c5d6e"
|
||||
DIGEST = hashlib.sha256(b"img").hexdigest()
|
||||
NOW = "2026-10-22T00:00:00.000000+00:00"
|
||||
MAX_LOCATOR = 4096
|
||||
|
||||
|
||||
def _compact(sql):
|
||||
return re.sub(r"\s+", "", sql or "").lower()
|
||||
|
||||
|
||||
def expected_catalog():
|
||||
objects = {("table", name, _compact(sql)) for name, sql in DDL.items()}
|
||||
objects |= {("index", name, _compact(sql)) for name, sql in INDEXES.items()}
|
||||
objects |= {
|
||||
("index", f"sqlite_autoindex_{table}_{n}", "")
|
||||
for table, count in AUTOINDEXES.items()
|
||||
for n in range(1, count + 1)
|
||||
}
|
||||
return objects
|
||||
|
||||
|
||||
def catalog(conn):
|
||||
names = tuple(TABLES) + tuple(INDEXES)
|
||||
marks = ",".join("?" * len(names))
|
||||
table_marks = ",".join("?" * len(TABLES))
|
||||
return {
|
||||
(kind, name, _compact(sql))
|
||||
for kind, name, sql in conn.execute(
|
||||
"SELECT type,name,sql FROM sqlite_master "
|
||||
f"WHERE name IN ({marks}) OR tbl_name IN ({table_marks})", # nosec B608
|
||||
names + tuple(TABLES),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def insert_group_rows(conn, tag_name="img-only", locator="/out/img.png"):
|
||||
"""One file, one byte record and one image tag carrying *tag_name*."""
|
||||
tag_id = conn.execute("INSERT INTO tags(name) VALUES(?)", (tag_name,)).lastrowid
|
||||
conn.execute(f"INSERT INTO {FILES} VALUES(?,?,?)", (FILE_ID, locator, NOW))
|
||||
conn.execute(
|
||||
f"INSERT INTO {BYTES} VALUES(?,?,?,?,?,?,?,?)",
|
||||
(BYTE_ID, FILE_ID, DIGEST, 3, 1_700_000_000_000_000_000, NOW, NOW, 1),
|
||||
)
|
||||
conn.execute(f"INSERT INTO {IMAGE_TAGS} VALUES(?,?,0)", (BYTE_ID, tag_id))
|
||||
return tag_id
|
||||
|
||||
|
||||
def rows(conn, sql, *params):
|
||||
"""Plain tuples whatever row factory *conn* uses."""
|
||||
return [tuple(row) for row in conn.execute(sql, params)]
|
||||
|
||||
|
||||
def image_tags(conn):
|
||||
return rows(
|
||||
conn,
|
||||
f"SELECT t.name, it.position FROM {IMAGE_TAGS} it "
|
||||
"JOIN tags t ON t.id = it.tag_id ORDER BY it.position",
|
||||
)
|
||||
|
||||
|
||||
def metadata(conn):
|
||||
return rows(conn, f"SELECT schema_version,image_index_revision FROM {METADATA}")
|
||||
|
||||
|
||||
def table_rows(conn, tables):
|
||||
found = {}
|
||||
for table in tables:
|
||||
present = conn.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,)
|
||||
).fetchone()
|
||||
if present:
|
||||
found[table] = rows(conn, f"SELECT * FROM {table}") # nosec B608
|
||||
return found
|
||||
|
||||
|
||||
def populate_legacy(conn):
|
||||
conn.execute(
|
||||
"INSERT INTO prompts(id,text,hash,category,notes,run_count) VALUES"
|
||||
"(7,'positive','p','keep','notes',12)"
|
||||
)
|
||||
conn.execute("INSERT INTO prompts(id,text,hash) VALUES(9,'negative','n')")
|
||||
conn.execute(
|
||||
"INSERT INTO generated_images(id,prompt_id,image_path,filename,file_path) "
|
||||
"VALUES(4,7,'/tmp/image.png','image.png','/tmp/image.png')"
|
||||
)
|
||||
conn.execute("INSERT INTO tags(id,name) VALUES(3,'keep-tag')")
|
||||
conn.execute("INSERT INTO prompt_tags VALUES(7,3)")
|
||||
conn.execute(
|
||||
"INSERT INTO prompt_pairs(id,positive_prompt_id,negative_prompt_id) "
|
||||
"VALUES(6,7,9)"
|
||||
)
|
||||
conn.execute("INSERT INTO prompt_pair_tags VALUES(6,3)")
|
||||
conn.execute("INSERT INTO image_favorites VALUES('root','a.png',?)", (NOW,))
|
||||
|
||||
|
||||
class AnnotationSchemaTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.addCleanup(assert_closed, self, self.temp.name)
|
||||
self.addCleanup(PromptModel.reset_schema_cache)
|
||||
self.models = []
|
||||
self.addCleanup(self.close_models)
|
||||
self.path = str(Path(self.temp.name) / "live.db")
|
||||
|
||||
def close_models(self):
|
||||
for model in self.models:
|
||||
model.close_all()
|
||||
|
||||
def model(self, path=None):
|
||||
model = PromptModel(path or self.path)
|
||||
self.models.append(model)
|
||||
return model
|
||||
|
||||
def require_module(self):
|
||||
self.assertIsNotNone(
|
||||
annotation_schema_error, "database.annotation_schema is not implemented"
|
||||
)
|
||||
|
||||
def drop_group(self, path):
|
||||
"""Remove the group from a closed database file (an old backup)."""
|
||||
with closing(sqlite3.connect(path)) as raw, raw:
|
||||
raw.execute("PRAGMA foreign_keys=OFF")
|
||||
for table in reversed(TABLES):
|
||||
raw.execute(f"DROP TABLE {table}")
|
||||
|
||||
def test_fresh_install_and_reopen_is_idempotent(self):
|
||||
self.require_module()
|
||||
model = self.model()
|
||||
with model.get_connection() as conn:
|
||||
self.assertEqual(catalog(conn), expected_catalog())
|
||||
self.assertIsNone(annotation_schema_error(conn, allow_missing=False))
|
||||
self.assertEqual(metadata(conn), [(1, 0)])
|
||||
insert_group_rows(conn)
|
||||
self.assertEqual(
|
||||
PromptModel.verify_database_file(self.path, require_library_state=True),
|
||||
(True, "ok"),
|
||||
)
|
||||
model.close_all()
|
||||
for _ in range(2):
|
||||
PromptModel.reset_schema_cache()
|
||||
model = self.model()
|
||||
with model.get_connection() as conn:
|
||||
self.assertEqual(catalog(conn), expected_catalog())
|
||||
self.assertIsNone(annotation_schema_error(conn, allow_missing=False))
|
||||
self.assertEqual(metadata(conn), [(1, 0)])
|
||||
self.assertEqual(image_tags(conn), [("img-only", 0)])
|
||||
# Running the migration again on a current database changes nothing.
|
||||
migrate_annotation_state(conn)
|
||||
self.assertEqual(metadata(conn), [(1, 0)])
|
||||
self.assertEqual(image_tags(conn), [("img-only", 0)])
|
||||
model.close_all()
|
||||
# A tag delete and a file delete cascade into the junction and bytes.
|
||||
PromptModel.reset_schema_cache()
|
||||
model = self.model()
|
||||
with model.get_connection() as conn:
|
||||
conn.execute("DELETE FROM tags WHERE name='img-only'")
|
||||
self.assertEqual(image_tags(conn), [])
|
||||
conn.execute(f"DELETE FROM {FILES}")
|
||||
self.assertEqual(rows(conn, f"SELECT * FROM {BYTES}"), [])
|
||||
self.assertIsNone(annotation_schema_error(conn, allow_missing=False))
|
||||
|
||||
def test_old_database_without_group_migrates_without_touching_legacy_rows(self):
|
||||
self.require_module()
|
||||
model = self.model()
|
||||
with model.get_connection() as conn:
|
||||
populate_legacy(conn)
|
||||
model.close_all()
|
||||
self.drop_group(self.path)
|
||||
with closing(sqlite3.connect(self.path)) as raw:
|
||||
self.assertIsNone(annotation_schema_error(raw))
|
||||
self.assertEqual(
|
||||
annotation_schema_error(raw, allow_missing=False),
|
||||
"Missing image annotation schema",
|
||||
)
|
||||
legacy_rows_before = table_rows(raw, LEGACY_TABLES)
|
||||
self.assertTrue(PromptModel.verify_database_file(self.path)[0])
|
||||
self.assertFalse(
|
||||
PromptModel.verify_database_file(self.path, require_library_state=True)[0]
|
||||
)
|
||||
PromptModel.reset_schema_cache()
|
||||
|
||||
model = self.model()
|
||||
|
||||
with model.get_connection() as conn:
|
||||
self.assertEqual(catalog(conn), expected_catalog())
|
||||
self.assertIsNone(annotation_schema_error(conn, allow_missing=False))
|
||||
self.assertEqual(metadata(conn), [(1, 0)])
|
||||
legacy_rows_after = table_rows(conn, LEGACY_TABLES)
|
||||
# No backfill: existing prompt tags never become image tags.
|
||||
counts = [
|
||||
conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
|
||||
for table in (FILES, BYTES, IMAGE_TAGS)
|
||||
]
|
||||
self.assertEqual(counts, [0, 0, 0])
|
||||
self.assertEqual(legacy_rows_after, legacy_rows_before)
|
||||
self.assertEqual(
|
||||
PromptModel.verify_database_file(self.path, require_library_state=True),
|
||||
(True, "ok"),
|
||||
)
|
||||
|
||||
def tampered(self, name, statements, *, checks_off=False):
|
||||
"""A closed current database whose group was altered after the fact."""
|
||||
path = str(Path(self.temp.name) / f"{name.replace(' ', '-')}.db")
|
||||
model = self.model(path)
|
||||
with model.get_connection() as conn:
|
||||
populate_legacy(conn)
|
||||
insert_group_rows(conn)
|
||||
model.close_all()
|
||||
with closing(sqlite3.connect(path)) as raw, raw:
|
||||
raw.execute("PRAGMA foreign_keys=OFF")
|
||||
if checks_off:
|
||||
raw.execute("PRAGMA ignore_check_constraints=ON")
|
||||
for statement in statements:
|
||||
if isinstance(statement, tuple):
|
||||
raw.execute(*statement)
|
||||
else:
|
||||
raw.execute(statement)
|
||||
return path
|
||||
|
||||
def assert_rejected_unrepaired(self, path):
|
||||
with closing(sqlite3.connect(path)) as raw:
|
||||
schema_before = raw.execute(
|
||||
"SELECT type,name,tbl_name,sql FROM sqlite_master ORDER BY name"
|
||||
).fetchall()
|
||||
rows_before = table_rows(raw, TABLES)
|
||||
for allow_missing in (False, True):
|
||||
error = annotation_schema_error(raw, allow_missing=allow_missing)
|
||||
self.assertIsNotNone(error)
|
||||
self.assertIn("image annotation", error.lower())
|
||||
self.assertFalse(PromptModel.verify_database_file(path)[0])
|
||||
self.assertFalse(
|
||||
PromptModel.verify_database_file(path, require_library_state=True)[0]
|
||||
)
|
||||
PromptModel.reset_schema_cache()
|
||||
with self.assertRaises(sqlite3.DatabaseError):
|
||||
self.model(path)
|
||||
PromptModel.reset_schema_cache()
|
||||
with closing(sqlite3.connect(path)) as raw:
|
||||
self.assertEqual(
|
||||
raw.execute(
|
||||
"SELECT type,name,tbl_name,sql FROM sqlite_master ORDER BY name"
|
||||
).fetchall(),
|
||||
schema_before,
|
||||
)
|
||||
self.assertEqual(table_rows(raw, TABLES), rows_before)
|
||||
|
||||
def test_partial_or_malformed_group_is_rejected_without_repair(self):
|
||||
self.require_module()
|
||||
cases = {
|
||||
"missing metadata": (f"DROP TABLE {METADATA}",),
|
||||
"missing files": (f"DROP TABLE {FILES}",),
|
||||
"missing bytes": (f"DROP TABLE {BYTES}",),
|
||||
"missing image tags": (f"DROP TABLE {IMAGE_TAGS}",),
|
||||
"reserved view": (
|
||||
f"DROP TABLE {FILES}",
|
||||
f"CREATE VIEW {FILES} AS SELECT 'x' AS id",
|
||||
),
|
||||
"extra trigger": (
|
||||
f"CREATE TRIGGER media_annotation_trigger AFTER INSERT ON {FILES} "
|
||||
"BEGIN SELECT 1; END",
|
||||
),
|
||||
"extra index": (
|
||||
f"CREATE INDEX idx_media_annotation_extra ON {FILES}(created_at)",
|
||||
),
|
||||
"extra column": (
|
||||
f"DROP TABLE {IMAGE_TAGS}",
|
||||
DDL[IMAGE_TAGS].replace(
|
||||
"position INTEGER", "note TEXT, position INTEGER"
|
||||
),
|
||||
),
|
||||
"wrong primary key": (
|
||||
f"DROP TABLE {FILES}",
|
||||
DDL[FILES].replace("id TEXT PRIMARY KEY", "id TEXT"),
|
||||
),
|
||||
"wrong foreign key": (
|
||||
f"DROP TABLE {BYTES}",
|
||||
DDL[BYTES].replace("ON DELETE CASCADE", "ON DELETE RESTRICT"),
|
||||
INDEXES["idx_media_annotation_bytes_fingerprint"],
|
||||
),
|
||||
"wrong check": (
|
||||
f"DROP TABLE {METADATA}",
|
||||
DDL[METADATA].replace("schema_version=1", "schema_version=2"),
|
||||
f"INSERT INTO {METADATA} VALUES(1,2,0)",
|
||||
),
|
||||
"missing unique": (
|
||||
f"DROP TABLE {FILES}",
|
||||
DDL[FILES].replace("NOT NULL UNIQUE", "NOT NULL"),
|
||||
),
|
||||
"missing index": ("DROP INDEX idx_media_image_tags_tag",),
|
||||
"non-unique position index": (
|
||||
"DROP INDEX idx_media_image_tags_position",
|
||||
INDEXES["idx_media_image_tags_position"].replace("UNIQUE ", ""),
|
||||
),
|
||||
"wrong index columns": (
|
||||
"DROP INDEX idx_media_annotation_bytes_fingerprint",
|
||||
INDEXES["idx_media_annotation_bytes_fingerprint"].replace(
|
||||
", mtime_ns", ""
|
||||
),
|
||||
),
|
||||
"orphan tag": (f"INSERT INTO {IMAGE_TAGS} VALUES('{BYTE_ID}',404,1)",),
|
||||
"orphan byte": (
|
||||
(
|
||||
f"INSERT INTO {BYTES} VALUES(?,?,?,1,1,?,?,0)",
|
||||
(
|
||||
OTHER_ID,
|
||||
"9" * 8 + "-0000-4000-8000-" + "0" * 12,
|
||||
DIGEST,
|
||||
NOW,
|
||||
NOW,
|
||||
),
|
||||
),
|
||||
),
|
||||
"bad file uuid": (
|
||||
(f"UPDATE {FILES} SET id=?", ("x" * 36,)),
|
||||
(f"UPDATE {BYTES} SET file_id=?", ("x" * 36,)),
|
||||
),
|
||||
"uppercase byte uuid": (
|
||||
f"UPDATE {BYTES} SET id=upper(id)",
|
||||
f"UPDATE {IMAGE_TAGS} SET byte_id=upper(byte_id)",
|
||||
),
|
||||
"null file id": ((f"INSERT INTO {FILES} VALUES(NULL,?,?)", ("/n", NOW)),),
|
||||
"blob ids": (
|
||||
f"UPDATE {FILES} SET id=CAST(id AS BLOB)",
|
||||
f"UPDATE {BYTES} SET file_id=CAST(file_id AS BLOB)",
|
||||
),
|
||||
"no metadata row": (f"DELETE FROM {METADATA}",),
|
||||
"long created_at": ((f"UPDATE {FILES} SET created_at=?", ("x" * 65,)),),
|
||||
"blob verified_at": (f"UPDATE {BYTES} SET verified_at=X'00'",),
|
||||
"text mtime": (f"UPDATE {BYTES} SET mtime_ns='soon'",),
|
||||
"duplicate position": (
|
||||
"DROP INDEX idx_media_image_tags_position",
|
||||
"INSERT INTO tags(id,name) VALUES(77,'dup')",
|
||||
f"INSERT INTO {IMAGE_TAGS} VALUES('{BYTE_ID}',77,0)",
|
||||
),
|
||||
"long locator": (
|
||||
(f"UPDATE {FILES} SET locator=?", ("x" * (MAX_LOCATOR + 1),)),
|
||||
),
|
||||
"multibyte long locator": (
|
||||
(f"UPDATE {FILES} SET locator=?", ("é" * (MAX_LOCATOR + 1),)),
|
||||
),
|
||||
"nul locator bypass": (
|
||||
(f"UPDATE {FILES} SET locator=?", ("x\0" + "y" * MAX_LOCATOR,)),
|
||||
),
|
||||
}
|
||||
for label, statements in cases.items():
|
||||
with self.subTest(label):
|
||||
self.assert_rejected_unrepaired(self.tampered(label, statements))
|
||||
unchecked = {
|
||||
"two metadata rows": (f"INSERT INTO {METADATA} VALUES(2,1,0)",),
|
||||
"wrong schema version": (f"UPDATE {METADATA} SET schema_version=2",),
|
||||
"negative index revision": (
|
||||
f"UPDATE {METADATA} SET image_index_revision=-1",
|
||||
),
|
||||
"bad digest": (f"UPDATE {BYTES} SET sha256=upper(sha256)",),
|
||||
"short digest": (f"UPDATE {BYTES} SET sha256=substr(sha256,1,63)",),
|
||||
"negative size": (f"UPDATE {BYTES} SET size=-1",),
|
||||
"negative tag revision": (f"UPDATE {BYTES} SET tag_revision=-1",),
|
||||
"negative position": (f"UPDATE {IMAGE_TAGS} SET position=-1",),
|
||||
}
|
||||
for label, statements in unchecked.items():
|
||||
with self.subTest(label):
|
||||
path = self.tampered(label, statements, checks_off=True)
|
||||
self.assert_rejected_unrepaired(path)
|
||||
accepted = {
|
||||
"locator at bound": ("x" * MAX_LOCATOR,),
|
||||
"multibyte locator at bound": ("é" * MAX_LOCATOR,),
|
||||
}
|
||||
for label, (locator,) in accepted.items():
|
||||
with self.subTest(label):
|
||||
path = self.tampered(
|
||||
label, ((f"UPDATE {FILES} SET locator=?", (locator,)),)
|
||||
)
|
||||
with closing(sqlite3.connect(path)) as raw:
|
||||
self.assertIsNone(annotation_schema_error(raw, allow_missing=False))
|
||||
self.assertEqual(
|
||||
PromptModel.verify_database_file(path, require_library_state=True),
|
||||
(True, "ok"),
|
||||
)
|
||||
|
||||
def test_install_failure_rolls_back_and_retries(self):
|
||||
self.require_module()
|
||||
with patch(
|
||||
"database.annotation_schema.annotation_schema_error",
|
||||
side_effect=[None, "injected final validation failure"],
|
||||
):
|
||||
with self.assertRaisesRegex(sqlite3.DatabaseError, "injected"):
|
||||
self.model()
|
||||
with closing(sqlite3.connect(self.path)) as raw:
|
||||
self.assertEqual(catalog(raw), set())
|
||||
self.assertIsNotNone(
|
||||
raw.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE name='saved_views'"
|
||||
).fetchone()
|
||||
)
|
||||
PromptModel.reset_schema_cache()
|
||||
model = self.model()
|
||||
self.assertEqual(
|
||||
PromptModel.verify_database_file(model.db_path, require_library_state=True),
|
||||
(True, "ok"),
|
||||
)
|
||||
with model.get_connection() as conn:
|
||||
self.assertEqual(metadata(conn), [(1, 0)])
|
||||
|
||||
def test_wal_backup_restore_round_trips_group(self):
|
||||
self.require_module()
|
||||
source_path = str(Path(self.temp.name) / "source.db")
|
||||
source = self.model(source_path)
|
||||
with source.get_connection() as conn:
|
||||
conn.execute("PRAGMA wal_autocheckpoint=0")
|
||||
conn.execute("INSERT INTO prompts(id,text,hash) VALUES(7,'p','p')")
|
||||
insert_group_rows(conn)
|
||||
conn.commit()
|
||||
source_files = rows(conn, f"SELECT * FROM {FILES}")
|
||||
source_bytes = rows(conn, f"SELECT * FROM {BYTES}")
|
||||
self.assertGreater(Path(source_path + "-wal").stat().st_size, 0)
|
||||
live = self.model()
|
||||
with live.get_connection() as conn:
|
||||
conn.execute("INSERT INTO prompts(id,text,hash) VALUES(1,'live','live')")
|
||||
insert_group_rows(conn, tag_name="live-only", locator="/out/live.png")
|
||||
source_before = Path(source_path).read_bytes()
|
||||
self.assertEqual(
|
||||
PromptModel.verify_database_file(source_path, require_library_state=True),
|
||||
(True, "ok"),
|
||||
)
|
||||
# The rows still sit in the WAL; a backup file must carry them too.
|
||||
backup_file = str(Path(self.temp.name) / "backup.db")
|
||||
self.assertTrue(source.backup_database(backup_file))
|
||||
with closing(sqlite3.connect(backup_file)) as raw:
|
||||
self.assertEqual(image_tags(raw), [("img-only", 0)])
|
||||
self.assertIsNone(annotation_schema_error(raw, allow_missing=False))
|
||||
|
||||
safety = live.restore_from_file(source_path)
|
||||
|
||||
self.assertTrue(Path(safety).is_file())
|
||||
self.assertEqual(Path(source_path).read_bytes(), source_before)
|
||||
with live.get_connection() as conn:
|
||||
restored_image_tags = image_tags(conn)
|
||||
self.assertEqual(rows(conn, f"SELECT * FROM {FILES}"), source_files)
|
||||
self.assertEqual(rows(conn, f"SELECT * FROM {BYTES}"), source_bytes)
|
||||
self.assertEqual(metadata(conn), [(1, 0)])
|
||||
self.assertIsNone(annotation_schema_error(conn, allow_missing=False))
|
||||
self.assertEqual(rows(conn, "SELECT id FROM prompts"), [(7,)])
|
||||
self.assertEqual(restored_image_tags, [("img-only", 0)])
|
||||
with closing(sqlite3.connect(safety)) as raw:
|
||||
self.assertEqual(image_tags(raw), [("live-only", 0)])
|
||||
self.assertEqual(raw.execute("SELECT id FROM prompts").fetchall(), [(1,)])
|
||||
# The same rows restore from the backup file as well.
|
||||
PromptModel.reset_schema_cache()
|
||||
live.restore_from_file(backup_file)
|
||||
with live.get_connection() as conn:
|
||||
self.assertEqual(image_tags(conn), [("img-only", 0)])
|
||||
PromptModel.close_all_instances()
|
||||
assert_closed(self, self.temp.name)
|
||||
|
||||
def test_staged_failure_preserves_source_live_safety_and_handles(self):
|
||||
self.require_module()
|
||||
live = self.model()
|
||||
with live.get_connection() as conn:
|
||||
conn.execute("INSERT INTO prompts(id,text,hash) VALUES(1,'live','live')")
|
||||
insert_group_rows(conn, tag_name="live-only")
|
||||
# A source with a malformed group is refused before any change.
|
||||
bad = self.tampered(
|
||||
"restore bad",
|
||||
(f"UPDATE {BYTES} SET sha256=upper(sha256)",),
|
||||
checks_off=True,
|
||||
)
|
||||
# A staging failure while installing the group must be refused too.
|
||||
old = str(Path(self.temp.name) / "old.db")
|
||||
self.model(old).close_all()
|
||||
self.drop_group(old)
|
||||
for source, patched in ((bad, False), (old, True)):
|
||||
with self.subTest(source=Path(source).name):
|
||||
before = Path(source).read_bytes()
|
||||
safety = sorted(Path(self.temp.name).glob("live.db.backup_*"))
|
||||
if patched:
|
||||
context = patch(
|
||||
"database.models.migrate_annotation_state",
|
||||
side_effect=sqlite3.OperationalError("staging failed"),
|
||||
)
|
||||
expected = sqlite3.OperationalError
|
||||
else:
|
||||
context = patch.object(PromptModel, "backup_database")
|
||||
expected = ValueError
|
||||
with context as hook, self.assertRaises(expected):
|
||||
live.restore_from_file(source)
|
||||
if not patched:
|
||||
hook.assert_not_called()
|
||||
self.assertEqual(Path(source).read_bytes(), before)
|
||||
self.assertEqual(
|
||||
sorted(Path(self.temp.name).glob("live.db.backup_*")), safety
|
||||
)
|
||||
assert_closed(self, source)
|
||||
with live.get_connection() as conn:
|
||||
self.assertEqual(image_tags(conn), [("live-only", 0)])
|
||||
self.assertEqual(metadata(conn), [(1, 0)])
|
||||
self.assertFalse(
|
||||
PromptModel.verify_database_file(old, require_library_state=True)[0]
|
||||
)
|
||||
self.assertTrue(PromptModel.verify_database_file(old)[0])
|
||||
|
||||
def test_verify_requires_group_only_with_require_library_state(self):
|
||||
self.require_module()
|
||||
self.model().close_all()
|
||||
self.assertEqual(
|
||||
PromptModel.verify_database_file(self.path, require_library_state=True),
|
||||
(True, "ok"),
|
||||
)
|
||||
self.drop_group(self.path)
|
||||
self.assertEqual(PromptModel.verify_database_file(self.path), (True, "ok"))
|
||||
self.assertEqual(
|
||||
PromptModel.verify_database_file(self.path, require_library_state=True),
|
||||
(False, "Missing image annotation schema"),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,598 @@
|
||||
"""Per-image tag sets (#249 A2): byte-exact storage in the shared vocabulary."""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
try:
|
||||
from tests.open_handles import assert_closed
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from open_handles import assert_closed
|
||||
|
||||
from database.annotation_schema import annotation_schema_error
|
||||
from database.library_commands import LibraryCommandError
|
||||
from database.operations import PromptDatabase
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
try: # guarded so a missing module fails assertions, not test loading
|
||||
from database import annotations as annotations_module
|
||||
from database.annotations import (
|
||||
MAX_IMAGE_TAGS,
|
||||
AnnotationCommandError,
|
||||
normalize_image_tags,
|
||||
)
|
||||
except ImportError:
|
||||
annotations_module = None
|
||||
MAX_IMAGE_TAGS = None
|
||||
AnnotationCommandError = None
|
||||
normalize_image_tags = None
|
||||
|
||||
UUID = re.compile(r"[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}")
|
||||
DIGEST_A = hashlib.sha256(b"bytes a").hexdigest()
|
||||
DIGEST_B = hashlib.sha256(b"bytes b").hexdigest()
|
||||
SIZE = 7
|
||||
MTIME = 1_700_000_000_123_456_789
|
||||
ANNOTATION_TABLES = (
|
||||
"media_annotation_files",
|
||||
"media_annotation_bytes",
|
||||
"media_image_tags",
|
||||
"media_annotation_metadata",
|
||||
)
|
||||
LEGACY_TABLES = (
|
||||
"tags",
|
||||
"prompt_tags",
|
||||
"prompt_pair_tags",
|
||||
"generated_images",
|
||||
"image_prompt_pairs",
|
||||
)
|
||||
|
||||
|
||||
class AnnotationTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.assertIsNotNone(
|
||||
annotations_module, "database.annotations is not implemented"
|
||||
)
|
||||
directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(directory.cleanup)
|
||||
self.addCleanup(assert_closed, self, directory.name)
|
||||
self.root = directory.name
|
||||
self.db = PromptDatabase(os.path.join(self.root, "annotations.db"))
|
||||
self.addCleanup(self.db.close_all)
|
||||
self.epoch = self.db.get_execution_epoch()
|
||||
|
||||
def locator(self, name: str) -> str:
|
||||
return os.path.normcase(os.path.join(os.path.realpath(self.root), name))
|
||||
|
||||
def replace(self, loc, digest=DIGEST_A, tags=(), *, revision=0, **over):
|
||||
options = {
|
||||
"locator": loc,
|
||||
"sha256": digest,
|
||||
"size": SIZE,
|
||||
"mtime_ns": MTIME,
|
||||
"expected_epoch": self.epoch,
|
||||
"expected_tag_revision": revision,
|
||||
"tags": list(tags) if isinstance(tags, tuple) else tags,
|
||||
}
|
||||
options.update(over)
|
||||
return self.db.replace_image_tags(**options)
|
||||
|
||||
def add(self, loc, digest=DIGEST_A, tags=(), **over):
|
||||
options = {
|
||||
"locator": loc,
|
||||
"sha256": digest,
|
||||
"size": SIZE,
|
||||
"mtime_ns": MTIME,
|
||||
"expected_epoch": self.epoch,
|
||||
"tags": list(tags) if isinstance(tags, tuple) else tags,
|
||||
}
|
||||
options.update(over)
|
||||
return self.db.add_image_tags(**options)
|
||||
|
||||
def read(self, loc, digest=DIGEST_A):
|
||||
return self.db.read_image_tags(loc, digest)
|
||||
|
||||
def rows(self, *tables):
|
||||
"""Every row of each table; ``verified_at`` is a fingerprint, not content."""
|
||||
conn = self.db.model.get_connection()
|
||||
result = []
|
||||
for table in tables:
|
||||
columns = [
|
||||
row[1]
|
||||
for row in conn.execute(f"PRAGMA table_info({table})")
|
||||
if row[1] != "verified_at"
|
||||
]
|
||||
result.append(
|
||||
[
|
||||
tuple(row)
|
||||
for row in conn.execute(
|
||||
f"SELECT {','.join(columns)} FROM {table} ORDER BY 1,2"
|
||||
)
|
||||
]
|
||||
)
|
||||
return result
|
||||
|
||||
def counts(self):
|
||||
conn = self.db.model.get_connection()
|
||||
return {
|
||||
table: conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
|
||||
for table in ANNOTATION_TABLES
|
||||
}
|
||||
|
||||
def index_revision(self):
|
||||
return (
|
||||
self.db.model.get_connection()
|
||||
.execute("SELECT image_index_revision FROM media_annotation_metadata")
|
||||
.fetchone()[0]
|
||||
)
|
||||
|
||||
def schema_clean(self):
|
||||
self.assertIsNone(
|
||||
annotation_schema_error(self.db.model.get_connection(), allow_missing=False)
|
||||
)
|
||||
|
||||
|
||||
class NormalizeImageTagsTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.assertIsNotNone(
|
||||
normalize_image_tags, "database.annotations is not implemented"
|
||||
)
|
||||
|
||||
def test_case_insensitive_dedupe_keeps_first_spelling_and_position(self):
|
||||
self.assertEqual(
|
||||
normalize_image_tags([" Forest ", "mist", "forest", "MIST", "Rain"]),
|
||||
["Forest", "mist", "Rain"],
|
||||
)
|
||||
self.assertEqual(normalize_image_tags([]), [])
|
||||
self.assertEqual(MAX_IMAGE_TAGS, 100)
|
||||
|
||||
def test_limit_and_invalid_names_rejected(self):
|
||||
for bad in (
|
||||
None,
|
||||
"forest",
|
||||
{"a": 1},
|
||||
[1],
|
||||
[None],
|
||||
["a,b"],
|
||||
[""],
|
||||
[" "],
|
||||
["x" * 101],
|
||||
[f"t{i}" for i in range(MAX_IMAGE_TAGS + 1)],
|
||||
):
|
||||
with self.subTest(bad=bad), self.assertRaises(ValueError):
|
||||
normalize_image_tags(bad)
|
||||
# Duplicates fold before the cap is applied.
|
||||
many = [f"t{i}" for i in range(MAX_IMAGE_TAGS)]
|
||||
self.assertEqual(normalize_image_tags(many + ["T0", "T99"]), many)
|
||||
|
||||
|
||||
class ReplaceImageTagsTests(AnnotationTestCase):
|
||||
def test_first_save_creates_owner_and_byte_atomically_including_empty_set(self):
|
||||
loc = self.locator("a.png")
|
||||
receipt = self.replace(loc, tags=["Forest", "mist", "forest"])
|
||||
self.assertEqual(receipt["tags"], ["Forest", "mist"])
|
||||
self.assertEqual(receipt["tag_revision"], 1)
|
||||
self.assertEqual(
|
||||
receipt,
|
||||
{
|
||||
"library_epoch": self.epoch,
|
||||
"sha256": DIGEST_A,
|
||||
"tags": ["Forest", "mist"],
|
||||
"tag_revision": 1,
|
||||
"replayed": False,
|
||||
},
|
||||
)
|
||||
conn = self.db.model.get_connection()
|
||||
files = conn.execute("SELECT * FROM media_annotation_files").fetchall()
|
||||
self.assertEqual(len(files), 1)
|
||||
self.assertRegex(files[0]["id"], UUID)
|
||||
self.assertEqual(files[0]["locator"], loc)
|
||||
byte = conn.execute("SELECT * FROM media_annotation_bytes").fetchone()
|
||||
self.assertRegex(byte["id"], UUID)
|
||||
self.assertEqual(
|
||||
(byte["file_id"], byte["sha256"], byte["size"], byte["mtime_ns"]),
|
||||
(files[0]["id"], DIGEST_A, SIZE, MTIME),
|
||||
)
|
||||
self.assertEqual(byte["tag_revision"], 1)
|
||||
self.assertEqual(byte["verified_at"], byte["first_seen_at"])
|
||||
self.assertEqual(
|
||||
[
|
||||
tuple(row)
|
||||
for row in conn.execute(
|
||||
"SELECT t.name, mit.position FROM media_image_tags mit "
|
||||
"JOIN tags t ON t.id = mit.tag_id ORDER BY mit.position"
|
||||
)
|
||||
],
|
||||
[("Forest", 0), ("mist", 1)],
|
||||
)
|
||||
self.assertEqual(self.index_revision(), 1)
|
||||
# An explicit empty set is a real save: owner and byte record exist.
|
||||
empty = self.replace(self.locator("b.png"), DIGEST_B, [])
|
||||
self.assertEqual((empty["tags"], empty["tag_revision"]), ([], 1))
|
||||
self.assertEqual(self.counts()["media_annotation_files"], 2)
|
||||
self.assertEqual(self.counts()["media_annotation_bytes"], 2)
|
||||
self.assertEqual(
|
||||
self.db.read_image_tags(self.locator("b.png"), DIGEST_B),
|
||||
{"tags": [], "tag_revision": 1, "prior_count": 0},
|
||||
)
|
||||
self.assertEqual(self.index_revision(), 2)
|
||||
self.schema_clean()
|
||||
|
||||
def test_save_changes_only_target_byte_set(self):
|
||||
a, b, c = self.locator("a.png"), self.locator("b.png"), self.locator("c.png")
|
||||
prompt = self.db.save_prompt(
|
||||
"Shared forest",
|
||||
tags=["shared"],
|
||||
prompt_hash=generate_prompt_hash("Shared forest"),
|
||||
)
|
||||
pair = self.db.save_prompt_pair("Shared forest", "blurry", tags=["pairtag"])
|
||||
image_a = self.db.link_image_to_prompt(prompt, a)
|
||||
image_b = self.db.link_image_to_prompt(prompt, b)
|
||||
self.db.link_image_to_pair(pair, image_a, {"seed": 1})
|
||||
self.db.link_image_to_pair(pair, image_b, {"seed": 2})
|
||||
self.replace(b, DIGEST_A, ["sibling"])
|
||||
self.replace(c, DIGEST_A, ["copy"]) # equal bytes at another path
|
||||
before = self.rows(*LEGACY_TABLES)
|
||||
sibling_before = self.db.read_image_tags(b, DIGEST_A)
|
||||
copy_before = self.db.read_image_tags(c, DIGEST_A)
|
||||
|
||||
receipt = self.replace(a, DIGEST_A, ["only a"])
|
||||
|
||||
self.assertEqual((receipt["tags"], receipt["tag_revision"]), (["only a"], 1))
|
||||
after = self.rows(*LEGACY_TABLES)
|
||||
# The registry gained the new name; nothing else moved.
|
||||
self.assertEqual(after[1:], before[1:])
|
||||
self.assertEqual([row for row in after[0] if row[1] != "only a"], before[0])
|
||||
self.assertEqual(self.db.read_image_tags(b, DIGEST_A), sibling_before)
|
||||
self.assertEqual(self.db.read_image_tags(c, DIGEST_A), copy_before)
|
||||
self.assertEqual(self.db.get_prompt_by_id(prompt)["tags"], ["shared"])
|
||||
self.assertEqual(self.db.get_prompt_pair(pair)["tags"], ["pairtag"])
|
||||
|
||||
def test_changed_bytes_at_same_locator_start_empty_and_keep_prior(self):
|
||||
loc = self.locator("a.png")
|
||||
self.replace(loc, DIGEST_A, ["old"])
|
||||
self.assertEqual(
|
||||
self.db.read_image_tags(loc, DIGEST_B),
|
||||
{"tags": [], "tag_revision": 0, "prior_count": 1},
|
||||
)
|
||||
receipt = self.replace(loc, DIGEST_B, ["new"], mtime_ns=MTIME + 5)
|
||||
self.assertEqual((receipt["tags"], receipt["tag_revision"]), (["new"], 1))
|
||||
self.assertEqual(
|
||||
self.db.read_image_tags(loc, DIGEST_A),
|
||||
{"tags": ["old"], "tag_revision": 1, "prior_count": 1},
|
||||
)
|
||||
self.assertEqual(
|
||||
self.db.read_image_tags(loc, DIGEST_B),
|
||||
{"tags": ["new"], "tag_revision": 1, "prior_count": 1},
|
||||
)
|
||||
self.assertEqual(self.counts()["media_annotation_files"], 1)
|
||||
self.assertEqual(self.counts()["media_annotation_bytes"], 2)
|
||||
self.assertEqual(
|
||||
self.db.read_image_tags(self.locator("unseen.png"), DIGEST_A),
|
||||
{"tags": [], "tag_revision": 0, "prior_count": 0},
|
||||
)
|
||||
|
||||
def test_revision_conflict_returns_current_set(self):
|
||||
loc = self.locator("a.png")
|
||||
self.replace(loc, tags=["a"])
|
||||
before = self.rows(*ANNOTATION_TABLES, "tags")
|
||||
with self.assertRaises(AnnotationCommandError) as ctx:
|
||||
self.replace(loc, tags=["b"], revision=0)
|
||||
self.assertEqual(
|
||||
(ctx.exception.code, ctx.exception.current["tag_revision"]),
|
||||
("tag-conflict", 1),
|
||||
)
|
||||
self.assertIsInstance(ctx.exception, LibraryCommandError)
|
||||
self.assertEqual(ctx.exception.epoch, self.epoch)
|
||||
self.assertEqual(ctx.exception.current["tags"], ["a"])
|
||||
self.assertEqual(ctx.exception.current["sha256"], DIGEST_A)
|
||||
with self.assertRaises(AnnotationCommandError) as ahead:
|
||||
self.replace(loc, tags=["a"], revision=5)
|
||||
self.assertEqual(ahead.exception.code, "tag-conflict")
|
||||
self.assertEqual(self.rows(*ANNOTATION_TABLES, "tags"), before)
|
||||
self.assertFalse(self.db.model.get_connection().in_transaction)
|
||||
|
||||
def test_identical_replay_after_commit_is_success_replayed(self):
|
||||
loc = self.locator("a.png")
|
||||
first = self.replace(loc, tags=["a", "b"])
|
||||
self.assertEqual((first["tag_revision"], first["replayed"]), (1, False))
|
||||
before = self.rows(*ANNOTATION_TABLES)
|
||||
# The lost-response retry: same set, the revision it was sent against.
|
||||
retry = self.replace(loc, tags=["a", "b"], revision=0)
|
||||
self.assertEqual(retry, {**first, "replayed": True})
|
||||
# Saving the current set at the current revision writes nothing either.
|
||||
again = self.replace(loc, tags=["a", "b"], revision=1)
|
||||
self.assertEqual(again, {**first, "replayed": True})
|
||||
self.assertEqual(self.rows(*ANNOTATION_TABLES), before)
|
||||
# Order is part of the set.
|
||||
reordered = self.replace(loc, tags=["b", "a"], revision=1)
|
||||
self.assertEqual(
|
||||
(reordered["tags"], reordered["tag_revision"], reordered["replayed"]),
|
||||
(["b", "a"], 2, False),
|
||||
)
|
||||
self.assertEqual(self.index_revision(), 2)
|
||||
|
||||
def test_replay_with_new_fingerprint_moves_only_the_index_revision(self):
|
||||
loc = self.locator("a.png")
|
||||
first = self.replace(loc, tags=["a"])
|
||||
index = self.index_revision()
|
||||
same = self.replace(loc, tags=["a"], revision=1)
|
||||
self.assertEqual(same, {**first, "replayed": True})
|
||||
self.assertEqual(self.index_revision(), index) # identical: nothing moved
|
||||
touched = self.replace(loc, tags=["a"], revision=1, mtime_ns=MTIME + 5)
|
||||
self.assertEqual(touched, {**first, "replayed": True})
|
||||
self.assertEqual(self.index_revision(), index + 1) # currency changed
|
||||
self.assertEqual(
|
||||
self.db.current_image_tags({loc: (SIZE, MTIME + 5)}), {loc: ["a"]}
|
||||
)
|
||||
self.assertEqual(self.db.current_image_tags({loc: (SIZE, MTIME)}), {})
|
||||
self.assertEqual(self.read(loc)["tag_revision"], 1)
|
||||
|
||||
def test_epoch_checked_inside_transaction(self):
|
||||
loc = self.locator("a.png")
|
||||
seen = []
|
||||
original = PromptDatabase._library_epoch
|
||||
|
||||
def spy(db, conn, expected_epoch):
|
||||
seen.append(conn.in_transaction)
|
||||
return original(db, conn, expected_epoch)
|
||||
|
||||
with patch.object(PromptDatabase, "_library_epoch", spy):
|
||||
self.replace(loc, tags=["a"])
|
||||
self.add(loc, tags=["b"])
|
||||
self.assertEqual(seen, [True, True])
|
||||
stale = "11111111-2222-4333-8444-555555555555"
|
||||
for call in (
|
||||
lambda: self.replace(
|
||||
self.locator("x.png"), tags=["a"], expected_epoch=stale
|
||||
),
|
||||
lambda: self.add(self.locator("x.png"), tags=["a"], expected_epoch=stale),
|
||||
):
|
||||
with self.assertRaises(LibraryCommandError) as ctx:
|
||||
call()
|
||||
self.assertEqual(ctx.exception.code, "storage-changed")
|
||||
self.assertEqual(ctx.exception.epoch, self.epoch)
|
||||
self.assertEqual(self.counts()["media_annotation_files"], 1)
|
||||
for bad_epoch in (None, 7, "not-a-uuid"):
|
||||
with self.assertRaises(ValueError):
|
||||
self.replace(loc, tags=["a"], expected_epoch=bad_epoch)
|
||||
|
||||
def test_limit_and_invalid_names_rejected_without_partial_write(self):
|
||||
loc = self.locator("a.png")
|
||||
before = self.rows(*ANNOTATION_TABLES, "tags")
|
||||
for tags in (
|
||||
["ok", "a,b"],
|
||||
["ok", ""],
|
||||
["ok", 3],
|
||||
"ok",
|
||||
[f"t{i}" for i in range(101)],
|
||||
):
|
||||
with self.subTest(tags=tags), self.assertRaises(ValueError):
|
||||
self.replace(loc, tags=tags)
|
||||
with self.subTest(tags=tags, command="add"), self.assertRaises(ValueError):
|
||||
self.add(loc, tags=tags)
|
||||
for field, value in (
|
||||
("locator", ""),
|
||||
("locator", None),
|
||||
("locator", "x" * 4097),
|
||||
("sha256", DIGEST_A.upper()),
|
||||
("sha256", DIGEST_A[:-1]),
|
||||
("sha256", None),
|
||||
("size", -1),
|
||||
("size", True),
|
||||
("size", "7"),
|
||||
("mtime_ns", 1.5),
|
||||
("mtime_ns", None),
|
||||
("expected_tag_revision", -1),
|
||||
("expected_tag_revision", False),
|
||||
):
|
||||
with self.subTest(field=field, value=value), self.assertRaises(ValueError):
|
||||
self.replace(loc, tags=["ok"], **{field: value})
|
||||
self.assertEqual(self.rows(*ANNOTATION_TABLES, "tags"), before)
|
||||
self.assertEqual(self.index_revision(), 0)
|
||||
|
||||
def test_writers_supply_uuid_ids_and_refuse_to_write_without_one(self):
|
||||
loc = self.locator("a.png")
|
||||
before = self.rows(*ANNOTATION_TABLES)
|
||||
with patch.object(annotations_module, "uuid4", return_value=""):
|
||||
with self.assertRaises(ValueError):
|
||||
self.replace(loc, tags=["a"])
|
||||
self.assertEqual(self.rows(*ANNOTATION_TABLES), before)
|
||||
self.replace(loc, tags=["a"])
|
||||
self.add(self.locator("b.png"), DIGEST_B, ["b"])
|
||||
conn = self.db.model.get_connection()
|
||||
for table in ("media_annotation_files", "media_annotation_bytes"):
|
||||
for (value,) in conn.execute(f"SELECT id FROM {table}"):
|
||||
self.assertRegex(value, UUID)
|
||||
self.schema_clean()
|
||||
|
||||
def test_never_resolves_owner_through_prompt_lookup(self):
|
||||
loc = self.locator("a.png")
|
||||
prompt = self.db.save_prompt("Linked", tags=["prompt-tag"])
|
||||
self.db.link_image_to_prompt(prompt, loc)
|
||||
with patch.object(
|
||||
PromptDatabase,
|
||||
"get_prompt_id_for_image",
|
||||
side_effect=AssertionError("prompt lookup is not an owner"),
|
||||
):
|
||||
self.replace(loc, tags=["img"])
|
||||
self.add(loc, tags=["more"])
|
||||
self.assertEqual(
|
||||
self.db.read_image_tags(loc, DIGEST_A)["tags"], ["img", "more"]
|
||||
)
|
||||
self.assertEqual(
|
||||
self.db.current_image_tags({loc: (SIZE, MTIME)}),
|
||||
{loc: ["img", "more"]},
|
||||
)
|
||||
self.assertTrue(
|
||||
self.db.refresh_image_fingerprint(loc, DIGEST_A, SIZE, MTIME + 1)
|
||||
)
|
||||
list(self.db.iter_image_tag_records())
|
||||
self.assertEqual(self.db.get_prompt_by_id(prompt)["tags"], ["prompt-tag"])
|
||||
|
||||
|
||||
class AddImageTagsTests(AnnotationTestCase):
|
||||
def test_add_merges_without_removing_and_caps_at_100(self):
|
||||
loc = self.locator("a.png")
|
||||
self.replace(loc, tags=["Forest", "mist"])
|
||||
receipt = self.add(loc, tags=["forest", "Rain", "rain"])
|
||||
self.assertEqual(
|
||||
receipt,
|
||||
{
|
||||
"library_epoch": self.epoch,
|
||||
"sha256": DIGEST_A,
|
||||
"tags": ["Forest", "mist", "Rain"],
|
||||
"tag_revision": 2,
|
||||
"replayed": False,
|
||||
"added": 1,
|
||||
},
|
||||
)
|
||||
nothing = self.add(loc, tags=["MIST"])
|
||||
self.assertEqual(
|
||||
(nothing["tags"], nothing["tag_revision"], nothing["replayed"]),
|
||||
(["Forest", "mist", "Rain"], 2, True),
|
||||
)
|
||||
self.assertEqual(nothing["added"], 0)
|
||||
before = self.rows(*ANNOTATION_TABLES, "tags")
|
||||
with self.assertRaises(AnnotationCommandError) as ctx:
|
||||
self.add(loc, tags=[f"t{i}" for i in range(98)])
|
||||
self.assertEqual(ctx.exception.code, "tag-limit")
|
||||
self.assertEqual(ctx.exception.current["tag_revision"], 2)
|
||||
self.assertEqual(self.rows(*ANNOTATION_TABLES, "tags"), before)
|
||||
exact = self.add(loc, tags=[f"t{i}" for i in range(97)])
|
||||
self.assertEqual((len(exact["tags"]), exact["added"]), (100, 97))
|
||||
# An unseen file: add creates the owner and byte record like a save.
|
||||
fresh = self.add(self.locator("b.png"), DIGEST_B, ["one"])
|
||||
self.assertEqual(
|
||||
(fresh["tags"], fresh["tag_revision"], fresh["added"]), (["one"], 1, 1)
|
||||
)
|
||||
self.schema_clean()
|
||||
|
||||
|
||||
class CurrentImageTagsTests(AnnotationTestCase):
|
||||
def test_current_tags_require_matching_fingerprint(self):
|
||||
loc = self.locator("a.png")
|
||||
other = self.locator("b.png")
|
||||
self.replace(loc, tags=["a"])
|
||||
self.replace(other, DIGEST_B, [], mtime_ns=MTIME + 9)
|
||||
self.assertEqual(
|
||||
self.db.current_image_tags({loc: (SIZE, MTIME), other: (SIZE, MTIME + 9)}),
|
||||
{loc: ["a"], other: []},
|
||||
)
|
||||
self.assertEqual(self.db.current_image_tags({loc: (SIZE, MTIME + 1)}), {})
|
||||
self.assertEqual(self.db.current_image_tags({loc: (SIZE + 1, MTIME)}), {})
|
||||
self.assertEqual(self.db.current_image_tags({}), {})
|
||||
self.assertEqual(
|
||||
self.db.current_image_tags({self.locator("none.png"): (SIZE, MTIME)}), {}
|
||||
)
|
||||
# Refreshing the verified fingerprint makes the record current again.
|
||||
self.assertTrue(
|
||||
self.db.refresh_image_fingerprint(loc, DIGEST_A, SIZE, MTIME + 1)
|
||||
)
|
||||
self.assertFalse(self.db.refresh_image_fingerprint(loc, DIGEST_B, SIZE, MTIME))
|
||||
self.assertFalse(
|
||||
self.db.refresh_image_fingerprint(
|
||||
self.locator("x.png"), DIGEST_A, SIZE, MTIME
|
||||
)
|
||||
)
|
||||
self.assertEqual(
|
||||
self.db.current_image_tags({loc: (SIZE, MTIME + 1)}), {loc: ["a"]}
|
||||
)
|
||||
self.assertEqual(self.db.current_image_tags({loc: (SIZE, MTIME)}), {})
|
||||
self.assertEqual(
|
||||
self.db.read_image_tags(loc, DIGEST_A),
|
||||
{"tags": ["a"], "tag_revision": 1, "prior_count": 0},
|
||||
)
|
||||
for bad in (None, [], {loc: (SIZE,)}, {loc: (SIZE, 1.5)}, {3: (SIZE, MTIME)}):
|
||||
with self.subTest(bad=bad), self.assertRaises(ValueError):
|
||||
self.db.current_image_tags(bad)
|
||||
|
||||
def test_newest_verification_wins_on_equal_fingerprints(self):
|
||||
loc = self.locator("a.png")
|
||||
stamps = iter(f"2026-10-22T00:00:0{i}.000000+00:00" for i in range(10))
|
||||
with patch.object(annotations_module, "utc_now_iso", side_effect=stamps):
|
||||
self.replace(loc, DIGEST_A, ["old bytes"])
|
||||
self.replace(loc, DIGEST_B, ["new bytes"]) # same size and mtime
|
||||
self.assertEqual(
|
||||
self.db.current_image_tags({loc: (SIZE, MTIME)}), {loc: ["new bytes"]}
|
||||
)
|
||||
with patch.object(
|
||||
annotations_module,
|
||||
"utc_now_iso",
|
||||
return_value="2026-10-22T00:01:00.000000+00:00",
|
||||
):
|
||||
self.assertTrue(
|
||||
self.db.refresh_image_fingerprint(loc, DIGEST_A, SIZE, MTIME)
|
||||
)
|
||||
self.assertEqual(
|
||||
self.db.current_image_tags({loc: (SIZE, MTIME)}), {loc: ["old bytes"]}
|
||||
)
|
||||
|
||||
def test_lookup_is_chunked_and_records_stream_in_position_order(self):
|
||||
locators = [self.locator(f"{i:04}.png") for i in range(503)]
|
||||
self.replace(locators[0], tags=["z", "y"])
|
||||
self.replace(locators[502], DIGEST_B, ["x"], mtime_ns=MTIME + 2)
|
||||
self.replace(locators[250], DIGEST_B, [], mtime_ns=MTIME + 1)
|
||||
statements = []
|
||||
conn = self.db.model.get_connection()
|
||||
conn.set_trace_callback(statements.append)
|
||||
try:
|
||||
current = self.db.current_image_tags(
|
||||
{
|
||||
loc: (SIZE, MTIME + (2 if i == 502 else 0))
|
||||
for i, loc in enumerate(locators)
|
||||
}
|
||||
)
|
||||
finally:
|
||||
conn.set_trace_callback(None)
|
||||
self.assertEqual(current, {locators[0]: ["z", "y"], locators[502]: ["x"]})
|
||||
lookups = [s for s in statements if "media_annotation_files" in s]
|
||||
self.assertEqual(len(lookups), 2)
|
||||
self.assertEqual(
|
||||
list(self.db.iter_image_tag_records()),
|
||||
[
|
||||
(locators[0], SIZE, MTIME, ["z", "y"]),
|
||||
(locators[250], SIZE, MTIME + 1, []),
|
||||
(locators[502], SIZE, MTIME + 2, ["x"]),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class AnnotationIsolationTests(AnnotationTestCase):
|
||||
def test_cascade_from_vocabulary_delete_keeps_byte_records(self):
|
||||
loc = self.locator("a.png")
|
||||
self.replace(loc, tags=["gone", "kept"])
|
||||
self.db.delete_tag_all_prompts("gone")
|
||||
self.assertEqual(
|
||||
self.db.read_image_tags(loc, DIGEST_A),
|
||||
{
|
||||
"tags": ["kept"],
|
||||
"tag_revision": 2,
|
||||
"prior_count": 0,
|
||||
}, # moved by the delete
|
||||
)
|
||||
self.schema_clean()
|
||||
|
||||
def test_junction_failure_leaves_nothing_behind(self):
|
||||
loc = self.locator("a.png")
|
||||
self.replace(loc, tags=["a"])
|
||||
conn = self.db.model.get_connection()
|
||||
conn.execute(
|
||||
"CREATE TRIGGER reject_image_tag BEFORE INSERT ON media_image_tags "
|
||||
"BEGIN SELECT RAISE(ABORT,'image tag failure'); END"
|
||||
)
|
||||
conn.commit()
|
||||
before = self.rows(*ANNOTATION_TABLES, "tags")
|
||||
with self.assertRaises(sqlite3.IntegrityError):
|
||||
self.replace(loc, tags=["b"], revision=1)
|
||||
with self.assertRaises(sqlite3.IntegrityError):
|
||||
self.add(self.locator("new.png"), DIGEST_B, ["c"])
|
||||
self.assertFalse(conn.in_transaction)
|
||||
self.assertEqual(self.rows(*ANNOTATION_TABLES, "tags"), before)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+52
-1
@@ -2476,7 +2476,8 @@ def _write_mp4(path, comment):
|
||||
|
||||
|
||||
class TestScanReadsVideos(AdminAPITestCase):
|
||||
"""Videos saved by ComfyUI carry the graph in their comment tag."""
|
||||
"""Videos carry the graph in per-key container tags (current savers) or in
|
||||
the old VideoHelperSuite comment envelope; the scan reads one carrier."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
@@ -2506,6 +2507,56 @@ class TestScanReadsVideos(AdminAPITestCase):
|
||||
images = self.api.db.get_prompt_images(prompts[0]["id"])
|
||||
self.assertTrue(images[0]["image_path"].endswith("clip.mp4"))
|
||||
|
||||
async def test_scan_links_per_key_tag_video_with_default_profile(self):
|
||||
"""A current saver's per-key tags scan exactly like the same graph as PNG."""
|
||||
from utils import video_metadata
|
||||
|
||||
_write_png(self.output_dir / "same.png", prompt_text="a cat")
|
||||
graph = {
|
||||
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": "a cat"}},
|
||||
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": "bad"}},
|
||||
"3": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {"positive": ["1", 0], "negative": ["2", 0]},
|
||||
},
|
||||
}
|
||||
(self.output_dir / "clip.mkv").write_bytes(b"\x1aE\xdf\xa3 fixture")
|
||||
probed = {
|
||||
"format": {
|
||||
"tags": {
|
||||
"PROMPT": json.dumps(graph),
|
||||
"WORKFLOW": '{"nodes":[]}',
|
||||
"COMMENT": json.dumps({"prompt": "must not win"}),
|
||||
}
|
||||
}
|
||||
}
|
||||
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.output_dir)]
|
||||
|
||||
with (
|
||||
patch.object(video_metadata.shutil, "which", return_value="/bin/ffprobe"),
|
||||
patch.object(
|
||||
video_metadata.subprocess,
|
||||
"run",
|
||||
return_value=MagicMock(returncode=0, stdout=json.dumps(probed)),
|
||||
) as run,
|
||||
self.assertNoLogs(level="ERROR"),
|
||||
):
|
||||
body = await (
|
||||
await self.client.request("POST", "/prompt_manager/scan")
|
||||
).text()
|
||||
|
||||
self.assertEqual(run.call_args.args[0][-1], str(self.output_dir / "clip.mkv"))
|
||||
self.assertIn('"found": 2', body)
|
||||
prompts = self.api.db.get_recent_prompts(limit=10)["prompts"]
|
||||
self.assertEqual(sorted(p["text"] for p in prompts), ["a cat", "bad"])
|
||||
positive = next(p for p in prompts if p["text"] == "a cat")
|
||||
images = self.api.db.get_prompt_images(positive["id"])
|
||||
self.assertEqual(
|
||||
sorted(os.path.basename(image["image_path"]) for image in images),
|
||||
["clip.mkv", "same.png"],
|
||||
)
|
||||
self.assertNotIn("must not win", json.dumps(prompts))
|
||||
|
||||
async def test_scan_without_ffprobe_skips_videos_quietly(self):
|
||||
from unittest.mock import patch
|
||||
from utils import video_metadata
|
||||
|
||||
@@ -0,0 +1,556 @@
|
||||
"""Scored top-N tag suggestions for a captured image (#249 B2).
|
||||
|
||||
``POST /prompt_manager/image-annotations/suggest`` runs the selected legacy
|
||||
engine on the private capture (never the current file), behind the one
|
||||
process-wide model gate shared with the legacy Auto-tag routes. It never
|
||||
downloads a model and never writes. No real model is loaded: the engine is a
|
||||
fake service.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import threading
|
||||
import unittest
|
||||
import uuid
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
try:
|
||||
from test_api_annotations import AnnotationAPITestCase
|
||||
from test_api_images import make_png
|
||||
except ImportError:
|
||||
from tests.test_api_annotations import AnnotationAPITestCase
|
||||
from tests.test_api_images import make_png
|
||||
|
||||
try: # guarded so a missing module fails assertions, not test loading
|
||||
from py import model_gate
|
||||
except ImportError:
|
||||
model_gate = None
|
||||
|
||||
from py import config as config_module
|
||||
|
||||
AutotagConfig = getattr(config_module, "AutotagConfig", None)
|
||||
|
||||
SUGGEST = "/prompt_manager/image-annotations/suggest"
|
||||
WAIT = 30.0
|
||||
WD14 = {"general_threshold": 0.35, "character_threshold": 0.85}
|
||||
LLM = {"instructions": "Generate tags."}
|
||||
UNAVAILABLE = "Download it from the legacy Auto-tag page."
|
||||
|
||||
SCORED = [("sandstone", 0.98), ("desert", 0.96)] + [
|
||||
(f"tag_{i:03d}", round(0.9 - i * 0.005, 4)) for i in range(150)
|
||||
]
|
||||
|
||||
|
||||
class FakeTagger:
|
||||
"""Stand-in for ``AutoTagService`` with the suggestion entry point."""
|
||||
|
||||
default_prompt = "server default prompt"
|
||||
custom_prompt = "legacy prompt"
|
||||
wd14_general_threshold = 0.35
|
||||
wd14_character_threshold = 0.85
|
||||
|
||||
def __init__(self):
|
||||
self.models_config = {
|
||||
"wd14-swinv2": {},
|
||||
"wd14-vit": {},
|
||||
"gguf": {},
|
||||
"hf": {},
|
||||
}
|
||||
self.downloaded = {name: True for name in self.models_config}
|
||||
self.loaded = None
|
||||
self.load_calls = []
|
||||
self.unload_calls = 0
|
||||
self.calls = []
|
||||
self.read_paths = []
|
||||
self.read_bytes = []
|
||||
self.download_model = MagicMock(side_effect=AssertionError("downloaded"))
|
||||
self.load_error = None
|
||||
self.block = None # (entered, proceed) events
|
||||
|
||||
def get_models_status(self):
|
||||
return {
|
||||
name: {"downloaded": self.downloaded[name], "model_path": None}
|
||||
for name in self.models_config
|
||||
}
|
||||
|
||||
def is_model_loaded(self):
|
||||
return self.loaded is not None
|
||||
|
||||
def get_loaded_model_type(self):
|
||||
return self.loaded
|
||||
|
||||
def load_model(self, model_type, use_gpu=True):
|
||||
self.load_calls.append((model_type, use_gpu))
|
||||
if self.load_error is not None:
|
||||
raise self.load_error
|
||||
self.loaded = model_type
|
||||
return True
|
||||
|
||||
def unload_model(self):
|
||||
self.unload_calls += 1
|
||||
self.loaded = None
|
||||
|
||||
def generate_tags(self, image_path, **kwargs):
|
||||
return ["legacy"]
|
||||
|
||||
def generate_tag_suggestions(
|
||||
self,
|
||||
image_path,
|
||||
*,
|
||||
limit,
|
||||
prompt=None,
|
||||
general_threshold=None,
|
||||
character_threshold=None,
|
||||
):
|
||||
self.read_paths.append(image_path)
|
||||
with open(image_path, "rb") as handle:
|
||||
self.read_bytes.append(handle.read())
|
||||
self.calls.append(
|
||||
{
|
||||
"limit": limit,
|
||||
"prompt": prompt,
|
||||
"general_threshold": general_threshold,
|
||||
"character_threshold": character_threshold,
|
||||
"model": self.loaded,
|
||||
}
|
||||
)
|
||||
if self.block is not None:
|
||||
entered, proceed = self.block
|
||||
entered.set()
|
||||
proceed.wait(WAIT)
|
||||
if self.loaded.startswith("wd14"):
|
||||
return list(SCORED[:limit])
|
||||
return [(name, None) for name, _ in SCORED[:limit]]
|
||||
|
||||
|
||||
class SuggestTestCase(AnnotationAPITestCase):
|
||||
async def get_application(self):
|
||||
app = await super().get_application()
|
||||
self.assertIsNotNone(model_gate, "py.model_gate is not implemented")
|
||||
self.assertIsNotNone(AutotagConfig, "AutotagConfig is not implemented")
|
||||
self.tagger = FakeTagger()
|
||||
patcher = patch("py.autotag.get_autotag_service", return_value=self.tagger)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
AutotagConfig.SUGGESTION_LIMIT = 10
|
||||
self.addCleanup(setattr, AutotagConfig, "SUGGESTION_LIMIT", 10)
|
||||
return app
|
||||
|
||||
async def tearDownAsync(self):
|
||||
leaked = model_gate.holder() if model_gate is not None else None
|
||||
if leaked is not None:
|
||||
model_gate.release(leaked)
|
||||
await super().tearDownAsync()
|
||||
self.assertIsNone(leaked, "the suggest route left the model gate held")
|
||||
|
||||
async def observed(self, path="sub/a.png", size=(8, 6)):
|
||||
make_png(self.output_dir / path, size=size)
|
||||
return await self.observe_ok(path)
|
||||
|
||||
def suggest_body(self, observed, model_type="wd14-vit", options=None, **changes):
|
||||
if options is None:
|
||||
options = dict(WD14 if model_type.startswith("wd14") else LLM)
|
||||
return {
|
||||
"target": dict(observed["target"]),
|
||||
"capture_token": observed["capture"]["token"],
|
||||
"expected_epoch": observed["library_epoch"],
|
||||
"model_type": model_type,
|
||||
"options": options,
|
||||
**changes,
|
||||
}
|
||||
|
||||
async def suggest(self, body):
|
||||
response = await self.client.post(SUGGEST, json=body)
|
||||
return response.status, await self.json_of(response)
|
||||
|
||||
async def suggest_ok(self, body):
|
||||
status, data = await self.suggest(body)
|
||||
self.assertEqual(status, 200, data)
|
||||
return data
|
||||
|
||||
def capture_path(self, observed):
|
||||
return self.api._captures.get(observed["capture"]["token"]).path
|
||||
|
||||
async def wait_until_free(self):
|
||||
for _ in range(3000):
|
||||
if model_gate.holder() is None:
|
||||
return
|
||||
await asyncio.sleep(0.01)
|
||||
self.fail(f"model gate still held by {model_gate.holder()}")
|
||||
|
||||
|
||||
class TestSuggestions(SuggestTestCase):
|
||||
async def test_wd14_suggestions_are_scored_top_n_from_the_capture(self):
|
||||
observed = await self.observed()
|
||||
|
||||
body = await self.suggest_ok(self.suggest_body(observed))
|
||||
|
||||
self.assertEqual(
|
||||
body["suggestions"][:2],
|
||||
[
|
||||
{"name": "sandstone", "confidence": 0.98},
|
||||
{"name": "desert", "confidence": 0.96},
|
||||
],
|
||||
)
|
||||
self.assertEqual(len(body["suggestions"]), 10)
|
||||
self.assertEqual(body["model_type"], "wd14-vit")
|
||||
self.assertEqual(
|
||||
body["options"],
|
||||
{
|
||||
"general_threshold": 0.35,
|
||||
"character_threshold": 0.85,
|
||||
"use_gpu": True,
|
||||
"suggestion_limit": 10,
|
||||
},
|
||||
)
|
||||
self.assertEqual(self.tagger.read_paths, [self.capture_path(observed)])
|
||||
self.assertEqual(
|
||||
self.tagger.calls[0],
|
||||
{
|
||||
"limit": 10,
|
||||
"prompt": None,
|
||||
"general_threshold": 0.35,
|
||||
"character_threshold": 0.85,
|
||||
"model": "wd14-vit",
|
||||
},
|
||||
)
|
||||
self.assertEqual(self.tagger.load_calls, [("wd14-vit", True)])
|
||||
self.tagger.download_model.assert_not_called()
|
||||
self.assertIsNone(model_gate.holder())
|
||||
self.assert_no_paths(body)
|
||||
|
||||
async def test_suggestions_keep_the_requested_limit(self):
|
||||
observed = await self.observed()
|
||||
for limit in (1, 10, 100):
|
||||
with self.subTest(limit=limit):
|
||||
options = {**WD14, "suggestion_limit": limit}
|
||||
body = await self.suggest_ok(
|
||||
self.suggest_body(observed, options=options)
|
||||
)
|
||||
self.assertEqual(len(body["suggestions"]), limit)
|
||||
self.assertEqual(body["options"]["suggestion_limit"], limit)
|
||||
# Omitted: the persisted autotag.defaults.suggestion_limit.
|
||||
AutotagConfig.SUGGESTION_LIMIT = 3
|
||||
body = await self.suggest_ok(self.suggest_body(observed))
|
||||
self.assertEqual(len(body["suggestions"]), 3)
|
||||
self.assertEqual(body["options"]["suggestion_limit"], 3)
|
||||
|
||||
async def test_llm_suggestions_send_instructions_and_null_confidence(self):
|
||||
observed = await self.observed()
|
||||
self.tagger.custom_prompt = "legacy prompt"
|
||||
for model_type in ("gguf", "hf"):
|
||||
with self.subTest(model_type=model_type):
|
||||
options = {"instructions": f"tags for {model_type}", "use_gpu": False}
|
||||
body = await self.suggest_ok(
|
||||
self.suggest_body(observed, model_type, options)
|
||||
)
|
||||
self.assertEqual(body["model_type"], model_type)
|
||||
self.assertEqual(body["options"], {**options, "suggestion_limit": 10})
|
||||
self.assertEqual(
|
||||
body["suggestions"][:2],
|
||||
[
|
||||
{"name": "sandstone", "confidence": None},
|
||||
{"name": "desert", "confidence": None},
|
||||
],
|
||||
)
|
||||
call = self.tagger.calls[-1]
|
||||
self.assertEqual(call["prompt"], f"tags for {model_type}")
|
||||
self.assertIsNone(call["general_threshold"])
|
||||
self.assertEqual(self.tagger.load_calls[-1], (model_type, False))
|
||||
self.assertEqual(self.tagger.custom_prompt, "legacy prompt")
|
||||
|
||||
async def test_loaded_model_is_reused_and_another_is_swapped_in(self):
|
||||
observed = await self.observed()
|
||||
self.tagger.loaded = "wd14-vit"
|
||||
await self.suggest_ok(self.suggest_body(observed))
|
||||
self.assertEqual(self.tagger.load_calls, [])
|
||||
await self.suggest_ok(self.suggest_body(observed, "wd14-swinv2"))
|
||||
self.assertEqual(self.tagger.load_calls, [("wd14-swinv2", True)])
|
||||
self.assertEqual(self.tagger.calls[-1]["model"], "wd14-swinv2")
|
||||
|
||||
async def test_suggest_uses_capture_bytes_not_current_file(self):
|
||||
observed = await self.observed()
|
||||
original = (self.output_dir / "sub" / "a.png").read_bytes()
|
||||
make_png(self.output_dir / "sub" / "a.png", size=(9, 9), color=(0, 0, 255))
|
||||
self.assertNotEqual((self.output_dir / "sub" / "a.png").read_bytes(), original)
|
||||
|
||||
await self.suggest_ok(self.suggest_body(observed))
|
||||
|
||||
capture_path = self.capture_path(observed)
|
||||
self.assertEqual(self.tagger.read_paths, [capture_path])
|
||||
self.assertEqual(self.tagger.read_bytes, [original])
|
||||
self.assertNotEqual(
|
||||
os.path.normcase(capture_path),
|
||||
os.path.normcase(str(self.output_dir / "sub" / "a.png")),
|
||||
)
|
||||
|
||||
async def test_suggest_writes_nothing(self):
|
||||
observed = await self.observed()
|
||||
before = self.db_snapshot()
|
||||
rows = self.bytes_rows()
|
||||
|
||||
await self.suggest_ok(self.suggest_body(observed))
|
||||
await self.suggest_ok(self.suggest_body(observed, "gguf"))
|
||||
|
||||
self.assertEqual(self.db_snapshot(), before)
|
||||
self.assertEqual(self.bytes_rows(), rows)
|
||||
# The capture stays usable for Save after Generate.
|
||||
self.assertIsNotNone(self.api._captures.get(observed["capture"]["token"]))
|
||||
|
||||
|
||||
class TestModelAvailability(SuggestTestCase):
|
||||
async def test_suggest_never_downloads_and_reports_model_unavailable(self):
|
||||
observed = await self.observed()
|
||||
self.tagger.downloaded["wd14-vit"] = False
|
||||
|
||||
status, body = await self.suggest(self.suggest_body(observed))
|
||||
|
||||
self.assertEqual((status, body["code"]), (409, "model-unavailable"))
|
||||
self.assertIn(UNAVAILABLE, body["error"])
|
||||
self.assertIn("WD14 ViT", body["error"])
|
||||
self.tagger.download_model.assert_not_called()
|
||||
self.assertEqual(self.tagger.load_calls, [])
|
||||
self.assertEqual(self.tagger.calls, [])
|
||||
self.assertIsNone(model_gate.holder())
|
||||
|
||||
async def test_load_failure_is_a_generic_error_and_frees_everything(self):
|
||||
observed = await self.observed()
|
||||
self.tagger.load_error = RuntimeError(f"cannot load {self.output_dir}")
|
||||
|
||||
status, body = await self.suggest(self.suggest_body(observed))
|
||||
|
||||
self.assertEqual((status, body["code"]), (500, "suggest-failed"))
|
||||
self.assert_no_paths(body)
|
||||
self.assertIsNone(model_gate.holder())
|
||||
entry = self.api._captures._entries[observed["capture"]["token"]]
|
||||
self.assertEqual(entry.uses, 0)
|
||||
|
||||
async def test_gate_work_that_never_starts_releases_the_capture_pin(self):
|
||||
observed = await self.observed()
|
||||
loop = asyncio.get_running_loop()
|
||||
real = loop.run_in_executor
|
||||
|
||||
def refuse_gate_work(executor, func, *args):
|
||||
if getattr(func, "__qualname__", "").startswith("ModelGate.run"):
|
||||
raise RuntimeError("cannot schedule new futures after shutdown")
|
||||
return real(executor, func, *args)
|
||||
|
||||
with patch.object(loop, "run_in_executor", refuse_gate_work):
|
||||
status, body = await self.suggest(self.suggest_body(observed))
|
||||
|
||||
self.assertEqual((status, body["code"]), (500, "suggest-failed"))
|
||||
self.assertIsNone(model_gate.holder())
|
||||
entry = self.api._captures._entries[observed["capture"]["token"]]
|
||||
self.assertEqual(entry.uses, 0)
|
||||
self.assertEqual(self.tagger.calls, [])
|
||||
|
||||
|
||||
class TestModelGate(SuggestTestCase):
|
||||
async def _blocked_suggest(self, observed, model_type="wd14-vit"):
|
||||
entered, proceed = threading.Event(), threading.Event()
|
||||
self.tagger.block = (entered, proceed)
|
||||
task = asyncio.ensure_future(
|
||||
self.client.post(SUGGEST, json=self.suggest_body(observed, model_type))
|
||||
)
|
||||
await asyncio.get_running_loop().run_in_executor(None, entered.wait, WAIT)
|
||||
self.assertTrue(entered.is_set())
|
||||
return task, proceed
|
||||
|
||||
async def test_suggest_reports_busy_while_legacy_batch_runs(self):
|
||||
observed = await self.observed()
|
||||
prompt_id = self._save_prompt("batch prompt")
|
||||
self.api.db.link_image_to_prompt(prompt_id, str(self.output_dir / "sub/a.png"))
|
||||
started, proceed = threading.Event(), threading.Event()
|
||||
|
||||
def slow_generate(image_path, **kwargs):
|
||||
started.set()
|
||||
proceed.wait(WAIT)
|
||||
return ["batch"]
|
||||
|
||||
self.tagger.generate_tags = slow_generate
|
||||
batch = asyncio.ensure_future(
|
||||
self.client.post("/prompt_manager/autotag/start?model_type=wd14-vit")
|
||||
)
|
||||
try:
|
||||
await asyncio.get_running_loop().run_in_executor(None, started.wait, WAIT)
|
||||
status, body = await self.suggest(self.suggest_body(observed, "gguf"))
|
||||
self.assertEqual((status, body["code"]), (409, "model-busy"))
|
||||
self.assertEqual(self.tagger.calls, [])
|
||||
self.assertEqual(self.tagger.loaded, "wd14-vit") # not swapped
|
||||
finally:
|
||||
proceed.set()
|
||||
response = await batch
|
||||
await response.read()
|
||||
await self.wait_until_free()
|
||||
await self.suggest_ok(self.suggest_body(observed))
|
||||
|
||||
async def test_legacy_unload_returns_model_busy_while_native_generate_runs(self):
|
||||
observed = await self.observed()
|
||||
task, proceed = await self._blocked_suggest(observed)
|
||||
try:
|
||||
response = await self.client.post("/prompt_manager/autotag/unload")
|
||||
self.assertEqual(response.status, 409)
|
||||
self.assertEqual((await response.json())["code"], "model-busy")
|
||||
response = await self.client.post(
|
||||
"/prompt_manager/autotag/single",
|
||||
json={"root_index": 0, "path": "sub/a.png", "model_type": "gguf"},
|
||||
)
|
||||
self.assertEqual(response.status, 409)
|
||||
status, body = await self.suggest(self.suggest_body(observed, "gguf"))
|
||||
self.assertEqual((status, body["code"]), (409, "model-busy"))
|
||||
self.assertEqual(self.tagger.unload_calls, 0)
|
||||
self.assertEqual(self.tagger.loaded, "wd14-vit")
|
||||
finally:
|
||||
proceed.set()
|
||||
response = await task
|
||||
self.assertEqual(response.status, 200)
|
||||
await self.wait_until_free()
|
||||
self.assertEqual([call["model"] for call in self.tagger.calls], ["wd14-vit"])
|
||||
|
||||
async def test_gate_held_after_client_disconnect_until_executor_finishes(self):
|
||||
observed = await self.observed()
|
||||
token = observed["capture"]["token"]
|
||||
task, proceed = await self._blocked_suggest(observed)
|
||||
task.cancel()
|
||||
with self.assertRaises(asyncio.CancelledError):
|
||||
await task
|
||||
try:
|
||||
# The client is gone; the inference still runs, so the gate and
|
||||
# the capture pin stay until the worker thread exits.
|
||||
self.assertIsNotNone(model_gate.holder())
|
||||
response = await self.client.post("/prompt_manager/autotag/unload")
|
||||
self.assertEqual(response.status, 409)
|
||||
self.assertEqual(self.api._captures._entries[token].uses, 1)
|
||||
finally:
|
||||
proceed.set()
|
||||
await self.wait_until_free()
|
||||
for _ in range(3000):
|
||||
if self.api._captures._entries[token].uses == 0:
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
self.assertEqual(self.api._captures._entries[token].uses, 0)
|
||||
response = await self.client.post("/prompt_manager/autotag/unload")
|
||||
self.assertEqual(response.status, 200)
|
||||
|
||||
|
||||
class TestRequestValidation(SuggestTestCase):
|
||||
async def test_strict_option_validation(self):
|
||||
observed = await self.observed()
|
||||
bad_options = {
|
||||
"wd14-vit": [
|
||||
{**WD14, "general_threshold": True},
|
||||
{**WD14, "general_threshold": math.nan},
|
||||
{**WD14, "general_threshold": math.inf},
|
||||
{**WD14, "general_threshold": "0.3"},
|
||||
{**WD14, "general_threshold": 1.5},
|
||||
{**WD14, "character_threshold": -0.1},
|
||||
{"general_threshold": 0.35},
|
||||
{**WD14, "instructions": "x"},
|
||||
{**WD14, "unknown": 1},
|
||||
{**WD14, "use_gpu": "yes"},
|
||||
{**WD14, "suggestion_limit": 0},
|
||||
{**WD14, "suggestion_limit": 101},
|
||||
{**WD14, "suggestion_limit": 2.5},
|
||||
{**WD14, "suggestion_limit": "10"},
|
||||
{**WD14, "suggestion_limit": True},
|
||||
],
|
||||
"gguf": [
|
||||
{**LLM, "general_threshold": 0.35},
|
||||
{**LLM, "character_threshold": 0.85},
|
||||
{"instructions": "x" * 4001},
|
||||
{"instructions": "\ud800"}, # not encodable as UTF-8
|
||||
{"instructions": " "},
|
||||
{"instructions": 5},
|
||||
{},
|
||||
{**LLM, "suggestion_limit": 0},
|
||||
],
|
||||
}
|
||||
for model_type, cases in bad_options.items():
|
||||
for options in cases:
|
||||
with self.subTest(model_type=model_type, options=repr(options)[:80]):
|
||||
status, body = await self.suggest(
|
||||
self.suggest_body(observed, model_type, options)
|
||||
)
|
||||
self.assertEqual(status, 400, body)
|
||||
for body in (
|
||||
{**self.suggest_body(observed), "extra": 1},
|
||||
{k: v for k, v in self.suggest_body(observed).items() if k != "options"},
|
||||
self.suggest_body(observed, options=[]),
|
||||
self.suggest_body(observed, model_type="ollama"),
|
||||
self.suggest_body(observed, model_type=7, options=dict(WD14)),
|
||||
self.suggest_body(observed, capture_token="../x"),
|
||||
self.suggest_body(observed, expected_epoch="nope"),
|
||||
self.suggest_body(observed, target={"root_index": 0}),
|
||||
):
|
||||
with self.subTest(body=json.dumps(body)[:120]):
|
||||
status, _ = await self.suggest(body)
|
||||
self.assertEqual(status, 400)
|
||||
# The edges that are allowed.
|
||||
await self.suggest_ok(
|
||||
self.suggest_body(
|
||||
observed,
|
||||
options={
|
||||
"general_threshold": 0,
|
||||
"character_threshold": 1,
|
||||
"suggestion_limit": 100,
|
||||
},
|
||||
)
|
||||
)
|
||||
await self.suggest_ok(
|
||||
self.suggest_body(observed, "hf", options={"instructions": "x" * 4000})
|
||||
)
|
||||
self.assertEqual(len(self.tagger.calls), 2)
|
||||
self.tagger.download_model.assert_not_called()
|
||||
|
||||
async def test_capture_token_must_match_target_and_epoch(self):
|
||||
observed = await self.observed()
|
||||
make_png(self.output_dir / "sub" / "b.png")
|
||||
other = await self.observe_ok("sub/b.png")
|
||||
|
||||
status, body = await self.suggest(
|
||||
self.suggest_body(observed, capture_token="x" * 43)
|
||||
)
|
||||
self.assertEqual((status, body["code"]), (409, "capture-expired"))
|
||||
status, body = await self.suggest(
|
||||
self.suggest_body(observed, capture_token=other["capture"]["token"])
|
||||
)
|
||||
self.assertEqual((status, body["code"]), (409, "capture-expired"))
|
||||
status, body = await self.suggest(
|
||||
self.suggest_body(observed, expected_epoch=str(uuid.uuid4()))
|
||||
)
|
||||
self.assertEqual((status, body["code"]), (409, "storage-changed"))
|
||||
self.assertEqual(body["library_epoch"], self.epoch)
|
||||
status, body = await self.suggest(
|
||||
self.suggest_body(
|
||||
observed, target={**observed["target"], "root_scope": "0" * 64}
|
||||
)
|
||||
)
|
||||
self.assertEqual((status, body["code"]), (409, "source-changed"))
|
||||
self.api._captures.release(observed["capture"]["token"])
|
||||
status, body = await self.suggest(self.suggest_body(observed))
|
||||
self.assertEqual((status, body["code"]), (409, "capture-expired"))
|
||||
self.assertEqual(self.tagger.calls, [])
|
||||
for token in (observed, other):
|
||||
entry = self.api._captures._entries.get(token["capture"]["token"])
|
||||
self.assertTrue(entry is None or entry.uses == 0)
|
||||
|
||||
async def test_video_and_oversized_dimensions_unsupported_media(self):
|
||||
(self.output_dir / "clip.mp4").write_bytes(b"\x00\x00\x00\x18ftypmp42" * 4)
|
||||
video = await self.observe_ok("clip.mp4")
|
||||
self.assertFalse(video["capture"]["generate"]["supported"])
|
||||
big = await self.observed("big.png", size=(4097, 1))
|
||||
self.assertFalse(big["capture"]["generate"]["supported"])
|
||||
|
||||
for observed in (video, big):
|
||||
status, body = await self.suggest(self.suggest_body(observed))
|
||||
self.assertEqual((status, body["code"]), (415, "unsupported-media"))
|
||||
self.assertEqual(body["error"], observed["capture"]["generate"]["reason"])
|
||||
self.assertEqual(self.tagger.calls, [])
|
||||
self.assertEqual(self.tagger.load_calls, [])
|
||||
self.assertIsNone(model_gate.holder())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
File diff suppressed because it is too large
Load Diff
+169
-7
@@ -11,7 +11,6 @@ import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import types
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
from pathlib import Path
|
||||
@@ -23,12 +22,12 @@ sys.modules.setdefault("server", _mock_server) # py.config imports it at module
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
if "folder_paths" not in sys.modules:
|
||||
_stub = types.ModuleType("folder_paths")
|
||||
_stub.models_dir = tempfile.gettempdir()
|
||||
_stub.base_path = tempfile.gettempdir()
|
||||
_stub.get_output_directory = tempfile.gettempdir
|
||||
sys.modules["folder_paths"] = _stub
|
||||
try:
|
||||
from host_stubs import install_folder_paths_stub
|
||||
except ImportError: # imported as tests.test_api_autotag
|
||||
from tests.host_stubs import install_folder_paths_stub
|
||||
|
||||
install_folder_paths_stub()
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import AioHTTPTestCase
|
||||
@@ -37,6 +36,11 @@ from database.operations import PromptDatabase
|
||||
from py.api import PromptManagerAPI
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
try: # guarded so a missing module fails assertions, not test loading
|
||||
from py import model_gate
|
||||
except ImportError:
|
||||
model_gate = None
|
||||
|
||||
|
||||
class FakeAutotagService:
|
||||
"""Stand-in for AutoTagService that records calls and returns fixed tags."""
|
||||
@@ -128,6 +132,11 @@ class AutotagAPITestCase(AioHTTPTestCase):
|
||||
async def tearDownAsync(self):
|
||||
await super().tearDownAsync() # closes the aiohttp test client
|
||||
self._service_patch.stop()
|
||||
if model_gate is not None:
|
||||
leaked = model_gate.holder()
|
||||
if leaked is not None: # never leak the process-wide gate
|
||||
model_gate.release(leaked)
|
||||
self.assertIsNone(leaked, "a route left the model gate held")
|
||||
self.api.db.close_all()
|
||||
for path in (
|
||||
self._temp_db.name,
|
||||
@@ -947,6 +956,159 @@ class TestUnloadAutotag(AutotagAPITestCase):
|
||||
self.assertEqual(resp.status, 500)
|
||||
|
||||
|
||||
class TestLegacyRoutesShareTheModelGate(AutotagAPITestCase):
|
||||
"""While another model operation holds the gate, every legacy model route
|
||||
answers ``409 model-busy`` and touches no model (the one allowed legacy
|
||||
change)."""
|
||||
|
||||
BUSY = {"success": False, "code": "model-busy"}
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.assertIsNotNone(model_gate, "py.model_gate is not implemented")
|
||||
|
||||
async def _busy(self, resp):
|
||||
self.assertEqual(resp.status, 409)
|
||||
data = await resp.json()
|
||||
self.assertEqual({k: data[k] for k in self.BUSY}, self.BUSY)
|
||||
self.assertIn("busy", data["error"].lower())
|
||||
return data
|
||||
|
||||
async def test_legacy_single_download_start_return_model_busy_while_gate_held(
|
||||
self,
|
||||
):
|
||||
image = self._make_image(self.output_dir)
|
||||
self.service._loaded_type = "wd14-vit"
|
||||
self.assertTrue(model_gate.try_acquire("test-holder"))
|
||||
try:
|
||||
await self._busy(
|
||||
await self._post_json(
|
||||
"/prompt_manager/autotag/single",
|
||||
{"path": str(image), "model_type": "gguf"},
|
||||
)
|
||||
)
|
||||
await self._busy(
|
||||
await self.client.request(
|
||||
"POST", "/prompt_manager/autotag/download/gguf"
|
||||
)
|
||||
)
|
||||
await self._busy(
|
||||
await self.client.request(
|
||||
"POST", "/prompt_manager/autotag/start?model_type=gguf"
|
||||
)
|
||||
)
|
||||
await self._busy(
|
||||
await self.client.request("POST", "/prompt_manager/autotag/unload")
|
||||
)
|
||||
self.assertEqual(model_gate.holder(), "test-holder")
|
||||
finally:
|
||||
model_gate.release("test-holder")
|
||||
|
||||
self.assertEqual(self.service.load_calls, [])
|
||||
self.assertEqual(self.service.generate_calls, [])
|
||||
self.assertEqual(self.service.download_calls, [])
|
||||
self.assertEqual(self.service.unload_calls, 0)
|
||||
self.assertEqual(self.service.get_loaded_model_type(), "wd14-vit")
|
||||
|
||||
# A refused start released the batch lock: the next start runs.
|
||||
resp = await self.client.request(
|
||||
"POST", "/prompt_manager/autotag/start?model_type=gguf"
|
||||
)
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertEqual(self._sse_events(await resp.text())[-1]["type"], "complete")
|
||||
|
||||
async def test_legacy_routes_hold_the_gate_only_while_they_run(self):
|
||||
image = self._make_image(self.output_dir)
|
||||
seen = []
|
||||
|
||||
def generate(image_path, **kwargs):
|
||||
seen.append(model_gate.holder())
|
||||
return ["x"]
|
||||
|
||||
def download(model_type, progress_callback=None):
|
||||
seen.append(model_gate.holder())
|
||||
return True
|
||||
|
||||
def load(model_type, use_gpu=True):
|
||||
seen.append(model_gate.holder())
|
||||
self.service._loaded_type = model_type
|
||||
return True
|
||||
|
||||
self.service.generate_tags = generate
|
||||
self.service.download_model = download
|
||||
self.service.load_model = load
|
||||
|
||||
resp = await self._post_json(
|
||||
"/prompt_manager/autotag/single", {"path": str(image)}
|
||||
)
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertIsNone(model_gate.holder())
|
||||
resp = await self.client.request(
|
||||
"POST", "/prompt_manager/autotag/download/gguf"
|
||||
)
|
||||
self.assertEqual(self._sse_events(await resp.text())[-1]["type"], "complete")
|
||||
self.assertIsNone(model_gate.holder())
|
||||
resp = await self.client.request("POST", "/prompt_manager/autotag/unload")
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertIsNone(model_gate.holder())
|
||||
|
||||
self.assertEqual(len(seen), 3)
|
||||
self.assertTrue(all(owner and owner.startswith("legacy-") for owner in seen))
|
||||
|
||||
async def test_validation_errors_win_over_the_gate(self):
|
||||
self.assertTrue(model_gate.try_acquire("test-holder"))
|
||||
try:
|
||||
resp = await self._post_json("/prompt_manager/autotag/single", {})
|
||||
self.assertEqual(resp.status, 400)
|
||||
resp = await self.client.request(
|
||||
"POST", "/prompt_manager/autotag/start?general_threshold=x"
|
||||
)
|
||||
self.assertEqual(resp.status, 400)
|
||||
finally:
|
||||
model_gate.release("test-holder")
|
||||
|
||||
async def test_batch_holds_the_gate_until_it_completes(self):
|
||||
prompt = self._save_prompt("p")
|
||||
self._link_image(prompt, self._make_image(self.output_dir, "a.png"))
|
||||
started = threading.Event()
|
||||
proceed = threading.Event()
|
||||
|
||||
def slow_generate(image_path, **kwargs):
|
||||
started.set()
|
||||
proceed.wait(30)
|
||||
return ["slow"]
|
||||
|
||||
self.service.generate_tags = slow_generate
|
||||
run = asyncio.ensure_future(
|
||||
self.client.request("POST", "/prompt_manager/autotag/start")
|
||||
)
|
||||
try:
|
||||
await asyncio.get_running_loop().run_in_executor(None, started.wait, 30)
|
||||
self.assertTrue(model_gate.holder().startswith("legacy-batch:"))
|
||||
await self._busy(
|
||||
await self.client.request("POST", "/prompt_manager/autotag/unload")
|
||||
)
|
||||
finally:
|
||||
proceed.set()
|
||||
resp = await run
|
||||
events = self._sse_events(await resp.text())
|
||||
self.assertEqual(events[-1]["type"], "complete")
|
||||
self.assertIsNone(model_gate.holder())
|
||||
|
||||
async def test_models_response_carries_the_suggestion_limit(self):
|
||||
from py.config import AutotagConfig
|
||||
|
||||
resp = await self.client.get("/prompt_manager/autotag/models")
|
||||
data = await resp.json()
|
||||
self.assertEqual(data["suggestion_limit"], 10)
|
||||
AutotagConfig.SUGGESTION_LIMIT = 25
|
||||
try:
|
||||
resp = await self.client.get("/prompt_manager/autotag/models")
|
||||
self.assertEqual((await resp.json())["suggestion_limit"], 25)
|
||||
finally:
|
||||
AutotagConfig.SUGGESTION_LIMIT = 10
|
||||
|
||||
|
||||
class TestScanOutputDir(AutotagAPITestCase):
|
||||
|
||||
async def test_lists_images_with_thumbnails(self):
|
||||
|
||||
@@ -448,6 +448,20 @@ class TestUiRoutes(CoreRoutesTestCase):
|
||||
resp = await self.client.request("GET", "/prompt_manager/test")
|
||||
self.assertTrue((await resp.json())["success"])
|
||||
|
||||
async def test_image_annotation_routes_are_registered(self):
|
||||
table = {
|
||||
(route.method, route.resource.canonical)
|
||||
for route in self.app.router.routes()
|
||||
if route.resource is not None
|
||||
}
|
||||
for entry in (
|
||||
("POST", "/prompt_manager/image-annotations/observe"),
|
||||
("GET", "/prompt_manager/image-annotations/capture/{token}"),
|
||||
("DELETE", "/prompt_manager/image-annotations/capture/{token}"),
|
||||
("PUT", "/prompt_manager/image-annotations/tags"),
|
||||
):
|
||||
self.assertIn(entry, table)
|
||||
|
||||
|
||||
class TestStaticRoutes(CoreRoutesTestCase):
|
||||
|
||||
|
||||
@@ -18,7 +18,12 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
# ComfyUI-only modules are stubbed so the API can be imported standalone.
|
||||
sys.modules.setdefault("folder_paths", MagicMock())
|
||||
try:
|
||||
from host_stubs import install_folder_paths_stub
|
||||
except ImportError: # imported as tests.test_api_images
|
||||
from tests.host_stubs import install_folder_paths_stub
|
||||
|
||||
install_folder_paths_stub()
|
||||
sys.modules.setdefault("server", MagicMock())
|
||||
|
||||
from aiohttp import web # noqa: E402
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
"""Physical-file prompt inspection is bounded, conservative and read-only."""
|
||||
|
||||
import io
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
import shutil
|
||||
import subprocess
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from PIL import Image, PngImagePlugin
|
||||
|
||||
@@ -15,6 +19,9 @@ except ImportError:
|
||||
from utils.pair_execution import EXECUTION_KEY, record_pair_execution
|
||||
from database.pair_ingestion import ingest_resolved_pair
|
||||
from utils.prompt_pair_resolver import resolve_prompt_pair
|
||||
from utils import video_metadata
|
||||
|
||||
HAVE_FFMPEG = bool(shutil.which("ffmpeg") and shutil.which("ffprobe"))
|
||||
|
||||
|
||||
def graph(positive="portrait", negative="blurry"):
|
||||
@@ -28,6 +35,54 @@ def graph(positive="portrait", negative="blurry"):
|
||||
}
|
||||
|
||||
|
||||
def write_video(path, metadata_tags=True, **tags):
|
||||
"""Tiny black clip carrying one container tag per keyword (needs ffmpeg).
|
||||
|
||||
MP4 keeps arbitrary keys only with ``use_metadata_tags`` (mdta atoms), the
|
||||
movflag the core SaveVideo node sets; without it ffmpeg writes the legacy
|
||||
ilst atoms that old VideoHelperSuite files carry. Matroska and WebM
|
||||
upper-case the key names.
|
||||
"""
|
||||
command = [
|
||||
"ffmpeg",
|
||||
"-v",
|
||||
"quiet",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"color=c=black:s=16x16:d=0.2",
|
||||
]
|
||||
if metadata_tags and path.suffix.lower() in (".mp4", ".mov", ".m4v"):
|
||||
command += ["-movflags", "use_metadata_tags"]
|
||||
for key, value in tags.items():
|
||||
command += ["-metadata", f"{key}={value}"]
|
||||
subprocess.run(command + ["-y", str(path)], check=True, timeout=60)
|
||||
return path
|
||||
|
||||
|
||||
class ProbeOutput(io.BytesIO):
|
||||
"""ffprobe stdout that records the bounded read size asked of it."""
|
||||
|
||||
requested_size = None
|
||||
|
||||
def read(self, size=-1):
|
||||
self.requested_size = size
|
||||
return super().read(size)
|
||||
|
||||
|
||||
def probe_process(payload, poll=0, returncode=0):
|
||||
"""A fake ffprobe whose stdout is ``payload`` bytes."""
|
||||
process = MagicMock()
|
||||
process.stdout = ProbeOutput(payload)
|
||||
process.poll.return_value = poll
|
||||
process.returncode = returncode
|
||||
return process
|
||||
|
||||
|
||||
def probe_payload(tags):
|
||||
return json.dumps({"format": {"tags": tags}}).encode()
|
||||
|
||||
|
||||
def png(path, **metadata):
|
||||
info = PngImagePlugin.PngInfo()
|
||||
for key, value in metadata.items():
|
||||
@@ -312,7 +367,7 @@ class TestPromptInspector(ImageAPITestCase):
|
||||
async def test_corrupt_truncated_duplicate_and_expanding_metadata_fail_closed(self):
|
||||
import struct
|
||||
import zlib
|
||||
from utils.prompt_inspector import MAX_METADATA_BYTES
|
||||
from utils.prompt_inspector import VIDEO_REPROBE_BYTES
|
||||
|
||||
path = self.output_dir / "image.png"
|
||||
base = png(path, prompt=graph()).read_bytes()
|
||||
@@ -325,8 +380,8 @@ class TestPromptInspector(ImageAPITestCase):
|
||||
cases.append(bytes(broken))
|
||||
for kind, payload in (
|
||||
(b"tEXt", b"prompt\0{}"),
|
||||
(b"zTXt", b"prompt\0\0" + zlib.compress(b"x" * (MAX_METADATA_BYTES + 1))),
|
||||
(b"tEXt", b"prompt\0" + b"x" * (MAX_METADATA_BYTES + 1)),
|
||||
(b"zTXt", b"prompt\0\0" + zlib.compress(b"x" * (VIDEO_REPROBE_BYTES + 1))),
|
||||
(b"tEXt", b"prompt\0" + b"x" * (VIDEO_REPROBE_BYTES + 1)),
|
||||
(b"tEXt", b"bad-key-without-separator"),
|
||||
(b"zTXt", b"prompt\0\1bad-compression"),
|
||||
(b"iTXt", b"prompt\0\2\0\0\0text"),
|
||||
@@ -385,9 +440,7 @@ class TestPromptInspector(ImageAPITestCase):
|
||||
self.assertEqual(result["prompts"]["positive"]["status"], "absent")
|
||||
|
||||
async def test_video_inspection_uses_bounded_container_evidence(self):
|
||||
import io
|
||||
from unittest.mock import MagicMock
|
||||
from utils.prompt_inspector import MAX_METADATA_BYTES
|
||||
from utils.prompt_inspector import VIDEO_PROBE_BYTES, VIDEO_REPROBE_BYTES
|
||||
|
||||
(self.output_dir / "clip.mp4").write_bytes(b"video fixture")
|
||||
metadata = {"prompt": graph("Video portrait", "")}
|
||||
@@ -412,14 +465,25 @@ class TestPromptInspector(ImageAPITestCase):
|
||||
process.wait.assert_called()
|
||||
self.assertNotIn("shell", spawn.call_args.kwargs)
|
||||
self.assertIn(str(self.output_dir / "clip.mp4"), spawn.call_args.args[0])
|
||||
process.stdout = io.BytesIO(b"x" * (MAX_METADATA_BYTES + 1))
|
||||
# An envelope over the probe bound cannot be split: the role-only
|
||||
# re-probe overflows too and the read fails instead of guessing.
|
||||
processes = [
|
||||
probe_process(b"x" * (VIDEO_PROBE_BYTES + 1)),
|
||||
probe_process(b"x" * (VIDEO_REPROBE_BYTES + 1)),
|
||||
]
|
||||
with (
|
||||
patch("utils.prompt_inspector.ffprobe_path", return_value="/tool/ffprobe"),
|
||||
patch("utils.prompt_inspector.subprocess.Popen", return_value=process),
|
||||
patch(
|
||||
"utils.prompt_inspector.subprocess.Popen", side_effect=processes
|
||||
) as spawn,
|
||||
):
|
||||
_, result = await self.inspect("clip.mp4")
|
||||
self.assertEqual(spawn.call_count, 2)
|
||||
self.assertEqual(result["prompts"]["positive"]["status"], "unresolved")
|
||||
self.assertTrue(process.stdout.closed)
|
||||
self.assertEqual(result["metadata"]["status"], "unreadable")
|
||||
for process in processes:
|
||||
self.assertTrue(process.stdout.closed)
|
||||
process.wait.assert_called()
|
||||
|
||||
async def test_video_missing_probe_and_timeout_preserve_uncertainty(self):
|
||||
import io
|
||||
@@ -895,11 +959,9 @@ class TestPromptInspector(ImageAPITestCase):
|
||||
self.assertNotIn("\ufffd".encode(), body)
|
||||
self.assertLessEqual(len(body), 8 * 1024 * 1024)
|
||||
|
||||
async def video_inspect(self, comment=None, description=None):
|
||||
import io
|
||||
async def video_inspect(self, comment=None, description=None, **per_key):
|
||||
import threading
|
||||
from unittest.mock import MagicMock
|
||||
from utils.prompt_inspector import MAX_METADATA_BYTES
|
||||
from utils.prompt_inspector import VIDEO_PROBE_BYTES
|
||||
|
||||
path = self.output_dir / "clip.mp4"
|
||||
path.write_bytes(b"fixture")
|
||||
@@ -908,17 +970,8 @@ class TestPromptInspector(ImageAPITestCase):
|
||||
for key, value in (("comment", comment), ("description", description))
|
||||
if value is not None
|
||||
}
|
||||
payload = json.dumps({"format": {"tags": tags}}).encode()
|
||||
|
||||
class ProbeOutput(io.BytesIO):
|
||||
def read(self, size=-1):
|
||||
self.requested_size = size
|
||||
return super().read(size)
|
||||
|
||||
process = MagicMock()
|
||||
process.stdout = ProbeOutput(payload)
|
||||
process.poll.return_value = 0
|
||||
process.returncode = 0
|
||||
tags.update(per_key)
|
||||
process = probe_process(probe_payload(tags))
|
||||
timers = []
|
||||
timer_type = threading.Timer
|
||||
|
||||
@@ -937,11 +990,156 @@ class TestPromptInspector(ImageAPITestCase):
|
||||
response, result = await self.inspect("clip.mp4")
|
||||
self.assertEqual(spawn.call_count, 1)
|
||||
self.assertTrue(process.stdout.closed)
|
||||
self.assertEqual(process.stdout.requested_size, MAX_METADATA_BYTES + 1)
|
||||
self.assertEqual(process.stdout.requested_size, VIDEO_PROBE_BYTES + 1)
|
||||
self.assertEqual(len(timers), 1)
|
||||
self.assertTrue(timers[0].finished.is_set())
|
||||
self.assertEqual(
|
||||
spawn.call_args.args[0][spawn.call_args.args[0].index("-show_entries") + 1],
|
||||
"format_tags=prompt,workflow,parameters,"
|
||||
"comfyassets_prompt_pairs,comment,description",
|
||||
)
|
||||
return response, result
|
||||
|
||||
async def test_video_separate_prompt_workflow_tags_are_read(self):
|
||||
source = graph("per-key portrait", "per-key negative")
|
||||
workflow = '{"nodes":[{"id":"per-key"}],"cfg":6.5000000000000000001}'
|
||||
for tags in (
|
||||
{"prompt": json.dumps(source), "workflow": workflow},
|
||||
{"PROMPT": json.dumps(source), "WORKFLOW": workflow},
|
||||
):
|
||||
_, result = await self.video_inspect(
|
||||
json.dumps({"prompt": graph("comment must not win")}), **tags
|
||||
)
|
||||
self.assertEqual(
|
||||
result["prompts"]["positive"], self.side("per-key portrait")
|
||||
)
|
||||
self.assertEqual(
|
||||
result["prompts"]["negative"], self.side("per-key negative")
|
||||
)
|
||||
self.assertEqual(
|
||||
result["metadata"]["record_formats"],
|
||||
{"prompt": "original-text", "workflow": "original-text"},
|
||||
)
|
||||
self.assertEqual(result["metadata"]["raw"]["workflow"], workflow)
|
||||
self.assertEqual(
|
||||
result["metadata"]["workflow_graph"]["value"]["cfg"],
|
||||
"6.5000000000000000001",
|
||||
)
|
||||
self.assertNotIn("must not win", json.dumps(result))
|
||||
_, result = await self.video_inspect(
|
||||
**{"PROMPT": json.dumps(source), "prompt": json.dumps(graph("other"))}
|
||||
)
|
||||
self.assertEqual(result["prompts"]["positive"]["status"], "unresolved")
|
||||
self.assertEqual(result["metadata"]["status"], "unreadable")
|
||||
|
||||
@unittest.skipUnless(HAVE_FFMPEG, "ffmpeg/ffprobe not installed")
|
||||
async def test_video_per_key_and_comment_tags_read_from_real_containers(self):
|
||||
video_metadata.ffprobe_path.cache_clear()
|
||||
self.addCleanup(video_metadata.ffprobe_path.cache_clear)
|
||||
source = json.dumps(graph("real portrait", ""))
|
||||
workflow = '{"nodes":[{"id":"real"}]}'
|
||||
for name in ("per_key.mp4", "per_key.mkv", "per_key.webm"):
|
||||
write_video(self.output_dir / name, prompt=source, workflow=workflow)
|
||||
response, result = await self.inspect(name)
|
||||
self.assertEqual(response.status, 200, name)
|
||||
self.assertEqual(
|
||||
result["prompts"]["positive"], self.side("real portrait"), name
|
||||
)
|
||||
self.assertEqual(result["metadata"]["raw"]["workflow"], workflow, name)
|
||||
self.assertEqual(
|
||||
result["metadata"]["record_formats"],
|
||||
{"prompt": "original-text", "workflow": "original-text"},
|
||||
name,
|
||||
)
|
||||
# Old VideoHelperSuite envelope in the legacy ilst comment atom, unchanged.
|
||||
write_video(
|
||||
self.output_dir / "vhs.mp4",
|
||||
metadata_tags=False,
|
||||
comment=json.dumps({"prompt": json.loads(source), "workflow": {"v": 1}}),
|
||||
)
|
||||
_, result = await self.inspect("vhs.mp4")
|
||||
self.assertEqual(result["prompts"]["positive"], self.side("real portrait"))
|
||||
self.assertEqual(
|
||||
result["metadata"]["record_formats"],
|
||||
{"prompt": "reserialized-json", "workflow": "reserialized-json"},
|
||||
)
|
||||
self.assertEqual(result["metadata"]["workflow_graph"]["value"], {"v": 1})
|
||||
|
||||
async def test_video_oversize_workflow_reprobes_role_only_and_marks_limited(self):
|
||||
from utils.prompt_inspector import VIDEO_PROBE_BYTES, VIDEO_REPROBE_BYTES
|
||||
|
||||
path = self.output_dir / "clip.mp4"
|
||||
path.write_bytes(b"fixture")
|
||||
source = json.dumps(graph("bounded portrait", ""))
|
||||
processes = [
|
||||
probe_process(b"x" * (VIDEO_PROBE_BYTES + 1)),
|
||||
probe_process(probe_payload({"PROMPT": source})),
|
||||
]
|
||||
with (
|
||||
patch("utils.prompt_inspector.ffprobe_path", return_value="/tool/ffprobe"),
|
||||
patch(
|
||||
"utils.prompt_inspector.subprocess.Popen", side_effect=processes
|
||||
) as spawn,
|
||||
):
|
||||
response, result = await self.inspect("clip.mp4")
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertEqual(spawn.call_count, 2)
|
||||
first, second = (call.args[0] for call in spawn.call_args_list)
|
||||
self.assertEqual(
|
||||
first[first.index("-show_entries") + 1],
|
||||
"format_tags=prompt,workflow,parameters,"
|
||||
"comfyassets_prompt_pairs,comment,description",
|
||||
)
|
||||
self.assertEqual(
|
||||
second[second.index("-show_entries") + 1],
|
||||
"format_tags=prompt,parameters,comfyassets_prompt_pairs,"
|
||||
"comment,description",
|
||||
)
|
||||
self.assertEqual(processes[0].stdout.requested_size, VIDEO_PROBE_BYTES + 1)
|
||||
self.assertEqual(processes[1].stdout.requested_size, VIDEO_REPROBE_BYTES + 1)
|
||||
for process in processes:
|
||||
self.assertTrue(process.stdout.closed)
|
||||
process.wait.assert_called()
|
||||
self.assertEqual(result["prompts"]["positive"], self.side("bounded portrait"))
|
||||
self.assertEqual(result["metadata"]["status"], "available")
|
||||
self.assertEqual(
|
||||
result["metadata"]["record_formats"],
|
||||
{"prompt": "original-text", "workflow": "limited"},
|
||||
)
|
||||
self.assertNotIn("workflow", result["metadata"]["raw"])
|
||||
self.assertEqual(result["metadata"]["workflow_graph"]["status"], "limited")
|
||||
# The dropped per-key workflow belonged to an unselected carrier when
|
||||
# an envelope wins the re-probe: that envelope is whole, not limited.
|
||||
envelope = json.dumps({"prompt": json.loads(source), "workflow": {"v": 2}})
|
||||
processes = [
|
||||
probe_process(b"x" * (VIDEO_PROBE_BYTES + 1)),
|
||||
probe_process(probe_payload({"comment": envelope})),
|
||||
]
|
||||
with (
|
||||
patch("utils.prompt_inspector.ffprobe_path", return_value="/tool/ffprobe"),
|
||||
patch("utils.prompt_inspector.subprocess.Popen", side_effect=processes),
|
||||
):
|
||||
_, result = await self.inspect("clip.mp4")
|
||||
self.assertEqual(result["prompts"]["positive"], self.side("bounded portrait"))
|
||||
self.assertEqual(result["metadata"]["workflow_graph"]["value"], {"v": 2})
|
||||
self.assertEqual(
|
||||
result["metadata"]["record_formats"]["workflow"], "reserialized-json"
|
||||
)
|
||||
# A workflow-only per-key video over budget is limited, not absent.
|
||||
processes = [
|
||||
probe_process(b"x" * (VIDEO_PROBE_BYTES + 1)),
|
||||
probe_process(probe_payload({})),
|
||||
]
|
||||
with (
|
||||
patch("utils.prompt_inspector.ffprobe_path", return_value="/tool/ffprobe"),
|
||||
patch("utils.prompt_inspector.subprocess.Popen", side_effect=processes),
|
||||
):
|
||||
_, result = await self.inspect("clip.mp4")
|
||||
self.assertEqual(result["metadata"]["status"], "available")
|
||||
self.assertEqual(result["metadata"]["record_formats"], {"workflow": "limited"})
|
||||
self.assertEqual(result["metadata"]["workflow_graph"]["status"], "limited")
|
||||
self.assertEqual(result["prompts"]["positive"]["status"], "absent")
|
||||
|
||||
async def test_video_role_description_wins_over_workflow_only_comment(self):
|
||||
evidence = {}
|
||||
source = graph("chosen", "")
|
||||
@@ -1444,3 +1642,86 @@ class TestPromptInspector(ImageAPITestCase):
|
||||
lookup.assert_called_once_with(
|
||||
str(path), False, report_ambiguity=True
|
||||
)
|
||||
|
||||
|
||||
class TestInspectorImageTags(ImageAPITestCase):
|
||||
"""B3: the inspector envelope's ``image_tags`` facet (spec amendment S1)."""
|
||||
|
||||
inspect = TestPromptInspector.inspect
|
||||
|
||||
def tag(self, path, tags):
|
||||
import hashlib
|
||||
import os
|
||||
|
||||
stat = path.stat()
|
||||
return self.api.db.replace_image_tags(
|
||||
locator=os.path.normcase(os.path.realpath(path)),
|
||||
sha256=hashlib.sha256(path.read_bytes()).hexdigest(),
|
||||
size=stat.st_size,
|
||||
mtime_ns=stat.st_mtime_ns,
|
||||
expected_epoch=self.api.db.get_execution_epoch(),
|
||||
expected_tag_revision=0,
|
||||
tags=tags,
|
||||
)
|
||||
|
||||
async def test_inspector_reports_current_none_and_unavailable_image_tags(self):
|
||||
import os
|
||||
|
||||
tagged = png(self.output_dir / "sub" / "tagged.png", prompt=graph())
|
||||
changed = png(self.output_dir / "changed.png", prompt=graph())
|
||||
png(self.output_dir / "untagged.png", prompt=graph())
|
||||
empty = png(self.output_dir / "empty.png", prompt=graph())
|
||||
self.tag(tagged, ["dunes", "warm_light"])
|
||||
self.tag(changed, ["dunes"])
|
||||
self.tag(empty, [])
|
||||
stat = changed.stat()
|
||||
os.utime(changed, ns=(stat.st_atime_ns, stat.st_mtime_ns + 1_000_000))
|
||||
|
||||
response, envelope = await self.inspect("sub/tagged.png")
|
||||
self.assertEqual(response.status, 200, envelope)
|
||||
self.assertEqual(
|
||||
envelope.get("image_tags"),
|
||||
{"state": "current", "tags": ["dunes", "warm_light"]},
|
||||
)
|
||||
# The tags never change the Library facts of the prompt link.
|
||||
self.assertEqual(envelope["library"]["tags"], [])
|
||||
_, changed_body = await self.inspect("changed.png")
|
||||
self.assertEqual(changed_body.get("image_tags"), {"state": "none", "tags": []})
|
||||
_, untagged = await self.inspect("untagged.png")
|
||||
self.assertEqual(untagged.get("image_tags"), {"state": "none", "tags": []})
|
||||
_, saved_empty = await self.inspect("empty.png")
|
||||
self.assertEqual(
|
||||
saved_empty.get("image_tags"), {"state": "current", "tags": []}
|
||||
)
|
||||
with patch.object(
|
||||
self.api.db, "current_image_tags", side_effect=RuntimeError("boom")
|
||||
):
|
||||
with self.assertLogs("prompt_manager", level="WARNING") as logged:
|
||||
response, failed = await self.inspect("sub/tagged.png")
|
||||
self.assertTrue(
|
||||
any(r.exc_info and "image tags" in r.getMessage() for r in logged.records),
|
||||
logged.output,
|
||||
)
|
||||
self.assertEqual(response.status, 200, failed)
|
||||
self.assertEqual(failed.get("image_tags"), {"state": "unavailable", "tags": []})
|
||||
self.assertEqual(failed["prompts"], envelope["prompts"])
|
||||
self.assertNotIn(str(self.comfy_dir), json.dumps(envelope))
|
||||
|
||||
async def test_inspector_image_tags_use_the_inspection_stable_stat(self):
|
||||
tagged = png(self.output_dir / "tagged.png", prompt=graph())
|
||||
self.tag(tagged, ["dunes"])
|
||||
stat = tagged.stat()
|
||||
seen = []
|
||||
real = self.api.db.current_image_tags
|
||||
|
||||
def spy(stats):
|
||||
seen.append(dict(stats))
|
||||
return real(stats)
|
||||
|
||||
with patch.object(self.api.db, "current_image_tags", side_effect=spy):
|
||||
_, body = await self.inspect("tagged.png")
|
||||
self.assertEqual(
|
||||
body.get("image_tags"), {"state": "current", "tags": ["dunes"]}
|
||||
)
|
||||
self.assertEqual(list(seen[0].values()), [(stat.st_size, stat.st_mtime_ns)])
|
||||
self.assertEqual(len(seen), 1)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user