Compare commits

..
Author SHA1 Message Date
Vito Sansevero 190afca720 docs: version 3.3.0, README changelog and refreshed sections
Bump to 3.3.0: new features (worker threads, background scans, tag
autocomplete, infinite scrolling, video prompt scanning) and API contract
changes (POST-only side-effect routes, relative image paths, paginated
search) go beyond a patch release.

README: changelog entries for 3.2.4 and 3.3.0, refreshed bulk scanning,
gallery settings, search, configuration and testing sections, and the
roadmap's recently-completed list.
2026-10-08 18:47:17 -07:00
Vito Sansevero 4c6dbc92fe fix: serve every gallery video format, validate settings before applying, dedupe output dirs
Review findings on #153 (Codex):
- the image serve route allowed only three video types while the gallery
  lists seven, so .avi/.mkv/.m4v/.wmv cards opened to a 403; the serve
  allow-list now covers every gallery video format
- a settings payload with one invalid field still applied the valid ones
  before returning 400; the whole payload is validated first and nothing
  changes on failure. webui_display_mode is validated too

Third Windows CI run:
- the same output directory spelled twice (different case or a link) was
  listed twice in diagnostics and served roots; both dedupe on the
  resolved, case-folded path
- the root-less subfolder test expects the "/" form

Code-quality bot: index definitions are (name, target) pairs instead of
implicitly concatenated strings; redundant local asyncio imports and
two trivial lambdas in test stubs removed.
2026-10-08 18:39:46 -07:00
Vito Sansevero 8462ecbb03 fix: Windows case-folded gallery roots in tests, "/" subfolders everywhere, longer busy timeout
Second CI run on Windows:
- gallery roots are stored resolved and case-folded there; the admin API
  tests now compare against that form instead of the mixed-case temp path
- the subfolder fallback for images outside every root also uses "/", and
  the test expects it
- a relative diagnostics db path is normalised after joining
- "database is locked" under many writers on a slow disk: connections now
  wait up to 30 seconds (connect timeout and busy_timeout), long enough for
  a scan or thumbnail run while ComfyUI saves images
2026-10-08 18:33:18 -07:00
Vito Sansevero b1bfce702d fix: first CI run on Windows and macOS; tests now enforce closed database files
The first push of the branch ran the Tests matrix on all three operating
systems. Every Python job failed; the causes were:

- numpy missing in CI: the WD14 engine tests import it (requirements-dev)
- macOS: temp directories resolve through /private; the admin API tests
  now use the real path like the gallery roots they compare against
- Windows: the startup banner printed an emoji, which raises
  UnicodeEncodeError on a legacy-code-page console and aborted loading
  the whole node; the banner is ASCII now and a test loads the package
  under a cp1252 stdout
- Windows: gallery subfolders came back with backslashes, which neither
  the folder tree nor the search filter understand; they are "/" joined
  on every platform. A relative DEFAULT_DB_PATH kept its forward slashes
  after being joined to the extension root; it is normalised now
- Windows: sqlite connections opened with `with sqlite3.connect()` are
  not closed by the context manager and linger until garbage collection,
  which holds a file lock there. Production sites (vacuum, diagnostics,
  restore inspection) close explicitly; a restore that swaps the API's
  database object closes the previous one; PromptModel keeps a registry
  so a shutdown or a test can close every instance
- Windows: served files were still open when their temp directory was
  removed (tests now read the body), a log download test compared text
  mode newlines, two tests compared paths with a hard-coded separator

tests/open_handles.py makes the Linux run fail the way Windows would:
every database test base checks, right before deleting its files, that
nothing in the process still holds them open.
2026-10-08 18:24:18 -07:00
Vito Sansevero 08d57b408a feat(dashboard): infinite scrolling for the prompt list, saved default plus session toggle
- PromptManagerConfig.INFINITE_SCROLL (web_ui.infinite_scroll) is reported
  and saved through the settings API; only booleans are accepted
- Settings modal gains an "Infinite Scrolling" default; saving applies it
  at once
- the results header gains an "Infinite scroll" checkbox that overrides the
  default for the current session without saving
- when on, the next page is appended as the list nears its bottom (same
  load-more rule as the gallery), page controls hide, a short page keeps
  loading until the list fills, and a refresh after an edit restarts from
  the top
2026-10-08 18:01:35 -07:00
Vito Sansevero f93412ab90 feat: tag search box autocompletes, narrowed by the tags already typed
- PromptDatabase.suggest_tags(prefix, with_tags, limit): tags starting
  with the prefix, counted over the prompts that carry every context tag,
  context tags excluded, ordered by count then name; LIKE wildcards in the
  prefix are escaped
- GET /prompt_manager/tags/suggest?q=&with=&limit= exposes it (limit
  clamped to 1..50, context capped at 10 tags, registered before the
  {tag_name} routes)
- tag-autocomplete.js splits the comma-separated input into committed tags
  and the one being typed, builds the query, applies a pick and handles
  wrap-around keyboard selection
- the dashboard Tags box shows suggestions with counts on focus and while
  typing, arrow keys and Enter/Tab pick, Escape closes, and each pick
  immediately offers the next tag narrowed by the previous ones
2026-10-08 18:01:35 -07:00
Vito Sansevero c5846306f6 fix(gallery): Rescan Output Folder drops the server listing cache and rewalks
The button only re-requested the listing, which the server answers from a
30-second in-memory cache, so a rescan showed exactly what was already on
screen.

- POST /prompt_manager/gallery/rescan invalidates the gallery cache,
  rewalks every output root and returns the file and root counts
- the button calls it, closes the settings modal, reloads the folder tree
  and the first page, and reports what it found
2026-10-08 18:01:35 -07:00
Vito Sansevero 6e3fa65914 feat(gallery): duplicate scan runs as a background job with progress; infinite scrolling
The duplicate scan was one blocking request with a spinner: no progress,
a modal that could not be put away, and a result lost if the tab closed.

- POST /prompt_manager/scan_duplicates/stream starts the scan as a
  background job (same ScanJob fan-out as the output scan) or attaches to
  the running one; GET /prompt_manager/scan_duplicates/status reports it.
  The job holds the duplicates lock, so deletes are refused while it runs.
- hashing runs on the configured worker threads and reports progress
  about every percent
- the gallery modal shows a progress bar, closing it minimizes to a pill
  that reopens it, results wait in the pill if the modal was closed, and a
  reload re-attaches to a running scan

Infinite scrolling is a new Display Setting (off by default): the next page
is appended when the viewport nears the bottom, pagination controls hide,
and the load-more rule lives in list-state.js where it is unit tested.
Thumb-size buttons and the column setting no longer override each other.
2026-10-08 18:01:35 -07:00
Vito Sansevero 672eb6a18c fix(gallery): display settings apply at load, columns work, media info toggles
Default View Mode, Items per Page and Grid Columns were only applied when
Save was pressed, never when the page loaded; the column setting swapped a
Tailwind class that was never compiled (only 3, 6 and 8 exist) and a wider
breakpoint class overrode it anyway; Show Media Info was saved and never
read.

- gallery-settings.js validates what localStorage holds (view mode, limit
  option, column range, booleans) and falls back to defaults per value
- settings are applied in the constructor before the first load; changing
  the page size reloads, toggling media info re-renders
- the column count sets an explicit grid template through a CSS variable
  that wins on desktop widths
- media info lines honour the toggle in grid and list views
- image load failures are handled by one delegated listener that tries the
  original before showing the failed-to-load card
2026-10-08 18:01:35 -07:00
Vito Sansevero 85d1031179 fix: thumbnails are written atomically and served under mtime-versioned urls
Cards showed "Failed to load" for thumbnails that were valid on disk. The
browser had fetched them while the thumbnail run was still writing the
file and, because image responses are cached for an hour, kept the broken
bytes. The card also had no fallback to the original.

- image and video thumbnails are written to a hidden temp sibling and
  renamed into place, so a partial file is never visible at the final path
- every thumbnail_url (gallery listing, prompt images, duplicates, autotag
  listing) carries ?v=<mtime>, so a regenerated thumbnail gets a new URL
  instead of the cached old one
- a 404 from the image serve route is marked no-store; ComfyUI's cache
  middleware would otherwise let browsers cache the miss for an hour
- image-helpers gains fallbackImageSource(): the original is tried once
  before a card gives up

Test hygiene: the autotag tests now stub ComfyUI's server module like the
other API test modules, so the module passes when run on its own.
2026-10-08 18:01:35 -07:00
Vito Sansevero 8c45209af1 feat(gallery): warn with the pending count when a thumbnail run stops at the cap 2026-10-08 18:01:34 -07:00
Vito Sansevero 23c43dd5bf fix: thumbnail runs skip fresh thumbnails before the cap and report what remains
The per-run cap of 10,000 applied to the directory walk itself, so a
library with more files than that never had thumbnails generated for the
rest, and a run that found 9,987 existing thumbnails spent its whole
budget on skipping them.

- _plan_thumbnails walks the whole tree (bounded like the gallery listing),
  counts files whose thumbnail is already newer as skipped without queuing
  them, and caps only the pending files; the leftover count is returned
- start and complete payloads carry skipped and remaining; total_images
  counts every file considered and the processing rate uses the files
  actually worked on
- the gallery page shows a warning naming the pending count instead of a
  success toast when a run stopped at the cap
2026-10-08 18:01:34 -07:00
Vito Sansevero b2ea2597e6 feat: scan reads prompts embedded in videos; unreadable files no longer log errors
The output scan handed every .mp4 to Pillow and logged one ERROR per file.
ComfyUI's video savers store the queued graph as JSON in the container's
comment tag, so those files carry prompts worth linking.

- utils/video_metadata.py reads the comment/description tag through
  ffprobe with a timeout, normalised to the PNG text-chunk shape so the
  existing parser handles both; without ffprobe videos are skipped and a
  single INFO notice is logged
- _extract_comfyui_metadata routes videos there and logs unreadable images
  as warnings, since the scan carries on past them

Test hygiene: every aiohttp test base class overrode tearDownAsync without
calling super(), so no test client was ever closed. That produced thousands
of ResourceWarnings, "Unclosed client session" errors from asyncio that
broke log assertions, and doubled the suite's run time.
2026-10-08 18:01:34 -07:00
Vito Sansevero ce983af09f fix: scan modal ignores backdrop clicks so a focus click cannot dismiss it
Only its own buttons dismiss it: Cancel before a scan starts, Hide (minimize
to the progress pill) while one runs.
2026-10-08 18:01:34 -07:00
Vito Sansevero b98c716b48 feat: worker-thread setting speeds up the output scan and thumbnail generation
Both jobs processed one file at a time on a single core. Pillow decodes the
whole image to reach trailing PNG text chunks and releases the GIL while it
does, so a bounded thread pool scales almost linearly: on a 1 GB sample of
400 PNGs the metadata pass dropped from 18 s to 2.5 s at 8 threads, and 120
thumbnails from 4.6 s to 0.6 s.

- utils/parallel.py: map_parallel(func, items, workers) keeps order, runs on
  the caller's thread when workers is 1, and caps the pool at the item count
- PromptManagerConfig.WORKER_THREADS defaults to half the detected cores,
  is clamped to max_worker_threads() when loaded from config.json, and is
  persisted under "performance"
- settings API returns worker_threads and cpu_count and validates saves;
  the settings modal gets a "Worker threads" slider capped at the core count
- the scan reads each metadata batch on the configured threads; thumbnail
  generation processes batches of files per executor job, still emitting
  one progress event per file

Threads rather than processes: this runs inside ComfyUI's server, where
forking a CUDA-initialised process is unsafe and the spawn start method
would re-import ComfyUI's main module in every child.
2026-10-08 18:01:34 -07:00
Vito Sansevero dc192d5836 fix: output scan runs as a background job that survives the browser going away
Closing the dashboard tab or clicking outside the scan modal raised
ClientConnectionResetError out of scan_images and abandoned the scan
mid-batch, leaving a half-scanned database. Two overlapping scans could
also run at once.

- py/api/scan_job.py: ScanJob fans progress out to any number of SSE
  subscribers (latest snapshot first, then live events)
- POST /prompt_manager/scan starts the scan as an asyncio task, or attaches
  to the one already running; a write to a dead socket ends that stream
  quietly while the scan continues
- GET /prompt_manager/scan/status reports the running flag and last event
- dashboard: dismissing the modal while a scan runs minimizes it to a
  progress pill, the page re-attaches to a running scan after a reload,
  a lost stream reconnects, and leaving the page mid-scan asks first
2026-10-08 18:01:34 -07:00
Vito Sansevero 13d1115406 test: close all database connections before deleting temp files
Windows refuses to unlink an open SQLite file and executor threads keep
their own connections, so every owned test that creates a PromptDatabase
calls close_all() before cleanup; the diagnostics temp dir ignores
cleanup errors.
2026-10-08 18:01:34 -07:00
Vito Sansevero b127bbeba1 fix: image worker thread exits on shutdown and restarts cleanly
ImageGenerationHandler.shutdown() clears the pending set, sets a stop
event the worker's delay and settle waits listen to, and puts a None
sentinel on the queue; the worker exits within the current wait. Each
worker owns its queue and stop event so a draining worker never swallows
an image scheduled after shutdown. ImageMonitor.stop_monitoring calls it.
2026-10-08 18:01:34 -07:00
Vito Sansevero 675168deff fix: private config.json, merged settings save, synthetic test-link path
PromptManagerConfig.save_to_file and the settings endpoint write
config.json through write_private_json: created with mode 0600 (chmod on
POSIX, best effort on Windows) because the file can hold an API key.
_persist_settings merges web_ui and gallery.monitoring.directories into
the existing file instead of overwriting it, so the integrations section
and any other key survive a settings save.

The test-link diagnostic no longer stores a client-supplied path; it
records a marker file name under the output directory.
2026-10-08 18:01:34 -07:00
Vito Sansevero 5dfe7a8409 fix: bound log file size and backup count in the logging config endpoint
max_file_size must be between 1 MB and 1024 MB (bytes) and backup_count
between 0 and 50; anything else is a 400. Also close every database
connection in the logging test teardown.
2026-10-08 18:01:34 -07:00
Vito Sansevero 40dde002a4 fix: validate hand-edited gallery roots everywhere and bound output scans
A root written straight into config.json skipped the containment check
the settings endpoint applies. GalleryConfig.update_config (and so
load_from_file) now drops entries that fail validate_gallery_root with a
warning, _find_comfyui_output_dir validates the configured directory
before using it, and ImageMonitor.start_monitoring validates every
directory before scheduling the observer.

The duplicate cleaner only deletes files with a media extension and
never anything under a thumbnails directory. _collect_media_files walks
with os.walk(followlinks=False), skips symlinked files and directories,
prunes thumbnails, stops MAX_SCAN_DEPTH (12) levels down and after
MAX_SCAN_FILES (50000) files.
2026-10-08 18:01:34 -07:00
Vito Sansevero 2b3cd5cbfb fix: harden database restore and keep server paths out of admin responses
Restore: verify_database_file and restore_from_file run through
_run_in_executor; the upload is also opened read-only with
PRAGMA trusted_schema=OFF and refused when sqlite_master lists a trigger
or a view (a prompts database has neither, a crafted one could use them
to run SQL against the live data); at most MAX_SAFETY_BACKUPS (10)
'<db>.backup_*' files are kept next to the live database, oldest first.

Responses: backup_created and the maintenance statistics database_path
go through _public_path, and backup/restore failure messages through
_public_error, so no absolute server path appears in an admin body.
2026-10-08 18:01:34 -07:00
Vito Sansevero b6f1e0a2c0 feat: capped JSON body reader and single-flight lock for long admin jobs
Add _read_json_body(request, max_bytes=1_000_000) to py/api: reads the
body in chunks, answers 413 past the cap (declared or streamed) and 400
for invalid or non-object JSON. Every JSON POST in the admin and logging
handlers goes through it; it is exposed as PromptManagerAPI._read_json_body
for the other route mixins.

Add _acquire_long_job(name) / _long_job_busy_response(label) on the API
object: one asyncio.Lock per job family, handed out as an async context
manager or None when busy, so a handler answers 409 instead of starting a
second scan. The duplicate scan and delete share the 'duplicates' family.
2026-10-08 18:01:34 -07:00
Vito Sansevero 8413f9e240 fix: static routes resolve under web/ with an extension allow-list
/prompt_manager/lib/{filepath} and /js/{filepath} only rejected '..' and a
leading '/'. On Windows os.path.join(root, 'C:/x') or a leading backslash
re-anchors the path, so any file on the host could be read. Requests are
now refused when the path is absolute on any platform (slash, backslash,
drive letter, UNC prefix), carries an empty, '.' or '..' segment, has an
extension outside the allow-list, or resolves (symlinks included) outside
the static directory; the resolved real path is served with its MIME type.
2026-10-08 18:01:34 -07:00
Vito Sansevero 8e2578ceb6 fix: bound resolve_text on diamond chains with memoisation, depth and length caps
A per-path visited set alone makes a chain of diamonds (node i reads node
i+1 on both inputs) exponential in depth. Each node's text is now
memoised so it is expanded once, every expansion is charged to the shared
MAX_VISITS budget, chains deeper than MAX_DEPTH (64) are unknowable so
recursion stays bounded, and text longer than MAX_TEXT_LENGTH (100k) is
unknowable so StringConcatenate doubling cannot exhaust memory.
2026-10-08 18:01:34 -07:00
Vito Sansevero 5b7fa1476a fix: resolve_text follows a node shared by two branches
The cycle guard used one visited set for the whole walk, so a string node
read twice (a diamond, or StringConcatenate with the same source on both
inputs) made the second branch None and the prompt text unknowable. Cycle
detection is now scoped to the current path; a separate visit counter
keeps MAX_VISITS as the bound on total work.
2026-10-08 18:01:34 -07:00
Vito Sansevero 4400444619 test: stop leaking a mocked utils.comfyui_integration and mock the IS_CHANGED clock
test_image_monitor_singleton replaced utils.comfyui_integration in
sys.modules with a MagicMock and never restored it, so running it before
test_comfyui_integration failed eight tests. The mock was unused; drop it
and install the server stub with setdefault in every owned test module.

The PromptSearchList IS_CHANGED test slept 10 ms between two time.time()
reads; the Windows clock ticks every 15.6 ms, so it was flaky there. Mock
the clock with an incrementing counter instead.
2026-10-08 18:01:34 -07:00
Vito Sansevero 9ba6c0ca63 fix: review flow uses the served image url and the lora-dirs test pins its config
Image paths in API responses are now relative to the ComfyUI tree, so the
auto-tag review page can no longer derive a serve URL from image_path; the
server always provides url. The lora-dirs serving test assumed an empty
LoraManager path, which only held in a checkout without a local config.json.
2026-10-08 18:01:34 -07:00
Vito Sansevero efcbd5a8aa fix: gate the LoraManager serving roots and accept relative link paths
A LoraManager path hand-edited into config.json went straight into
find_lora_directories and so into the image-serving allow-list. When a
path is configured it must now pass lora_utils.resolve_lora_manager_path
(a custom_nodes child whose name contains 'lora'); otherwise, or when the
resolver is unavailable, no LoRA directories are served. An empty path
keeps the custom_nodes-only auto-detection.

POST /images/link now also accepts the relative forms the API hands out
(root-relative 'sub/a.png' and ComfyUI-relative 'output/sub/a.png'),
resolving them against the allowed roots and anchors; '..' segments in
a relative path are refused and unknown files are 404.
2026-10-08 18:01:34 -07:00
Vito Sansevero 2381958fc7 fix: export every prompt by paging instead of truncating at 10000
/prompt_manager/export fetched a single page of 10000 prompts and
silently dropped the rest. It now walks the table in EXPORT_PAGE_SIZE
pages (1000) inside one executor job until a short page is returned.
2026-10-08 18:01:34 -07:00
Vito Sansevero f0e7561fb2 fix: validate tags on every tag mutation route
add_prompt_tag, add_tags_to_prompt, bulk_add_tags, merge_tags and
rename_tag accepted whatever the body carried; a non-string tag raised
AttributeError (500) and over-long or control-character tags were
stored. They now run utils.validators.validate_tags through a shared
tags_error helper and answer 400 with the validator's message. Blank
entries in add_tags_to_prompt are still skipped rather than rejected.
2026-10-08 18:01:34 -07:00
Vito Sansevero 9db5fc6acc fix: decode tag names from the URL only once
The tag lookup, rename and delete routes ran urllib.parse.unquote on
match_info values that aiohttp had already percent-decoded, so a tag
literally named '%41' (requested as %2541) was handled as 'A'. The
second decode is dropped.
2026-10-08 18:01:34 -07:00
Vito Sansevero cd209131ee fix: check thumbnail directory containment with a real path test
clear_thumbnails compared resolved paths with str.startswith, so a
thumbnails directory that resolved to a sibling with a shared prefix
(out vs out2) passed the safety check and was emptied. It now uses the
module's _is_within (realpath + normcase + Path.is_relative_to).
2026-10-08 18:01:34 -07:00
Vito Sansevero e720aeb73a fix: describe images relative to the ComfyUI tree in API responses
Gallery and prompt responses carried absolute server paths: /images/output
entries (path, root_dir), thumbnails_path, the images/prompt lookup, every
image list (image_path plus its normalised file_path duplicate) and the
preview images attached to prompt listings. They now go through
_public_path: /images/output 'path' is relative to its root (what the
delete and autotag endpoints resolve against), 'root_dir' and every
other image_path are relative to the ComfyUI base, and file_path is
dropped. recent/all/search image lists also gain url/thumbnail_url via
the same enrichment the prompt-images route already had. Remaining
str(e) error bodies in both modules use the basename-only error text.
2026-10-08 18:01:34 -07:00
Vito Sansevero 1c37f0ab56 fix: make the thumbnail progress stream POST-only
GET /prompt_manager/images/generate-thumbnails/progress wrote thumbnails
to disk, so any prefetch, link preview or <img src> could start a full
output-directory scan. The route now accepts POST only (GET answers
405); quality still comes from the query string so the SSE client can
keep the same URL shape.
2026-10-08 18:01:34 -07:00
Vito Sansevero 2dbb21edc2 fix: clamp paging on tag listings and the page-derived recent offset
tags/stats, tags/{tag}/prompts and tags/filter parsed limit/offset with a
bare int(), so limit=999999 was passed straight to SQLite and a huge
offset raised OverflowError (500). They now share parse_page_params
(limit 1..500, offset 0..MAX_PAGE_OFFSET, 400 on non-integers), and
/recent clamps the offset it derives from ?page= the same way.
2026-10-08 18:01:34 -07:00
Vito Sansevero d6cef849a1 fix: validate image file_info before it reaches the generated_images columns
link_image_to_prompt stored size, dimensions and format straight from the
request body, so a string such as '"><img src=x>' ended up in INTEGER
columns and was echoed back to the gallery. Size and dimensions are now
coerced with int() and bounded (size 0..10^12, width/height 0..65535),
format is matched against a whitelist of media formats, and anything
else is stored as NULL while the image still links. Non-dict metadata or
file_info no longer aborts the link.
2026-10-08 18:01:34 -07:00
Vito Sansevero 507c8a8993 test: close every database before unlinking and anchor public paths in image tests
Each owned test that opens a PromptDatabase now calls close_all() before
removing the file, and temp directories holding a database use
ignore_cleanup_errors, so the suite tears down cleanly on Windows. The
image API fixture lays out a fake ComfyUI tree (<tmp>/output) and pins
GalleryConfig.path_anchors to it so responses can be checked for
root-relative paths.
2026-10-08 18:01:34 -07:00
Vito Sansevero f9da6488d6 fix(autotag): contain batch rows, require media extensions, bound the scan
- The batch tagger runs every generated_images row through
  _resolve_allowed_image_path; rows that exist but lie outside the served
  output directories are skipped and counted in skipped_outside_roots on
  progress and complete events, and the engine receives the resolved path.
- _resolve_allowed_image_path requires a media extension (.png .jpg .jpeg
  .webp .gif .bmp .tiff .tif), so autotag/single returns 403 for other files.
- autotag/start holds a module-level lock; a concurrent start gets 409
  {"success": false, "error": "Auto-tagging is already running"}.
- scan_output_dir walks with a 50,000-file and 12-level cap, skips symlinked
  files and thumbnails, returns path through _public_path, relative_path in
  POSIX form, and a truncated flag.
- Autotag handlers report exceptions via the shared _public_error; the
  local copy is removed. Tests close the database before unlinking it.
2026-10-08 18:01:34 -07:00
Vito Sansevero d83383b9e0 fix(lora): confine LoraManager paths to custom_nodes and hide server paths
- is_civitai_url also refuses URLs with userinfo or a non-443 port, so
  https://evil.com\@civitai.com/x and https://civitai.com:8443/x are rejected.
- resolve_lora_manager_path / custom_nodes_directories: lora/enable and
  lora/scan only accept a LoraManager directory that is a direct child of
  ComfyUI's custom_nodes (folder_paths base_path or registered custom_nodes
  folders, else this package's parent) and whose name contains "lora",
  compared via realpath+normcase; relative public paths resolve against the
  API path anchors. Anything else is 400, so find_lora_directories can no
  longer widen the image allow-list with arbitrary trees.
- lora/enable persists through PromptManagerConfig.save_to_file(), which
  honours PROMPT_MANAGER_CONFIG_PATH, instead of a hard-coded config.json.
- detect/status/enable return path and detected_path through _public_path;
  every lora handler reports exceptions via _public_error, and lora/scan
  errors raised after the SSE stream opened become an error event.
- Tests close the database before unlinking it.
2026-10-08 18:01:34 -07:00
Vito Sansevero 1d44d574cb fix: drop the unused copy-text data-action handler
Nothing in the admin markup emits data-action="copy-text"; the prompt
copy buttons call copyToClipboard directly.
2026-10-08 18:01:34 -07:00
Vito Sansevero b792da53ba fix: read thumbnail generation progress over POST with SseStream
images/generate-thumbnails/progress is becoming POST-only, which the
browser EventSource cannot request. Both thumbnail progress consumers in
the gallery now use SseStream.connect(url, {method: 'POST'}) exactly as
the autotag streams do. SseStream gains addEventListener/removeEventListener
for named event: frames (tested) so the status, start, progress,
file_error, complete and error listeners stay as they were, and an onclose
guard rejects when the stream ends without a terminal frame.
2026-10-08 18:01:34 -07:00
Vito Sansevero 4ab0214f09 fix: address gallery auto-tag review images by path, not digest id
The gallery review flow builds its list from /prompt_manager/images/output,
whose id is a 16-hex path digest, but posted it as image_id to
autotag/single, so the server looked up a database row and every request
404ed. autotagSingleBody (pure, tested) now sends path for output-folder
entries and keeps image_id for database rows, which the admin review flow
(/images/all) still uses.
2026-10-08 18:01:34 -07:00
Vito Sansevero 0f9569cdd8 fix: escape remaining server values rendered through innerHTML
Maintenance and diagnostics results, review-tag errors, video info and
metadata error messages, consistency issues, recent image filenames,
output directory names, the prompt rating attribute, gallery media types
and duplicate group headers were interpolated unescaped. The copy
fallback modal also inlined the prompt text inside <textarea>, which a
closing tag could break out of; it now sets textarea.value.
2026-10-08 18:01:34 -07:00
Vito Sansevero e709a158e6 fix: escape image dimensions and workflow JSON before rendering
openGalleryModal interpolated width, height and file_size (stored from a
POST body, so possibly strings) straight into data-caption and the card
text. showWorkflowData wrote JSON.stringify(workflow) raw into a blank
same-origin window, so a workflow containing </pre><script> ran. Both now
go through ImageHelpers (new pure module, node:test covered); a string
file_size no longer reaches formatFileSize's toFixed either, and a blocked
popup shows a notice instead of throwing.
2026-10-08 18:01:34 -07:00
Vito Sansevero a3877b9200 chore: flake8 ignores the gitignored standalone tagger script 2026-10-08 18:01:34 -07:00
Vito Sansevero d7be0df380 style: wrap long lines in tests
Split long docstrings and SQL literals (values unchanged) and drop the
unused os import from test_path_security.
2026-10-08 18:01:34 -07:00
Vito Sansevero ea7696bb9a style: wrap long lines and drop unused imports in utils
Re-wrap module docstrings, comments and f-string log messages; remove the
unused typing.List and PngInfo imports; mark the PIL availability probe in
diagnostics with noqa: F401; turn two placeholder-less f-strings into plain
strings; strip trailing whitespace inside the diagnostics SQL.
2026-10-08 18:01:34 -07:00
Vito Sansevero c8613bdb66 style: wrap long lines in the API layer, autotag and config
Split long f-string log messages, response messages, URL builders and
docstrings; DEFAULT_PROMPT and SYSTEM_PROMPT are rewritten as implicit
concatenations with identical values. Drop the unused shutil import in
admin and the unused HFCacheInfo import in autotag; keep the
hf_hub_download/snapshot_download availability probe with noqa: F401 and
mark the deliberately late imports in config with noqa: E402.
2026-10-08 18:01:34 -07:00
Vito Sansevero 3ad10404dc style: wrap long lines in node modules
Split tooltip, description and docstring strings that exceed 88 columns
using implicit concatenation; string values are unchanged. Drop the unused
typing imports from prompt_search_list and mark the deliberately late node
imports in __init__ with noqa: E402.
2026-10-08 18:01:34 -07:00
Vito Sansevero 1f422d2c43 fix: restore through SQLite's backup API, close_all for shared files, safer output scans
restore_from_file copied the uploaded file over the live database and deleted
its WAL while other PromptModel instances (nodes, image monitor, queue hook)
still held connections, which corrupts on POSIX and fails on Windows. It now
copies pages through the online backup API into the live connection so every
open handle sees the new content. PromptDatabase.close_all() closes the
connections of every thread, which tests need before deleting a database.
Output scans skip symlinked files, filesystem entries get a stable digest id
instead of a per-process hash(), and thumbnail streaming stops when the
client disconnects.
2026-10-08 18:01:34 -07:00
Vito Sansevero 0393d01746 fix: page image listings in SQL and drop three unused helpers
get_recent_images and search_images_by_prompt take limit/offset so the recent
and search image routes no longer over-fetch a 5000-row window and slice it.
generate_content_hash, PromptModel.migrate_database and find_duplicates had no
callers outside their own tests. The isolated logger tests re-apply their
thread filter after update_config rebuilds the handlers. Long lines in the
touched database and image modules are wrapped so the full flake8 hook passes.
2026-10-08 18:01:34 -07:00
Vito Sansevero 11aea013ca test: cover frontend modules
browser-globals.test.js loads every page module through vm with a fake
window, the way the pages do, and asserts the global each one exposes
(HtmlEscape/escapeHtml, DataActions, ApiClient, ListState, SseStream,
PngMetadata, ViewRouter, PromptListSort, ComfyMetadata). sse-stream gains
cases for a POST body, missing handlers and close() before or during
delivery. Every pure module is above 85% line coverage under
node --test --experimental-test-coverage.
2026-10-08 18:01:34 -07:00
Vito Sansevero 247abc3475 fix: tags deep link renders and admin modals close
admin.html loaded tags-page.js after admin.js, so opening the page at #/tags
found no TagsPageManager and rendered nothing; the script order is now
theme-bridge, html-escape, api-client, png-metadata, (list-state, sse-stream,
data-actions), viewer, comfy-metadata, prompt-list-sort, view-router,
tags-page, admin. Route resolution lives in the pure ViewRouter.resolveView().

The auto-tag modal's close buttons called a bare hideModal(), which only
gallery.js defines; they call window.admin.hideModal(). PromptAdmin is
constructed from startAdmin() on DOMContentLoaded (or immediately when the
document is already parsed) instead of at script evaluation time.
2026-10-08 18:01:34 -07:00
Vito Sansevero de194af8bc refactor: one PNG metadata parser
admin.js, gallery.js and metadata.html each carried a copy of the PNG chunk
walker that reads ComfyUI's tEXt/iTXt entries (admin.js had two until the
shadowed one was removed). web/js/png-metadata.js is now the only parser:
it checks the signature, bounds-checks every chunk so a truncated file or a
bogus length cannot throw, decodes tEXt as Latin-1 and iTXt as UTF-8, skips
compressed zTXt/iTXt payloads instead of decoding them as garbage, and stops
at IEND. The three page-level parsePNGMetadata functions delegate to it.
2026-10-08 18:01:34 -07:00
Vito Sansevero b2fb630f61 fix: follow the POST-only autotag streams and the new LoRA and list contracts
autotag/download and autotag/start are POST-only now; EventSource can only
GET, so both pages stream their progress through SseStream.connect(url,
{method: 'POST'}), which keeps the onmessage/onerror/close surface the
handlers already use. autotag/single is called with image_id and the re-tag
dialog shows the server's filename instead of splitting an absolute path.

lora/status reports has_civitai_api_key instead of the key: the field shows a
'key is stored' placeholder, an empty field keeps the key on save and a
'Clear the stored key on save' checkbox sends clear_civitai_api_key.

Thumbnail generation reports per-file failures as file_error events; both
progress views count them (and show error_count from progress) without
closing the stream. The auto-tag review follows pagination.has_more on the
capped /images/all endpoint, and /search is paged like /recent so paging in
search mode no longer switches the list back to recent prompts.
2026-10-08 18:01:34 -07:00
Vito Sansevero 7b6b7ecf3d fix: stale responses, double submits and leaked listeners
web/js/api-client.js wraps fetch with one failure path (ApiError for non-2xx,
success:false and non-JSON bodies) and latestOnly(), which aborts the previous
in-flight request and resolves superseded calls to STALE. The admin search and
recent-page loads share one guard, the gallery page load has its own, so fast
typing or clicking can no longer paint an older result over a newer one.

In-place prompt editing used a blur listener with {once:true} plus a keydown
listener added on every edit, which sent the PUT twice and stacked handlers.
The list now has one keydown/focusout pair bound at construction and
finishEdit() clears the edit state before touching the element, so a save is
sent exactly once. Mutations re-fetch the page currently shown (refreshList)
instead of calling search(), and delete, bulk delete, edit, restore and scan no
longer reload the whole page.

Pagination state is committed only after a successful fetch via the pure
ListState.nextPageState(current, response) in both admin and gallery, so a
failed or aborted page request leaves the current page intact. The gallery
lightbox removed its keydown handlers on a non-existent 'remove' event; a
MutationObserver now drops them when the modal leaves the document.

web/js/sse-stream.js reads server-sent events over fetch so POST-only progress
endpoints can stream.
2026-10-08 18:01:34 -07:00
Vito Sansevero 38fd581f09 refactor: remove shadowed and dead frontend code
admin.js defined parsePNGMetadata, extractComfyUIData, showFullPrompt,
showWorkflowData, downloadWorkflowJSON and copyAllMetadata twice; the first
definitions were unreachable because the later ones shadow them.
updateMetadataPanel, showMetadataError, copyPrompt and tryFallbackMetadata
only ever wrote to #metadataContent, an element no page creates, so they are
gone too. gallery.js defined escapeHtml and showNotification twice. The admin
keydown/resize handlers referenced closeImageViewer, previousImage, nextImage
and applyImageSizing, none of which exist, and the imageViewMode state they
used; only the Escape-closes-gallery branch remains.

Debug console.log calls are removed; the ones that were the only statement of
a catch block are console.warn so parse failures stay visible.
2026-10-08 18:01:34 -07:00
Vito Sansevero edc0ee3d00 fix: HTML escaping covers attribute context
escapeHtml now escapes & < > " ' (shared web/js/html-escape.js, loaded by
admin, gallery and metadata pages) instead of the textContent/innerHTML
round-trip that left quotes intact, so a tag, category, checkpoint or file
name containing a quote can no longer break out of a data-*, value or title
attribute.

Inline onclick handlers that interpolated server data now carry data-action
and data-* attributes and are dispatched through one delegated listener per
page (web/js/data-actions.js); the img onerror fallback uses
data-fallback-src with a capturing error listener. Every remaining template
value written into an attribute or text node (prompts, checkpoints, file
paths, log lines, directory names, categories, suggestions) is escaped.

metadata.html no longer builds an "explorer /select" shell command from the
file name (Electron-only branch); the path is copied to the clipboard.
2026-10-08 18:01:34 -07:00
Vito Sansevero f99e76a135 fix: truncating logs no longer rolls the main log over or pads it with zeros
truncate_logs emptied prompt_manager.log through a second open(), leaving the
rotating handler's stream at its old offset: the next record was written past
the new end (zero padding on POSIX) and tell() still reported the old size, so
the file rolled over and resurrected the backup that had just been deleted.
It also logged once per file from inside the deletion loop, which could do the
same with a small size limit. The main log is now truncated through the
handler's own stream and a single summary line is logged afterwards.
2026-10-08 18:01:34 -07:00
Vito Sansevero 9d6eb728fc test: make admin, logging and diagnostics tests independent of test order
The diagnostics message no longer carries the absolute path, the restore test
closes its raw sqlite connections before the live file is replaced, the logs
tests declare their own folder_paths base directory, and the isolated logger
filters every handler to the test thread.
2026-10-08 18:01:34 -07:00
Vito Sansevero 78a12b67fd test: cover admin and core API routes
Route tests for stats, cleanup, duplicate scan/delete, settings,
diagnostics, test-link, maintenance (every operation, success and
failure), the output scan internals, and contract-level tests for
backup/restore (status codes and envelope only).
2026-10-08 18:01:34 -07:00
Vito Sansevero 7128957d2c refactor: split diagnostics, maintenance and HTML serving into helpers
- run_diagnostics composes four module-level probes (_diagnose_database,
  _diagnose_dependencies, _diagnose_output_dirs, _diagnose_image_monitor).
- run_maintenance is table-driven: _maintenance_runners maps each
  operation name to a callable and _run_maintenance_operations reports
  per-operation success or a public error message.
- _read_json answers 400 for malformed or non-object JSON bodies in
  settings, duplicate delete, test-link and maintenance instead of 500.
- test_image_link delegates to _link_test_image.
- The four HTML page routes share _serve_html_page.
2026-10-08 18:01:34 -07:00
Vito Sansevero cdd5fd6b5e test: cover core API module and gzip middleware 2026-10-08 18:01:34 -07:00
Vito Sansevero e282f6ea50 fix: clamp the logs limit and reject non-integers
GET /prompt_manager/logs accepted any limit up to 1000 and answered 500
when the value was not an integer. The limit is now clamped to [1, 500]
and a non-integer value is a 400.
2026-10-08 18:01:34 -07:00
Vito Sansevero 879247f1ef refactor: settings reads the image monitor through its module
get_settings located the ImageMonitor singleton by scanning sys.modules
for any module with 'image_monitor' in its name, a workaround for the
package being importable under two identities. It now uses the same
try-package / except-top-level import as the rest of the API
(_current_image_monitor) and falls back to the configured roots when no
monitor is running.
2026-10-08 18:01:34 -07:00
Vito Sansevero 38f4c0af43 perf: run duplicate and output scans off the event loop
The duplicate scan walked the output tree and hashed every file on the
aiohttp event loop, and the duplicate delete removed files inline; the
output scan ran its per-file database work one executor hop at a time.

- _find_duplicate_images_sync / _describe_media_file do the walk and
  hashing; find_duplicate_images() runs them via _run_in_executor.
- _delete_duplicate_images_sync / _delete_one_duplicate do the removal
  (absolute or output-relative paths) off the loop.
- The output scan is split into _collect_output_media_sync,
  _extract_batch_metadata_sync and _ingest_scan_batch_sync (one
  executor call per batch for all DB work) with a small SSE generator.
- Media collection is shared and de-duplicates by normcase'd path.
2026-10-08 18:01:34 -07:00
Vito Sansevero b0e60ec4b8 fix: no absolute server paths in API responses
Settings, diagnostics, duplicate/output scans and the logging endpoints
returned absolute filesystem paths, and several error envelopes echoed
str(e) which for OSError carries the full path.

- _public_path() renders a path relative to the ComfyUI base directory
  (folder_paths.base_path, else the output dir's parent) or to the
  parent of an opted-in extra gallery root, falling back to the
  basename; _public_error() reduces OSError to strerror + basename.
- GET /settings returns gallery roots and monitored directories in that
  form; POST /settings resolves relative roots against the same anchors
  (GalleryConfig.resolve_gallery_root) so the GET output round-trips.
- Diagnostics output_dirs, monitored_directories and the missing-DB
  message no longer carry absolute paths; the image monitor is reached
  through the package-or-top-level import fallback instead of a
  three-dot relative import that failed outside the package.
- Duplicate scan 'path' is now relative to the output root and the
  delete endpoint accepts relative paths.
- /logs/files 'path' and /logs/stats 'log_directory' use the public
  form; unexpected errors answer with a generic message.
2026-10-08 18:01:34 -07:00
Vito Sansevero e7fa993a42 fix: log config accepts only known keys
POST /prompt_manager/logs/config forwarded the whole request body into
PromptManagerLogger.update_config(), so any key could be injected into
the logging configuration. The handler now accepts only LOG_CONFIG_KEYS
(level, max_file_size, backup_count, console_logging, file_logging,
buffer_size), type-checks each value (level name, non-negative int,
bool) and answers 400 listing unknown keys. Malformed or non-object
bodies are 400 instead of 500.
2026-10-08 18:01:34 -07:00
Vito Sansevero c855a1e23b fix: gallery roots are restricted to ComfyUI directories
A gallery root could be set to '/' (or 'C:\') through POST
/prompt_manager/settings because the deny list only matched a few
system prefixes. The root was persisted to config.json and fed into the
image-serving containment check, which then allowed any file on disk to
be read through /prompt_manager/images/serve/.

- GalleryConfig.validate_gallery_root() accepts a directory only when its
  realpath lies inside a ComfyUI directory (folder_paths output/input/
  temp/user) or a directory listed in PROMPT_MANAGER_EXTRA_GALLERY_ROOTS;
  filesystem roots, the home directory, files and missing paths are
  rejected.
- save_settings returns 400 and persists nothing when any root fails.
- _get_all_output_dirs() filters configured roots through the validator
  and never falls back to a rejected root.
- PROMPT_MANAGER_CONFIG_PATH relocates config.json; load/save default
  to it.
2026-10-08 18:01:34 -07:00
Vito Sansevero 5110ff1aff test: isolate the clock and log buffer from background threads of other tests 2026-10-08 18:01:34 -07:00
Vito Sansevero 3a67c4b952 test: cover database operations
Behaviour tests for export (json/csv/errors), duplicate detection and
merging, image metadata storage and lookup, missing-file cleanup,
statistics, bulk operations, orphan pruning, consistency checks,
subfolder listing, tag queries and management edge cases, legacy tag
column parsing, config-driven path resolution, model housekeeping and
the TEXT -> INTEGER prompt_id migration. database/operations.py and
database/models.py are both above 85% statement+branch coverage.
2026-10-08 18:01:34 -07:00
Vito Sansevero b05225490e fix: escape LIKE wildcards in image prompt search
search_images_by_prompt interpolated the user's text into LIKE
unescaped, so '50%' also matched '50 percent'. It now goes through the
same escape_like helper as search_prompts. Also drops an unused import
and an unused cursor assignment flagged by pyflakes.
2026-10-08 18:01:34 -07:00
Vito Sansevero ae11e81203 fix: image uniqueness by full path, escaped LIKE search, no usage stamp on save
generated_images was UNIQUE(prompt_id, filename) on the basename, so
ComfyUI's same-named files in different date folders were silently
ignored by INSERT OR IGNORE, and the lastrowid == 0 branch after it
never ran. The table now carries file_path (normcase/normpath/abspath
of the image path) with UNIQUE(prompt_id, file_path); the migration
collapses rows that normalise to one file and supersedes the old
basename migration, which would otherwise undo it on every start.

search_prompts and the tag search escaped nothing, so 100% matched
'100 percent' and a_b matched aXb; user text now goes through
escape_like with ESCAPE. save_prompt no longer stamps last_used_at, the
usage backfill runs only on the upgrade that adds the columns, and the
Recently Used / Most Used orders put never-run prompts (NULL) after run
ones instead of first.
2026-10-08 18:01:34 -07:00
Vito Sansevero 9234dac211 fix: migrations are idempotent and schema init runs once per process
Every PromptDatabase() (nodes, image monitor, API, diagnostics) re-ran
table creation and all migrations. Schema init is now cached per
normalised path (class-level set under a lock) and re-run only when the
file disappeared, after a restore, or after reset_schema_cache().

The copy-and-swap migrations drop a stale *_new table left by an
interrupted run, execute inside an explicit transaction with
PRAGMA foreign_keys = OFF (DROP TABLE prompts used to cascade-delete
every generated_images row), roll back cleanly and log and re-raise
sqlite3.Error instead of swallowing it.
2026-10-08 18:01:34 -07:00
Vito Sansevero b5720904cd fix: backups include WAL data and restores are integrity-checked
The database runs in WAL mode, so a shutil.copy2 of prompts.db silently
dropped every committed row still in prompts.db-wal; the backup
download and the pre-restore safety copy both did that. Restore also
overwrote the live file with connections open, read the whole upload
into memory before checking the 100 MB cap and never ran
PRAGMA integrity_check.

PromptModel.backup_database now uses the SQLite online backup API,
verify_database_file opens a candidate read-only and checks header,
integrity and the prompts table, and restore_from_file backs up the
live database, closes every thread's connection, copies the file,
removes stale -wal/-shm and re-runs migrations. The admin handlers
stream the upload to a temp file with an early size cutoff, reject
anything verify_database_file refuses with a 400 and keep the
{success: ...} JSON shape. The schema-init connection is now closed
instead of leaking.
2026-10-08 18:01:34 -07:00
Vito Sansevero 43d2352fff fix: saving an existing prompt concurrently returns its id
Node execution, the queue hook and the image monitor all do
get_prompt_by_hash() then save_prompt(). When two of them race on the
same text the second INSERT failed on UNIQUE(hash) and surfaced an
IntegrityError. save_prompt now catches it, re-fetches by hash and
returns the existing id, so eight threads saving one prompt end with a
single row and every record_prompt_use counted.
2026-10-08 18:01:34 -07:00
Vito Sansevero 1a957b8fb3 fix: give every thread its own sqlite connection
PromptModel.get_connection() handed every thread the same
check_same_thread=False connection, so a commit or rollback from the
watchdog, executor or queue-hook thread could land on another thread's
half-done write (sqlite3 raised 'bad parameter or other API misuse'
under concurrent saves). Connections now live in threading.local, each
with WAL, Row factory, foreign_keys and busy_timeout, and are tracked so
they can be closed. PromptModel.close() / PromptDatabase.close() close
the calling thread's connection so tests and restores can delete the
file on Windows.
2026-10-08 18:01:34 -07:00
Vito Sansevero 0ab510f9a5 docs: point at the moved canvas extension under web/comfy 2026-10-08 18:01:34 -07:00
Vito Sansevero 0ff59b5f5b test: cover __init__
Version parsing from pyproject (and the unknown fallback), and a server
whose add_on_prompt_handler raises: nodes and monitoring still come up.
2026-10-08 18:01:34 -07:00
Vito Sansevero 3e1e15a629 test: cover utils/comfyui_integration
Singleton re-initialisation keeps registrations, latest prompt per thread,
cross-thread fallback with the five-minute recency limit, global lookup for
the current thread, and cleanup of old registrations.
2026-10-08 18:01:34 -07:00
Vito Sansevero 580063cf5c test: cover utils/logging_config
Isolated logger manager on a temporary log directory: memory buffer order,
level filter and 1000-entry cap, rotation and backup listing, tail and whole
file reads, path traversal rejection, truncation (including removal
errors), level changes across prompt_manager loggers, handler rebuilds.
2026-10-08 18:01:34 -07:00
Vito Sansevero c001f94213 fix: diagnostics dependency check survives a watchdog without __version__
check_dependencies read watchdog.__version__ for its log line; recent
watchdog releases have no such attribute, so the AttributeError escaped and
took the whole diagnostics run down. Read it with a default.

Also covers utils/diagnostics.py: database and images-table checks against
a real temporary database, file system check, ComfyUI output detection
(folder_paths, configured directories, relative fallback, nothing found),
dependencies, the full run and the test image link.
2026-10-08 18:01:34 -07:00
Vito Sansevero ee80a344e2 test: cover prompt_manager_base, prompt_manager and prompt_search_list
Node base: init wiring, metadata update on re-save, run counting, database
error paths, tag parsing, search and recent API wrappers, gallery start
failure, LoRA trigger-word injection (disabled, missing modules, enabled).
PromptManager: missing CLIP error, encoding of the combined text with a fake
CLIP, tracking failure tolerance, empty text. PromptManagerText: tracking
failure tolerance. PromptSearchList: filters, multipart and LoRA-only
skipping, newline collapsing, preview, empty and error results.
2026-10-08 18:01:34 -07:00
Vito Sansevero 205cf153ea fix: image monitor recent-prompt fallback reads the paginated result
get_recent_prompts returns {"prompts": [...]} but _get_fallback_prompt
indexed it like a list, so the last-resort link to the most recent prompt
always failed silently. Accept both shapes.
2026-10-08 18:01:34 -07:00
Vito Sansevero 3df88df583 fix: stop ComfyUI from loading the admin bundle into the canvas page
WEB_DIRECTORY = "web" made ComfyUI inject every .js under web/ (admin.js,
gallery.js, vendored Tailwind and ViewerJS) into the canvas page. The node
extension moves to web/comfy/prompt_manager.js (content unchanged; its
../../scripts/app.js import resolves against the served URL, which is the
same) and WEB_DIRECTORY points at that folder only. Admin and gallery assets
are still served from web/ by the extension's own routes.

tests/test_sort_contract.py still reads web/prompt_manager.js and must be
updated to the new path by its owner.
2026-10-08 18:01:34 -07:00
Vito Sansevero 7d65ee5516 refactor: one prompt hash implementation
utils.hashing.generate_prompt_hash is now the only prompt hash:
PromptManagerBase._generate_hash delegates to it and the inline
hashlib.sha256 copies in the prompt tracker and image monitor are gone.

Remove PromptManagerBase._open_web_interface (it targeted a web_interface
folder that never existed) and the unused restart_gallery.py script.

IS_CHANGED on PromptManager and PromptManagerText now includes category and
tags, so editing either re-runs the node and updates the saved metadata.
2026-10-08 18:01:34 -07:00
Vito Sansevero 59ad23201b fix: image monitor validates directories before creating the observer
start_monitoring assigned self.observer before checking that any directory
existed, so with no valid directory the monitor was stuck "already running"
and stop_monitoring raised on join of a never-started observer. Directories
are validated first and only a started observer is kept; stop_monitoring is
safe to call at any time and always joins. New is_monitoring property.

The per-image threading.Timer is replaced by one daemon worker: paths are
deduplicated and bounded (MAX_PENDING_IMAGES), processed in creation order
(batch images pop the prompt queue in order) after the processing delay and
once the file size is unchanged across two reads.
2026-10-08 18:01:34 -07:00
Vito Sansevero 5464e3295c fix: keep batch image queue aligned when unrelated workflows save images
The metadata strategy returned None for images whose embedded workflow has
no positive PromptManager node, so the handler popped the batch FIFO for
them and every later batch image linked to the wrong prompt. It now returns
a tri-state: linked (prompt found), foreign (no PromptManager prompt in the
workflow, so the image is skipped and no fallback applies) or unknown (only
then the queue, snapshot, tracker and recent-prompt fallbacks run).
2026-10-08 18:01:34 -07:00
Vito Sansevero 729190309c fix: count prompt runs when ComfyUI dequeues the prompt, not when it is submitted
The on-prompt hook runs before ComfyUI validates the request, so counting
there bumped run_count for prompts that were rejected or deleted from the
queue. The hook now only resolves the positive PromptManager texts, mints a
UUID4 prompt_id when the client sent none, and stashes the hashes under that
id (QueuedRuns, bounded to 1000 entries). PromptQueue.get is wrapped
idempotently; when a prompt is dequeued for execution its stashed runs are
counted (or remembered as pending first use). Servers without a prompt_queue
fall back to counting at submit time with one warning.

__init__ defines _global_db before the monitoring block and passes a factory
that raises RuntimeError while the database is unavailable; the package is
now imported under test with server and folder_paths stubbed.
2026-10-08 18:01:34 -07:00
Vito Sansevero 0a46420385 fix: do not follow negative text inputs when tracing the positive prompt
_TEXT_KEY matched input names such as text_negative, so a styler node feeding
a sampler's positive input pulled its negative PromptManager node into the
positive trace. Input names matching (^|_)neg(ative)?($|_) are now skipped
(new follows_input helper).
2026-10-08 18:01:34 -07:00
Vito Sansevero 248d5880e6 fix: never treat negative-only PromptManager nodes as the run prompt
run_prompt_nodes fell back to every PromptManager node whenever no positive
node was traced, including graphs that do have a sampler and whose only
PromptManager node feeds its negative input. Fall back only when the graph
has no recognisable sampler (new has_sampler helper). Samplers named like
one are recognised even without a model link. Adversarial graphs (cycles,
10k-node fan-out, non-dict inputs) are covered.
2026-10-08 18:01:34 -07:00
Vito Sansevero 2cf6bdd393 refactor: share the internal error message in autotag routes 2026-10-08 18:01:34 -07:00
Vito Sansevero 5c592e4a87 fix: saving LoRA settings without a key keeps the stored CivitAI key 2026-10-08 18:01:34 -07:00
Vito Sansevero 114f8c779d test: cover autotag and lora integration 2026-10-08 18:01:34 -07:00
Vito Sansevero a6d4facfbf refactor: autotag model loading is injectable 2026-10-08 18:01:34 -07:00
Vito Sansevero 2d3fc1cd08 fix: CivitAI downloads never follow redirects 2026-10-08 18:01:34 -07:00
Vito Sansevero b836e7cef9 fix: autotag/single checks real paths and DB rows against output dirs 2026-10-08 18:01:34 -07:00
Vito Sansevero 22d23020fe fix: CivitAI key is never returned or sent off-host 2026-10-08 18:01:34 -07:00
Vito Sansevero bcb2713baa fix: autotag endpoints no longer read arbitrary paths or run on GET 2026-10-08 18:01:34 -07:00
Vito Sansevero 4a8dd545d8 test: cover image and prompt routes
Every route in py/api/images.py and py/api/prompts.py now has a success
path and an error path test: save/update/delete/rate/tag/category/bulk
endpoints, image list/link/unlink/delete/metadata/scan endpoints, the
thumbnail SSE stream, clear-thumbnails, and the video thumbnail chain
(fake cv2 success/failure, ffmpeg absent or failing, PIL placeholder).
Combined statement+branch coverage: images.py 97.7%, prompts.py 97.4%.
2026-10-08 18:01:34 -07:00
Vito Sansevero 027fdc09bb fix: cap every request-driven resource in the image routes
- /images/recent, /images/all and /images/output parse limit/offset
  through parse_page_params on every path (absent, 0, negative, float
  strings), and the clamped values are what reach the DB. /images/all
  gets its own MAX_BULK_LIMIT ceiling instead of an unbounded query;
  /images/recent caps its fetch window at MAX_RECENT_IMAGES_WINDOW.
- parse_page_params also clamps offset to MAX_PAGE_OFFSET so values past
  int64 no longer surface as a 500 from SQLite.
- Gallery and thumbnail scans share _iter_media_files: no symlink
  following, MAX_SCAN_DEPTH, and MAX_GALLERY_FILES / MAX_THUMBNAIL_FILES
  caps. Thumbnail size comes only from named presets.
- JSON bodies on /images/link and /images/generate-thumbnails are read
  in bounded chunks; over MAX_JSON_BODY_BYTES answers 413, malformed or
  non-object JSON answers 400.
- /images/link only accepts media paths inside the allowed directories
  (403 otherwise), closing a path-existence oracle.
2026-10-08 18:01:34 -07:00
Vito Sansevero 5fddf56eee perf: thumbnail generation no longer blocks the event loop
generate_thumbnails_with_progress is split into _thumbnail_targets
(scan, executor), _generate_one (one PIL/ffmpeg job, executor) and an
async driver that awaits each file via _run_in_executor, emits one
progress frame per file and yields between files. Per-file failures
are reported as file_error events (error stays fatal for the UI) and
the loop continues. The wildcard Access-Control-Allow-Origin header is
gone. The blocking POST variant reuses the same helpers. The scan never
follows symlinks and is capped by MAX_SCAN_DEPTH / MAX_THUMBNAIL_FILES.
2026-10-08 18:01:34 -07:00
Vito Sansevero 3228824161 fix: bound page sizes on list endpoints
/search, /recent and /images/recent share parse_page_params: limit is
clamped to [1, MAX_PAGE_LIMIT=500], offset to >= 0, and non-integer
values answer 400 instead of 500. /search now honours offset and all
three return a pagination block with the effective limit and offset.
2026-10-08 18:01:34 -07:00
Vito Sansevero 191d02eb6c fix: image routes serve only media files inside allowed directories
serve_image and serve_output_image now reject anything whose extension
is not in IMAGE_EXTENSIONS, fail closed when no output directory is
configured, and answer 403 (not 404) for dot-dot, absolute and
symlink-escaping paths. Containment is checked after realpath+normcase
so it behaves the same on Windows, macOS and Linux. 500 responses use
safe_error_message so OSError text no longer leaks absolute paths.
2026-10-08 18:01:34 -07:00
Vito Sansevero d8e9f290ef chore: keep agent worktrees out of coverage measurement 2026-10-08 18:01:34 -07:00
Vito Sansevero 2fdef18355 refactor: drop validate_workflow_name, the column it validated no longer exists 2026-10-08 18:01:34 -07:00
Vito Sansevero 9ffaa1e6eb ci: test on Linux, Windows and macOS with a coverage gate and lint job 2026-10-08 18:01:34 -07:00
Vito Sansevero 5093cb213f chore: valid packaging metadata, flake8 config file, ignore local config with secrets
pyproject.toml carried requirements.txt comment lines as dependencies, which
fails PEP 508 parsing. flake8 never read [tool.flake8] from pyproject, so the
settings move to .flake8 and pre-commit runs the full rule set. config.json
(holds the CivitAI API key and machine paths) and other local artifacts are
now ignored. coverage joins the dev requirements.
2026-10-08 18:01:34 -07:00
89 changed files with 445 additions and 5552 deletions
-29
View File
@@ -1,29 +0,0 @@
{
"required_status_checks": {
"strict": true,
"contexts": [
"py3.10 on ubuntu-latest",
"py3.11 on ubuntu-latest",
"py3.12 on ubuntu-latest",
"py3.13 on ubuntu-latest",
"py3.10 on windows-latest",
"py3.11 on windows-latest",
"py3.12 on windows-latest",
"py3.13 on windows-latest",
"py3.10 on macos-latest",
"py3.11 on macos-latest",
"py3.12 on macos-latest",
"py3.13 on macos-latest",
"coverage gate (85%)",
"black and flake8",
"frontend modules",
"viewerjs",
"real ComfyUI compatibility"
]
},
"enforce_admins": true,
"required_pull_request_reviews": null,
"restrictions": null,
"allow_force_pushes": false,
"allow_deletions": false
}
-12
View File
@@ -46,15 +46,3 @@ updates:
update-types:
- "minor"
- "patch"
# Maintainer browser test dependencies; Viewer.js itself has an asset-aware updater.
- package-ecosystem: "npm"
directory: "/tools/vendor"
schedule:
interval: "weekly"
allow:
- dependency-name: "@playwright/test"
labels:
- "dependencies"
commit-message:
prefix: "deps(vendor-tools)"
+2 -68
View File
@@ -17,7 +17,7 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
python-version: ["3.10", "3.11", "3.12", "3.13"]
python-version: ["3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v7
@@ -37,62 +37,6 @@ jobs:
- name: Run tests
run: python -m unittest discover -s tests -v
comfyui:
name: real ComfyUI compatibility
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: "24"
cache: npm
cache-dependency-path: tools/vendor/package-lock.json
- name: Read the reviewed runtime pins
id: runtime
run: |
node --input-type=module <<'JS' >> "$GITHUB_OUTPUT"
import runtime from './tools/vendor/comfyui-runtime.json' with { type: 'json' };
for (const [key, value] of Object.entries(runtime)) console.log(`${key}=${value}`);
JS
- name: Check out pinned ComfyUI
uses: actions/checkout@v7
with:
repository: ${{ steps.runtime.outputs.repository }}
ref: ${{ steps.runtime.outputs.revision }}
path: .ci-comfyui
persist-credentials: false
- uses: actions/setup-python@v7
with:
python-version: ${{ steps.runtime.outputs.pythonVersion }}
cache: pip
cache-dependency-path: |
requirements-dev.txt
.ci-comfyui/requirements.txt
tools/vendor/comfyui-runtime.json
- name: Install CPU runtime and test dependencies
run: |
python -m pip install --upgrade pip
pip install "torch==${{ steps.runtime.outputs.torchVersion }}" "torchvision==${{ steps.runtime.outputs.torchvisionVersion }}" --index-url https://download.pytorch.org/whl/cpu
pip install -r .ci-comfyui/requirements.txt -r requirements-dev.txt "torch==${{ steps.runtime.outputs.torchVersion }}" "torchvision==${{ steps.runtime.outputs.torchvisionVersion }}"
- run: npm ci --prefix tools/vendor --ignore-scripts
- run: npx --prefix tools/vendor playwright install --with-deps chromium
- name: Classic and Nodes 2.0 startup and compatibility
env:
PM_COMFY_ROOT: ${{ github.workspace }}/.ci-comfyui
PM_TEST_PYTHON: python
run: npm run --prefix tools/vendor test:comfyui
- name: Upload real-host reports and startup logs
if: always()
uses: actions/upload-artifact@v7
with:
name: real-comfyui-reports
path: tools/vendor/test-results/
if-no-files-found: ignore
retention-days: 14
coverage:
name: coverage gate (85%)
runs-on: ubuntu-latest
@@ -134,16 +78,6 @@ jobs:
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: "24"
- name: Check shipped extension and page modules parse
run: |
shopt -s globstar nullglob
for f in web/comfy/**/*.js web/comfy/**/*.mjs web/js/**/*.js web/js/**/*.mjs; do
node --input-type=module --check < "$f" || exit 1
done
# Pure-JS frontend modules, tested with the runner's preinstalled Node
- name: Run JS tests
run: node --test tests/js/*.test.js
-47
View File
@@ -1,47 +0,0 @@
name: Viewer.js vendor and compatibility
on:
pull_request:
branches: [main]
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
jobs:
viewerjs:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: '3.12'
cache: pip
cache-dependency-path: requirements-dev.txt
- run: python -m pip install -r requirements-dev.txt
- uses: actions/setup-node@v7
with:
node-version: '24'
cache: npm
cache-dependency-path: 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
- run: npx --prefix tools/vendor playwright install --with-deps chromium firefox webkit
- name: Frontend unit, viewer, workflow, and coverage checks
run: npm run --prefix tools/vendor test:coverage
- name: Firefox and WebKit page workflows
run: npm run --prefix tools/vendor test:workflows -- --project firefox --project webkit
- name: Upload browser traces and frontend coverage
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
-83
View File
@@ -1,83 +0,0 @@
name: Update vendored Viewer.js
on:
schedule:
- cron: '30 7 * * *'
workflow_dispatch:
inputs:
version:
description: 'Stable version to vendor; leave blank for the latest npm release'
required: false
type: string
permissions:
contents: read
concurrency:
group: viewerjs-update
cancel-in-progress: false
jobs:
update:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
actions: write
env:
GH_TOKEN: ${{ github.token }}
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.event.repository.default_branch }}
- uses: actions/setup-node@v7
with:
node-version: '24'
cache: npm
cache-dependency-path: tools/vendor/package-lock.json
- run: npm ci --prefix tools/vendor --ignore-scripts
- name: Update the dependency and regenerate assets
env:
VIEWER_VERSION: ${{ inputs.version }}
run: |
if [ -n "$VIEWER_VERSION" ]; then
npm run --prefix tools/vendor update -- --version "$VIEWER_VERSION"
else
npm run --prefix tools/vendor update
fi
- name: Detect changes
id: changes
run: |
if git diff --quiet -- tools/vendor/package.json tools/vendor/package-lock.json web/lib/viewerjs; then
echo 'changed=false' >> "$GITHUB_OUTPUT"
else
echo 'changed=true' >> "$GITHUB_OUTPUT"
fi
- if: steps.changes.outputs.changed == 'true'
run: |
npm run --prefix tools/vendor check
npm run --prefix tools/vendor test
node --test tests/js/*.test.js
npx --prefix tools/vendor playwright install --with-deps chromium
npm run --prefix tools/vendor test:browser
- name: Open the update PR
if: steps.changes.outputs.changed == 'true'
env:
BASE_BRANCH: ${{ github.event.repository.default_branch }}
run: |
version=$(node -p "require('./web/lib/viewerjs/provenance.json').version")
branch="deps/viewerjs-$version"
existing=$(gh pr list --repo "$GITHUB_REPOSITORY" --state all --head "$branch" --json number --jq 'length')
if [ "$existing" != '0' ]; then
echo "A PR already exists for Viewer.js $version; leaving it for review."
exit 0
fi
remote=$(git ls-remote origin "refs/heads/$branch")
if [ -z "$remote" ]; then
git switch -c "$branch"
git add tools/vendor/package.json tools/vendor/package-lock.json web/lib/viewerjs
git -c user.name='github-actions[bot]' -c user.email='41898282+github-actions[bot]@users.noreply.github.com' commit -m "deps: update vendored Viewer.js to $version"
git push origin "HEAD:refs/heads/$branch"
fi
gh pr create --repo "$GITHUB_REPOSITORY" --base "$BASE_BRANCH" --head "$branch" --title "deps: update vendored Viewer.js to $version" --body-file tools/vendor/update-pr.md
gh workflow run viewerjs-check.yml --repo "$GITHUB_REPOSITORY" --ref "$branch"
-11
View File
@@ -200,14 +200,3 @@ cython_debug/
# PyPI configuration file
.pypirc
# Maintainer-only JavaScript tooling and browser test artifacts
node_modules/
test-results/
playwright-report/
# Combined frontend coverage reports
coverage/
# Disposable upstream checkout for real-host CI
.ci-comfyui/
-16
View File
@@ -139,17 +139,6 @@ The comprehensive gallery system provides complete management of all ComfyUI out
_Powered by [ViewerJS](https://github.com/fengyuanchen/viewerjs) for professional image viewing capabilities._
Viewer.js is vendored locally. See [Viewer.js maintenance](tools/vendor/README.md)
for reproducible updates, verification, and automated update PRs.
Prompt Manager appears as **Prompt Manager (Classic)** in the Add Node menu.
It keeps the existing layout and behavior while using shared
[ComfyAssets Toolkit](https://github.com/ComfyAssets/ComfyAssets-Toolkit) controls
that follow ComfyUI's light/dark theme. Existing workflows keep the same internal
node identifier and sockets. The reusable architecture is inspired by
[Pixaroma](https://github.com/pixaroma/ComfyUI-Pixaroma); no implementation or assets
were copied. See the [toolkit maintenance notes](tools/vendor/README.md#shared-comfyassets-browser-controls).
### 🔬 ComfyUI Workflow Analysis
- **PNG Metadata Parsing**: Advanced PNG chunk analysis to extract embedded ComfyUI data
@@ -926,11 +915,6 @@ MIT License - see LICENSE file for details.
## Changelog
### v3.3.1 (Proxy Routing Fix)
- **🌐 Proxy support**: the dashboard, gallery, tag page and metadata viewer now work when ComfyUI is reached through a proxy mount such as SwarmUI's `/ComfyBackendDirect/`. Scripts, images, API calls, progress streams and gallery folder filters stay under the mount, and the canvas node's buttons use ComfyUI's `fileURL()`. Direct ComfyUI URLs are unchanged. Thanks to @ghosttigerllc-bit ([#164](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/164))
- **📦 Dependencies**: the runtime minimums stay at `Pillow>=8.0.0` and `watchdog>=2.1.0`, so updating Prompt Manager never forces upgrades in the shared ComfyUI environment
### v3.3.0 (Hardening, Background Jobs & Worker Threads)
- **🔒 Security**: gallery roots restricted to ComfyUI directories and validated wherever they are read; media-only, fail-closed image serving; Windows path escape in static routes closed; quote-safe HTML escaping with inline handlers moved to data attributes; the CivitAI key is never returned and only sent to HTTPS CivitAI hosts; auto-tag paths contained; side-effect routes are POST-only; backups include the WAL and restores are size-capped, integrity-checked and applied through SQLite's online backup API
-130
View File
@@ -1,130 +0,0 @@
# Testing and merge confidence
Routine changes should merge after the required checks pass and the diff is
reviewed. Add a regression test for each bug; prove it fails before the fix.
## What runs
| Layer | Real components | Replaced boundaries |
| --- | --- | --- |
| Python tests | Domain logic, SQLite, API handlers, filesystem checks | ComfyUI host and ML runtimes where needed |
| JavaScript tests | Frontend modules and controller methods | DOM/network fixtures for focused edge cases |
| Viewer checks | Production Viewer.js and frontend methods in Chromium desktop/touch | Controlled images and page fixtures |
| Real ComfyUI | Pinned CPU host, actual extension, node definitions, widgets, routes and SQLite | Inference is not queued; browser startup settings and host preference writes are isolated |
| Page workflows | Production HTML, startup, click handlers, HTTP routes, SQLite, PNG files, thumbnails | ComfyUI host services and the tagging model |
Page workflows cover save/edit/reload, duplicate-edit rejection, gallery image
identity, Tags selection and viewer cleanup, delayed responses, auto-tag review
and persistence, and infinite scrolling without replacing existing cards.
Data safety workflows download and upload actual SQLite backups, verify restored
prompts/tags/image links, reject corrupt uploads without changing live data, and
exercise cancellation and network-failure recovery for restores and deletion.
Duplicate cleanup verifies only the selected file disappears; prompt deletion
verifies the other prompt and original image files survive.
They run in Chromium, Firefox, WebKit, and Chromium touch emulation. No GPU,
model downloads, running ComfyUI instance, or personal configuration is required.
The server binds to localhost and creates disposable directories and a database;
its reset endpoint exists only in the test harness.
## Run locally
Use a Python environment containing `requirements-dev.txt` and Node.js 24:
```sh
python -m unittest discover -s tests -q
npm ci --prefix tools/vendor --ignore-scripts
npx --prefix tools/vendor playwright install --with-deps chromium firefox webkit
npm run --prefix tools/vendor test
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
distributions, browser system dependencies may need manual installation; CI uses
Ubuntu. For a quick browser run, use `test:workflows -- --project chromium`.
## Managed real ComfyUI
The shared engine comes from an exact commit of
[ComfyAssets Toolkit](https://github.com/ComfyAssets/ComfyAssets-Toolkit), recorded
in `tools/vendor/package.json` and its integrity-checked lockfile. The local
adapter sets PromptManager's database/configuration and monitoring policy;
source discovery, coverage mapping/gates and process lifecycle are shared code.
KikoTools remains the reference and is unchanged by this integration.
Use a **dedicated disposable** ComfyUI checkout at the revision in
`tools/vendor/comfyui-runtime.json`. With a separate Python environment, install
that checkout's requirements, this repo's `requirements-dev.txt`, and the exact
CPU torch/torchvision pins from the JSON (using PyTorch's CPU wheel index).
Install Chromium using the Playwright command above, then run:
```sh
PM_COMFY_ROOT=/path/to/disposable/ComfyUI \
PM_TEST_PYTHON=/path/to/cpu-env/bin/python \
npm run --prefix tools/vendor test:comfyui
```
CI performs this setup automatically. `PM_COMFY_URL` can select another
`http://127.0.0.1:PORT/` address (default port 8189). This is an owned managed
server: a running host is never reused. Do not point it at your live checkout.
The launcher verifies the upstream SHA and clean tracked files, snapshots only
tracked PromptManager files, and creates temporary user/input/output/database/
configuration/log directories. Personal untracked files are excluded. It runs
CPU/offline with only PromptManager enabled and removes owned state on shutdown.
The upstream checkout must also be disposable: ComfyUI may initialize local
runtime files there. Browser host-settings writes are intercepted; actual
PromptManager routes use the disposable host and database.
`real-comfyui-reports` retains startup logs, version attachments, screenshots and
traces for 14 days. Update upstream/frontend/CPU pins together after reviewing
and running both startup projects; never replace the SHA with a moving branch.
Update the toolkit via an exact reviewed commit archive and commit both npm
manifest and lockfile. No toolkit or npm installation is needed by end users.
## Coverage and failures
Python CI enforces 85% combined statement/branch coverage. Frontend coverage merges
Node V8 unit coverage with Chromium page-workflow coverage through c8. Every
`web/js/**/*.js` and `web/js/**/*.mjs` file appears, including untouched files at zero coverage. The
controller tests provide V8 filenames so their executed code is counted too.
`tools/vendor/frontend-coverage-baseline.json` holds a floor for every file and
metric. CI rejects regressions, missing measurements, and new files without a
reviewed baseline. These floors reflect current coverage, with two percentage
points of allowance for runtime/timing differences; they do not claim 80% coverage
of the large controllers. New files need at least 80% line coverage when added via
the baseline updater. Raise floors with `npm run --prefix tools/vendor
coverage:update`; it reruns the suites and cannot lower an existing floor.
Review baseline changes like code changes.
The `viewerjs` check runs on every PR, including backend-only changes. GitHub
artifacts named `frontend-test-reports` contain HTML coverage, a JSON summary,
and failure screenshots/traces. Open traces with Playwright's `show-trace` tool.
The real-host suite covers both Classic and Nodes 2.0 startup, switching both
ways, actual database prompt selection, execution-text serialization, workflow
reload, widget/style cleanup, node schemas and standalone-page opening. It does
not queue inference or certify every existing feature, arbitrary host versions,
GPU/model changes, cross-pack installation or visual design changes. Those still
need targeted validation.
## Required merge checks
`.github/branch-protection.json` records the required checks: all twelve Python/OS
combinations, Python coverage, formatting, frontend modules, `viewerjs`, and `real ComfyUI compatibility`.
The branch must be current with main, and administrators follow the same checks.
It does not require an additional human approval.
A repository administrator applies the configuration after the new workflow is
passing, then verifies it:
```sh
gh api --method PUT repos/ComfyAssets/ComfyUI_PromptManager/branches/main/protection \
--input .github/branch-protection.json
gh api repos/ComfyAssets/ComfyUI_PromptManager/branches/main/protection/required_status_checks
```
Update the manifest whenever required job names change. Keep the frontend check
free of path filters so a required check is never silently skipped.
+1 -1
View File
@@ -49,7 +49,7 @@ NODE_CLASS_MAPPINGS = {
}
NODE_DISPLAY_NAME_MAPPINGS = {
"PromptManager": "Prompt Manager (Classic)",
"PromptManager": "Prompt Manager",
"PromptManagerText": "Prompt Manager Text",
"PromptSearchList": "Prompt Search List",
}
+6 -17
View File
@@ -13,14 +13,12 @@ from .models import PromptModel, normalize_image_path, utc_now_iso
# Import logging system
try:
from ..utils.logging_config import get_logger
from ..utils.hashing import generate_prompt_hash
except ImportError:
import sys
current_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, current_dir)
from utils.logging_config import get_logger
from utils.hashing import generate_prompt_hash
# Subquery to fetch tags from junction table, embedded in SELECT statements
@@ -1856,20 +1854,16 @@ class PromptDatabase:
}
def update_prompt_text(self, prompt_id: int, new_text: str) -> bool:
"""Update prompt text and its deduplication hash atomically.
"""Update only the text of a prompt.
Returns:
True if a row was updated.
Raises:
sqlite3.IntegrityError: Another prompt already has the new hash.
"""
with self.model.get_connection() as conn:
cursor = conn.execute(
"UPDATE prompts SET text = ?, hash = ?, updated_at = ? WHERE id = ?",
"UPDATE prompts SET text = ?, updated_at = ? WHERE id = ?",
(
new_text,
generate_prompt_hash(new_text),
datetime.datetime.now(datetime.timezone.utc).isoformat(),
prompt_id,
),
@@ -1973,27 +1967,23 @@ class PromptDatabase:
conn.commit()
return count
def get_image_prompt_info(
self, image_path: str, allow_filename_fallback: bool = True
) -> Optional[Dict[str, Any]]:
def get_image_prompt_info(self, image_path: str) -> Optional[Dict[str, Any]]:
"""Look up prompt + image metadata for a given image path.
Returns a dict with prompt_id, text, category, tags, rating, notes,
workflow_data, prompt_metadata, generation_time, parameters — or None.
workflow_data, prompt_metadata, generation_time — or None.
"""
with self.model.get_connection() as conn:
cursor = conn.execute(
"""SELECT gi.prompt_id, p.text, p.category, p.rating, p.notes,
gi.workflow_data, gi.prompt_metadata, gi.generation_time,
gi.parameters
gi.workflow_data, gi.prompt_metadata, gi.generation_time
FROM generated_images gi
JOIN prompts p ON gi.prompt_id = p.id
WHERE gi.image_path = ? OR gi.file_path = ?
OR (? AND gi.image_path LIKE ? ESCAPE '\\')""",
OR gi.image_path LIKE ? ESCAPE '\\'""",
(
image_path,
normalize_image_path(image_path),
allow_filename_fallback,
f"%{escape_like(os.path.basename(image_path))}",
),
)
@@ -2019,7 +2009,6 @@ class PromptDatabase:
"workflow_data": workflow,
"prompt_metadata": prompt_meta,
"generation_time": row[7],
"parameters": row[8] if len(row) > 8 else None,
}
def get_prompt_id_for_image(self, image_path: str) -> Optional[int]:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

-71
View File
@@ -1,71 +0,0 @@
# Shared ComfyAssets UI adoption
Status: approved direction; toolkit/testing foundation in progress.
Product UI brainstorming comes before node changes.
Tracking: [PromptManager #186](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/186)
and [KikoTools #81](https://github.com/ComfyAssets/ComfyUI-KikoTools/issues/81).
KikoTools is the validated model. The
[ComfyAssets Toolkit](https://github.com/ComfyAssets/ComfyAssets-Toolkit) is the
shared source; PromptManager proves the package boundaries before a later
KikoTools adoption PR. Other custom nodes can become additional consumers.
## Approved sequence
1. Extract reusable controls and test infrastructure from the working KikoTools
reference into the toolkit. Keep routes, node schemas and controllers local.
2. Bring PromptManager's automated tests up to parity using the pinned toolkit.
3. Brainstorm PromptManager node layouts, controls and behavior with the maintainer
before changing its production UI. Select one bounded first consumer together.
4. Validate shared distribution, themes, cleanup and compatibility through that
consumer, then migrate incrementally. Test both packs together as appropriate.
5. Backport toolkit adoption to KikoTools after PromptManager proves it, preserving
the existing KikoTools appearance, behavior and workflow compatibility.
KikoTools is not being migrated again during this phase. The original
[foundation mockup](https://github.com/ComfyAssets/ComfyUI-KikoTools/pull/82)
records the approved visual direction and inspiration.
## PromptManager requirements
- Preserve existing node names, inputs/outputs, API routes, saved workflows,
gallery behavior, and existing test/coverage gates.
- Use shared control factories, editor slots, settings/help/window behavior and
the ComfyUI adapter where applicable. Keep PromptManager controllers and HTTP
behavior in this repository.
- Both projects must look identical: one shared set of controls, typography,
spacing, colors and interaction states. Follow ComfyUI light/dark mode with
our own styling, a blue primary accent and strong red errors/destructive actions.
Use compact buttons, pill switches, segmented selections and responsive rows.
Review the pilot mockup before production styling. Existing `--pm-*` tokens
are migration context, not an approved design to retain.
- Theme standalone pages explicitly: `window.open` pages do not inherit the
host's CSS variables. Test the agreed palette policy, live changes, fallbacks
and persistence, including errors when host settings are unavailable.
- Ship a pinned, reproducible browser distribution. Installation must work
without npm or KikoTools, including when both packs ship different core versions.
- Attribute [Pixaroma](https://github.com/pixaroma/ComfyUI-Pixaroma) as architectural
inspiration. Do not copy/vendor his code, styles, or assets.
## First migration
With the maintainer, inventory the PromptManager nodes and gallery dialogs and
choose one bounded first UI consumer after brainstorming. Record its current behavior with tests before changing it.
Verify keyboard opening/closing, focus restoration, accessible labels, confirm
and cancel behavior, network failures, and light/dark appearances. Keep the
existing gallery and destructive-action browser workflows passing.
Build shared-source extraction and distribution checks before importing the core.
Test both packs alone and together, scoped styling, isolated settings/storage,
and independent cleanup. ComfyUI-facing changes require the real-host Classic,
Nodes 2.0 and live-switching matrix from the foundation proposal. Standalone
dialogs additionally run through PromptManager's existing browser harness.
## Delivery
Use small issue-linked PRs and TDD: verify each new behavioral test fails before
implementing it, then run relevant suites and required CI checks. Include visual
evidence for appearance changes. Migrate additional nodes/dialogs/editors only after
the first consumer validates the package boundaries; do not rewrite all pages
or change backend schemas as part of this initiative.
+8 -33
View File
@@ -703,43 +703,18 @@ class PromptManagerAPI(
"negative_prompt": None,
}
# Check for A1111/Forge style parameters first.
# The chunk is one block: a possibly multi-line positive prompt, then a
# "Negative prompt:" section, then a trailing "Steps: ..." settings line.
# Taking lines[0] as the positive prompt truncates any multi-line prompt
# to one line, so split on the settings line instead.
# Check for A1111 style parameters first (like parse-metadata.py)
if "parameters" in metadata:
import re as _re
params = metadata["parameters"]
body = params
settings = ""
_sm = _re.search(r"\nSteps:\s", body)
if _sm:
settings = body[_sm.start() + 1 :].strip()
body = body[: _sm.start()]
negative = None
_nm = _re.search(r"(?im)^\s*negative prompt:\s*", body)
if _nm:
negative = body[_nm.end() :].strip()
body = body[: _nm.start()]
elif body.lower().lstrip().startswith("negative prompt:"):
negative = body.lower().lstrip()[len("negative prompt:") :].strip()
body = ""
result["positive_prompt"] = body.strip() or None
if negative:
result["negative_prompt"] = negative
lines = params.splitlines()
if lines:
result["positive_prompt"] = lines[0].strip()
for line in lines:
if line.lower().startswith("negative prompt:"):
result["negative_prompt"] = line.split(":", 1)[1].strip()
break
# Store raw parameters too
result["parameters"]["parameters"] = params
if settings:
for _f in ("Steps", "Sampler", "Schedule type", "CFG scale", "Seed"):
_fm = _re.search(
r"(?i)(?:^|,\s*)" + _re.escape(_f) + r":\s*([^,]+)", settings
)
if _fm:
result["parameters"][_f.lower().replace(" ", "_")] = _fm.group(
1
).strip()
# If no A1111 format found, proceed with ComfyUI parsing
if result["positive_prompt"] is None:
-16
View File
@@ -484,22 +484,6 @@ class AutotagRoutesMixin:
if not raw_path or not isinstance(raw_path, str):
return None, None, self._bad_request("image_id or path is required")
if "root_index" in data:
root_index = data["root_index"]
if isinstance(root_index, bool) or not isinstance(root_index, int):
return None, None, self._bad_request("root_index must be an integer")
roots = self._get_all_output_dirs()
if not 0 <= root_index < len(roots):
return None, None, self._forbidden(raw_path)
root = roots[root_index]
if Path(raw_path).is_absolute() or ".." in raw_path.replace(
"\\", "/"
).split("/"):
return None, None, self._forbidden(raw_path)
raw_path = str(Path(root) / raw_path)
if not path_is_within(raw_path, root):
return None, None, self._forbidden(raw_path)
resolved = self._resolve_allowed_image_path(raw_path)
if resolved is None:
return None, None, self._forbidden(raw_path)
+114 -173
View File
@@ -214,9 +214,9 @@ def _thumbnail_complete_payload(total, stats, start, remaining=0, processed=None
def _thumbnail_is_fresh(src, dst):
"""True when ``dst`` exists and is at least as new as ``src`` (blocking stat)."""
"""True when ``dst`` exists and is newer than ``src`` (blocking stat)."""
try:
return dst.stat().st_mtime >= src.stat().st_mtime
return dst.stat().st_mtime > src.stat().st_mtime
except OSError:
return False
@@ -316,9 +316,6 @@ def _output_image_entry(media_path, output_path, root_index, public_root=None):
f"{'.jpg' if is_video else extension}"
)
thumbnail_url = thumbnail_url_for(thumb_rel, output_path / thumb_rel)
if thumbnail_url is not None:
thumbnail_url += f"&root={root_index}"
encoded_path = urllib.parse.quote(rel_path.as_posix(), safe="/")
return {
"id": hashlib.sha1(str(media_path).encode("utf-8")).hexdigest()[:16],
@@ -327,7 +324,7 @@ def _output_image_entry(media_path, output_path, root_index, public_root=None):
"relative_path": str(rel_path),
"root_dir": public_root,
"root_index": root_index,
"url": f"/prompt_manager/images/serve/{encoded_path}?root={root_index}",
"url": f"/prompt_manager/images/serve/{rel_path.as_posix()}",
"thumbnail_url": thumbnail_url,
"size": stat.st_size,
"modified_time": stat.st_mtime,
@@ -385,10 +382,6 @@ class ImageRoutesMixin:
async def delete_image_route(request):
return await self.delete_image(request)
@routes.get("/prompt_manager/images/thumbnails/status")
async def thumbnail_status_route(request):
return await self.get_thumbnail_status(request)
@routes.post("/prompt_manager/images/generate-thumbnails")
async def generate_thumbnails_route(request):
return await self.generate_thumbnails(request)
@@ -706,10 +699,10 @@ class ImageRoutesMixin:
try:
idx = int(root_param)
except ValueError:
return []
return output_dirs
if 0 <= idx < len(output_dirs):
return [output_dirs[idx]]
return []
return output_dirs
async def serve_output_image(self, request):
"""Serve a media file from the ComfyUI output folder(s)."""
@@ -822,15 +815,19 @@ class ImageRoutesMixin:
return error
thumbnail_size = self._thumbnail_size(data.get("quality", "medium"))
output_dirs = self._get_all_output_dirs()
if not output_dirs:
output_dir = self._find_comfyui_output_dir()
if not output_dir:
return web.json_response(
{"success": False, "error": "ComfyUI output directory not found"},
status=404,
)
output_path = Path(output_dir)
result = await self._run_in_executor(
self._generate_gallery_thumbnails_sync, output_dirs, thumbnail_size
self._generate_thumbnails_sync,
output_path,
output_path / "thumbnails",
thumbnail_size,
)
self.invalidate_gallery_cache()
return web.json_response(result)
@@ -845,22 +842,6 @@ class ImageRoutesMixin:
"""Blocking thumbnail generation loop (run in executor)."""
start = _time.monotonic()
plan = self._plan_thumbnails(output_path, thumbnails_dir)
payload = self._execute_thumbnail_plan(plan, thumbnail_size, start)
payload["thumbnails_path"] = _public_path(thumbnails_dir)
return payload
def _generate_gallery_thumbnails_sync(
self, output_dirs: list[Path], thumbnail_size: tuple[int, int]
) -> dict:
start = _time.monotonic()
plan = self._plan_gallery_thumbnails(output_dirs)
payload = self._execute_thumbnail_plan(plan, thumbnail_size, start)
payload["thumbnails_path"] = _public_path(output_dirs[0] / "thumbnails")
return payload
def _execute_thumbnail_plan(
self, plan: dict, thumbnail_size: tuple[int, int], start: float
) -> dict:
targets = plan["targets"]
stats = _new_thumbnail_stats()
stats["skipped"] = plan["skipped"]
@@ -874,50 +855,10 @@ class ImageRoutesMixin:
processed=len(targets),
)
payload["success"] = True
payload["thumbnails_path"] = _public_path(thumbnails_dir)
self.logger.info(payload["message"])
return payload
def _plan_gallery_thumbnails(self, output_dirs: list[Path]) -> dict:
"""Plan every gallery root with one shared per-run generation cap."""
combined = {"targets": [], "skipped": 0, "remaining": 0}
for root in output_dirs:
root = Path(root)
plan = self._plan_thumbnails(root, root / "thumbnails")
available = max(0, MAX_THUMBNAIL_FILES - len(combined["targets"]))
combined["targets"].extend(plan["targets"][:available])
combined["skipped"] += plan["skipped"]
combined["remaining"] += plan["remaining"] + max(
0, len(plan["targets"]) - available
)
return combined
async def get_thumbnail_status(self, request: web.Request) -> web.Response:
"""Count pending thumbnails off-loop without generating files."""
try:
output_dirs = self._get_all_output_dirs()
plan = await self._run_in_executor(
self._plan_gallery_thumbnails, output_dirs
)
pending = len(plan["targets"]) + plan["remaining"]
collection = json.dumps(sorted(str(_canonical(p)) for p in output_dirs))
return web.json_response(
{
"success": True,
"total_images": pending + plan["skipped"],
"pending": pending,
"ready": plan["skipped"],
"collection_id": hashlib.sha256(collection.encode()).hexdigest()[
:16
],
},
headers={"Cache-Control": "no-store"},
)
except Exception as exc:
self.logger.exception("Thumbnail status failed")
return web.json_response(
{"success": False, "error": _safe_error(exc)}, status=500
)
def _plan_thumbnails(self, output_path, thumbnails_dir):
"""Decide which media files need a thumbnail (blocking walk).
@@ -982,7 +923,7 @@ class ImageRoutesMixin:
try:
if not src.is_file():
return {**result, "action": "error", "error": "File no longer exists"}
if _thumbnail_is_fresh(src, dst):
if dst.exists() and dst.stat().st_mtime > src.stat().st_mtime:
return {**result, "action": "skipped"}
dst.parent.mkdir(parents=True, exist_ok=True)
if is_video:
@@ -1016,20 +957,21 @@ class ImageRoutesMixin:
)
await response.prepare(request)
try:
output_dirs = self._get_all_output_dirs()
if not output_dirs:
output_dir = self._find_comfyui_output_dir()
if not output_dir:
await self._emit_sse(
response, "error", {"error": "ComfyUI output directory not found"}
)
return response
output_path = Path(output_dir)
await self._emit_sse(
response,
"status",
{"phase": "scanning", "message": "Scanning for images and videos..."},
)
plan = await self._run_in_executor(
self._plan_gallery_thumbnails, output_dirs
self._plan_thumbnails, output_path, output_path / "thumbnails"
)
await self._stream_thumbnails(
response,
@@ -1254,109 +1196,119 @@ class ImageRoutesMixin:
)
return False
async def clear_thumbnails(self, request: web.Request) -> web.Response:
"""Clear generated thumbnails across gallery roots, preserving originals.
Invalid thumbnail directories return 400; unavailable gallery roots
return 404, and unexpected filesystem failures return 500.
"""
async def clear_thumbnails(self, request):
"""Safely clear only our generated thumbnails, never touch original images."""
try:
output_dirs = self._get_all_output_dirs()
if not output_dirs:
# Find ComfyUI output directory
output_dir = self._find_comfyui_output_dir()
if not output_dir:
return web.json_response(
{"success": False, "error": "ComfyUI output directory not found"},
status=404,
)
cleared_count, cleared_size = await self._run_in_executor(
self._clear_gallery_thumbnails_sync, output_dirs
)
self.invalidate_gallery_cache()
def format_size(bytes_size: int) -> str:
size = float(bytes_size)
output_path = Path(output_dir)
thumbnails_dir = output_path / "thumbnails"
if not thumbnails_dir.exists():
return web.json_response(
{
"success": True,
"message": "No thumbnails directory found - nothing to clear",
"cleared_files": 0,
}
)
# Verify this is actually our thumbnails directory: a real child
# of the output root (symlinks resolved), not merely a path that
# shares its prefix such as "<root>2/thumbnails".
try:
if (
not _is_within(thumbnails_dir, output_path)
or thumbnails_dir.name != "thumbnails"
):
self.logger.error(
"Safety check failed: thumbnails directory path invalid: "
f"{thumbnails_dir}"
)
return web.json_response(
{
"success": False,
"error": "Safety check failed: invalid thumbnails "
"directory path",
},
status=400,
)
except Exception as e:
self.logger.error(f"Path validation failed: {e}")
return web.json_response(
{"success": False, "error": "Path validation failed"}, status=500
)
# Count files before deletion
cleared_count = 0
cleared_size = 0
for root, dirs, files in os.walk(thumbnails_dir):
for file in files:
file_path = Path(root) / file
if "_thumb" in file.lower() and any(
file.lower().endswith(ext)
for ext in [".png", ".jpg", ".jpeg", ".webp", ".gif"]
):
try:
file_size = file_path.stat().st_size
file_path.unlink()
cleared_count += 1
cleared_size += file_size
self.logger.debug(f"Cleared thumbnail: {file_path}")
except Exception as e:
self.logger.warning(
f"Failed to delete thumbnail {file_path}: {e}"
)
# Remove empty directories within thumbnails folder
try:
for root, dirs, files in os.walk(thumbnails_dir, topdown=False):
if root != str(thumbnails_dir):
try:
Path(root).rmdir()
except OSError:
pass
except Exception as e:
self.logger.debug(f"Directory cleanup info: {e}")
def format_size(bytes_size):
for unit in ["B", "KB", "MB", "GB"]:
if size < 1024.0:
return f"{size:.1f} {unit}"
size /= 1024.0
return f"{size:.1f} TB"
if bytes_size < 1024.0:
return f"{bytes_size:.1f} {unit}"
bytes_size /= 1024.0
return f"{bytes_size:.1f} TB"
size_formatted = format_size(cleared_size)
self.logger.info(
f"Thumbnail cleanup: cleared {cleared_count} files ({size_formatted})"
f"Thumbnail cleanup: cleared {cleared_count} files "
f"({format_size(cleared_size)})"
)
return web.json_response(
{
"success": True,
"cleared_files": cleared_count,
"cleared_size": cleared_size,
"cleared_size_formatted": size_formatted,
"cleared_size_formatted": format_size(cleared_size),
"message": f"Cleared {cleared_count} thumbnail files "
f"({size_formatted})",
"({format_size(cleared_size)})",
}
)
except ValueError as exc:
self.logger.error(f"Thumbnail path validation failed: {exc}")
except Exception as e:
self.logger.error(f"Clear thumbnails error: {e}")
return web.json_response(
{"success": False, "error": _safe_error(exc)}, status=400
{"success": False, "error": _safe_error(e)}, status=500
)
except Exception as exc:
self.logger.error(f"Clear thumbnails error: {exc}")
return web.json_response(
{"success": False, "error": _safe_error(exc)}, status=500
)
def _clear_gallery_thumbnails_sync(
self, output_dirs: list[Path]
) -> tuple[int, int]:
"""Validate all thumbnail directories before deleting generated files.
Raises ValueError for symlinked or out-of-root thumbnail directories.
Filesystem errors during path validation propagate to the route.
"""
directories = []
for output_path in output_dirs:
output_path = Path(output_path)
thumbnails_dir = output_path / "thumbnails"
if not thumbnails_dir.exists() and not thumbnails_dir.is_symlink():
continue
if (
thumbnails_dir.is_symlink()
or not _is_within(thumbnails_dir, output_path)
or _canonical(thumbnails_dir) == _canonical(output_path)
):
raise ValueError(
"Safety check failed: invalid thumbnails directory path"
)
directories.append(thumbnails_dir)
cleared_count = 0
cleared_size = 0
for thumbnails_dir in directories:
for root, _dirs, files in os.walk(thumbnails_dir):
for name in files:
path = Path(root) / name
if (
"_thumb" not in name.lower()
or path.suffix.lower() not in GALLERY_IMAGE_EXTENSIONS
or path.is_symlink()
):
continue
try:
size = path.stat().st_size
path.unlink()
cleared_count += 1
cleared_size += size
except OSError as exc:
self.logger.warning(f"Failed to delete thumbnail {path}: {exc}")
for root, _dirs, _files in os.walk(thumbnails_dir, topdown=False):
if Path(root) != thumbnails_dir:
try:
Path(root).rmdir()
except OSError as exc:
self.logger.debug(
"Keeping thumbnail directory %s: %s", root, exc
)
return cleared_count, cleared_size
@staticmethod
def _resolve_client_media_path(raw_path, allowed_dirs):
@@ -1437,7 +1389,7 @@ class ImageRoutesMixin:
try:
# Get the image path from URL
raw_image_path = request.match_info.get("image_path", "")
image_path = raw_image_path
image_path = urllib.parse.unquote(raw_image_path)
if not image_path:
return web.json_response(
@@ -1445,26 +1397,15 @@ class ImageRoutesMixin:
)
# Convert relative path to absolute if needed
if "root" in request.query:
roots = self._select_root(
self._get_all_output_dirs(), request.query["root"]
)
if not roots:
return _forbidden()
if not os.path.isabs(image_path):
image_path = str(Path(roots[0]) / image_path)
if not _is_within(image_path, roots[0]):
return _forbidden()
elif not os.path.isabs(image_path):
if not os.path.isabs(image_path):
output_dir = self._find_comfyui_output_dir()
if output_dir:
image_path = str(Path(output_dir) / image_path)
# Look up the image in generated_images table
try:
args = (image_path, False) if "root" in request.query else (image_path,)
prompt_data = await self._run_in_executor(
self.db.get_image_prompt_info, *args
self.db.get_image_prompt_info, image_path
)
if prompt_data:
prompt_data["image_path"] = _public_path(image_path)
-6
View File
@@ -3,7 +3,6 @@
import datetime
import json
import os
import sqlite3
from aiohttp import web
from urllib.parse import quote
@@ -840,11 +839,6 @@ class PromptRoutesMixin:
{"success": False, "error": "Prompt not found"}, status=404
)
except sqlite3.IntegrityError:
return web.json_response(
{"success": False, "error": "Another prompt already has this text"},
status=409,
)
except ValueError:
return web.json_response(
{"success": False, "error": "Invalid prompt ID"}, status=400
+2 -2
View File
@@ -1,13 +1,13 @@
[project]
name = "promptmanager"
description = "A powerful ComfyUI custom node that extends the standard text encoder with persistent prompt storage, advanced search capabilities, and an automatic image gallery system using SQLite."
version = "3.3.1"
version = "3.3.0"
license = {file = "LICENSE"}
requires-python = ">=3.10"
dependencies = ["watchdog>=2.1.0", "Pillow>=8.0.0"]
[build-system]
requires = ["setuptools>=84.0.0"]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
[project.urls]
+9 -9
View File
@@ -1,18 +1,18 @@
# Runtime dependencies
watchdog>=6.0.0
Pillow>=12.3.0
aiohttp>=3.14.5
watchdog>=2.1.0
Pillow>=8.0.0
aiohttp>=3.8.0
# Tests and coverage
coverage>=7.16.2
numpy>=2.2.6 # WD14 engine tests
coverage>=7.0
numpy>=1.24 # WD14 engine tests
# Code quality
black>=26.10.1
flake8>=7.4.1
black>=24.0.0
flake8>=7.0.0
# Security scanning
bandit[toml]>=1.9.4
bandit[toml]>=1.7.0
# Pre-commit hooks
pre-commit>=4.6.2
pre-commit>=4.0.0
-1
View File
@@ -1 +0,0 @@
"""Browser workflow fixtures; never imported by production code."""
-150
View File
@@ -1,150 +0,0 @@
import { readFileSync } from 'node:fs';
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
test.beforeEach(async ({page, browserName}) => {
await startCoverage(page, browserName);
page.classicErrors = [];
page.on('pageerror', error => page.classicErrors.push(error.message));
await page.route('**/scripts/app.js', route => route.fulfill({contentType:'text/javascript', body:'export const app = window.fixtureApp;'}));
await page.route('**/scripts/api.js', route => route.fulfill({contentType:'text/javascript', body:'export const api = {fileURL: path => path, queuePrompt: async (...args) => args};'}));
await page.route('**/extensions/promptmanager/prompt_manager.js', route => route.fulfill({contentType:'text/javascript', body:readFileSync(new URL('../../web/comfy/prompt_manager.js', import.meta.url),'utf8')}));
await page.goto('/prompt_manager/test');
await page.evaluate(() => {
document.body.replaceChildren();
document.body.style.setProperty('--bg-color','#222');
window.fixtureApp={extensions:[],registerExtension(extension){this.extensions.push(extension);},ui:{settings:new EventTarget()},canvas:{},graph:{setDirtyCanvas(){}}};
});
await page.evaluate(async () => {
await import('/extensions/promptmanager/prompt_manager.js');
class Node {
constructor(){
this.widgets=['text','category','tags','search_text'].map(name=>({name,value:'',inputEl:document.createElement('textarea')}));
this.widgets_values=[]; this.inputs=[];
}
addDOMWidget(name,type,element){document.body.append(element);return {element};}
setSize(){} setDirtyCanvas(){} serialize(){return {properties:{...this.properties}};}
}
await fixtureApp.extensions[0].beforeRegisterNodeDef(Node,{name:'PromptManager'},fixtureApp);
window.node=new Node();node.onNodeCreated();
});
await expect(page.locator('.pm-classic')).toBeVisible();
});
test.afterEach(async ({page,browserName}) => {
await saveCoverage(page,browserName);
expect(page.classicErrors).toEqual([]);
});
test('Classic keeps immediate selection, search query and metadata serialization',async({page})=>{
await page.route('**/prompt_manager/search?**', route=>route.fulfill({json:{success:true,results:[{text:'A new portrait',category:'Portrait',tags:['film grain'],rating:5}]}}));
await page.evaluate(()=>{node.widgets.find(w=>w.name==='search_text').value='tag:"film grain" portrait';});
const response=page.waitForRequest('**/prompt_manager/search?**');
await page.getByRole('button',{name:'🔍 Search',exact:true}).click();
expect(new URL((await response).url()).searchParams.get('tags')).toBe('film grain');
const result=page.getByRole('button',{name:/A new portrait/});
await result.focus();await result.press('Enter');
expect(await page.evaluate(()=>node.serialize().widgets_values.slice(0,3))).toEqual(['A new portrait','Portrait','film grain']);
await expect(result).toHaveAttribute('aria-pressed','true');
const released=await page.evaluate(()=>{
const previous=node.resultsSection.querySelector('button');
node.displayResults([{text:'Replacement',tags:[]}]);
previous.click();
return node.widgets.find(widget=>widget.name==='text').value;
});
expect(released).toBe('A new portrait');
await expect(page.getByRole('button',{name:'🌐 Web UI',exact:true})).toBeVisible();
});
test('Classic detaches native text synchronization on removal',async({page})=>{
await page.waitForTimeout(60);
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'));
const before=widget.value;node.onRemoved();
widget.inputEl.value='After removal';widget.inputEl.dispatchEvent(new Event('input'));
return [before,widget.value];
});
expect(values).toEqual(['Before removal','Before removal']);
});
test('Classic follows host themes, keeps restore behavior and disposes resources',async({page})=>{
const panel=page.locator('.pm-classic');
await expect(panel).toHaveAttribute('data-ca-theme','dark');
await page.evaluate(()=>document.body.style.setProperty('--bg-color','#fff'));
await expect(panel).toHaveAttribute('data-ca-theme','light');
await page.evaluate(()=>{node.properties.resultTimeout=0;node.displayResults([{text:'Example',tags:[]}]);node.hideResults();});
await page.getByRole('button',{name:/Results auto-hidden/}).click();
await expect(page.getByRole('button',{name:/Example/})).toBeVisible();
const state=await page.evaluate(()=>{
node.showNotification('Failure','error');node.onRemoved();
const disposed=node._classicScope.signal.aborted;
node.showNotification('Late response','info');
return {disposed,notifications:document.querySelectorAll('.pm-classic-notification').length,style:node._styleSheet.isConnected};
});
expect(state).toEqual({disposed:true,notifications:0,style:false});
await page.evaluate(()=>document.body.style.setProperty('--bg-color','#222'));
await expect(panel).toHaveAttribute('data-ca-theme','light');
});
test('Classic handles empty queries, empty results, failed requests and late responses',async({page})=>{
await page.getByRole('button',{name:'🔍 Search',exact:true}).click();
await expect(page.locator('.pm-classic-results')).toContainText('Enter search text');
await page.route('**/prompt_manager/recent?**',route=>route.fulfill({json:{success:true,results:[]}}));
await page.getByRole('button',{name:'📋 Recent',exact:true}).click();
await expect(page.locator('.pm-classic-results')).toContainText('No prompts found');
await page.evaluate(async()=>{
node.callNodeMethod=async()=>{throw new Error('<img src=x onerror=alert(1)>');};
await node.loadRecentPrompts();
});
await expect(page.locator('.pm-classic-results [role="alert"]')).toContainText('<img src=x');
await expect(page.locator('.pm-classic-results img')).toHaveCount(0);
const late=await page.evaluate(async()=>{
let finish; node.callNodeMethod=()=>new Promise(resolve=>{finish=resolve;});
const loading=node.loadRecentPrompts();node.onRemoved();finish({results:[{text:'Late'}]});await loading;
return {notifications:node._classicNotifications.size,late:node.resultsSection.textContent.includes('Late')};
});
expect(late).toEqual({notifications:0,late:false});
});
test('shipped shared factories keep keyboard behavior and theme isolation across mounts',async({page})=>{
await page.evaluate(async()=>{
const ui=await import('/prompt_manager/js/shared/toolkit/core.js');
const scope=ui.createScope();const panel=document.createElement('div');panel.className='ca-tools';panel.id='shared-contract';
window.shared={ui,scope,panel,events:[]};
document.body.append(panel);
ui.mountTheme(panel,null,scope);
const save=ui.createButton('Save contract','ca-primary');scope.listen(save,'click',()=>shared.events.push('save'));
panel.append(save,ui.createSlider('Size',{id:'contract-size',min:0,max:100,step:1,value:50,unit:'px'},scope,value=>shared.events.push(value)),ui.createSwitch('Enabled','','contract-enabled'),ui.createField('Order',ui.createSelect([['name','Name'],['date','Date']],''),'contract-order'),ui.createSegments('View',[['grid','Grid'],['list','List']],'contract-view',scope,value=>shared.events.push(value)));
});
await page.getByRole('button',{name:'Save contract',exact:true}).press('Enter');
await page.getByRole('slider',{name:'Size'}).press('ArrowRight');
await expect(page.locator('#shared-contract output')).toHaveText('51 px');
await page.getByRole('switch',{name:'Enabled'}).uncheck();
await page.getByLabel('Order').selectOption('date');
await page.getByRole('radio',{name:'List',exact:true}).check();
expect(await page.evaluate(()=>shared.events)).toEqual(['save',51,'list']);
await page.evaluate(()=>{document.body.style.removeProperty('--bg-color');fixtureApp.ui.settings.dispatchEvent(new Event('Comfy.ColorPalette.change'));});
await page.emulateMedia({colorScheme:'light'});
await expect(page.locator('#shared-contract')).toHaveAttribute('data-ca-theme','light');
await page.emulateMedia({colorScheme:'dark'});
await expect(page.locator('#shared-contract')).toHaveAttribute('data-ca-theme','dark');
await page.evaluate(()=>{shared.scope.dispose();document.body.style.setProperty('--bg-color','#fff');});
await expect(page.locator('#shared-contract')).toHaveAttribute('data-ca-theme','dark');
await expect(page.locator('.pm-classic')).toHaveAttribute('data-ca-theme','light');
});
test('Classic test button reports success and failure and releases expired notifications',async({page})=>{
await page.clock.install();
await page.evaluate(()=>{node.properties.showTestButton=true;node.createSearchUI(node._classicContainer);});
await page.getByRole('button',{name:'🔧 Test API',exact:true}).click();
await expect(page.locator('.pm-classic-results')).toContainText('API Connection Successful');
await page.route('**/prompt_manager/test',route=>route.fulfill({status:503,body:'unavailable'}));
await page.getByRole('button',{name:'🔧 Test API',exact:true}).click();
await expect(page.locator('.pm-classic-results [role="alert"]')).toContainText('503');
await page.route('**/prompt_manager/test',route=>route.fulfill({json:{success:false,message:'Not ready'}}));
await page.getByRole('button',{name:'🔧 Test API',exact:true}).click();
await expect(page.locator('.pm-classic-results [role="alert"]')).toContainText('Not ready');
await page.clock.fastForward(3001);
await expect(page.locator('.pm-classic-notification')).toHaveCount(0);
});
-154
View File
@@ -1,154 +0,0 @@
"""Serve production pages and API routes with disposable state for browser tests."""
import argparse
import json
import os
import shutil
from pathlib import Path
import sys
import tempfile
import time
import types
from aiohttp import web
from PIL import Image
from PIL.PngImagePlugin import PngInfo
class TestModel:
default_prompt = "Describe the image"
wd14_general_threshold = 0.35
wd14_character_threshold = 0.85
custom_prompt = ""
loaded = 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")
}
def is_model_loaded(self) -> bool:
return self.loaded is not None
def get_loaded_model_type(self) -> str | None:
return self.loaded
def load_model(self, model_type: str, use_gpu: bool = True) -> bool:
self.loaded = model_type
return True
def unload_model(self) -> None:
self.loaded = None
def generate_tags(self, image_path: str, **kwargs: object) -> list[str]:
return ["generated", Path(image_path).parent.parent.name]
def create_app(root: Path) -> web.Application:
os.environ["PROMPT_MANAGER_CONFIG_PATH"] = str(root / "config.json")
os.chdir(root)
app = web.Application()
routes = web.RouteTableDef()
server = types.ModuleType("server")
server.PromptServer = types.SimpleNamespace(
instance=types.SimpleNamespace(app=app, routes=routes)
)
sys.modules["server"] = server
folder_paths = types.ModuleType("folder_paths")
folder_paths.base_path = str(root)
folder_paths.models_dir = str(root / "models")
for name in ("output", "input", "temp", "user"):
directory = root / name
directory.mkdir()
setattr(folder_paths, f"get_{name}_directory", lambda path=directory: str(path))
sys.modules["folder_paths"] = folder_paths
from py.config import GalleryConfig, PromptManagerConfig
PromptManagerConfig.DEFAULT_DB_PATH = str(root / "browser.db")
GalleryConfig.MONITORING_DIRECTORIES = [str(root / "output"), str(root / "input")]
from py.api import PromptManagerAPI
from py import autotag
from utils.hashing import generate_prompt_hash
model = TestModel()
autotag.get_autotag_service = lambda: model
api = PromptManagerAPI()
api.add_routes(routes)
def seed(extra_images: int = 0) -> None:
with api.db.model.get_connection() as conn:
for table in ("generated_images", "prompt_tags", "tags", "prompts"):
conn.execute(f"DELETE FROM {table}")
for name in ("output", "input"):
shutil.rmtree(root / name)
(root / name).mkdir()
for index, (directory, text, tag) in enumerate(
[("output", "Cat portrait", "cats"), ("input", "Dog landscape", "dogs")]
):
image_path = root / directory / "shared" / "same.png"
image_path.parent.mkdir(exist_ok=True)
info = PngInfo()
info.add_text(
"prompt",
json.dumps(
{"1": {"class_type": "CLIPTextEncode", "inputs": {"text": text}}}
),
)
with Image.new("RGB", (64, 64), (index * 255, 64, 128)) as image:
image.save(image_path, pnginfo=info)
stamp = time.time() - index * 10
os.utime(image_path, (stamp, stamp))
pid = api.db.save_prompt(
text, tags=[tag], prompt_hash=generate_prompt_hash(text)
)
api.db.link_image_to_prompt(pid, str(image_path))
if index == 0:
for number in range(extra_images):
extra = root / directory / "bulk" / f"{number:04}.png"
extra.parent.mkdir(exist_ok=True)
shutil.copyfile(image_path, extra)
api.db.link_image_to_prompt(pid, str(extra))
api._generate_gallery_thumbnails_sync(api._get_all_output_dirs(), (150, 150))
api.invalidate_gallery_cache()
@routes.post("/__test__/reset")
async def reset(request: web.Request) -> web.Response:
data = await request.json() if request.can_read_body else {}
extra = data.get("extra_images", 0)
if (
isinstance(extra, bool)
or not isinstance(extra, int)
or not 0 <= extra <= 200
):
raise web.HTTPBadRequest()
await api._run_in_executor(seed, extra)
return web.json_response({"success": True})
@routes.get("/api/settings/Comfy.ColorPalette")
async def palette(request: web.Request) -> web.Response:
return web.json_response("dark")
async def cleanup(application: web.Application) -> None:
api.db.close_all()
seed()
app.on_cleanup.append(cleanup)
app.add_routes(routes)
return app
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--port", type=int, default=8765)
args = parser.parse_args()
with tempfile.TemporaryDirectory(prefix="pm-browser-") as directory:
web.run_app(
create_app(Path(directory).resolve()), host="127.0.0.1", port=args.port
)
if __name__ == "__main__":
main()
-344
View File
@@ -1,344 +0,0 @@
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
test.beforeEach(async ({ request, page, browserName }) => {
await startCoverage(page, browserName);
await expect(await request.post('/__test__/reset')).toBeOK();
page.workflowErrors = [];
page.on('pageerror', error => page.workflowErrors.push(error.message));
page.on('response', response => {
if (response.status() >= 500 && response.url().includes('/prompt_manager/')) {
page.workflowErrors.push(`${response.status()} ${response.url()}`);
}
});
await page.addInitScript(() => { localStorage.setItem('pm_update_notice_dismissed', '3.2.0'); });
});
test.afterEach(async ({ page, browserName }) => {
await saveCoverage(page, browserName);
expect(page.workflowErrors).toEqual([]);
});
test('actual gallery form saves a prompt that the admin page can edit and reload', async ({ page, request }) => {
await page.goto('/prompt_manager/gallery');
await page.locator('#addPromptBtn').click();
await page.locator('#addPromptText').fill('Browser saved landscape');
await page.locator('#addPromptCategory').fill('Browser tests');
await page.locator('#addPromptTagInput').fill('browser-tag');
await page.locator('#addPromptTagInput').press('Enter');
await page.locator('#saveNewPromptBtn').click();
await expect(page.locator('#addPromptModal')).toBeHidden();
await page.goto('/prompt_manager/admin');
const text = page.locator('.prompt-text').filter({ hasText: 'Browser saved landscape' });
await expect(text).toBeVisible();
const id = await text.getAttribute('data-id');
await page.locator(`[data-action="edit"][data-prompt-id="${id}"]`).click();
const editor = page.locator(`.prompt-text[data-id="${id}"]`);
await editor.fill('Browser edited portrait');
await editor.press('Enter');
await expect(page.locator('.prompt-text').filter({ hasText: 'Browser edited portrait' })).toBeVisible();
await page.reload();
await expect(page.locator('.prompt-text').filter({ hasText: 'Browser edited portrait' })).toBeVisible();
const saved = await request.post('/prompt_manager/save', { data: { text: 'Browser saved landscape' } });
expect((await saved.json()).prompt_id).not.toBe(Number(id));
});
test('actual gallery review generates and persists tags for the selected root', async ({ page, request }) => {
await page.goto('/prompt_manager/gallery');
await expect(page.locator('#galleryGrid .image-item')).toHaveCount(2);
await page.locator('#autoTagBtn').click();
const generated = page.waitForResponse(response => response.url().endsWith('/prompt_manager/autotag/single'));
await page.locator('#startReviewBtn').click();
const generation = await generated;
expect(generation.request().postDataJSON().root_index).toBe(0);
await expect(page.locator('#reviewTagsContainer')).toContainText('generated');
const result = page.waitForResponse(response => response.url().endsWith('/prompt_manager/autotag/apply'));
await page.locator('#applyReviewBtn').click();
expect((await (await result).json()).success).toBe(true);
const data = await (await request.get('/prompt_manager/recent?limit=20')).json();
expect(data.results.find(prompt => prompt.text === 'Cat portrait').tags).toContain('generated');
expect(data.results.find(prompt => prompt.text === 'Dog landscape').tags).not.toContain('generated');
});
test('actual Tags page reloads the selected filter and opens its image viewer', async ({ page }) => {
await page.goto('/prompt_manager/admin#/tags');
await page.locator('[data-tag-name="cats"]').click();
await expect(page.locator('#tagPromptsGrid')).toContainText('Cat portrait');
await page.locator('[data-tag-name="dogs"]').click();
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();
await page.locator('.viewer-container').getByRole('button', { name: 'Close', exact: true }).click();
await expect(page.locator('.viewer-container')).toHaveCount(0);
});
test('actual gallery serves colliding filenames and shows the matching prompt', async ({ page }) => {
await page.goto('/prompt_manager/gallery');
await expect(page.locator('#galleryGrid .image-item')).toHaveCount(2);
const images = page.locator('#galleryGrid img[data-original]');
const first = await images.nth(0).getAttribute('data-original');
const second = await images.nth(1).getAttribute('data-original');
expect(first).not.toBe(second);
await page.locator('#galleryGrid .image-item[data-image-index="0"]').click();
await expect(page.locator('#metadata-content')).toContainText('Cat portrait');
await page.keyboard.press('ArrowRight');
await expect(page.locator('#metadata-content')).toContainText('Dog landscape');
await page.keyboard.press('Escape');
await expect(page.locator('#metadata-sidebar')).toHaveCount(0);
});
test('an edit collision is rejected by the backend and the page restores the original text', async ({ page }) => {
await page.goto('/prompt_manager/admin');
const original = page.locator('.prompt-text').filter({ hasText: 'Cat portrait' });
const id = await original.getAttribute('data-id');
await page.locator(`[data-action="edit"][data-prompt-id="${id}"]`).click();
const editor = page.locator(`.prompt-text[data-id="${id}"]`);
await editor.fill('Dog landscape');
const rejected = page.waitForResponse(response => response.request().method() === 'PUT' && response.url().endsWith(`/prompts/${id}`));
await editor.press('Enter');
expect((await rejected).status()).toBe(409);
await expect(editor).toHaveText('Cat portrait');
await page.reload();
await expect(page.locator(`.prompt-text[data-id="${id}"]`)).toHaveText('Cat portrait');
});
test('a delayed real Tags response cannot replace a newer selection', async ({ page }) => {
let release;
const delayed = new Promise(resolve => { release = resolve; });
let received;
const waiting = new Promise(resolve => { received = resolve; });
await page.route('**/prompt_manager/tags/cats/prompts?*', async route => {
const response = await route.fetch();
received();
await delayed;
await route.fulfill({ response });
});
await page.goto('/prompt_manager/admin#/tags');
await page.locator('[data-tag-name="cats"]').click();
await waiting;
await page.locator('[data-tag-name="dogs"]').click();
await expect(page.locator('#tagPromptsGrid')).toContainText('Dog landscape');
const completed = page.waitForResponse(response => response.url().includes('/tags/cats/prompts?'));
release();
await completed;
await expect(page.locator('#tagPromptsGrid')).not.toContainText('Cat portrait');
});
test('gallery infinite scroll appends a real backend page while retaining existing cards', async ({ page, request }) => {
await expect(await request.post('/__test__/reset', { data: { extra_images: 108 } })).toBeOK();
await page.goto('/prompt_manager/gallery');
await expect(page.locator('#galleryGrid .image-item')).toHaveCount(100);
await page.locator('#settingsBtn').click();
await page.locator('#infiniteScrollToggle').check();
await page.locator('#saveSettingsBtn').click();
await expect(page.locator('#settingsModal')).toBeHidden();
const first = page.locator('#galleryGrid .image-item').first();
await first.evaluate(element => { element.dataset.preserved = 'yes'; });
await page.locator('#galleryScroll').evaluate(element => { element.scrollTop = element.scrollHeight; });
await expect(page.locator('#galleryGrid .image-item')).toHaveCount(110);
await expect(first).toHaveAttribute('data-preserved', 'yes');
});
async function promptSnapshot(request) {
const response = await request.get('/prompt_manager/recent?limit=100');
await expect(response).toBeOK();
return (await response.json()).results.map(({ id, text, tags, images }) => ({
id, text, tags: [...tags].sort(),
images: images.map(image => image.image_path).sort(),
})).sort((a, b) => a.id - b.id);
}
async function downloadBackup(page) {
const downloading = page.waitForEvent('download');
await page.locator('#backupBtn').click();
const download = await downloading;
expect(download.suggestedFilename()).toMatch(/^prompts_backup_.*\.db$/);
expect(await download.failure()).toBeNull();
const chunks = [];
for await (const chunk of await download.createReadStream()) chunks.push(chunk);
const buffer = Buffer.concat(chunks);
expect(buffer.subarray(0, 16).toString()).toBe('SQLite format 3\0');
return { name: download.suggestedFilename(), mimeType: 'application/octet-stream', buffer };
}
async function chooseRestore(page, file) {
await page.locator('#restoreBtn').click();
await expect(page.locator('#restoreModal')).toBeVisible();
await page.locator('#restoreFileInput').setInputFiles(file);
}
function confirmNextDialog(page, accept, message) {
page.once('dialog', async dialog => {
expect(dialog.type()).toBe('confirm');
expect(dialog.message()).toContain(message);
await (accept ? dialog.accept() : dialog.dismiss());
});
}
async function deletePromptButton(page, text) {
const prompt = page.locator('.prompt-text').filter({ hasText: text });
await expect(prompt).toBeVisible();
const id = await prompt.getAttribute('data-id');
return page.locator(`[data-action="delete"][data-prompt-id="${id}"]`);
}
test('downloaded backup restores deleted prompts, tags, and image links through the actual upload form', async ({ page, request }) => {
await page.goto('/prompt_manager/admin');
await expect(page.locator('.prompt-text')).toHaveCount(2);
const before = await promptSnapshot(request);
expect(before.map(prompt => prompt.tags)).toEqual([['cats'], ['dogs']]);
expect(before.every(prompt => prompt.images.length === 1)).toBe(true);
const backup = await downloadBackup(page);
const button = await deletePromptButton(page, 'Cat portrait');
confirmNextDialog(page, true, 'delete this prompt');
await button.click();
await expect(page.locator('.prompt-text')).toHaveCount(1);
await expect(await request.post('/prompt_manager/save', { data: { text: 'Added after backup', tags: ['temporary'] } })).toBeOK();
expect(await promptSnapshot(request)).not.toEqual(before);
await chooseRestore(page, backup);
confirmNextDialog(page, true, backup.name);
const restored = page.waitForResponse(response => response.url().endsWith('/prompt_manager/restore'));
await page.locator('#confirmRestore').click();
const response = await restored;
expect(response.ok()).toBe(true);
expect(await response.json()).toMatchObject({ success: true, prompt_count: 2, backup_created: expect.any(String) });
await expect(page.locator('#restoreModal')).toBeHidden();
await expect(page.locator('.prompt-text')).toHaveCount(2);
await page.reload();
await expect(page.locator('.prompt-text').filter({ hasText: 'Cat portrait' })).toBeVisible();
await expect(page.locator('.prompt-text').filter({ hasText: 'Added after backup' })).toHaveCount(0);
expect(await promptSnapshot(request)).toEqual(before);
await page.goto('/prompt_manager/admin#/tags');
await page.locator('[data-tag-name="cats"]').click();
await expect(page.locator('#tagPromptsGrid')).toContainText('Cat portrait');
await page.locator('#tagPromptsGrid img').first().click();
await expect(page.locator('.viewer-canvas img')).toBeVisible();
await expect.poll(() => page.locator('.viewer-canvas img').evaluate(image => image.complete && image.naturalWidth > 0)).toBe(true);
});
test('restore validation and cancellation preserve live data and allow a valid retry', async ({ page, request }) => {
await page.goto('/prompt_manager/admin');
const before = await promptSnapshot(request);
const backup = await downloadBackup(page);
let uploads = 0;
page.on('request', request => { if (request.url().endsWith('/prompt_manager/restore')) uploads++; });
await page.locator('#restoreBtn').click();
await page.locator('#confirmRestore').click();
await expect(page.getByText('Please select a database file to restore', { exact: true })).toBeVisible();
await page.locator('#restoreFileInput').setInputFiles({ ...backup, name: 'backup.txt' });
await page.locator('#confirmRestore').click();
await expect(page.getByText('Please select a valid .db file', { exact: true })).toBeVisible();
expect(uploads).toBe(0);
await page.locator('#restoreFileInput').setInputFiles(backup);
confirmNextDialog(page, false, 'replace your current database');
await page.locator('#confirmRestore').click();
await expect(page.locator('#restoreModal')).toBeVisible();
expect(uploads).toBe(0);
await page.locator('#restoreFileInput').setInputFiles({ name: 'corrupt.db', mimeType: 'application/octet-stream', buffer: Buffer.from('not a SQLite database') });
confirmNextDialog(page, true, 'corrupt.db');
const rejected = page.waitForResponse(response => response.url().endsWith('/prompt_manager/restore'));
await page.locator('#confirmRestore').click();
expect((await rejected).status()).toBe(400);
await expect(page.getByText(/Restore failed:/)).toBeVisible();
await expect(page.locator('#restoreModal')).toBeVisible();
await expect(page.locator('#confirmRestore')).toBeEnabled();
expect(await promptSnapshot(request)).toEqual(before);
await page.locator('#restoreFileInput').setInputFiles(backup);
confirmNextDialog(page, true, backup.name);
await page.locator('#confirmRestore').click();
await expect(page.locator('#restoreModal')).toBeHidden();
expect(await promptSnapshot(request)).toEqual(before);
});
test('backup and restore recover from interrupted network requests without losing data', async ({ page, request }) => {
await page.goto('/prompt_manager/admin');
const before = await promptSnapshot(request);
await page.route('**/prompt_manager/backup', route => route.abort('failed'), { times: 1 });
await page.locator('#backupBtn').click();
await expect(page.getByText(/Backup failed:/)).toBeVisible();
const backup = await downloadBackup(page);
await chooseRestore(page, backup);
await page.route('**/prompt_manager/restore', route => route.abort('failed'), { times: 1 });
confirmNextDialog(page, true, backup.name);
await page.locator('#confirmRestore').click();
await expect(page.getByText(/Restore failed:/)).toBeVisible();
await expect(page.locator('#confirmRestore')).toBeEnabled();
await expect(page.locator('#restoreModal')).toBeVisible();
expect(await promptSnapshot(request)).toEqual(before);
confirmNextDialog(page, true, backup.name);
await page.locator('#confirmRestore').click();
await expect(page.locator('#restoreModal')).toBeHidden();
expect(await promptSnapshot(request)).toEqual(before);
});
test('prompt deletion requires confirmation and can retry after a network failure', async ({ page, request }) => {
await page.goto('/prompt_manager/admin');
const before = await promptSnapshot(request);
const button = await deletePromptButton(page, 'Cat portrait');
let deletes = 0;
page.on('request', request => { if (request.method() === 'DELETE') deletes++; });
confirmNextDialog(page, false, 'delete this prompt');
await button.click();
expect(deletes).toBe(0);
expect(await promptSnapshot(request)).toEqual(before);
await page.route('**/prompt_manager/delete/*', route => route.abort('failed'), { times: 1 });
confirmNextDialog(page, true, 'delete this prompt');
await button.click();
await expect(page.getByText('Failed to delete prompt', { exact: true })).toBeVisible();
expect(await promptSnapshot(request)).toEqual(before);
confirmNextDialog(page, true, 'delete this prompt');
await button.click();
await expect(page.locator('.prompt-text')).toHaveCount(1);
await page.reload();
await expect(page.locator('.prompt-text')).toHaveCount(1);
expect(await promptSnapshot(request)).toEqual(before.filter(prompt => prompt.text !== 'Cat portrait'));
await page.goto('/prompt_manager/gallery');
await expect(page.locator('#galleryGrid .image-item')).toHaveCount(2);
for (const url of await page.locator('#galleryGrid img[data-original]').evaluateAll(images => images.map(image => image.dataset.original))) {
await expect(await request.get(url)).toBeOK();
}
});
test('duplicate cleanup cancels safely and deletes only the selected file after a retry', async ({ page, request }) => {
await expect(await request.post('/__test__/reset', { data: { extra_images: 2 } })).toBeOK();
await page.goto('/prompt_manager/gallery');
await expect(page.locator('#galleryGrid .image-item')).toHaveCount(4);
const originals = await page.locator('#galleryGrid img[data-original]').evaluateAll(images => images.map(image => image.dataset.original));
await page.locator('#settingsBtn').click();
await page.locator('#scanDuplicatesBtn').click();
await expect(page.locator('#duplicatesCount')).toHaveText('1');
const candidates = page.locator('.duplicate-checkbox');
await expect(candidates).toHaveCount(2);
const selected = await candidates.first().getAttribute('data-image-path');
const retained = await candidates.last().getAttribute('data-image-path');
const serve = path => `/prompt_manager/images/serve/${path.split(/[\\/]/).map(encodeURIComponent).join('/')}`;
await candidates.first().check();
let deletes = 0;
page.on('request', request => { if (request.url().endsWith('/delete_duplicate_images')) deletes++; });
confirmNextDialog(page, false, 'permanently delete 1 duplicate image files');
await page.locator('#removeDuplicatesBtn').click();
expect(deletes).toBe(0);
await expect(await request.get(serve(selected))).toBeOK();
await page.route('**/prompt_manager/delete_duplicate_images', route => route.abort('failed'), { times: 1 });
confirmNextDialog(page, true, 'permanently delete 1 duplicate image files');
await page.locator('#removeDuplicatesBtn').click();
await expect(page.getByText('Failed to delete duplicate images', { exact: true })).toBeVisible();
await expect(page.locator('#removeDuplicatesBtn')).toBeEnabled();
await expect(await request.get(serve(selected))).toBeOK();
confirmNextDialog(page, true, 'permanently delete 1 duplicate image files');
const deleted = page.waitForResponse(response => response.url().endsWith('/delete_duplicate_images'));
await page.locator('#removeDuplicatesBtn').click();
expect((await (await deleted).json())).toMatchObject({ success: true, deleted_count: 1, failed_count: 0 });
await expect(page.locator('#duplicatesModal')).toBeHidden();
expect((await request.get(serve(selected))).status()).toBe(404);
await expect(await request.get(serve(retained))).toBeOK();
await page.reload();
await expect(page.locator('#galleryGrid .image-item')).toHaveCount(3);
const remaining = await page.locator('#galleryGrid img[data-original]').evaluateAll(images => images.map(image => image.dataset.original));
expect(remaining).toHaveLength(originals.length - 1);
for (const url of remaining) {
expect(originals).toContain(url);
await expect(await request.get(url)).toBeOK();
}
});
-159
View File
@@ -1,159 +0,0 @@
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import runtime from '../../tools/vendor/comfyui-runtime.json' with { type: 'json' };
test.beforeEach(async ({ page, context, request }, testInfo) => {
const response = await request.get('/system_stats');
expect(response.ok()).toBe(true);
const info = (await response.json()).system;
expect(info.comfyui_version).toBe(runtime.serverVersion);
expect(info.pytorch_version).toBe(`${runtime.torchVersion}+cpu`);
expect(info.required_frontend_version).toBe(runtime.frontendVersion);
expect(info.comfy_package_versions.find(item => item.name === 'comfyui-frontend-package')?.installed).toBe(runtime.frontendVersion);
await testInfo.attach('host-versions.json', { body: JSON.stringify(info, null, 2), contentType: 'application/json' });
page.hostErrors = [];
page.on('pageerror', error => page.hostErrors.push(error.message));
await context.route('**/*', async route => {
const request = route.request();
const path = new URL(request.url()).pathname.replace(/^\/api\//, '/');
if (path === '/settings' && request.method() === 'GET') {
const response = await route.fetch();
return route.fulfill({ json: { ...await response.json(),
'Comfy.VueNodes.Enabled': testInfo.project.metadata.initialRenderer,
'Comfy.TutorialCompleted': true, 'Comfy.Workflow.Persist': false,
'Comfy.Notification.ShowVersionUpdates': false } });
}
if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method()) && !path.startsWith('/prompt_manager/')) return route.fulfill({ json: {} });
return route.continue();
});
await page.goto('/', { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('status', { name: 'Loading ComfyUI' })).toBeHidden({ timeout: 30000 });
await page.waitForFunction(async () => {
const { app } = await import('/scripts/app.js');
return !!app.graph && ['PromptManager', 'PromptManagerText', 'PromptSearchList'].every(name => globalThis.LiteGraph?.registered_node_types?.[name]);
});
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(testInfo.project.metadata.initialRenderer);
await page.evaluate(async () => { const { app } = await import('/scripts/app.js'); app.graph.clear(); });
});
test.afterEach(async ({ page }) => {
expect(page.hostErrors).toEqual([]);
});
for (const type of ['PromptManager', 'PromptManagerText']) {
test(`${type} selects real database prompts and preserves text across renderers and reload`, async ({ page, request }, testInfo) => {
const text = `Host test ${type} ${testInfo.project.name}`;
const saved = await request.post('/prompt_manager/save', { data: { text, category: 'Host tests', tags: ['host-test'] } });
expect(saved.ok()).toBe(true);
expect((await saved.json()).success).toBe(true);
const id = await page.evaluate(async type => {
const { app } = await import('/scripts/app.js');
const node = LiteGraph.createNode(type);
app.graph.add(node);
node.setSize([650, 800]);
app.canvas.centerOnNode(node);
return node.id;
}, type);
const recent = page.getByRole('button', { name: '📋 Recent', exact: true });
await expect(recent).toBeVisible();
if (type === 'PromptManager') {
await expect(page.locator('.pm-classic')).toBeVisible();
await expect(page.getByRole('button', { name: '🔍 Search', exact: true })).toHaveCSS('background-color', 'rgb(8, 119, 187)');
}
await recent.click();
await page.getByText(text, { exact: true }).click();
const state = () => page.evaluate(async id => {
const { app } = await import('/scripts/app.js');
const node = app.graph.getNodeById(id);
return Object.fromEntries(node.widgets.filter(widget => ['text', 'category', 'tags'].includes(widget.name)).map(widget => [widget.name, widget.value]));
}, id);
await expect.poll(state).toMatchObject({ text, category: 'Host tests', tags: 'host-test' });
for (const renderer of [!testInfo.project.metadata.initialRenderer, testInfo.project.metadata.initialRenderer]) {
await page.evaluate(async renderer => {
const { app } = await import('/scripts/app.js');
await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer);
app.canvas.ds.scale = .8;
app.canvas.centerOnNode(app.graph._nodes[0]);
}, renderer);
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
await expect(recent).toHaveCount(1);
await expect(recent).toBeVisible();
expect(await state()).toMatchObject({ text });
}
const output = await page.evaluate(async id => {
const { app } = await import('/scripts/app.js');
const prompt = await app.graphToPrompt();
const workflow = app.graph.serialize();
await app.loadGraphData(workflow);
return prompt.output[String(id)].inputs;
}, id);
expect(output.text).toBe(text);
await expect.poll(state).toMatchObject({ text, category: 'Host tests', tags: 'host-test' });
await expect(recent).toHaveCount(1);
await page.screenshot({ path: testInfo.outputPath(`${type}-reloaded.png`) });
const removedStyle = await page.evaluate(async id => {
const { app } = await import('/scripts/app.js');
const node = app.graph.getNodeById(id);
const style = node._styleSheet;
app.graph.remove(node);
return !style.isConnected;
}, id);
expect(removedStyle).toBe(true);
await expect(recent).toHaveCount(0);
});
}
test('all node schemas and extension entrypoints load from the real host', async ({ page, request }) => {
const definitions = await (await request.get('/object_info')).json();
expect(definitions.PromptManager.display_name).toBe('Prompt Manager (Classic)');
for (const name of ['PromptManager', 'PromptManagerText', 'PromptSearchList']) expect(definitions[name]?.name).toBe(name);
const extensions = await (await request.get('/extensions')).json();
expect(extensions.filter(path => path.startsWith('/extensions/promptmanager/'))).toEqual(['/extensions/promptmanager/prompt_manager.js']);
const result = await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const node = LiteGraph.createNode('PromptSearchList');
app.graph.add(node);
const workflow = app.graph.serialize();
await app.loadGraphData(workflow);
return { type: app.graph._nodes[0].type, extension: app.extensions.some(item => item.name === 'PromptManager.UI') };
});
expect(result).toEqual({ type: 'PromptSearchList', extension: true });
});
for (const type of ['PromptManager', 'PromptManagerText']) {
test(`${type} Web UI button opens actual standalone pages`, async ({ page }) => {
await page.evaluate(async type => {
const { app } = await import('/scripts/app.js');
const node = LiteGraph.createNode(type);
app.graph.add(node);
app.canvas.centerOnNode(node);
}, type);
const popupPromise = page.waitForEvent('popup');
await page.getByRole('button', { name: '🌐 Web UI', exact: true }).click();
const popup = await popupPromise;
await popup.waitForLoadState('domcontentloaded');
await expect(popup).toHaveURL(/\/prompt_manager\/(web|admin)/);
await expect(popup.locator('body')).toContainText('Prompt');
await popup.goto(new URL('/prompt_manager/gallery', page.url()).href);
await expect(popup.locator('#addPromptBtn')).toBeVisible();
await popup.close();
});
}
test('Classic controls follow actual ComfyUI light and dark palettes', async ({ page }, testInfo) => {
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const node = LiteGraph.createNode('PromptManager');
app.graph.add(node);
node.setSize([650, 800]);
app.canvas.centerOnNode(node);
});
for (const palette of ['light', 'dark']) {
await page.evaluate(async palette => {
const { app } = await import('/scripts/app.js');
await app.ui.settings.setSettingValue('Comfy.ColorPalette', palette);
}, palette);
await expect(page.locator('.pm-classic')).toHaveAttribute('data-ca-theme', palette);
await expect(page.getByRole('button', { name: '🔍 Search', exact: true })).toHaveCSS('background-color', 'rgb(8, 119, 187)');
await page.screenshot({ path: testInfo.outputPath(`classic-${palette}.png`) });
}
});
-50
View File
@@ -1,50 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
function fixture() {
const panel = { innerHTML: '', isConnected: true };
const requests = new Map();
const context = vm.createContext({ document: { readyState: 'loading', addEventListener() {},
getElementById: id => id === 'metadata-content' ? panel : null },
window: {}, console: { error() {} },
});
const Admin = vm.runInContext(fs.readFileSync(path.join(__dirname, '../../web/js/admin.js'), 'utf8') + '\nPromptAdmin;', context, { filename: path.join(__dirname, '../../web/js/admin.js') });
const manager = Object.create(Admin.prototype);
manager.extractImageMetadata = url => new Promise((resolve, reject) => requests.set(url, { resolve, reject }));
manager.updateMetadataSidebar = metadata => { panel.innerHTML = metadata?.imagePath || 'No metadata'; };
manager.hideModal = () => {};
const load = url => manager.loadMetadataForImage({ getAttribute: () => url });
return { manager, panel, requests, load };
}
test('admin metadata and copy actions stay on the selected image', async () => {
const { manager, panel, requests, load } = fixture();
const old = load('old.png');
const current = load('current.png');
requests.get('current.png').resolve({ imagePath: 'current.png' }); await current;
requests.get('old.png').resolve({ imagePath: 'old.png' }); await old;
assert.equal(panel.innerHTML, 'current.png');
assert.equal(manager.currentMetadata.imagePath, 'current.png');
});
test('navigation clears old action data and late errors cannot replace current metadata', async () => {
const { manager, panel, requests, load } = fixture();
manager.currentMetadata = { imagePath: 'previous.png' };
const old = load('old.png');
assert.equal(manager.currentMetadata, null);
const current = load('current.png');
requests.get('current.png').resolve({ imagePath: 'current.png' }); await current;
requests.get('old.png').reject(new Error('old error')); await old;
assert.equal(panel.innerHTML, 'current.png');
});
for (const close of ['close gallery', 'detach sidebar']) {
test(`${close} prevents pending admin metadata from restoring copy state`, async () => {
const { manager, panel, requests, load } = fixture();
const pending = load('old.png');
if (close === 'close gallery') manager.closeGallery();
else panel.isConnected = false;
requests.get('old.png').resolve({ imagePath: 'old.png' }); await pending;
assert.equal(manager.currentMetadata, null);
assert.notEqual(panel.innerHTML, 'old.png');
});
}
+1 -32
View File
@@ -1,38 +1,7 @@
// Run with: node --test tests/js/
const test = require("node:test");
const assert = require("node:assert/strict");
const { createApiClient, resolveUrl, fetchUrl, ApiError, latestOnly, STALE } = require("../../web/js/api-client.js");
test("Prompt Manager URLs follow the page's proxy mount without losing query strings", () => {
const url = "/prompt_manager/images/output?limit=100&subfolder=2025%2F04%2F02";
assert.equal(resolveUrl(url, "/ComfyBackendDirect/prompt_manager/gallery"),
"/ComfyBackendDirect" + url);
assert.equal(resolveUrl(url, "/prompt_manager/gallery"), url);
assert.equal(resolveUrl(url, "/nested/proxy/prompt_manager/admin"), "/nested/proxy" + url);
assert.equal(resolveUrl("/other/api?x=1", "/ComfyBackendDirect/prompt_manager/gallery"), "/other/api?x=1");
assert.equal(resolveUrl("/ComfyBackendDirect/prompt_manager/images/serve/a.png", "/ComfyBackendDirect/prompt_manager/gallery"),
"/ComfyBackendDirect/prompt_manager/images/serve/a.png");
assert.equal(resolveUrl("/prompt_manager/images/serve/a.png?root=1", "/ComfyBackendDirect/prompt_manager/gallery"),
"/ComfyBackendDirect/prompt_manager/images/serve/a.png?root=1");
});
test("fetchUrl sends Prompt Manager requests under the current proxy mount", async () => {
const previousFetch = globalThis.fetch;
const previousLocation = globalThis.location;
const calls = [];
globalThis.location = { pathname: "/ComfyBackendDirect/prompt_manager/admin" };
globalThis.fetch = async (url, init) => {
calls.push([url, init]);
return { ok: true };
};
try {
await fetchUrl("/prompt_manager/settings?show=all", { method: "GET" });
assert.deepEqual(calls, [["/ComfyBackendDirect/prompt_manager/settings?show=all", { method: "GET" }]]);
} finally {
globalThis.fetch = previousFetch;
globalThis.location = previousLocation;
}
});
const { createApiClient, ApiError, latestOnly, STALE } = require("../../web/js/api-client.js");
/** A fetch stand-in whose responses the test resolves by hand, in any order. */
function fakeFetch() {
-76
View File
@@ -1,76 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const path = require('node:path');
function deferred() {
let resolve, reject;
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
function fixture() {
const context = vm.createContext({ document: { addEventListener() {}, getElementById() {} },
window: {}, console: { error() {} }, escapeHtml: value => value });
const Manager = vm.runInContext(fs.readFileSync(path.join(__dirname, '../../web/js/gallery.js'), 'utf8') + '\nGalleryManager;', context, { filename: path.join(__dirname, '../../web/js/gallery.js') });
const manager = Object.create(Manager.prototype);
const requests = new Map();
manager.extractImageMetadata = url => {
const pending = deferred();
requests.set(url, pending);
return pending.promise;
};
manager.displayMetadata = (metadata, panel) => {
panel.innerHTML = metadata?.positivePrompt || 'No metadata';
manager.currentMetadata = metadata;
};
manager.stopViewerSlideshow = () => {};
return { manager, requests, panel: { innerHTML: '', isConnected: true } };
}
test('a late response cannot overwrite the selected image or copy state', async () => {
const { manager, requests, panel } = fixture();
const first = manager.loadImageMetadata('old.png', panel);
const second = manager.loadImageMetadata('new.png', panel);
requests.get('new.png').resolve({ positivePrompt: 'new prompt' });
await second;
requests.get('old.png').resolve({ positivePrompt: 'old prompt' });
await first;
assert.equal(panel.innerHTML, 'new prompt');
assert.equal(manager.currentMetadata.positivePrompt, 'new prompt');
});
test('navigation clears old action data while the new request is pending', async () => {
const { manager, requests, panel } = fixture();
manager.currentMetadata = { positivePrompt: 'old prompt' };
const pending = manager.loadImageMetadata('new.png', panel);
assert.equal(manager.currentMetadata, null);
requests.get('new.png').resolve(null);
await pending;
assert.equal(manager.currentMetadata, null);
});
test('a stale error cannot replace newer metadata', async () => {
const { manager, requests, panel } = fixture();
const first = manager.loadImageMetadata('old.png', panel);
const second = manager.loadImageMetadata('new.png', panel);
requests.get('new.png').resolve({ positivePrompt: 'new prompt' });
await second;
requests.get('old.png').reject(new Error('old request failed'));
await first;
assert.equal(panel.innerHTML, 'new prompt');
});
for (const close of ['destroy viewer', 'remove modal']) {
test(`closing via ${close} prevents pending metadata from restoring action data`, async () => {
const { manager, requests, panel } = fixture();
const pending = manager.loadImageMetadata('old.png', panel);
if (close === 'destroy viewer') manager.destroyViewer();
else panel.isConnected = false;
requests.get('old.png').resolve({ positivePrompt: 'closed prompt' });
await pending;
assert.equal(manager.currentMetadata, null);
assert.notEqual(panel.innerHTML, 'closed prompt');
});
}
-209
View File
@@ -1,209 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const path = require('node:path');
const { escapeHtml } = require('../../web/js/html-escape.js');
function fixture(count = 2000) {
const elements = new Map();
function element() {
return {
dataset: {}, children: [], listeners: {}, classList: { add() {}, remove() {} },
set innerHTML(html) { this.html = html; this.children = [{ html }]; },
get innerHTML() { return this.html || ''; },
insertAdjacentHTML(position, html) { this.children.push({ html }); },
addEventListener(type, handler) { (this.listeners[type] ||= []).push(handler); },
querySelectorAll() { return []; },
remove() {},
};
}
const document = {
addEventListener() {},
getElementById(id) { if (!elements.has(id)) elements.set(id, element()); return elements.get(id); },
createElement: element,
};
const instances = [];
const timers = new Map();
let timerId = 0;
class Viewer {
constructor(holder, options) {
this.element = holder; this.options = { fullscreen: true, ...options };
this.index = options.initialViewIndex; this.isShown = true;
this.length = (holder.innerHTML.match(/<img /g) || []).length;
instances.push(this);
}
requestFullscreen() { this.fullscreenRequested = true; }
exitFullscreen() { this.fullscreenExited = true; }
show() { this.shown = true; }
view(index) { this.index = index; }
clearEnforceFocus() { this.focusCleared = true; }
destroy() { this.destroyed = true; }
}
const context = vm.createContext({ document, window: {}, Viewer, escapeHtml,
console, setTimeout(callback) { timers.set(++timerId, callback); return timerId; },
clearTimeout(id) { timers.delete(id); },
ApiClient: { resolveUrl: value => value, STALE: Symbol() },
ListState: { nextPageState: (state, next) => ({ ...state, ...next }) },
});
const source = fs.readFileSync(path.join(__dirname, '../../web/js/gallery.js'), 'utf8');
const Manager = vm.runInContext(source + '\nGalleryManager;', context, { filename: path.join(__dirname, '../../web/js/gallery.js') });
const manager = Object.create(Manager.prototype);
Object.assign(manager, { images: Array.from({ length: count }, (_, i) => ({
filename: `${i}.png`, url: `/original/${i}.png`, thumbnail_url: `/thumb/${i}.jpg`,
relative_path: `${i}.png`, size: 100, modified_time: 1,
})), viewMode: 'grid', viewer: null, currentPage: 1, limit: 100, total: count,
getSettings: () => ({ showImageInfo: true }), });
return { manager, document, instances, timers };
}
for (const mode of ['grid', 'list']) {
test(`${mode} append retains existing cards and adds only the new page`, () => {
const { manager, document, timers } = fixture(100);
manager.viewMode = mode;
manager.renderGallery();
const container = document.getElementById(mode === 'grid' ? 'galleryGrid' : 'galleryList');
assert.equal(timers.size, 0);
const first = container.children[0];
manager.images.push({ ...manager.images[0], filename: 'new.png' });
manager.renderGallery({ from: 100 });
assert.equal(container.children[0], first);
assert.equal(container.children.length, 2);
assert.match(container.children[1].html, /new.png/);
assert.doesNotMatch(container.children[1].html, /alt="0.png"/);
assert.match(container.children[1].html, /data-image-index="100"/);
});
}
test('loadImages passes the previous length for append, and zero for replacement', async () => {
const { manager } = fixture(200);
const offsets = [];
Object.assign(manager, { imagesRequest: async () => ({ total: 500, images: [manager.images[0]] }),
renderGallery: options => offsets.push(options.from), updateStats() {},
updatePagination() {}, maybeLoadMore() {}, showLoading() {}, });
await manager.loadImages(3, { append: true });
await manager.loadImages(1);
assert.deepEqual(offsets, [200, 0]);
assert.equal(manager.images.length, 1);
});
test('viewer opening is bounded and selects the clicked original', () => {
const { manager, instances } = fixture();
manager.openViewer(1500);
const viewer = instances[0];
assert.ok(viewer.element.innerHTML.match(/<img /g).length <= 100);
assert.match(viewer.element.innerHTML, /data-original="\/original\/1500.png"/);
const urls = [...viewer.element.innerHTML.matchAll(/data-original="([^"]+)"/g)].map(m => m[1]);
assert.equal(urls[viewer.options.initialViewIndex], '/original/1500.png');
assert.equal(viewer.shown, true);
});
test('previous/next cross window edges and wrap only at gallery edges', () => {
const { manager, instances } = fixture();
manager.openViewer(1500);
const first = manager.viewer;
first.index = 99;
const lastUrl = [...first.element.innerHTML.matchAll(/data-original="\/original\/(\d+).png"/g)].at(-1);
first.next(true);
const next = manager.viewer;
const urls = [...next.element.innerHTML.matchAll(/data-original="\/original\/(\d+).png"/g)];
assert.equal(Number(urls[next.index][1]), Number(lastUrl[1]) + 1);
manager.openViewer(0);
manager.viewer.prev(true);
const wrapped = manager.viewer;
const wrappedUrls = [...wrapped.element.innerHTML.matchAll(/data-original="\/original\/(\d+).png"/g)];
assert.equal(Number(wrappedUrls[wrapped.index][1]), 1999);
assert.ok(instances.length >= 3);
});
test('delegated card clicks bind once, open appended indices, and preserve video dispatch', () => {
const { manager, document } = fixture();
const opened = [];
manager.openViewer = index => opened.push(index);
manager.showVideoModal = (...args) => opened.push(args);
manager.addFallbackClickHandlers();
manager.addFallbackClickHandlers();
const grid = document.getElementById('galleryGrid');
assert.equal(grid.listeners.click.length, 1);
const item = { dataset: { imageIndex: '1500' }, querySelector: () => ({ dataset: {
original: '/original/1500.png', caption: 'caption', isVideo: 'false',
} }) };
const click = () => grid.listeners.click[0]({ target: { closest: () => item }, preventDefault() {}, stopPropagation() {} });
click();
item.querySelector = () => ({ dataset: { original: '/video.mp4', caption: 'video', isVideo: 'true' } });
click();
assert.deepEqual(opened, [1500, ['/video.mp4', 'video', 1500]]);
});
test('navigation inside the window reuses the viewer and honors non-looping edges', () => {
const { manager, instances } = fixture();
manager.openViewer(1500);
const viewer = manager.viewer;
viewer.next(true);
assert.equal(manager.viewer, viewer);
assert.equal(viewer.index, 51);
viewer.prev(true);
assert.equal(viewer.index, 50);
manager.openViewer(0);
const edge = manager.viewer;
edge.prev(false);
assert.equal(manager.viewer, edge);
assert.equal(edge.index, 0);
assert.equal(instances.length, 2);
});
test('append leaves an open viewer intact; a full render destroys it', () => {
const { manager } = fixture();
manager.openViewer(1500);
const viewer = manager.viewer;
manager.renderGallery({ from: 1900 });
assert.equal(manager.viewer, viewer);
manager.renderGallery();
assert.equal(viewer.destroyed, true);
assert.equal(manager.viewer, null);
});
test('each viewed image updates sidebar metadata without waiting for DOM mutations', () => {
const { manager, document } = fixture();
const urls = [];
document.querySelector = () => ({});
const sidebar = document.getElementById('metadata-sidebar');
sidebar.querySelector = () => ({});
manager.loadImageMetadata = url => urls.push(url);
manager.openViewer(1500);
manager.viewer.options.viewed({ detail: { originalImage: { dataset: { original: '/original/1500.png' } } } });
manager.viewer.options.viewed({ detail: { originalImage: { dataset: { original: '/original/1501.png' } } } });
assert.deepEqual(urls, ['/original/1500.png', '/original/1501.png']);
});
test('slideshow advances across windows without preloading every original and stops on hide', () => {
const { manager, timers } = fixture();
manager.openViewer(1500);
let prevented = false;
manager.viewer.options.play({ preventDefault() { prevented = true; } });
assert.equal(prevented, true);
assert.equal(manager.viewer.fullscreenRequested, true);
assert.equal(timers.size, 1);
manager.viewer.index = 99;
const tick = timers.values().next().value;
timers.clear();
tick();
const viewer = manager.viewer;
const urls = [...viewer.element.innerHTML.matchAll(/data-original="([^"]+)"/g)];
assert.equal(urls[viewer.index][1], '/original/1550.png');
assert.equal(timers.size, 1);
viewer.options.hide();
assert.equal(viewer.fullscreenExited, true);
assert.equal(timers.size, 0);
});
test('destroying a viewer removes its focus trap and slideshow timer', () => {
const { manager, timers } = fixture();
manager.openViewer(1500);
const viewer = manager.viewer;
viewer.options.play({ preventDefault() {} });
manager.destroyViewer();
assert.equal(viewer.focusCleared, true);
assert.equal(viewer.destroyed, true);
assert.equal(timers.size, 0);
});
-88
View File
@@ -1,88 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const ImageHelpers = require('../../web/js/image-helpers.js');
function deferred() {
let resolve;
const promise = new Promise(yes => { resolve = yes; });
return { promise, resolve };
}
function fixture() {
const elements = new Map();
const requests = [];
const document = { addEventListener() {}, getElementById(id) {
if (!elements.has(id)) elements.set(id, {});
return elements.get(id);
} };
const context = vm.createContext({ document, window: {}, ImageHelpers,
escapeHtml: value => value, console: { error() {} },
ApiClient: { resolveUrl: value => value, fetchUrl: (url, options) => {
const request = deferred();
requests.push({ url, body: JSON.parse(options.body), ...request });
return request.promise;
} },
});
const Manager = vm.runInContext(fs.readFileSync(path.join(__dirname, '../../web/js/gallery.js'), 'utf8') + '\nGalleryManager;', context, { filename: path.join(__dirname, '../../web/js/gallery.js') });
const manager = Object.create(Manager.prototype);
const notifications = [];
Object.assign(manager, { autoTagState: { reviewImages: [
{ id: 'digest', path: 'same.png', root_index: 1, url: '/same.png' },
{ id: 'other', path: 'next.png', root_index: 0, url: '/next.png' },
], reviewIndex: 0, currentTags: [], modelType: 'gguf' },
renderReviewTags() {}, showNotification: (...args) => notifications.push(args), hideModal() {}, loadImages() {},
});
const reply = (index, data) => requests[index].resolve({ json: async () => data });
return { manager, requests, reply, notifications };
}
test('gallery review retains root identity and applies tags with the returned prompt ID', async () => {
const { manager, requests, reply } = fixture();
const load = manager.loadNextReviewImage();
assert.equal(requests[0].body.root_index, 1);
reply(0, { success: true, prompt_id: 42, tags: ['smile'] });
await load;
manager.loadNextReviewImage = () => {};
const apply = manager.applyReviewTags();
assert.deepEqual(requests[1].body, { prompt_id: 42, tags: ['smile'] });
reply(1, { success: true });
await apply;
});
test('unlinked images do not submit an invalid apply request or advance', async () => {
const { manager, requests, reply } = fixture();
const load = manager.loadNextReviewImage();
reply(0, { success: true, prompt_id: null, tags: ['smile'] });
await load;
const apply = manager.applyReviewTags();
if (requests[1]) reply(1, { success: false, error: "Unexpected apply" });
await apply;
assert.equal(requests.length, 1);
assert.equal(manager.autoTagState.reviewIndex, 0);
});
test('a failed apply keeps the current image available for retry', async () => {
const { manager, requests, reply } = fixture();
const load = manager.loadNextReviewImage();
reply(0, { success: true, prompt_id: 42, tags: ['smile'] });
await load;
const apply = manager.applyReviewTags();
reply(1, { success: false, error: 'Try again' });
await apply;
assert.equal(manager.autoTagState.reviewIndex, 0);
assert.equal(requests.length, 2);
});
test('late generation responses cannot restore tags from a skipped or closed image', async () => {
const { manager, reply } = fixture();
const first = manager.loadNextReviewImage();
manager.autoTagState.reviewIndex = 1;
const second = manager.loadNextReviewImage();
reply(1, { success: true, prompt_id: 2, tags: ['current'] });
await second;
reply(0, { success: true, prompt_id: 1, tags: ['stale'] });
await first;
assert.deepEqual(Array.from(manager.autoTagState.currentTags), ['current']);
const third = manager.loadNextReviewImage();
manager.cancelReview();
reply(2, { success: true, prompt_id: 2, tags: ['closed'] });
await third;
assert.equal(manager.autoTagState.currentTags.length, 0);
});
-30
View File
@@ -1,30 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const path = require('node:path');
const ImageHelpers = require('../../web/js/image-helpers.js');
test('gallery metadata requests retain root identity without including cache parameters in filenames', async () => {
const requests = [];
const context = vm.createContext({ document: { addEventListener() {} }, window: {}, console,
ImageHelpers, ApiClient: { fetchUrl: async url => {
requests.push(url);
return { ok: true, json: async () => ({ success: true, prompt: { text: url } }) };
} },
});
const Manager = vm.runInContext(fs.readFileSync(path.join(__dirname, '../../web/js/gallery.js'), 'utf8') + '\nGalleryManager;', context, { filename: path.join(__dirname, '../../web/js/gallery.js') });
const manager = Object.create(Manager.prototype);
manager.extractImageMetadata = async () => null;
manager.displayMetadata = (metadata, panel) => { panel.prompt = metadata.positivePrompt; };
for (const root of [0, 1]) {
const panel = {};
const expected = `/prompt_manager/images/prompt/sub%2Fsame.png?root=${root}`;
await manager.loadImageMetadata(`/prompt_manager/images/serve/sub/same.png?v=123&root=${root}`, panel);
assert.equal(panel.prompt, expected);
}
assert.deepEqual(requests, [
'/prompt_manager/images/prompt/sub%2Fsame.png?root=0',
'/prompt_manager/images/prompt/sub%2Fsame.png?root=1',
]);
});
-90
View File
@@ -1,90 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
function fixture(status, settings = {}) {
const timers = [];
const prompts = [];
const requests = [];
const buttons = {};
const storage = new Map();
const document = {
addEventListener() {},
createElement: () => ({ addEventListener() {} }),
body: { appendChild() {}, removeChild() {} },
getElementById: id => buttons[id] ||= { addEventListener(type, handler) { this[type] = handler; } },
};
const context = vm.createContext({ document, window: {}, console: { debug() {}, warn() {} },
setTimeout: callback => timers.push(callback),
localStorage: { setItem: (key, value) => storage.set(key, value) },
ApiClient: { fetchUrl: async () => ({ json: async () => ({ success: true,
total: 10000, images: Array.from({ length: 50 }, () => ({ thumbnail_url: null })),
}) }) },
});
const Manager = vm.runInContext(fs.readFileSync(path.join(__dirname, '../../web/js/gallery.js'), 'utf8') + '\nGalleryManager;', context, { filename: path.join(__dirname, '../../web/js/gallery.js') });
const manager = Object.create(Manager.prototype);
Object.assign(manager, { getSettings: () => ({ checkThumbnailsAtStartup: true, ...settings }),
api: { get: async url => { requests.push(url); return status; } },
});
const originalPrompt = manager.showThumbnailGenerationPrompt.bind(manager);
manager.showThumbnailGenerationPrompt = (...args) => prompts.push(args);
async function check() {
await manager.checkThumbnailsAtStartup();
await timers.shift()();
}
return { manager, check, prompts, requests, settings, buttons, storage, originalPrompt };
}
test('startup uses exact collection counts instead of extrapolating the newest 50 images', async () => {
const f = fixture({ success: true, total_images: 10000, pending: 50, ready: 9950, collection_id: 'a' });
await f.check();
assert.deepEqual(f.requests, ['/prompt_manager/images/thumbnails/status']);
assert.equal(f.prompts.length, 0);
});
test('startup prompt reports the actual number needing generation', async () => {
const f = fixture({ success: true, total_images: 10000, pending: 1200, collection_id: 'a' });
await f.check();
assert.equal(f.prompts.length, 1);
assert.deepEqual(f.prompts[0].slice(0, 2), [10000, 1200]);
});
test('a complete collection never prompts again', async () => {
const f = fixture({ success: true, total_images: 10000, pending: 0, collection_id: 'a' });
await f.check();
await f.check();
assert.equal(f.prompts.length, 0);
});
test('Not Now persists the collection state and suppresses the unchanged prompt', async () => {
const status = { success: true, total_images: 100, pending: 30, collection_id: 'a' };
const f = fixture(status);
await f.check();
f.originalPrompt(...f.prompts[0]);
f.buttons.thumbnailPromptNo.click();
Object.assign(f.settings, JSON.parse(f.storage.get('gallerySettings')));
f.prompts.length = 0;
await f.check();
assert.equal(f.prompts.length, 0);
status.pending = 40;
await f.check();
assert.equal(f.prompts.length, 1);
});
test('disabled checks do not request collection status', async () => {
const f = fixture({}, { checkThumbnailsAtStartup: false });
await f.check();
assert.equal(f.requests.length, 0);
assert.equal(f.prompts.length, 0);
});
test('saving gallery settings retains the remembered thumbnail dismissal', () => {
const f = fixture({}, { thumbnailPromptDismissed: 'remembered-state' });
f.manager.showNotification = () => {};
f.manager.hideSettings = () => {};
f.manager.applySettings = () => {};
f.manager.saveSettings();
assert.equal(JSON.parse(f.storage.get('gallerySettings')).thumbnailPromptDismissed, 'remembered-state');
});
-13
View File
@@ -108,16 +108,3 @@ test("fallbackImageSource gives up when the original itself failed or was alread
assert.equal(fallbackImageSource("/serve/t.png", {}), null);
assert.equal(fallbackImageSource("/serve/t.png", undefined), null);
});
const { metadataLookupUrl } = require('../../web/js/image-helpers.js');
test('metadata lookup keeps root identity and strips thumbnail cache query parameters', () => {
assert.equal(metadataLookupUrl('/prompt_manager/images/serve/sub/a.png?root=1&v=123'), '/prompt_manager/images/prompt/sub%2Fa.png?root=1');
assert.equal(metadataLookupUrl('/prompt_manager/images/serve/a.png?root=0'), '/prompt_manager/images/prompt/a.png?root=0');
});
test('metadata lookup preserves encoded filename characters and works behind a proxy', () => {
assert.equal(metadataLookupUrl('https://example.test/ComfyBackendDirect/prompt_manager/images/serve/space%20%23%3F%2520%20caf%C3%A9.png?root=1'), '/prompt_manager/images/prompt/space%20%23%3F%2520%20caf%C3%A9.png?root=1');
assert.equal(metadataLookupUrl('/prompt_manager/images/serve/a.png?v=123'), '/prompt_manager/images/prompt/a.png');
assert.equal(metadataLookupUrl('/elsewhere/a.png'), null);
});
-22
View File
@@ -64,28 +64,6 @@ test("connect POSTs and delivers each event to onmessage, then onclose", async (
assert.equal(source.readyState, source.CLOSED);
});
test("connect keeps streaming requests under the current proxy mount", async () => {
const previousApiClient = globalThis.ApiClient;
const previousLocation = globalThis.location;
globalThis.ApiClient = require("../../web/js/api-client.js");
globalThis.location = { pathname: "/ComfyBackendDirect/prompt_manager/gallery" };
let requestedUrl;
try {
const source = connect("/prompt_manager/images/generate-thumbnails/progress?quality=80", {
method: "POST",
fetchImpl: async (url) => {
requestedUrl = url;
return new Response(streamOf([]), { status: 200 });
},
});
await until(() => source.readyState === source.CLOSED);
assert.equal(requestedUrl, "/ComfyBackendDirect/prompt_manager/images/generate-thumbnails/progress?quality=80");
} finally {
globalThis.ApiClient = previousApiClient;
globalThis.location = previousLocation;
}
});
test("connect reports a non-2xx response through onerror and never onmessage", async () => {
let error = null;
const fetchImpl = async () => new Response("nope", { status: 405 });
-71
View File
@@ -1,71 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
function fixture() {
const requests = [];
const grid = { replaceChildren() {}, appendChild() {} };
const context = vm.createContext({ window: {}, URLSearchParams, console: { error() {} },
requestAnimationFrame() {}, document: { getElementById: id => id === 'tagPromptsGrid' ? grid : null,
querySelector: () => null, createElement: () => ({ appendChild() {} }) },
});
const Manager = vm.runInContext(fs.readFileSync(path.join(__dirname, '../../web/js/tags-page.js'), 'utf8') + '\nTagsPageManager;', context, { filename: path.join(__dirname, '../../web/js/tags-page.js') });
const manager = Object.create(Manager.prototype);
Object.assign(manager, { selectedTags: ['cats'], filterMode: 'and', promptLimit: 20,
promptOffset: 0, isLoadingPrompts: false, prompts: [], hasMorePrompts: false,
fetchJson(url) { return new Promise((resolve, reject) => requests.push({ url, resolve, reject })); },
renderTagPrompts() {}, appendTagPrompts() {}, updatePromptCountBadge() {}, updateLoadMoreButton() {},
});
const reply = (index, text, more = false) => requests[index].resolve({ success: true,
prompts: [{ text }], pagination: { has_more: more, total: more ? 40 : 1 } });
return { manager, requests, reply };
}
test('changing a filter starts a new request and late results cannot overwrite it', async () => {
const { manager, requests, reply } = fixture();
const old = manager.loadTagPrompts();
manager.selectedTags = ['dogs'];
const current = manager.loadTagPrompts();
assert.equal(requests.length, 2);
assert.match(requests[1].url, /dogs/);
reply(1, 'dog prompt'); await current;
reply(0, 'cat prompt', true); await old;
assert.equal(manager.prompts[0].text, 'dog prompt');
assert.equal(manager.promptTotal, 1);
assert.equal(manager.hasMorePrompts, false);
});
test('clearing selection invalidates pending results', async () => {
const { manager, reply } = fixture();
const old = manager.loadTagPrompts();
manager.selectedTags = [];
manager.clearPrompts();
reply(0, 'cat prompt'); await old;
assert.equal(manager.prompts.length, 0);
assert.equal(manager.promptTotal, 0);
assert.equal(manager.isLoadingPrompts, false);
});
test('a stale pagination response cannot append rows from the previous filter', async () => {
const { manager, requests, reply } = fixture();
Object.assign(manager, { prompts: [{ text: 'cat first page' }], hasMorePrompts: true, promptOffset: 20 });
const old = manager.loadMoreTagPrompts();
manager.selectedTags = ['dogs'];
const current = manager.loadTagPrompts();
assert.equal(requests.length, 2);
reply(1, 'dog prompt'); await current;
reply(0, 'cat second page', true); await old;
assert.equal(manager.prompts.length, 1);
assert.equal(manager.prompts[0].text, 'dog prompt');
assert.equal(manager.promptOffset, 20);
});
test('a stale failure cannot finish the new request or clear its loading state', async () => {
const { manager, requests, reply } = fixture();
const old = manager.loadTagPrompts();
manager.selectedTags = ['dogs'];
const current = manager.loadTagPrompts();
assert.equal(requests.length, 2);
requests[0].reject(new Error('old failure')); await old;
assert.equal(manager.isLoadingPrompts, true);
reply(1, 'dog prompt'); await current;
assert.equal(manager.isLoadingPrompts, false);
assert.equal(manager.prompts[0].text, 'dog prompt');
});
-42
View File
@@ -1,42 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createScope, themeFromColor } from '../../web/js/shared/toolkit/core.js';
test('host background luminance determines mode without assuming palette names', () => {
for (const color of ['#fff', '#F4F4F6', 'rgb(240, 240, 240)']) assert.equal(themeFromColor(color), 'light');
for (const color of ['#222', '#202020', 'rgb(30, 32, 34)']) assert.equal(themeFromColor(color), 'dark');
assert.equal(themeFromColor(''), null);
assert.equal(themeFromColor('transparent'), null);
});
test('scope cancels requests and callbacks, removes listeners and disposes once', () => {
const scope = createScope();
const target = new EventTarget();
let events = 0;
let cleanups = 0;
scope.listen(target, 'change', () => events++);
scope.cleanup(() => cleanups++);
target.dispatchEvent(new Event('change'));
assert.equal(events, 1);
scope.dispose();
scope.dispose();
target.dispatchEvent(new Event('change'));
assert.equal(events, 1);
assert.equal(cleanups, 1);
assert.equal(scope.signal.aborted, true);
});
test('debounced work runs once with the latest value and stops after disposal', async () => {
const scope = createScope();
const values = [];
const change = scope.debounce(value => values.push(value), 1);
change('old');
change('new');
await new Promise(resolve => setTimeout(resolve, 20));
assert.deepEqual(values, ['new']);
change('cancelled');
scope.dispose();
change('after removal');
await new Promise(resolve => setTimeout(resolve, 20));
assert.deepEqual(values, ['new']);
});
+1 -1
View File
@@ -1867,7 +1867,7 @@ class TestScanReadsVideos(AdminAPITestCase):
(self.output_dir / "bad.png").write_bytes(b"\x89PNG not really")
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.output_dir)]
with self.assertLogs("prompt_manager", level="WARNING") as cm:
with self.assertLogs(level="WARNING") as cm:
body = await (
await self.client.request("POST", "/prompt_manager/scan")
).text()
-69
View File
@@ -1049,72 +1049,3 @@ class TestScanOutputDir(AutotagAPITestCase):
if __name__ == "__main__":
unittest.main()
class TestGalleryReviewContract(AutotagAPITestCase):
async def test_root_relative_gallery_targets_generate_and_apply_to_their_prompt(
self,
):
second = self.tmp_root / "second-output"
second.mkdir()
roots = [self.output_dir, second]
self.api._get_all_output_dirs = lambda: roots
for index, root in enumerate(roots):
pid = self._save_prompt(f"root {index}")
self._link_image(pid, self._make_image(root, "same.png"))
response = await self._post_json(
"/prompt_manager/autotag/single",
{"path": "same.png", "root_index": index, "model_type": "gguf"},
)
self.assertEqual(response.status, 200)
data = await response.json()
self.assertEqual(data["prompt_id"], pid)
self.assertEqual(
self.service.generate_calls[-1][0], str((root / "same.png").resolve())
)
response = await self._post_json(
"/prompt_manager/autotag/apply",
{"prompt_id": data["prompt_id"], "tags": data["tags"]},
)
self.assertEqual(response.status, 200)
self.assertEqual(
set(self.api.db.get_prompt_by_id(pid)["tags"]), set(data["tags"])
)
async def test_gallery_unlinked_image_returns_no_prompt_id(self):
self._make_image(self.output_dir)
response = await self._post_json(
"/prompt_manager/autotag/single", {"path": "img.png", "root_index": 0}
)
self.assertEqual(response.status, 200)
self.assertIsNone((await response.json())["prompt_id"])
async def test_gallery_root_selection_rejects_invalid_roots_and_escapes(self):
self._make_image(self.output_dir)
for root, path, expected in [
(9, "img.png", 403),
(-1, "img.png", 403),
(True, "img.png", 400),
([], "img.png", 400),
(0, "../secret/img.png", 403),
(0, str(self.secret_dir / "img.png"), 403),
]:
with self.subTest(root=root, path=path):
response = await self._post_json(
"/prompt_manager/autotag/single", {"path": path, "root_index": root}
)
self.assertEqual(response.status, expected)
async def test_gallery_root_selection_rejects_symlink_into_another_root(self):
second = self.tmp_root / "second-output"
second.mkdir()
target = self._make_image(second)
try:
(self.output_dir / "link.png").symlink_to(target)
except (OSError, NotImplementedError):
self.skipTest("symlinks unavailable")
self.api._get_all_output_dirs = lambda: [self.output_dir, second]
response = await self._post_json(
"/prompt_manager/autotag/single", {"path": "link.png", "root_index": 0}
)
self.assertEqual(response.status, 403)
+11 -260
View File
@@ -224,7 +224,7 @@ class TestServeOutputImage(ImageAPITestCase):
self.assertEqual(hit.status, 200)
self.assertEqual(miss.status, 404)
self.assertEqual(bad_index.status, 403)
self.assertEqual(bad_index.status, 200)
for resp in (hit, miss, bad_index):
await resp.read() # release the served file before the directory goes
@@ -486,7 +486,7 @@ class TestThumbnailProgressStream(ThumbnailTestCase):
# PIL work ran in the executor in batches (never on the loop), plus the scan.
self.assertGreaterEqual(calls.count("_generate_batch"), 1)
self.assertNotIn("_generate_one", calls)
self.assertIn("_plan_gallery_thumbnails", calls)
self.assertIn("_plan_thumbnails", calls)
async def test_capped_run_reports_remaining_and_the_next_run_finishes(self):
for name in ("a", "b", "c"):
@@ -573,7 +573,7 @@ class TestThumbnailProgressStream(ThumbnailTestCase):
self.assertEqual(events[-1][1]["total_images"], 0)
async def test_missing_output_dir_sends_error_event(self):
self.api._get_all_output_dirs = lambda: []
self.api._find_comfyui_output_dir = lambda: None
resp = await self.client.request("POST", self.PROGRESS_URL)
@@ -616,7 +616,7 @@ class TestGenerateThumbnailsPost(ThumbnailTestCase):
self.assertEqual(data2["count"], 0)
async def test_missing_output_dir_is_404(self):
self.api._get_all_output_dirs = lambda: []
self.api._find_comfyui_output_dir = lambda: None
resp = await self.client.request(
"POST", "/prompt_manager/images/generate-thumbnails", json={}
@@ -849,7 +849,7 @@ class TestThumbnailFilesAreNeverSeenHalfWritten(ThumbnailTestCase):
)
self.assertRegex(
entry["thumbnail_url"],
r"^/prompt_manager/images/serve/thumbnails/a_thumb\.png\?v=\d+&root=0$",
r"^/prompt_manager/images/serve/thumbnails/a_thumb\.png\?v=\d+$",
)
first = int(re.search(r"v=(\d+)", entry["thumbnail_url"]).group(1))
self.assertEqual(first, int(thumb.stat().st_mtime))
@@ -1189,12 +1189,12 @@ class TestServeErrorBranches(ImageRouteCoverageCase):
status, _ = await self._json("GET", "/prompt_manager/images/serve/x.png")
self.assertEqual(status, 500)
async def test_out_of_range_root_index_is_rejected(self):
async def test_out_of_range_root_index_searches_all_roots(self):
make_png(self.output_dir / "r.png")
resp = await self.client.request(
"GET", "/prompt_manager/images/serve/r.png?root=7"
)
self.assertEqual(resp.status, 403)
self.assertEqual(resp.status, 200)
async def test_directory_named_like_media_is_404(self):
(self.output_dir / "dir.png").mkdir()
@@ -1420,7 +1420,7 @@ class TestThumbnailUnits(ImageRouteCoverageCase):
self.assertEqual(events[-1][0], "error")
async def test_post_generate_reports_internal_failure(self):
self.api._get_all_output_dirs = _raise
self.api._find_comfyui_output_dir = _raise
status, _ = await self._json(
"POST", "/prompt_manager/images/generate-thumbnails", json={}
)
@@ -1536,7 +1536,7 @@ class TestClearThumbnails(ImageRouteCoverageCase):
URL = "/prompt_manager/images/clear-thumbnails"
async def test_missing_output_dir_is_404(self):
self.api._get_all_output_dirs = lambda: []
self.api._find_comfyui_output_dir = lambda: None
status, _ = await self._json("POST", self.URL)
self.assertEqual(status, 404)
@@ -1569,7 +1569,7 @@ class TestClearThumbnails(ImageRouteCoverageCase):
self.assertEqual(data["cleared_files"], 0)
async def test_internal_failure_is_500(self):
self.api._get_all_output_dirs = _raise
self.api._find_comfyui_output_dir = _raise
status, _ = await self._json("POST", self.URL)
self.assertEqual(status, 500)
@@ -1581,7 +1581,7 @@ class TestClearThumbnails(ImageRouteCoverageCase):
out.mkdir()
sibling = make_png(self.comfy_dir / "out2" / "thumbnails" / "a_thumb.png")
self._make_symlink(sibling.parent, out / "thumbnails")
self.api._get_all_output_dirs = lambda: [out]
self.api._find_comfyui_output_dir = lambda: str(out)
status, data = await self._json("POST", self.URL)
@@ -1589,79 +1589,6 @@ class TestClearThumbnails(ImageRouteCoverageCase):
self.assertFalse(data["success"])
self.assertTrue(sibling.is_file())
async def test_clears_all_roots_and_combines_counts_without_touching_originals(
self,
):
second = self.comfy_dir / "extra-output"
roots = [self.output_dir, second]
self.api._get_all_output_dirs = lambda: roots
expected_size = 0
for root in roots:
make_png(root / "original_thumb.png")
thumb = make_png(root / "thumbnails" / "sub" / "generated_thumb.png")
expected_size += thumb.stat().st_size
(root / "thumbnails" / "notes.txt").write_text("keep")
status, data = await self._json("POST", self.URL)
self.assertEqual(status, 200)
self.assertEqual(data["cleared_files"], 2)
self.assertEqual(data["cleared_size"], expected_size)
self.assertNotIn("{format_size", data["message"])
for root in roots:
self.assertTrue((root / "original_thumb.png").is_file())
self.assertTrue((root / "thumbnails" / "notes.txt").is_file())
self.assertFalse((root / "thumbnails" / "sub").exists())
async def test_missing_thumbnails_in_first_root_does_not_skip_later_roots(self):
second = self.comfy_dir / "extra-output"
thumb = make_png(second / "thumbnails" / "a_thumb.png")
self.api._get_all_output_dirs = lambda: [self.output_dir, second]
status, data = await self._json("POST", self.URL)
self.assertEqual(status, 200)
self.assertEqual(data["cleared_files"], 1)
self.assertFalse(thumb.exists())
async def test_invalid_later_root_is_rejected_before_deleting_any_files(self):
first_thumb = make_png(self.output_dir / "thumbnails" / "a_thumb.png")
second = self.comfy_dir / "extra-output"
second.mkdir()
outside = make_png(self.comfy_dir / "elsewhere" / "a_thumb.png")
self._make_symlink(outside.parent, second / "thumbnails")
self.api._get_all_output_dirs = lambda: [self.output_dir, second]
status, data = await self._json("POST", self.URL)
self.assertEqual(status, 400)
self.assertFalse(data["success"])
self.assertTrue(first_thumb.is_file())
self.assertTrue(outside.is_file())
async def test_thumbnail_symlink_back_to_root_cannot_delete_originals(self):
original = make_png(self.output_dir / "original_thumb.png")
self._make_symlink(self.output_dir, self.output_dir / "thumbnails")
status, data = await self._json("POST", self.URL)
self.assertEqual(status, 400)
self.assertFalse(data["success"])
self.assertTrue(original.is_file())
async def test_cleanup_runs_off_loop_and_invalidates_gallery_cache(self):
import threading
make_png(self.output_dir / "thumbnails" / "a_thumb.png")
loop_thread = threading.get_ident()
deletion_threads = []
original_unlink = Path.unlink
def unlink(path, *args, **kwargs):
deletion_threads.append(threading.get_ident())
return original_unlink(path, *args, **kwargs)
self.api._gallery_cache = {"previous": object()}
with patch.object(Path, "unlink", unlink):
status, data = await self._json("POST", self.URL)
self.assertEqual(status, 200)
self.assertEqual(data["cleared_files"], 1)
self.assertTrue(deletion_threads)
self.assertNotIn(loop_thread, deletion_threads)
self.assertEqual(self.api._gallery_cache, {})
class TestImagePromptLookup(ImageRouteCoverageCase):
@@ -1892,179 +1819,3 @@ class TestOutputScanHelpers(ImageAPITestCase):
targets = [(Path(f"/x/{i}.png"), Path(f"/x/t{i}.jpg"), False) for i in range(3)]
await self.api._stream_thumbnails(GoneResponse(), targets, (256, 256))
self.assertEqual(generated, [])
class TestThumbnailStartupStatus(ThumbnailTestCase):
"""Startup counts and generation cover the same configured collection."""
STATUS_URL = "/prompt_manager/images/thumbnails/status"
async def test_status_reports_exact_pending_count_without_generating(self):
for name in ("ready", "missing", "outdated"):
make_png(self.output_dir / f"{name}.png")
thumbs = self.output_dir / "thumbnails"
ready = make_png(thumbs / "ready_thumb.png")
outdated = make_png(thumbs / "outdated_thumb.png")
source_time = (self.output_dir / "ready.png").stat().st_mtime
os.utime(ready, (source_time + 10, source_time + 10))
os.utime(outdated, (source_time - 10, source_time - 10))
calls = self._spy_executor()
response = await self.client.get(self.STATUS_URL)
self.assertEqual(response.status, 200)
data = await response.json()
self.assertTrue(data["success"])
self.assertEqual(data["total_images"], 3)
self.assertEqual(data["pending"], 2)
self.assertEqual(data["ready"], 1)
self.assertTrue(data["collection_id"])
self.assertIn("_plan_gallery_thumbnails", calls)
self.assertFalse((thumbs / "missing_thumb.png").exists())
async def test_both_generation_routes_cover_all_gallery_roots(self):
second = self.comfy_dir / "another-output"
make_png(self.output_dir / "a.png")
make_png(second / "b.png")
self.api._get_all_output_dirs = lambda: [self.output_dir, second]
for route in (
"/prompt_manager/images/generate-thumbnails",
self.PROGRESS_URL,
):
with self.subTest(route=route):
for root in (self.output_dir, second):
for thumb in (root / "thumbnails").glob("*_thumb.png"):
thumb.unlink()
response = await self.client.post(route, json={})
if route == self.PROGRESS_URL:
data = dict(parse_sse(await response.text()))["complete"]
else:
data = await response.json()
self.assertEqual(data["count"], 2)
self.assertTrue((second / "thumbnails" / "b_thumb.png").exists())
status = await (await self.client.get(self.STATUS_URL)).json()
self.assertEqual(status["pending"], 0)
self.assertEqual(status["total_images"], 2)
async def test_pending_count_includes_files_beyond_the_generation_cap(self):
for name in ("a", "b", "c"):
make_png(self.output_dir / f"{name}.png")
with patch.object(images_module, "MAX_THUMBNAIL_FILES", 1):
response = await self.client.get(self.STATUS_URL)
self.assertEqual(response.status, 200)
data = await response.json()
self.assertEqual(data["pending"], 3)
async def test_generation_cap_is_shared_across_roots(self):
second = self.comfy_dir / "another-output"
make_png(self.output_dir / "a.png")
make_png(second / "b.png")
self.api._get_all_output_dirs = lambda: [self.output_dir, second]
with patch.object(images_module, "MAX_THUMBNAIL_FILES", 1):
response = await self.client.post(self.PROGRESS_URL)
data = dict(parse_sse(await response.text()))["complete"]
self.assertEqual(data["count"], 1)
self.assertEqual(data["remaining"], 1)
self.assertEqual(data["total_images"], 2)
async def test_equal_timestamps_do_not_regenerate_existing_thumbnails(self):
source = make_png(self.output_dir / "ready.png")
thumb = make_png(self.output_dir / "thumbnails" / "ready_thumb.png")
timestamp = int(source.stat().st_mtime)
os.utime(source, (timestamp, timestamp))
os.utime(thumb, (timestamp, timestamp))
response = await self.client.post(self.PROGRESS_URL)
data = dict(parse_sse(await response.text()))["complete"]
self.assertEqual(data["count"], 0)
self.assertEqual(data["skipped"], 1)
class TestGalleryRootIdentity(ThumbnailTestCase):
async def test_colliding_original_and_thumbnail_urls_serve_their_own_roots(self):
roots = [self.output_dir, self.comfy_dir / "second-output"]
for index, root in enumerate(roots):
make_png(root / "same.png", color=(index * 255, 0, 255 - index * 255))
self.api._get_all_output_dirs = lambda: roots
await self.client.post("/prompt_manager/images/generate-thumbnails", json={})
data = await (await self.client.get("/prompt_manager/images/output")).json()
self.assertEqual(len({image["url"] for image in data["images"]}), 2)
self.assertEqual(len({image["thumbnail_url"] for image in data["images"]}), 2)
for image in data["images"]:
root = roots[image["root_index"]]
for key, path in (
("url", root / "same.png"),
("thumbnail_url", root / "thumbnails" / "same_thumb.png"),
):
response = await self.client.get(image[key])
self.assertEqual(response.status, 200)
self.assertEqual(await response.read(), path.read_bytes())
async def test_root_scoped_prompt_lookup_uses_the_matching_database_image(self):
roots = [self.output_dir, self.comfy_dir / "second-output"]
for index, root in enumerate(roots):
self._link_image(make_png(root / "same.png"), text=f"root {index} prompt")
self.api._get_all_output_dirs = lambda: roots
for index in range(2):
response = await self.client.get(
f"/prompt_manager/images/prompt/same.png?root={index}"
)
self.assertEqual(response.status, 200)
data = await response.json()
self.assertEqual(data["prompt"]["text"], f"root {index} prompt")
async def test_special_characters_in_paths_survive_serving_and_prompt_lookup(self):
from urllib.parse import quote
filenames = ["space #%20 café.png"]
if os.name != "nt":
filenames.append("space #?%20 café.png")
for filename in filenames:
self._link_image(make_png(self.output_dir / filename), text="special path")
for filename in filenames:
with self.subTest(filename=filename):
original = self.output_dir / filename
data = await (
await self.client.get("/prompt_manager/images/output")
).json()
entry = next(
image for image in data["images"] if image["filename"] == filename
)
response = await self.client.get(entry["url"])
self.assertEqual(response.status, 200)
self.assertEqual(await response.read(), original.read_bytes())
response = await self.client.get(
f"/prompt_manager/images/prompt/{quote(filename, safe='')}?root=0"
)
data = await response.json()
self.assertEqual(data["prompt"]["text"], "special path")
async def test_invalid_explicit_roots_never_fall_back_to_another_image(self):
self._link_image(make_png(self.output_dir / "same.png"), text="first root")
for root in ("bad", "-1", "7", ""):
for route in ("serve", "prompt"):
with self.subTest(root=root, route=route):
response = await self.client.get(
f"/prompt_manager/images/{route}/same.png?root={root}"
)
self.assertEqual(response.status, 403)
async def test_explicit_root_prompt_lookup_rejects_paths_outside_that_root(self):
from urllib.parse import quote
outside = make_png(self.comfy_dir / "outside.png")
self._link_image(outside, text="outside")
response = await self.client.get(
f"/prompt_manager/images/prompt/{quote(str(outside), safe='')}?root=0"
)
self.assertEqual(response.status, 403)
async def test_unlinked_image_does_not_inherit_a_prompt_from_another_root(self):
second = self.comfy_dir / "second-output"
self._link_image(make_png(self.output_dir / "same.png"), text="first root")
make_png(second / "same.png")
self.api._get_all_output_dirs = lambda: [self.output_dir, second]
response = await self.client.get(
"/prompt_manager/images/prompt/same.png?root=1"
)
self.assertEqual(response.status, 200)
self.assertFalse((await response.json())["success"])
-46
View File
@@ -894,49 +894,3 @@ class TestBulkRoutes(RouteCoverageCase):
if __name__ == "__main__":
unittest.main()
class TestPromptEditHash(PromptAPITestCase):
async def test_edit_moves_the_hash_and_original_text_can_be_saved_again(self):
original_id = self._save_prompt("Original landscape")
response = await self.client.put(
f"/prompt_manager/prompts/{original_id}", json={"text": "Edited portrait"}
)
self.assertEqual(response.status, 200)
row = self.api.db.get_prompt_by_id(original_id)
self.assertEqual(row["hash"], generate_prompt_hash("Edited portrait"))
self.assertIsNone(
self.api.db.get_prompt_by_hash(generate_prompt_hash("Original landscape"))
)
response = await self.client.post(
"/prompt_manager/save", json={"text": "Original landscape"}
)
self.assertEqual(response.status, 200)
self.assertNotEqual((await response.json())["prompt_id"], original_id)
response = await self.client.post(
"/prompt_manager/save", json={"text": "Edited portrait"}
)
self.assertEqual((await response.json())["prompt_id"], original_id)
async def test_duplicate_edit_returns_conflict_without_changing_either_prompt(self):
first = self._save_prompt("Original landscape", tags=["landscape"])
second = self._save_prompt("Existing portrait", tags=["portrait"])
before = [self.api.db.get_prompt_by_id(pid) for pid in (first, second)]
response = await self.client.put(
f"/prompt_manager/prompts/{first}", json={"text": " EXISTING PORTRAIT "}
)
self.assertEqual(response.status, 409)
self.assertFalse((await response.json())["success"])
self.assertEqual(
[self.api.db.get_prompt_by_id(pid) for pid in (first, second)], before
)
async def test_edit_with_same_normalized_hash_keeps_its_identity(self):
pid = self._save_prompt("Original landscape")
response = await self.client.put(
f"/prompt_manager/prompts/{pid}", json={"text": "ORIGINAL LANDSCAPE"}
)
self.assertEqual(response.status, 200)
self.assertEqual(
self.api.db.get_prompt_by_id(pid)["text"], "ORIGINAL LANDSCAPE"
)
-30
View File
@@ -263,35 +263,5 @@ class TestConfiguration(IsolatedLoggerTestCase):
self.manager.update_config({"console_logging": False})
class TestConsoleNoise(IsolatedLoggerTestCase):
"""ComfyUI's console only sees warnings unless the level is DEBUG."""
def _console_handler(self) -> logging.StreamHandler:
return next(
h
for h in logging.getLogger("prompt_manager").handlers
if type(h) is logging.StreamHandler
)
def test_records_do_not_propagate_to_comfyui_root_logger(self) -> None:
self.assertFalse(logging.getLogger("prompt_manager").propagate)
def test_console_hides_info_at_the_default_level(self) -> None:
self.manager.update_config({"console_logging": True})
self.assertEqual(self._console_handler().level, logging.WARNING)
self.logger.info("startup chatter")
self.assertEqual(self.manager.get_recent_logs(limit=1)[0]["level"], "INFO")
log_file = pathlib.Path(self.log_dir) / "prompt_manager.log"
self.assertIn("startup chatter", log_file.read_text())
self.manager.update_config({"console_logging": False})
def test_debug_level_opens_the_console(self) -> None:
self.manager.update_config({"console_logging": True, "level": "DEBUG"})
self.assertEqual(self._console_handler().level, logging.DEBUG)
self.manager.update_config({"level": "INFO"})
self.assertEqual(self._console_handler().level, logging.WARNING)
self.manager.update_config({"console_logging": False})
if __name__ == "__main__":
unittest.main()
-29
View File
@@ -1,29 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
test('browser coverage maps nested JS/MJS and excludes unrelated and traversal paths', async t => {
const directory = mkdtempSync(join(tmpdir(), 'pm-browser-coverage-'));
const previous = process.env.PM_JS_COVERAGE;
process.env.PM_JS_COVERAGE = directory;
t.after(() => {
if (previous === undefined) delete process.env.PM_JS_COVERAGE;
else process.env.PM_JS_COVERAGE = previous;
rmSync(directory, { recursive: true, force: true });
});
const urls = [
'/prompt_manager/js/gallery.js',
'/prompt_manager/js/shared/controls/button.mjs?v=1',
'/prompt_manager/js/shared/panel.js',
'/scripts/app.js', '/prompt_manager/js/../private.js',
'/prompt_manager/js/shared/theme.css',
];
await saveCoverage({ coverage: { stopJSCoverage: async () => urls.map(url => ({ url: `http://127.0.0.1:8765${url}`, functions: [] })) } }, 'chromium');
const report = JSON.parse(readFileSync(join(directory, readdirSync(directory)[0]), 'utf8'));
const root = fileURLToPath(new URL('../../web/js/', import.meta.url));
assert.deepEqual(report.result.map(entry => fileURLToPath(entry.url)), [join(root, 'gallery.js'), join(root, 'shared/controls/button.mjs'), join(root, 'shared/panel.js')]);
});
-15
View File
@@ -1,15 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const root = fileURLToPath(new URL('../../', import.meta.url));
test('real-ComfyUI suite registers Classic and Nodes 2.0 startup projects', () => {
const output = execFileSync(process.execPath, ['tools/vendor/node_modules/@playwright/test/cli.js',
'test', '--config', 'tools/vendor/comfyui.config.mjs', '--list'], {
cwd: root, encoding: 'utf8', env: { ...process.env,
PM_COMFY_ROOT: '/unused-pinned-checkout', PM_COMFY_URL: 'http://127.0.0.1:8199/' },
});
assert.match(output, /\[classic-start\]/);
assert.match(output, /\[nodes2-start\]/);
});
-61
View File
@@ -1,61 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { mkdtemp, writeFile, mkdir, readFile, realpath, rm, access } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { once } from 'node:events';
import { launchComfyHost } from '../../tools/vendor/comfyui-host.mjs';
async function fixture(t, program) {
const directory = await mkdtemp(join(tmpdir(), 'pm-host-test-'));
t.after(() => rm(directory, { recursive: true, force: true }));
const checkout = join(directory, 'host');
const repository = join(directory, 'plugin');
await mkdir(checkout);
await mkdir(repository);
await writeFile(join(repository, '__init__.py'), '');
execFileSync('git', ['init', '--quiet', repository]);
execFileSync('git', ['-C', repository, 'add', '__init__.py']);
await writeFile(join(repository, 'config.json'), 'private-user-config');
await writeFile(join(repository, 'prompts.db'), 'private-user-database');
await writeFile(join(checkout, 'main.py'), program);
const git = args => execFileSync('git', ['-C', checkout, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
git(['init', '--quiet']);
git(['add', 'main.py']);
git(['-c', 'user.name=PromptManager Tests', '-c', 'user.email=tests@example.invalid', 'commit', '--quiet', '-m', 'fixture']);
return { checkout, repository, python: process.execPath, port: 8199,
pins: { revision: git(['rev-parse', 'HEAD']) }, logPath: join(directory, 'host.log'), forwardOutput: false };
}
const idle = `console.log('ready'); setInterval(() => {}, 1000);`;
test('PromptManager adapter isolates database, configuration and monitored directories', async t => {
const options = await fixture(t, idle);
const host = await launchComfyHost(options);
t.after(() => host.stop());
await once(host.child.stdout, 'data');
const args = host.child.spawnargs;
for (const flag of ['--cpu', '--offline', '--disable-all-custom-nodes']) assert.ok(args.includes(flag));
assert.equal(args[args.indexOf('--base-directory') + 1], host.stateDirectory);
assert.equal(args[args.indexOf('--listen') + 1], '127.0.0.1');
assert.equal(args[args.indexOf('--whitelist-custom-nodes') + 1], 'promptmanager');
const installed = join(host.stateDirectory, 'custom_nodes/promptmanager');
assert.notEqual(await realpath(installed), await realpath(options.repository));
assert.equal(await readFile(join(installed, '__init__.py'), 'utf8'), '');
await assert.rejects(access(join(installed, 'prompts.db')), { code: 'ENOENT' });
assert.equal(JSON.parse(await readFile(join(installed, 'config.json'), 'utf8')).database.default_path, join(host.stateDirectory, 'prompts.db'));
await writeFile(join(installed, 'prompts.db'), 'test data');
assert.equal(await readFile(join(options.repository, 'prompts.db'), 'utf8'), 'private-user-database');
assert.equal(await readFile(join(options.repository, 'config.json'), 'utf8'), 'private-user-config');
const config = JSON.parse(await readFile(join(installed, 'config.json'), 'utf8'));
assert.equal(config.gallery.monitoring.enabled, false);
assert.deepEqual(config.gallery.monitoring.directories, [join(host.stateDirectory, 'output')]);
assert.equal(config.web_ui.result_timeout, 0);
await access(join(host.stateDirectory, 'user'));
await host.stop();
await assert.rejects(access(host.stateDirectory), { code: 'ENOENT' });
await access(join(options.checkout, 'main.py'));
await access(join(options.repository, '__init__.py'));
});
-33
View File
@@ -1,33 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const sourceRoot = join(process.cwd(), 'web/js');
import { checkCoverage, discoverSources } from '../../tools/vendor/frontend-coverage.mjs';
const metrics = value => Object.fromEntries(['lines', 'branches', 'functions', 'statements'].map(key => [key, { pct: value }]));
const summary = { total: metrics(90), [join(sourceRoot, 'a.js')]: metrics(85) };
const baseline = { 'a.js': { lines: 80, branches: 80, functions: 80, statements: 80 } };
test('coverage gate accepts measured files at or above their reviewed floor', () => {
assert.deepEqual(checkCoverage(summary, baseline, sourceRoot, ['a.js']), []);
});
test('coverage gate rejects a regression even when the aggregate percentage is high', () => {
assert.match(checkCoverage({ ...summary, [join(sourceRoot, 'a.js')]: metrics(70) }, baseline, sourceRoot, ['a.js']).join('\n'), /a.js.*lines/);
});
test('coverage gate rejects absent source files and unreviewed new files', () => {
assert.match(checkCoverage({ total: metrics(100) }, baseline, sourceRoot, ['a.js']).join('\n'), /missing.*a.js/i);
assert.match(checkCoverage(summary, baseline, sourceRoot, ['a.js', 'new.js']).join('\n'), /baseline.*new.js/i);
});
test('coverage gate rejects invalid baselines and missing metrics', () => {
assert.throws(() => checkCoverage(summary, { 'a.js': { lines: -1 } }, sourceRoot, ['a.js']), /baseline/i);
assert.match(checkCoverage({ total: metrics(100), [join(sourceRoot, 'a.js')]: {} }, baseline, sourceRoot, ['a.js']).join('\n'), /lines/);
});
test('coverage discovers nested JS and MJS while excluding non-source files', t => {
const root = mkdtempSync(join(tmpdir(), 'pm-coverage-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
mkdirSync(join(root, 'shared/controls'), { recursive: true });
for (const file of ['entry.js', 'shared/core.mjs', 'shared/controls/button.js', 'shared/theme.css', 'shared/notes.txt']) writeFileSync(join(root, file), '');
assert.deepEqual(discoverSources(root), ['entry.js', 'shared/controls/button.js', 'shared/core.mjs']);
assert.match(checkCoverage({}, {}, root, discoverSources(root)).join('\n'), /baseline.*shared\/controls\/button.js/i);
});
-62
View File
@@ -1,62 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { syncToolkit } from '../../tools/vendor/vendor-toolkit.mjs';
function fixture(t) {
const root = mkdtempSync(join(tmpdir(), 'pm-toolkit-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const tooling = join(root, 'tools/vendor');
const dependency = join(tooling, 'node_modules/@comfyassets/toolkit');
mkdirSync(join(dependency, 'ui'), { recursive: true });
const resolved = 'https://codeload.github.com/ComfyAssets/ComfyAssets-Toolkit/tar.gz/' + 'a'.repeat(40);
writeFileSync(join(tooling, 'package.json'), JSON.stringify({ devDependencies: { '@comfyassets/toolkit': resolved } }));
writeFileSync(join(tooling, 'package-lock.json'), JSON.stringify({ packages: { 'node_modules/@comfyassets/toolkit': { version: '0.1.0', resolved, integrity: 'sha512-fixture' } } }));
writeFileSync(join(dependency, 'package.json'), JSON.stringify({ name: '@comfyassets/toolkit', version: '0.1.0', license: 'MIT' }));
for (const file of ['ui/core.js', 'ui/controls.css', 'LICENSE', 'SOURCE.md']) writeFileSync(join(dependency, file), file);
return { root, tooling, dependency, destination: join(root, 'web/js/shared/toolkit') };
}
test('toolkit browser assets ship unchanged with deterministic locked provenance', t => {
const f = fixture(t);
syncToolkit({ root: f.root });
for (const [name, source] of Object.entries({ 'core.js': 'ui/core.js', 'controls.css': 'ui/controls.css', LICENSE: 'LICENSE', 'SOURCE.md': 'SOURCE.md' })) assert.deepEqual(readFileSync(join(f.destination, name)), readFileSync(join(f.dependency, source)));
const first = readFileSync(join(f.destination, 'provenance.json'), 'utf8');
assert.equal(JSON.parse(first).integrity, 'sha512-fixture');
syncToolkit({ root: f.root });
assert.equal(readFileSync(join(f.destination, 'provenance.json'), 'utf8'), first);
syncToolkit({ root: f.root, check: true });
writeFileSync(join(f.destination, 'core.js'), 'local edit');
assert.throws(() => syncToolkit({ root: f.root, check: true }), /drift/);
assert.equal(readFileSync(join(f.destination, 'core.js'), 'utf8'), 'local edit');
});
test('toolkit sync rejects moving pins, identity mismatches and missing sources before writing', t => {
const f = fixture(t);
writeFileSync(join(f.tooling, 'package.json'), JSON.stringify({ devDependencies: { '@comfyassets/toolkit': 'main' } }));
assert.throws(() => syncToolkit({ root: f.root }), /pin/);
const second = fixture(t);
writeFileSync(join(second.dependency, 'package.json'), JSON.stringify({ name: '@comfyassets/toolkit', version: '0.2.0', license: 'MIT' }));
assert.throws(() => syncToolkit({ root: second.root }), /identity/);
const third = fixture(t);
rmSync(join(third.dependency, 'ui/controls.css'));
assert.throws(() => syncToolkit({ root: third.root }), /ENOENT/);
assert.throws(() => readFileSync(join(third.destination, 'core.js')), /ENOENT/);
});
test('toolkit sync rejects lookalike download hosts and archive paths before writing', t => {
for (const prefix of [
'https://codeloadXgithub.com/ComfyAssets/ComfyAssets-Toolkit/tar.gz/',
'https://codeload.githubXcom/ComfyAssets/ComfyAssets-Toolkit/tar.gz/',
'https://codeload.github.com/ComfyAssets/ComfyAssets-Toolkit/tarXgz/',
]) {
const f = fixture(t);
const resolved = prefix + 'a'.repeat(40);
writeFileSync(join(f.tooling, 'package.json'), JSON.stringify({ devDependencies: { '@comfyassets/toolkit': resolved } }));
writeFileSync(join(f.tooling, 'package-lock.json'), JSON.stringify({ packages: { 'node_modules/@comfyassets/toolkit': { version: '0.1.0', resolved, integrity: 'sha512-fixture' } } }));
assert.throws(() => syncToolkit({ root: f.root }), /immutable commit pin/);
assert.throws(() => readFileSync(join(f.destination, 'core.js')), /ENOENT/);
}
});
-107
View File
@@ -1,107 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
import { syncViewer } from '../../tools/vendor/vendor-viewerjs.mjs';
import { updateViewer } from '../../tools/vendor/update-viewerjs.mjs';
function fixture(t) {
const root = mkdtempSync(join(tmpdir(), 'pm-vendor-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const tooling = join(root, 'tools/vendor');
const dependency = join(tooling, 'node_modules/viewerjs');
mkdirSync(join(dependency, 'dist'), { recursive: true });
writeFileSync(join(tooling, 'package.json'), JSON.stringify({ devDependencies: { viewerjs: '1.15.2' } }));
writeFileSync(join(tooling, 'package-lock.json'), JSON.stringify({ packages: {
'node_modules/viewerjs': { version: '1.15.2', resolved: 'https://registry.npmjs.org/viewerjs/-/viewerjs-1.15.2.tgz', integrity: 'sha512-fixture' },
} }));
writeFileSync(join(dependency, 'package.json'), JSON.stringify({ name: 'viewerjs', version: '1.15.2', license: 'MIT' }));
for (const name of ['viewer.min.js', 'viewer.min.css']) {
writeFileSync(join(dependency, 'dist', name), `/*! Viewer.js v1.15.2 */\n${name}`);
}
writeFileSync(join(dependency, 'LICENSE'), 'The MIT License (MIT)\nCopyright Chen Fengyuan\n');
return { root, tooling, dependency, destination: join(root, 'web/lib/viewerjs') };
}
test('sync copies pristine assets and license and records locked provenance deterministically', t => {
const f = fixture(t);
syncViewer({ root: f.root });
const first = readFileSync(join(f.destination, 'provenance.json'), 'utf8');
const record = JSON.parse(first);
assert.equal(record.version, '1.15.2');
assert.equal(record.integrity, 'sha512-fixture');
for (const [target, source] of Object.entries({ 'viewer.min.js': 'dist/viewer.min.js', 'viewer.min.css': 'dist/viewer.min.css', LICENSE: 'LICENSE' })) {
const original = readFileSync(join(f.dependency, source));
assert.deepEqual(readFileSync(join(f.destination, target)), original);
assert.equal(record.files[target].sha256, createHash('sha256').update(original).digest('hex'));
}
syncViewer({ root: f.root });
assert.equal(readFileSync(join(f.destination, 'provenance.json'), 'utf8'), first);
syncViewer({ root: f.root, check: true });
});
test('check rejects drift without overwriting local changes', t => {
const f = fixture(t);
syncViewer({ root: f.root });
writeFileSync(join(f.destination, 'viewer.min.js'), 'local edit');
assert.throws(() => syncViewer({ root: f.root, check: true }), /viewer.min.js/);
assert.equal(readFileSync(join(f.destination, 'viewer.min.js'), 'utf8'), 'local edit');
rmSync(join(f.destination, 'LICENSE'));
assert.throws(() => syncViewer({ root: f.root, check: true }), /LICENSE/);
});
test('sync refuses mismatched installed versions and unpinned dependencies', t => {
const f = fixture(t);
writeFileSync(join(f.dependency, 'package.json'), JSON.stringify({ name: 'viewerjs', version: '1.11.6', license: 'MIT' }));
assert.throws(() => syncViewer({ root: f.root }), /version/i);
writeFileSync(join(f.tooling, 'package.json'), JSON.stringify({ devDependencies: { viewerjs: '^1.15.2' } }));
assert.throws(() => syncViewer({ root: f.root }), /exact|pin/i);
});
test('sync validates every upstream asset before writing anything', t => {
const f = fixture(t);
writeFileSync(join(f.dependency, 'dist/viewer.min.css'), '/*! Viewer.js v1.11.6 */');
assert.throws(() => syncViewer({ root: f.root }), /version|banner/i);
assert.throws(() => readFileSync(join(f.destination, 'viewer.min.js')), /ENOENT/);
});
test('updater resolves the stable release and installs it exactly with lifecycle scripts disabled', t => {
const f = fixture(t);
const calls = [];
let synced = false;
const run = (args, options) => {
calls.push({ args, options });
return args[0] === 'view' ? '"1.15.3"' : '';
};
assert.equal(updateViewer({ root: f.root, run, sync: () => { synced = true; } }), '1.15.3');
assert.deepEqual(calls[1].args, ['install', '--save-dev', '--save-exact', '--ignore-scripts', 'viewerjs@1.15.3']);
assert.equal(calls[1].options.cwd, f.tooling);
assert.equal(synced, true);
});
test('updater is a no-op at the current release and rejects invalid version arguments', t => {
const f = fixture(t);
const run = () => { throw new Error('must not install'); };
assert.equal(updateViewer({ root: f.root, version: '1.15.2', run }), null);
for (const version of ['latest', '1.15.3-beta.1', '1.15.3;anything', '--global']) {
assert.throws(() => updateViewer({ root: f.root, version, run }), /version/i);
}
});
test('failed installation never copies vendor assets', t => {
const f = fixture(t);
let synced = false;
assert.throws(() => updateViewer({ root: f.root, version: '1.15.3', run: () => { throw new Error('offline'); }, sync: () => { synced = true; } }), /offline/);
assert.equal(synced, false);
});
test('updater accepts a single registry result array and rejects ambiguous metadata', t => {
const f = fixture(t);
const calls = [];
const run = args => { calls.push(args); return '["1.15.2"]'; };
assert.equal(updateViewer({ root: f.root, run }), null);
assert.equal(calls.length, 1);
assert.throws(() => updateViewer({ root: f.root, run: () => '["1.15.2", "1.15.3"]' }), /version/i);
});
-108
View File
@@ -1,108 +0,0 @@
# Viewer.js maintenance
ComfyUI serves the committed files in `web/lib/viewerjs/`. Node and npm are only
needed by maintainers and CI. Keep upstream files pristine; application behavior
belongs in `web/js/`, and theme overrides belong in `comfyui-theme.css`.
## Install and verify
Use Node 24 or newer. From the repository root:
```sh
npm ci --prefix tools/vendor --ignore-scripts
npm run --prefix tools/vendor check
npm run --prefix tools/vendor test
node --test tests/js/*.test.js
npx --prefix tools/vendor playwright install chromium
npm run --prefix tools/vendor test:browser
```
On Linux, use `playwright install --with-deps chromium` when browser system
libraries are missing. The browser tests use synthetic images and mock the
ComfyUI API boundary; they run production viewer methods and the committed
Viewer.js JS/CSS, without a running ComfyUI instance.
`check` is read-only and fails if any JS, CSS, license, or provenance file differs
from the installed package. The manifest, lockfile, installed package, and asset
version banners must agree. The lockfile records npm's archive integrity;
`provenance.json` records the upstream source, package integrity, and SHA-256 of
each copied file. A normal `npm ci --ignore-scripts` verifies archive integrity.
Full-page workflows, frontend coverage gates, and required merge checks are
documented in [TESTING.md](../../TESTING.md). The `viewerjs` CI check runs both
the fixture tests above and the real-page workflows on every PR.
## Update
```sh
npm run --prefix tools/vendor update
# Or select a specific stable version:
npm run --prefix tools/vendor update -- --version 1.15.2
```
The command installs an exact version with lifecycle scripts disabled and copies
`dist/viewer.min.js`, `dist/viewer.min.css`, and `LICENSE`. It writes deterministic
provenance with no timestamps. Repeat the checks above and commit the manifest,
lockfile, and all vendor files together. To regenerate files from the current
pin without changing versions, run `npm run --prefix tools/vendor sync`.
## Automated update PRs
`Update vendored Viewer.js` checks npm's stable release daily at 07:30 UTC and
supports **Actions → Update vendored Viewer.js → Run workflow**, with an optional
version. It tests the generated update before opening `deps/viewerjs-X.Y.Z`.
Existing open or closed PRs for that release are left alone. A failed test does
not publish a branch or PR. Changes require review; the workflow never merges
or approves PRs.
The organization and repository must enable **Actions → General → Workflow
permissions → Allow GitHub Actions to create and approve pull requests**.
Keep default workflow permissions read-only. Only the update job requests
`contents: write`, `pull-requests: write`, and `actions: write`.
The workflow uses `GITHUB_TOKEN`; no personal token is needed. Because bot-token
pushes do not normally trigger other workflows, it explicitly dispatches the
read-only `Viewer.js vendor and compatibility` workflow on the update branch.
Both workflow files must be merged into the default branch before scheduling or
manual dispatch becomes available.
## Compatibility checklist
Check gallery grid/list views, navigation across 100-image windows, metadata,
slideshow/fullscreen, keyboard navigation, Escape and focus cleanup. Check the
admin gallery/filmstrip and tag viewer. On touch devices, test long swipes,
short drags, and pinch/rotate gestures. Confirm the ComfyUI theme remains usable.
The gallery keeps its bounded viewer and custom slideshow. Upstream navbar
windowing is not a replacement for bounding the supplied image list: native
slideshow playback can still preload every supplied original. Review new
preloading defaults, gesture behavior, CSS changes, and any adapter use of
Viewer.js internal methods before adopting another release.
## Shared ComfyAssets test tooling
The exact toolkit commit archive in `package.json` / `package-lock.json` supplies
host orchestration and coverage helpers. `comfyui-host.mjs` is PromptManager's
configuration adapter; keep reusable lifecycle changes in the toolkit source.
The existing npm scripts remain the public maintainer entry points. See
[TESTING.md](../../TESTING.md) for the managed real-host matrix and update policy.
## Shared ComfyAssets browser controls
Prompt Manager (Classic) consumes the same pinned toolkit's browser controls.
The node identifier remains `PromptManager`; existing workflows, sockets, and
immediate result selection are preserved. PromptManager Text is unchanged.
Committed assets in `web/js/shared/toolkit/` work without npm or KikoTools installed.
Maintain common controls in [ComfyAssets Toolkit](https://github.com/ComfyAssets/ComfyAssets-Toolkit),
then update the exact archive pin and lockfile here. Regenerate using
`npm run --prefix tools/vendor sync:toolkit`; `npm run --prefix tools/vendor check`
verifies both Viewer.js and the toolkit without rewriting files. The toolkit
distribution includes its license, source history, archive integrity and per-file
hashes. Do not edit its copied files directly. Classic's controller and layout
remain in `web/js/classic-node.js` and `web/js/classic-node.css`.
Run the frontend coverage/browser suites and managed Classic/Nodes 2.0 matrix
after updates. Shared modules participate in the frontend coverage gate.
Pixaroma's reusable framework inspired this architecture; no Pixaroma code,
styles or assets are included.
-18
View File
@@ -1,18 +0,0 @@
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { mapBrowserCoverage } from '@comfyassets/toolkit/testing/coverage';
import { randomUUID } from 'node:crypto';
import { repositoryRoot } from './vendor-viewerjs.mjs';
export async function startCoverage(page, browserName) {
if (process.env.PM_JS_COVERAGE && browserName === 'chromium') {
await page.coverage.startJSCoverage({ resetOnNavigation: false });
}
}
export async function saveCoverage(page, browserName) {
if (!process.env.PM_JS_COVERAGE || browserName !== 'chromium') return;
const entries = await page.coverage.stopJSCoverage();
const result = mapBrowserCoverage(entries, { urlPrefix: '/prompt_manager/js/', sourceRoot: join(repositoryRoot, 'web/js') });
mkdirSync(process.env.PM_JS_COVERAGE, { recursive: true });
writeFileSync(join(process.env.PM_JS_COVERAGE, `browser-${randomUUID()}.json`), JSON.stringify({ result }));
}
-39
View File
@@ -1,39 +0,0 @@
import { writeFile } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { launchComfyHost as launchSharedHost } from '@comfyassets/toolkit/testing/host';
import runtime from './comfyui-runtime.json' with { type: 'json' };
const repositoryRoot = fileURLToPath(new URL('../../', import.meta.url));
export function launchComfyHost({ checkout, repository = repositoryRoot,
python = process.env.PM_TEST_PYTHON || 'python', port = 8189, pins = runtime,
logPath = join(repositoryRoot, 'tools/vendor/test-results/comfyui-host.log'), forwardOutput = true }) {
return launchSharedHost({ checkout, repository, python, port, pins, logPath, forwardOutput,
pluginName: 'promptmanager',
async preparePlugin({ installed, stateDirectory }) {
const configPath = join(installed, 'config.json');
await writeFile(configPath, JSON.stringify({
database: { default_path: join(stateDirectory, 'prompts.db') },
gallery: { monitoring: { enabled: false, directories: [join(stateDirectory, 'output')] } },
web_ui: { result_timeout: 0 },
}), { mode: 0o600 });
return { PROMPT_MANAGER_CONFIG_PATH: configPath, PROMPT_MANAGER_EXTRA_GALLERY_ROOTS: '' };
},
});
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
try {
const host = await launchComfyHost({ checkout: process.env.PM_COMFY_ROOT,
port: Number(process.env.PM_COMFY_PORT || 8189) });
const stop = () => { host.stop().catch(error => { console.error(error.message); process.exitCode = 1; }); };
process.once('SIGINT', stop);
process.once('SIGTERM', stop);
const result = await host.finished;
process.removeListener('SIGINT', stop);
process.removeListener('SIGTERM', stop);
if (result.code) console.error(`ComfyUI host exited with status ${result.code}; see tools/vendor/test-results/comfyui-host.log`);
process.exitCode = result.code;
} catch (error) { console.error(error.message); process.exitCode = 1; }
}
-9
View File
@@ -1,9 +0,0 @@
{
"repository": "Comfy-Org/ComfyUI",
"revision": "e8665ca8e4089ab917567cdb491308e1397663d4",
"serverVersion": "0.39.0",
"frontendVersion": "1.55.14",
"pythonVersion": "3.12",
"torchVersion": "2.11.0",
"torchvisionVersion": "0.26.0"
}
-28
View File
@@ -1,28 +0,0 @@
import { defineConfig } from './node_modules/@playwright/test/index.mjs';
import { fileURLToPath } from 'node:url';
if (!process.env.PM_COMFY_ROOT) throw new Error('Set PM_COMFY_ROOT to a pinned disposable ComfyUI checkout');
const host = new URL(process.env.PM_COMFY_URL || 'http://127.0.0.1:8189/');
if (host.protocol !== 'http:' || host.hostname !== '127.0.0.1' || !host.port) throw new Error('A managed host needs an HTTP 127.0.0.1 URL with an explicit port');
const servers = [{
command: 'node tools/vendor/comfyui-host.mjs',
cwd: fileURLToPath(new URL('../../', import.meta.url)),
url: new URL('system_stats', host).href, timeout: 120000, reuseExistingServer: false,
env: { PM_COMFY_PORT: host.port },
gracefulShutdown: { signal: 'SIGTERM', timeout: 10000 },
}];
export default defineConfig({
testDir: '../../tests/comfyui',
outputDir: './test-results/comfyui',
timeout: 60000,
workers: 1,
forbidOnly: Boolean(process.env.CI),
retries: 0,
use: { baseURL: host.href, browserName: 'chromium', viewport: { width: 1600, height: 1100 }, trace: 'retain-on-failure', screenshot: 'only-on-failure' },
webServer: servers,
projects: [
{ name: 'classic-start', metadata: { initialRenderer: false } },
{ name: 'nodes2-start', metadata: { initialRenderer: true } },
],
});
-116
View File
@@ -1,116 +0,0 @@
{
"admin.js": {
"lines": 28,
"branches": 58,
"functions": 23,
"statements": 28
},
"api-client.js": {
"lines": 98,
"branches": 94,
"functions": 98,
"statements": 98
},
"comfy-metadata.js": {
"lines": 97,
"branches": 90,
"functions": 98,
"statements": 97
},
"data-actions.js": {
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
},
"gallery-settings.js": {
"lines": 98,
"branches": 90,
"functions": 98,
"statements": 98
},
"gallery.js": {
"lines": 39,
"branches": 63,
"functions": 43,
"statements": 39
},
"html-escape.js": {
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
},
"image-helpers.js": {
"lines": 96,
"branches": 91,
"functions": 98,
"statements": 96
},
"list-state.js": {
"lines": 98,
"branches": 92,
"functions": 98,
"statements": 98
},
"png-metadata.js": {
"lines": 98,
"branches": 90,
"functions": 98,
"statements": 98
},
"prompt-list-sort.js": {
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
},
"scan-progress.js": {
"lines": 97,
"branches": 91,
"functions": 98,
"statements": 97
},
"sse-stream.js": {
"lines": 98,
"branches": 90,
"functions": 98,
"statements": 98
},
"tag-autocomplete.js": {
"lines": 98,
"branches": 94,
"functions": 98,
"statements": 98
},
"tags-page.js": {
"lines": 57,
"branches": 48,
"functions": 56,
"statements": 57
},
"theme-bridge.js": {
"lines": 90,
"branches": 53,
"functions": 98,
"statements": 90
},
"view-router.js": {
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
},
"classic-node.js": {
"lines": 98,
"branches": 79,
"functions": 98,
"statements": 98
},
"shared/toolkit/core.js": {
"lines": 98,
"branches": 96,
"functions": 98,
"statements": 98
}
}
-60
View File
@@ -1,60 +0,0 @@
import { readFileSync, readdirSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, join, relative } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { spawnSync } from 'node:child_process';
import { repositoryRoot } from './vendor-viewerjs.mjs';
import { discoverSources, checkCoverage } from '@comfyassets/toolkit/testing/coverage';
export { discoverSources, checkCoverage };
const metrics = ['lines', 'branches', 'functions', 'statements'];
function run(args, env = {}) {
const result = spawnSync(process.execPath, args, { cwd: repositoryRoot, stdio: 'inherit',
env: { ...process.env, ...env } });
if (result.status !== 0) throw new Error(`Command failed (${result.status}): ${args.join(' ')}`);
}
async function main() {
const tools = dirname(fileURLToPath(import.meta.url));
const reportRoot = join(repositoryRoot, 'coverage/js');
const raw = join(reportRoot, 'raw');
const sourceRoot = join(repositoryRoot, 'web/js');
const files = discoverSources(sourceRoot);
rmSync(reportRoot, { recursive: true, force: true });
mkdirSync(raw, { recursive: true });
const tests = readdirSync(join(repositoryRoot, 'tests/js')).filter(file => file.endsWith('.test.js')).map(file => `tests/js/${file}`);
run(['--test', ...tests], { NODE_V8_COVERAGE: raw });
const playwright = join(tools, 'node_modules/@playwright/test/cli.js');
run([playwright, 'test', '--config', join(tools, 'playwright.config.mjs')]);
run([playwright, 'test', '--config', join(tools, 'playwright.workflows.config.mjs'),
'--project', 'chromium', '--project', 'touch'], { PM_JS_COVERAGE: raw });
run([join(tools, 'node_modules/c8/bin/c8.js'), 'report', '--all', '--src', sourceRoot,
'--include', 'web/js/**/*.js', '--include', 'web/js/**/*.mjs', '--temp-directory', raw, '--reports-dir', reportRoot,
'--reporter', 'text', '--reporter', 'json-summary', '--reporter', 'html']);
const summary = JSON.parse(readFileSync(join(reportRoot, 'coverage-summary.json'), 'utf8'));
const baselinePath = join(tools, 'frontend-coverage-baseline.json');
if (process.argv.includes('--update-baseline')) {
const previous = JSON.parse(readFileSync(baselinePath, 'utf8'));
const failures = checkCoverage(summary, previous, sourceRoot, files.filter(file => previous[file]));
if (failures.length) throw new Error(`Baseline updates cannot lower coverage:\n${failures.join('\n')}`);
const next = Object.fromEntries(files.map(file => {
const measured = summary[join(sourceRoot, file)];
if (!measured || metrics.some(metric => !Number.isFinite(measured[metric]?.pct))) {
throw new Error(`Missing coverage for ${file}`);
}
if (Object.keys(previous).length && !previous[file] && measured.lines.pct < 80) {
throw new Error(`New frontend files need at least 80% line coverage: ${file}`);
}
return [file, Object.fromEntries(metrics.map(metric => [metric, Math.max(previous[file]?.[metric] || 0, Math.floor(measured[metric].pct) - 2, 0)]))];
}));
writeFileSync(baselinePath, JSON.stringify(next, null, 2) + '\n');
} else {
const baseline = JSON.parse(readFileSync(baselinePath, 'utf8'));
const failures = checkCoverage(summary, baseline, sourceRoot, files);
if (failures.length) throw new Error(`Frontend coverage regressed:\n${failures.join('\n')}`);
}
console.log(`Frontend coverage passed for ${files.length} files. Report: ${relative(repositoryRoot, reportRoot)}/index.html`);
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch(error => { console.error(error.message); process.exitCode = 1; });
}
-803
View File
@@ -1,803 +0,0 @@
{
"name": "promptmanager-vendor-tools",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "promptmanager-vendor-tools",
"version": "1.0.0",
"devDependencies": {
"@comfyassets/toolkit": "https://codeload.github.com/ComfyAssets/ComfyAssets-Toolkit/tar.gz/66644f5620e10efeaba090dca2d5be54baa2ee14",
"@playwright/test": "1.64.1",
"c8": "12.0.0",
"viewerjs": "1.15.2"
}
},
"node_modules/@bcoe/v8-coverage": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@comfyassets/toolkit": {
"version": "0.1.0",
"resolved": "https://codeload.github.com/ComfyAssets/ComfyAssets-Toolkit/tar.gz/66644f5620e10efeaba090dca2d5be54baa2ee14",
"integrity": "sha512-jvDj3iYJHjRCH7nZimoF4KNLwEVdzKVQIOZrMfoLg9aLnHm98R8YvBUOvnlJ+JNrM8/LnD2m30u3ggSTnR0/sw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=24"
}
},
"node_modules/@istanbuljs/schema": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz",
"integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@playwright/test": {
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz",
"integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.64.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@types/istanbul-lib-coverage": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
"integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==",
"dev": true,
"license": "MIT"
},
"node_modules/ansi-regex": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz",
"integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/brace-expansion": {
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/c8": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/c8/-/c8-12.0.0.tgz",
"integrity": "sha512-4zpJvrd1nKWutnnKC2pXkFmb6iM1l+ffN//o1CzlTNwW7GSOs9a1xrLqkC48nU8oEkjmPZLPiwMsIaOvoF4Pqg==",
"dev": true,
"license": "ISC",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.1",
"@istanbuljs/schema": "^0.1.3",
"find-up": "^5.0.0",
"foreground-child": "^3.1.1",
"istanbul-lib-coverage": "^3.2.0",
"istanbul-lib-report": "^3.0.1",
"istanbul-reports": "^3.1.6",
"test-exclude": "^8.0.0",
"v8-to-istanbul": "^9.0.0",
"yargs": "^18.0.0",
"yargs-parser": "^21.1.1"
},
"bin": {
"c8": "bin/c8.js"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=23"
},
"peerDependencies": {
"monocart-coverage-reports": "^2"
},
"peerDependenciesMeta": {
"monocart-coverage-reports": {
"optional": true
}
}
},
"node_modules/cliui": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
"integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^7.2.0",
"strip-ansi": "^7.1.0",
"wrap-ansi": "^9.0.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/cliui/node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^10.3.0",
"get-east-asian-width": "^1.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true,
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/emoji-regex": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
"dev": true,
"license": "MIT"
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/find-up": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
"dev": true,
"license": "MIT",
"dependencies": {
"locate-path": "^6.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/foreground-child": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
"dev": true,
"license": "ISC",
"dependencies": {
"cross-spawn": "^7.0.6",
"signal-exit": "^4.0.1"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-east-asian-width": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz",
"integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/glob": {
"version": "13.0.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
"integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"minimatch": "^10.2.2",
"minipass": "^7.1.3",
"path-scurry": "^2.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true,
"license": "MIT"
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=8"
}
},
"node_modules/istanbul-lib-report": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"istanbul-lib-coverage": "^3.0.0",
"make-dir": "^4.0.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-reports": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"html-escaper": "^2.0.0",
"istanbul-lib-report": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-locate": "^5.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lru-cache": {
"version": "11.5.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
"integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^7.5.3"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimatch": {
"version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.8"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minipass": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"yocto-queue": "^0.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-limit": "^3.0.2"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-scurry": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
"integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^11.0.0",
"minipass": "^7.1.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/playwright": {
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
"integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.64.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/string-width": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.3.0.tgz",
"integrity": "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"get-east-asian-width": "^1.5.0",
"strip-ansi": "^7.1.2"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/strip-ansi": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.2.2"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/test-exclude": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz",
"integrity": "sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"@istanbuljs/schema": "^0.1.2",
"glob": "^13.0.6",
"minimatch": "^10.2.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/v8-to-istanbul": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
"integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==",
"dev": true,
"license": "ISC",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.12",
"@types/istanbul-lib-coverage": "^2.0.1",
"convert-source-map": "^2.0.0"
},
"engines": {
"node": ">=10.12.0"
}
},
"node_modules/viewerjs": {
"version": "1.15.2",
"resolved": "https://registry.npmjs.org/viewerjs/-/viewerjs-1.15.2.tgz",
"integrity": "sha512-CYEqSFfPUSnmn1PjrhYxXOScaUC7n8+NpSqI+MrOSM8wcKPe35MlwQYwksuD5rjFkYv/ePmY5NsUa51uRNN2jA==",
"dev": true,
"license": "MIT"
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/wrap-ansi": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.1",
"string-width": "^7.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrap-ansi/node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^10.3.0",
"get-east-asian-width": "^1.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/yargs": {
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.2.0.tgz",
"integrity": "sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^9.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"string-width": "^8.2.1",
"y18n": "^5.0.5",
"yargs-parser": "^22.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=23"
}
},
"node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/yargs/node_modules/yargs-parser": {
"version": "22.0.0",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
"integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
"dev": true,
"license": "ISC",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=23"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
}
}
}
-24
View File
@@ -1,24 +0,0 @@
{
"name": "promptmanager-vendor-tools",
"private": true,
"version": "1.0.0",
"description": "Maintainer-only tools for reproducible browser dependencies",
"scripts": {
"sync": "node vendor-viewerjs.mjs",
"check": "node vendor-viewerjs.mjs --check && node vendor-toolkit.mjs --check",
"sync:toolkit": "node vendor-toolkit.mjs",
"update": "node update-viewerjs.mjs",
"test": "node --test ../../tests/vendor/*.test.mjs",
"test:browser": "playwright test --config playwright.config.mjs",
"test:workflows": "playwright test --config playwright.workflows.config.mjs",
"test:comfyui": "playwright test --config comfyui.config.mjs",
"test:coverage": "node frontend-coverage.mjs",
"coverage:update": "node frontend-coverage.mjs --update-baseline"
},
"devDependencies": {
"@comfyassets/toolkit": "https://codeload.github.com/ComfyAssets/ComfyAssets-Toolkit/tar.gz/66644f5620e10efeaba090dca2d5be54baa2ee14",
"@playwright/test": "1.64.1",
"c8": "12.0.0",
"viewerjs": "1.15.2"
}
}
-17
View File
@@ -1,17 +0,0 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
outputDir: './test-results/viewer',
testMatch: '*.spec.mjs',
fullyParallel: true,
workers: 2,
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 1 : 0,
reporter: 'list',
use: { browserName: 'chromium', headless: true, trace: 'retain-on-failure' },
projects: [
{ name: 'desktop', use: { viewport: { width: 1280, height: 800 } } },
{ name: 'touch', use: { viewport: { width: 800, height: 700 }, hasTouch: true, isMobile: true } },
],
});
-27
View File
@@ -1,27 +0,0 @@
import { defineConfig, devices } from '@playwright/test';
import { repositoryRoot } from './vendor-viewerjs.mjs';
const port = Number(process.env.PM_TEST_PORT || 8765);
export default defineConfig({
testDir: '../../tests/browser',
outputDir: './test-results/workflows',
testMatch: '*.spec.mjs',
workers: 1,
fullyParallel: false,
forbidOnly: Boolean(process.env.CI),
retries: 0,
reporter: 'list',
use: { baseURL: `http://127.0.0.1:${port}`, trace: 'retain-on-failure', screenshot: 'only-on-failure' },
webServer: {
command: `node tools/vendor/start-browser-server.mjs --port ${port}`,
cwd: repositoryRoot,
url: `http://127.0.0.1:${port}/prompt_manager/test`,
reuseExistingServer: false,
gracefulShutdown: { signal: 'SIGTERM', timeout: 5000 },
},
projects: [
{ name: 'chromium', use: { browserName: 'chromium' } },
{ name: 'firefox', use: { browserName: 'firefox' } },
{ name: 'webkit', use: { browserName: 'webkit' } },
{ name: 'touch', use: { ...devices['Pixel 7'], browserName: 'chromium' } },
],
});
-8
View File
@@ -1,8 +0,0 @@
import { spawn } from 'node:child_process';
const child = spawn(process.env.PM_TEST_PYTHON || 'python',
['-m', 'tests.browser.server', ...process.argv.slice(2)], { stdio: 'inherit' });
child.on('error', error => { console.error(error.message); process.exitCode = 1; });
child.on('exit', code => { process.exitCode = code ?? 1; });
for (const signal of ['SIGTERM', 'SIGINT']) {
process.once(signal, () => child.kill(signal));
}
-270
View File
@@ -1,270 +0,0 @@
import { test, expect } from '@playwright/test';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { repositoryRoot as root } from '../vendor-viewerjs.mjs';
const source = file => readFileSync(join(root, file), 'utf8');
async function waitForImage(page, index) {
await expect.poll(() => page.evaluate(() => window.gallery.viewer?.viewed)).toBe(true);
await expect.poll(() => page.evaluate(() => window.gallery.lastMetadata)).toContain(`/original/${index}.svg`);
}
async function loadGallery(page) {
await page.addScriptTag({ content: source('web/js/gallery.js') + '\nwindow.TestGallery = GalleryManager;' });
await page.evaluate(() => {
const manager = window.gallery = Object.create(TestGallery.prototype);
Object.assign(manager, {
viewer: null, viewMode: 'grid',
images: Array.from({ length: 2000 }, (_, i) => ({ filename: `${i}.svg`,
url: `http://viewer.test/original/${i}.svg`, thumbnail_url: `http://viewer.test/thumb/${i}.svg`,
relative_path: `${i}.svg`, size: 100, modified_time: 1 })),
getSettings: () => ({ showImageInfo: true }),
loadImageMetadata(url, content) { this.lastMetadata = url; content.textContent = url; },
});
manager.renderGallery();
});
}
test.beforeEach(async ({ page }) => {
page.viewerErrors = [];
page.on('pageerror', error => page.viewerErrors.push(error.message));
await page.route('http://viewer.test/**', route => route.request().resourceType() === 'document'
? route.fulfill({ contentType: 'text/html', body: '<head><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><button id="outside">Outside</button><div id="galleryGrid"></div><div id="galleryList"></div><div id="loadingState"></div><div id="errorState"></div><div id="galleryContent"></div><div id="galleryEmpty"></div><h1 id="galleryTitle"></h1></body>' })
: route.fulfill({ contentType: 'image/svg+xml', body: '<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128"><rect width="128" height="128" fill="teal"/></svg>' }));
await page.goto('http://viewer.test/');
await page.addStyleTag({ path: join(root, 'web/lib/tailwind/comfyui-theme.css') });
await page.addStyleTag({ path: join(root, 'web/lib/tailwind/styles.css') });
await page.addStyleTag({ path: join(root, 'web/lib/viewerjs/viewer.min.css') });
await page.addStyleTag({ content: '.hidden{display:none} #galleryGrid{display:grid;grid-template-columns:repeat(8,1fr)} #galleryGrid img{width:100px;height:100px}' });
for (const file of ['web/lib/viewerjs/viewer.min.js', 'web/js/html-escape.js', 'web/js/image-helpers.js', 'web/js/api-client.js']) {
await page.addScriptTag({ path: join(root, file) });
}
});
test.afterEach(async ({ page }) => {
expect(page.viewerErrors).toEqual([]);
});
test('gallery retains cards and navigates across bounded windows with correct metadata', async ({ page }) => {
await loadGallery(page);
await page.locator('[data-image-index="1500"]').click();
await waitForImage(page, 1500);
expect(await page.evaluate(() => gallery.viewer.length)).toBeLessThanOrEqual(100);
expect(await page.evaluate(() => {
const first = document.querySelector('.image-item');
gallery.images.push({ ...gallery.images[0], filename: 'appended.svg' });
gallery.renderGallery({ from: 2000 });
return first === document.querySelector('.image-item');
})).toBe(true);
await page.evaluate(() => gallery.viewer.view(99));
await waitForImage(page, 1549);
await page.keyboard.press('ArrowRight');
await waitForImage(page, 1550);
await page.keyboard.press('ArrowLeft');
await waitForImage(page, 1549);
await page.keyboard.press('Escape');
await expect.poll(() => page.evaluate(() => gallery.viewer.isShown)).toBe(false);
await expect(page.locator('#metadata-sidebar')).toHaveCount(0);
await page.locator('#outside').focus();
await expect(page.locator('#outside')).toBeFocused();
});
test('gallery slideshow advances without preloading the collection and cleans up on close', async ({ page }) => {
await loadGallery(page);
await page.evaluate(() => gallery.openViewer(1500));
await waitForImage(page, 1500);
await page.clock.install();
await page.locator('[data-viewer-action="play"]').click();
expect(await page.evaluate(() => gallery.slideshowPlaying)).toBe(true);
await expect(page.locator('.viewer-player img')).toHaveCount(0);
await page.clock.fastForward(5000);
await waitForImage(page, 1501);
await page.locator('[data-viewer-action="play"]').click();
expect(await page.evaluate(() => gallery.slideshowPlaying)).toBe(false);
await page.keyboard.press('Escape');
await expect.poll(() => page.evaluate(() => gallery.viewer.isShown)).toBe(false);
await page.evaluate(() => gallery.destroyViewer());
await expect(page.locator('.viewer-container')).toHaveCount(0);
});
test('admin gallery and filmstrip use the real viewer events and destroy temporary containers', async ({ page }) => {
// Exercise the production class without starting the ComfyUI dashboard bootstrap.
const adminSource = source('web/js/admin.js');
await page.addScriptTag({ content: adminSource.slice(0, adminSource.indexOf(' function startAdmin()')) + '\nwindow.TestAdmin = PromptAdmin;' });
await page.evaluate(async () => {
const admin = window.admin = Object.create(TestAdmin.prototype);
const images = Array.from({ length: 3 }, (_, i) => ({ filename: `${i}.svg`, url: `http://viewer.test/original/${i}.svg`, size: 100, created_at: 1 }));
Object.assign(admin, {
prompts: [{ id: 1, text: 'test' }],
getImageUrl: image => image.url,
loadFilmStripImages: async () => images,
addMetadataSidebar() {},
loadMetadataForImage(image) { this.lastMetadata = image.src; },
});
await admin.openFilmStripViewer(1, 1);
});
await expect.poll(() => page.evaluate(() => admin.lastMetadata)).toContain('/1.svg');
await page.keyboard.press('ArrowRight');
await expect.poll(() => page.evaluate(() => admin.lastMetadata)).toContain('/2.svg');
await page.keyboard.press('Escape');
await expect(page.locator('#filmStripViewerContainer')).toHaveCount(0);
await expect(page.locator('.viewer-container')).toHaveCount(0);
await page.evaluate(() => admin.renderGallery([{ filename: 'a.svg', url: 'http://viewer.test/original/0.svg', size: 100, created_at: 1 }], 1));
await expect.poll(() => page.evaluate(() => Boolean(admin.galleryViewer))).toBe(true);
await page.evaluate(() => admin.galleryViewer.show());
await expect.poll(() => page.evaluate(() => admin.galleryViewer.viewed)).toBe(true);
await page.keyboard.press('Escape');
await expect.poll(() => page.evaluate(() => admin.galleryViewer.isShown)).toBe(false);
await page.evaluate(() => admin.galleryViewer.destroy());
});
test('tag viewer opens at the selected image and cleans up on Escape', async ({ page }) => {
await page.addScriptTag({ path: join(root, 'web/js/tags-page.js') });
await page.evaluate(async () => {
const tags = Object.create(TagsPageManager.prototype);
tags.admin = { getImageUrl: image => image.url };
await tags.openImageViewer({ images: [0, 1, 2].map(i => ({ filename: `${i}.svg`, url: `http://viewer.test/original/${i}.svg` })), image_count: 3 }, 1);
});
await expect(page.locator('.viewer-canvas img')).toHaveAttribute('src', /\/1.svg$/);
await page.keyboard.press('ArrowRight');
await expect(page.locator('.viewer-canvas img')).toHaveAttribute('src', /\/2.svg$/);
await page.keyboard.press('Escape');
await expect(page.locator('.viewer-container')).toHaveCount(0);
});
test('touch swipes navigate the gallery and restore short drags', async ({ page, isMobile }) => {
test.skip(!isMobile, 'Touch interaction project');
await loadGallery(page);
await page.evaluate(() => gallery.openViewer(1500));
await waitForImage(page, 1500);
await page.locator('.metadata-collapse-btn').click();
await expect(page.locator('.metadata-sidebar')).toHaveClass(/collapsed/);
const session = await page.context().newCDPSession(page);
const width = await page.evaluate(() => gallery.viewer.viewerData.width);
const swipe = async distance => {
await session.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: width * 0.9, y: 220 }] });
for (let step = 1; step <= 8; step++) {
await session.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: width * 0.9 - distance * step / 8, y: 220 }] });
}
await session.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
};
await swipe(width * 0.8);
await waitForImage(page, 1501);
const initialX = await page.evaluate(() => gallery.viewer.imageData.x);
await swipe(width * 0.1);
await waitForImage(page, 1501);
await expect.poll(() => page.evaluate(() => gallery.viewer.imageData.x)).toBe(initialX);
});
test('touch pinch zoom and rotation remain usable', async ({ page, isMobile }) => {
test.skip(!isMobile, 'Touch interaction project');
await loadGallery(page);
await page.evaluate(() => gallery.openViewer(1500));
await waitForImage(page, 1500);
await page.locator('.metadata-collapse-btn').click();
await expect(page.locator('.metadata-sidebar')).toHaveClass(/collapsed/);
const session = await page.context().newCDPSession(page);
const points = (radius, angle = 0) => [-1, 1].map((sign, id) => ({
id, x: 400 + sign * radius * Math.cos(angle), y: 220 + sign * radius * Math.sin(angle),
}));
await session.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: points(30) });
for (let step = 1; step <= 6; step++) {
await session.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: points(30 + step * 8) });
}
await session.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
await expect.poll(() => page.evaluate(() => gallery.viewer.imageData.ratio > 1.2)).toBe(true);
await session.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: points(70) });
for (let step = 1; step <= 8; step++) {
await session.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: points(70, Math.PI / 2 * step / 8) });
}
await session.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
await expect.poll(() => page.evaluate(() => Math.abs(gallery.viewer.imageData.rotate))).toBe(90);
});
test('metadata follows the selected image when responses finish out of order', async ({ page }) => {
await loadGallery(page);
await page.evaluate(() => {
delete gallery.loadImageMetadata;
window.metadataRequests = new Map();
gallery.extractImageMetadata = url => new Promise(resolve => metadataRequests.set(url, resolve));
gallery.openViewer(0);
});
await expect.poll(() => page.evaluate(() => metadataRequests.has('http://viewer.test/original/0.svg'))).toBe(true);
await page.evaluate(() => gallery.viewer.next());
await expect.poll(() => page.evaluate(() => metadataRequests.has('http://viewer.test/original/1.svg'))).toBe(true);
await page.evaluate(() => metadataRequests.get('http://viewer.test/original/1.svg')({ positivePrompt: 'Selected prompt' }));
await expect(page.locator('#metadata-content')).toContainText('Selected prompt');
await page.evaluate(() => metadataRequests.get('http://viewer.test/original/0.svg')({ positivePrompt: 'Stale prompt' }));
await expect(page.locator('#metadata-content')).toContainText('Selected prompt');
await expect(page.locator('#metadata-content')).not.toContainText('Stale prompt');
expect(await page.evaluate(() => gallery.currentMetadata.positivePrompt)).toBe('Selected prompt');
await page.evaluate(() => gallery.viewer.next());
await expect.poll(() => page.evaluate(() => metadataRequests.has('http://viewer.test/original/2.svg'))).toBe(true);
await page.keyboard.press('Escape');
await expect(page.locator('#metadata-sidebar')).toHaveCount(0);
await page.evaluate(() => metadataRequests.get('http://viewer.test/original/2.svg')({ positivePrompt: 'Closed prompt' }));
expect(await page.evaluate(() => gallery.currentMetadata)).toBeNull();
});
for (const mode of ['gallery', 'filmstrip']) {
test(`admin ${mode} metadata ignores stale responses and clears copy state on close`, async ({ page }) => {
const urlFor = index => mode === 'gallery' ? `http://viewer.test/prompt_manager/images/${index}/file` : `http://viewer.test/original/${index}.svg`;
const adminSource = source('web/js/admin.js');
await page.addScriptTag({ content: adminSource.slice(0, adminSource.indexOf(' function startAdmin()')) + '\nwindow.TestAdmin = PromptAdmin;' });
await page.evaluate(async mode => {
const admin = window.admin = Object.create(TestAdmin.prototype);
const images = Array.from({ length: 3 }, (_, i) => ({ id: i, filename: `${i}.svg`, url: `http://viewer.test/original/${i}.svg`, size: 100, created_at: 1 }));
window.pendingMetadata = new Map();
Object.assign(admin, { prompts: [{ id: 1, text: 'test' }],
getImageUrl: image => image.url, loadFilmStripImages: async () => images,
extractImageMetadata: url => new Promise(resolve => pendingMetadata.set(url, resolve)),
});
if (mode === 'gallery') {
admin.renderGallery(images, 1);
} else {
await admin.openFilmStripViewer(1, 0);
}
}, mode);
if (mode === 'gallery') {
await expect.poll(() => page.evaluate(() => Boolean(admin.galleryViewer))).toBe(true);
await page.evaluate(() => admin.galleryViewer.show());
}
await expect.poll(() => page.evaluate(url => pendingMetadata.has(url), urlFor(0))).toBe(true);
await page.keyboard.press('ArrowRight');
await expect.poll(() => page.evaluate(url => pendingMetadata.has(url), urlFor(1))).toBe(true);
await page.evaluate(url => pendingMetadata.get(url)({ positivePrompt: 'Selected prompt', negativePrompt: '', imagePath: '1.svg' }), urlFor(1));
await expect(page.locator('#metadata-content')).toContainText('Selected prompt');
await page.evaluate(url => pendingMetadata.get(url)({ positivePrompt: 'Stale prompt', negativePrompt: '', imagePath: '0.svg' }), urlFor(0));
await expect(page.locator('#metadata-content')).not.toContainText('Stale prompt');
expect(await page.evaluate(() => admin.currentMetadata.imagePath)).toBe('1.svg');
await page.keyboard.press('ArrowRight');
await expect.poll(() => page.evaluate(url => pendingMetadata.has(url), urlFor(2))).toBe(true);
await page.keyboard.press('Escape');
await expect(page.locator('#metadata-sidebar')).toHaveCount(0);
await page.evaluate(url => pendingMetadata.get(url)({ positivePrompt: 'Closed prompt', negativePrompt: '', imagePath: '2.svg' }), urlFor(2));
expect(await page.evaluate(() => admin.currentMetadata)).toBeNull();
});
}
test('Tags selection stays current during a delayed prompt response', async ({ page }) => {
await page.addScriptTag({ path: join(root, 'web/js/tags-page.js') });
await page.evaluate(() => {
const grid = document.createElement('div'); grid.id = 'tagPromptsGrid'; document.body.append(grid);
const manager = window.tagsManager = Object.create(TagsPageManager.prototype);
window.pendingTagPrompts = [];
Object.assign(manager, { selectedTags: ['cats'], filterMode: 'and', promptLimit: 20, prompts: [],
fetchJson: url => new Promise(resolve => pendingTagPrompts.push({ url, resolve })),
renderTagPrompts() { grid.textContent = this.prompts.map(prompt => prompt.text).join(','); },
updatePromptCountBadge() {}, updateLoadMoreButton() {}, refreshPromptObserver() {},
});
manager.loadTagPrompts();
manager.selectedTags = ['dogs'];
manager.loadTagPrompts();
});
expect(await page.evaluate(() => pendingTagPrompts.length)).toBe(2);
await page.evaluate(() => pendingTagPrompts[1].resolve({ success: true, prompts: [{ text: 'dog prompt' }], pagination: { total: 1, has_more: false } }));
await expect(page.locator('#tagPromptsGrid')).toHaveText('dog prompt');
await page.evaluate(() => pendingTagPrompts[0].resolve({ success: true, prompts: [{ text: 'cat prompt' }], pagination: { total: 1, has_more: false } }));
await expect(page.locator('#tagPromptsGrid')).toHaveText('dog prompt');
});
-8
View File
@@ -1,8 +0,0 @@
Update the exact Viewer.js dependency pin and lockfile, pristine JavaScript and CSS, MIT license, and provenance hashes together.
Validation completed before this PR was opened:
- Vendored assets match the installed locked package.
- Vendoring tooling and existing frontend tests pass.
- Chromium desktop and touch compatibility checks pass for the gallery, admin, and tag viewers.
Review the [upstream releases](https://github.com/fengyuanchen/viewerjs/releases) for behavioral changes. This PR requires review and is not automatically merged.
-34
View File
@@ -1,34 +0,0 @@
import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { repositoryRoot, stableVersion, syncViewer } from './vendor-viewerjs.mjs';
function npm(args, options) {
if (!process.env.npm_execpath) throw new Error('Run the updater through npm run update');
return execFileSync(process.execPath, [process.env.npm_execpath, ...args], options);
}
export function updateViewer({ root = repositoryRoot, version, run = npm, sync = syncViewer } = {}) {
const cwd = join(root, 'tools/vendor');
const current = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8')).devDependencies.viewerjs;
const result = version ?? JSON.parse(run(['view', 'viewerjs', 'version', '--json'], { cwd, encoding: 'utf8' }));
const target = Array.isArray(result) && result.length === 1 ? result[0] : result;
if (typeof target !== 'string' || !stableVersion.test(target)) throw new Error('Expected a stable Viewer.js version such as 1.15.2');
if (target === current) return null;
run(['install', '--save-dev', '--save-exact', '--ignore-scripts', `viewerjs@${target}`], { cwd, stdio: 'inherit' });
sync({ root });
return target;
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
try {
const args = process.argv.slice(2);
if (args.length && (args.length !== 2 || args[0] !== '--version')) throw new Error('Usage: npm run update -- [--version X.Y.Z]');
const version = updateViewer({ version: args[1] });
console.log(version ? `Updated Viewer.js to ${version}` : 'Viewer.js is already current');
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}
-43
View File
@@ -1,43 +0,0 @@
import { createHash } from 'node:crypto';
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { repositoryRoot } from './vendor-viewerjs.mjs';
export function syncToolkit({ root = repositoryRoot, check = false } = {}) {
const tooling = join(root, 'tools/vendor');
const readJson = path => JSON.parse(readFileSync(path, 'utf8'));
const pin = readJson(join(tooling, 'package.json')).devDependencies['@comfyassets/toolkit'];
if (!/^https:\/\/codeload\.github\.com\/ComfyAssets\/ComfyAssets-Toolkit\/tar\.gz\/[a-f0-9]{40}$/.test(pin)) throw new Error('Toolkit needs an immutable commit pin');
const locked = readJson(join(tooling, 'package-lock.json')).packages['node_modules/@comfyassets/toolkit'];
const installed = join(tooling, 'node_modules/@comfyassets/toolkit');
const info = readJson(join(installed, 'package.json'));
if (locked?.resolved !== pin || !locked.integrity || locked.version !== info.version || info.name !== '@comfyassets/toolkit' || info.license !== 'MIT') throw new Error('Toolkit package identity or lock mismatch; run npm ci --ignore-scripts');
const output = new Map();
const files = {};
for (const [name, source] of Object.entries({ 'core.js': 'ui/core.js', 'controls.css': 'ui/controls.css', LICENSE: 'LICENSE', 'SOURCE.md': 'SOURCE.md' })) {
const content = readFileSync(join(installed, source));
output.set(name, content);
files[name] = { source, sha256: createHash('sha256').update(content).digest('hex') };
}
output.set('provenance.json', Buffer.from(JSON.stringify({ name: info.name, version: info.version, resolved: pin, integrity: locked.integrity, files }, null, 2) + '\n'));
const destination = join(root, 'web/js/shared/toolkit');
if (check) {
const drift = [...output].filter(([name, content]) => {
try { return !readFileSync(join(destination, name)).equals(content); }
catch (error) { if (error.code === 'ENOENT') return true; throw error; }
}).map(([name]) => name);
if (drift.length) throw new Error(`Toolkit browser distribution drift: ${drift.join(', ')}`);
} else {
mkdirSync(destination, { recursive: true });
for (const [name, content] of output) writeFileSync(join(destination, name), content);
}
return info.version;
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
try {
if (process.argv.slice(2).some(arg => arg !== '--check')) throw new Error('Usage: vendor-toolkit.mjs [--check]');
console.log(`Toolkit ${syncToolkit({ check: process.argv.includes('--check') })}: verified`);
} catch (error) { console.error(error.message); process.exitCode = 1; }
}
-70
View File
@@ -1,70 +0,0 @@
import { createHash } from 'node:crypto';
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { resolve, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
export const repositoryRoot = fileURLToPath(new URL('../..', import.meta.url));
export const stableVersion = /^\d+\.\d+\.\d+$/;
const readJson = path => JSON.parse(readFileSync(path, 'utf8'));
export function syncViewer({ root = repositoryRoot, check = false } = {}) {
const tooling = join(root, 'tools/vendor');
const manifest = readJson(join(tooling, 'package.json'));
const version = manifest.devDependencies.viewerjs;
if (!stableVersion.test(version)) throw new Error('Viewer.js must use an exact stable version pin');
const locked = readJson(join(tooling, 'package-lock.json')).packages['node_modules/viewerjs'];
const installed = join(tooling, 'node_modules/viewerjs');
const info = readJson(join(installed, 'package.json'));
if (locked?.version !== version || info.version !== version) {
throw new Error('Viewer.js version mismatch: run npm ci --ignore-scripts in tools/vendor');
}
if (!locked.integrity || !locked.resolved || info.name !== 'viewerjs' || info.license !== 'MIT') {
throw new Error('Viewer.js package identity, integrity, or license is missing');
}
const files = {};
const output = new Map();
for (const [target, source] of Object.entries({
'viewer.min.js': 'dist/viewer.min.js',
'viewer.min.css': 'dist/viewer.min.css',
LICENSE: 'LICENSE',
})) {
const content = readFileSync(join(installed, source));
if (target !== 'LICENSE' && !content.toString('utf8').includes(`Viewer.js v${version}\n`) &&
!content.toString('utf8').includes(`Viewer.js v${version} */`)) {
throw new Error(`Viewer.js version banner mismatch in ${source}`);
}
files[target] = { source, sha256: createHash('sha256').update(content).digest('hex') };
output.set(target, content);
}
output.set('provenance.json', Buffer.from(JSON.stringify({
name: info.name,
version,
source: `https://github.com/fengyuanchen/viewerjs/tree/v${version}`,
resolved: locked.resolved,
integrity: locked.integrity,
files,
}, null, 2) + '\n'));
const destination = join(root, 'web/lib/viewerjs');
if (check) {
const drift = [...output].filter(([name, content]) => {
try { return !readFileSync(join(destination, name)).equals(content); }
catch (error) { if (error.code === 'ENOENT') return true; throw error; }
}).map(([name]) => name);
if (drift.length) throw new Error(`Vendored Viewer.js drift: ${drift.join(', ')}. Run npm run sync in tools/vendor.`);
} else {
mkdirSync(destination, { recursive: true });
for (const [name, content] of output) writeFileSync(join(destination, name), content);
}
return version;
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
try {
const args = process.argv.slice(2);
if (args.some(arg => arg !== '--check')) throw new Error('Usage: vendor-viewerjs.mjs [--check]');
console.log(`Viewer.js ${syncViewer({ check: args.includes('--check') })}: ${args.includes('--check') ? 'verified' : 'synced'}`);
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}
-16
View File
@@ -85,8 +85,6 @@ class PromptManagerLogger:
# Main logger
self.logger = logging.getLogger("prompt_manager")
self.logger.setLevel(getattr(logging, self.config["level"]))
# ComfyUI's root handler would print every record a second time.
self.logger.propagate = False
# Clear existing handlers
for handler in self.logger.handlers[:]:
@@ -102,7 +100,6 @@ class PromptManagerLogger:
# Console handler
if self.config["console_logging"]:
console_handler = logging.StreamHandler()
console_handler.setLevel(self._console_level())
console_handler.setFormatter(formatter)
self.logger.addHandler(console_handler)
@@ -127,15 +124,6 @@ class PromptManagerLogger:
# Component-specific loggers
self._setup_component_loggers()
def _console_level(self) -> int:
"""Console threshold: everything at DEBUG, otherwise warnings only.
INFO still reaches the log file and the web log viewer.
"""
if str(self.config["level"]).upper() == "DEBUG":
return logging.DEBUG
return logging.WARNING
def _setup_component_loggers(self):
"""Set up loggers for specific components.
@@ -375,10 +363,6 @@ class PromptManagerLogger:
logger = logging.getLogger(logger_name)
logger.setLevel(level)
for handler in self.logger.handlers:
if type(handler) is logging.StreamHandler:
handler.setLevel(self._console_level())
# Re-setup loggers if handlers changed
if any(
key in new_config
+15 -15
View File
@@ -4,16 +4,16 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PromptManager - Admin Dashboard</title>
<script src="js/theme-bridge.js"></script>
<script src="js/html-escape.js"></script>
<script src="js/image-helpers.js"></script>
<script src="js/api-client.js"></script>
<script src="js/png-metadata.js"></script>
<script src="js/list-state.js"></script>
<script src="js/tag-autocomplete.js"></script>
<script src="js/sse-stream.js"></script>
<script src="js/scan-progress.js"></script>
<script src="js/data-actions.js"></script>
<script src="/prompt_manager/js/theme-bridge.js"></script>
<script src="/prompt_manager/js/html-escape.js"></script>
<script src="/prompt_manager/js/image-helpers.js"></script>
<script src="/prompt_manager/js/api-client.js"></script>
<script src="/prompt_manager/js/png-metadata.js"></script>
<script src="/prompt_manager/js/list-state.js"></script>
<script src="/prompt_manager/js/tag-autocomplete.js"></script>
<script src="/prompt_manager/js/sse-stream.js"></script>
<script src="/prompt_manager/js/scan-progress.js"></script>
<script src="/prompt_manager/js/data-actions.js"></script>
<link rel="stylesheet" href="lib/tailwind/comfyui-theme.css">
<link rel="stylesheet" href="lib/tailwind/styles.css">
<link rel="stylesheet" href="lib/viewerjs/viewer.min.css">
@@ -1437,10 +1437,10 @@ No sentences, no commentary, no captions. Only tags. Keep length short but descr
</div>
</div>
<script src="js/comfy-metadata.js"></script>
<script src="js/prompt-list-sort.js"></script>
<script src="js/view-router.js"></script>
<script src="js/tags-page.js"></script>
<script src="js/admin.js"></script>
<script src="/prompt_manager/js/comfy-metadata.js"></script>
<script src="/prompt_manager/js/prompt-list-sort.js"></script>
<script src="/prompt_manager/js/view-router.js"></script>
<script src="/prompt_manager/js/tags-page.js"></script>
<script src="/prompt_manager/js/admin.js"></script>
</body>
</html>
+17 -26
View File
@@ -2,7 +2,6 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
const { installClassicControls } = await import(api.fileURL('/prompt_manager/js/classic-node.js'));
// ============================================================================
// Top Bar Button Integration (similar to rgthree-comfy approach)
@@ -120,7 +119,7 @@ function addPMTopBarButton() {
transition: "background 0.2s",
},
onclick: () => {
window.open(api.fileURL("/prompt_manager/admin"), "_blank");
window.open("/prompt_manager/admin", "_blank");
},
onmouseenter: (e) => { e.target.style.background = "#0d9488"; },
onmouseleave: (e) => { e.target.style.background = "#14b8a6"; },
@@ -176,7 +175,7 @@ function addPMTopBarButtonLegacy() {
btn.onmouseleave = () => btn.style.background = "#14b8a6";
btn.onclick = () => {
window.open(api.fileURL("/prompt_manager/admin"), "_blank");
window.open("/prompt_manager/admin", "_blank");
};
topBar.appendChild(btn);
@@ -303,8 +302,7 @@ app.registerExtension({
// CRITICAL FIX: Ensure text widget always returns current value during serialization
// This fixes the issue where graphToPrompt reads stale cached values
const self = this;
this._textSyncTimer = setTimeout(() => {
if (self._classicScope?.signal.aborted) return;
setTimeout(() => {
const textWidget = self.widgets?.find((w) => w.name === "text");
if (textWidget) {
// Store reference to the widget for the closure
@@ -336,16 +334,13 @@ app.registerExtension({
};
// Sync on every keystroke
const listen = self._classicScope
? (event) => self._classicScope.listen(textWidget.inputEl, event, syncValue)
: (event) => textWidget.inputEl.addEventListener(event, syncValue);
listen('input');
textWidget.inputEl.addEventListener('input', syncValue);
// Sync on change (for paste, autocomplete, etc.)
listen('change');
textWidget.inputEl.addEventListener('change', syncValue);
// Sync when focus leaves the field
listen('blur');
textWidget.inputEl.addEventListener('blur', syncValue);
// Sync on keyup as backup
listen('keyup');
textWidget.inputEl.addEventListener('keyup', syncValue);
// Initial sync
syncValue();
@@ -382,7 +377,7 @@ app.registerExtension({
width: 100%;
height: 100%;
padding: 8px;
background-color: ${nodeData.name === 'PromptManager' ? 'var(--ca-surface)' : '#2a2a2a'};
background-color: #2a2a2a;
border-radius: 4px;
box-sizing: border-box;
display: flex;
@@ -691,7 +686,7 @@ app.registerExtension({
// Method to load settings from API
nodeType.prototype.loadSettings = async function () {
try {
const response = await fetch(api.fileURL("/prompt_manager/settings"));
const response = await fetch("/prompt_manager/settings");
if (response.ok) {
const data = await response.json();
if (data.success && data.settings) {
@@ -988,8 +983,7 @@ app.registerExtension({
throw new Error(`Unknown method: ${method}`);
}
if (this._classicScope) options.signal = this._classicScope.signal;
const response = await fetch(api.fileURL(url), options);
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
@@ -1003,12 +997,10 @@ app.registerExtension({
return { results: data.results || [] };
} catch (error) {
if (!this._classicScope?.signal.aborted) {
console.error(
`[PromptManager] API call failed for ${method}:`,
error,
);
}
console.error(
`[PromptManager] API call failed for ${method}:`,
error,
);
throw error;
}
};
@@ -1016,7 +1008,7 @@ app.registerExtension({
// Method to save a prompt via API
nodeType.prototype.savePromptToDatabase = async function (promptData) {
try {
const response = await fetch(api.fileURL("/prompt_manager/save"), {
const response = await fetch("/prompt_manager/save", {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -1044,7 +1036,7 @@ app.registerExtension({
// Method to delete a prompt via API
nodeType.prototype.deletePrompt = async function (promptId) {
try {
const response = await fetch(api.fileURL(`/prompt_manager/delete/${promptId}`), {
const response = await fetch(`/prompt_manager/delete/${promptId}`, {
method: "DELETE",
});
@@ -1071,7 +1063,7 @@ app.registerExtension({
this.resultsSection.innerHTML =
'<div style="color: #999; text-align: center;">🔧 Testing API connection...</div>';
const response = await fetch(api.fileURL("/prompt_manager/test"));
const response = await fetch("/prompt_manager/test");
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
@@ -1182,7 +1174,6 @@ app.registerExtension({
document.body.removeChild(notification);
}, 3000);
};
if (nodeData.name === 'PromptManager') installClassicControls(nodeType, app, parseSearchQuery, api);
}
},
});
+12 -12
View File
@@ -4,16 +4,16 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PromptManager - Gallery</title>
<script src="js/theme-bridge.js"></script>
<script src="js/html-escape.js"></script>
<script src="js/image-helpers.js"></script>
<script src="js/gallery-settings.js"></script>
<script src="js/scan-progress.js"></script>
<script src="js/api-client.js"></script>
<script src="js/png-metadata.js"></script>
<script src="js/list-state.js"></script>
<script src="js/sse-stream.js"></script>
<script src="js/data-actions.js"></script>
<script src="/prompt_manager/js/theme-bridge.js"></script>
<script src="/prompt_manager/js/html-escape.js"></script>
<script src="/prompt_manager/js/image-helpers.js"></script>
<script src="/prompt_manager/js/gallery-settings.js"></script>
<script src="/prompt_manager/js/scan-progress.js"></script>
<script src="/prompt_manager/js/api-client.js"></script>
<script src="/prompt_manager/js/png-metadata.js"></script>
<script src="/prompt_manager/js/list-state.js"></script>
<script src="/prompt_manager/js/sse-stream.js"></script>
<script src="/prompt_manager/js/data-actions.js"></script>
<link rel="stylesheet" href="lib/tailwind/comfyui-theme.css">
<link rel="stylesheet" href="lib/tailwind/styles.css">
<link rel="stylesheet" href="lib/viewerjs/viewer.min.css">
@@ -837,7 +837,7 @@ No sentences, no commentary, no captions. Only tags. Keep length short but descr
</div>
<script src="js/comfy-metadata.js"></script>
<script src="js/gallery.js"></script>
<script src="/prompt_manager/js/comfy-metadata.js"></script>
<script src="/prompt_manager/js/gallery.js"></script>
</body>
</html>
+2 -2
View File
@@ -55,7 +55,7 @@
<script>
// Redirect to admin interface immediately
window.location.href = window.location.pathname.replace(/\/(?:web|index\.html)\/?$/, '/admin');
window.location.href = '/prompt_manager/admin';
</script>
</body>
</html>
</html>
+52 -68
View File
@@ -293,7 +293,7 @@
async loadSettings() {
try {
const response = await ApiClient.fetchUrl("/prompt_manager/settings");
const response = await fetch("/prompt_manager/settings");
if (response.ok) {
const data = await response.json();
if (data.success && data.settings) {
@@ -321,7 +321,7 @@
async loadStatistics() {
try {
const response = await ApiClient.fetchUrl("/prompt_manager/stats");
const response = await fetch("/prompt_manager/stats");
if (response.ok) {
const data = await response.json();
@@ -351,7 +351,7 @@
async loadCategories() {
try {
const response = await ApiClient.fetchUrl("/prompt_manager/categories");
const response = await fetch("/prompt_manager/categories");
if (response.ok) {
const data = await response.json();
if (data.success) {
@@ -366,7 +366,7 @@
async loadTags() {
try {
const response = await ApiClient.fetchUrl("/prompt_manager/tags");
const response = await fetch("/prompt_manager/tags");
if (response.ok) {
const data = await response.json();
if (data.success) {
@@ -391,7 +391,7 @@
async loadSubfolders() {
try {
const response = await ApiClient.fetchUrl("/prompt_manager/subfolders?include_ancestors=true");
const response = await fetch("/prompt_manager/subfolders?include_ancestors=true");
if (response.ok) {
const data = await response.json();
if (data.success) {
@@ -979,7 +979,7 @@
async updateMonitoringStatus() {
const statusEl = document.getElementById("monitoringStatus");
try {
const response = await ApiClient.fetchUrl("/prompt_manager/settings");
const response = await fetch("/prompt_manager/settings");
if (response.ok) {
const data = await response.json();
const dirs = data.settings?.monitored_directories || [];
@@ -1003,7 +1003,7 @@
try {
// First check current status
const statusRes = await ApiClient.fetchUrl("/prompt_manager/lora/status");
const statusRes = await fetch("/prompt_manager/lora/status");
if (statusRes.ok) {
const status = await statusRes.json();
if (status.success && status.enabled) {
@@ -1027,7 +1027,7 @@
}
// Try auto-detection
const detectRes = await ApiClient.fetchUrl("/prompt_manager/lora/detect");
const detectRes = await fetch("/prompt_manager/lora/detect");
if (!detectRes.ok) return;
const data = await detectRes.json();
@@ -1084,7 +1084,7 @@
else if (civitaiKey) payload.civitai_api_key = civitaiKey;
try {
const res = await ApiClient.fetchUrl("/prompt_manager/lora/enable", {
const res = await fetch("/prompt_manager/lora/enable", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
@@ -1115,7 +1115,7 @@
this.showModal("loraImportModal");
try {
const res = await ApiClient.fetchUrl("/prompt_manager/lora/scan", { method: "POST" });
const res = await fetch("/prompt_manager/lora/scan", { method: "POST" });
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
@@ -1175,7 +1175,7 @@
this.settings.galleryRootPaths = galleryPaths;
try {
const response = await ApiClient.fetchUrl("/prompt_manager/settings", {
const response = await fetch("/prompt_manager/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -1291,7 +1291,7 @@
}
try {
const response = await ApiClient.fetchUrl(`/prompt_manager/prompts/${promptId}/rating`, {
const response = await fetch(`/prompt_manager/prompts/${promptId}/rating`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rating }),
@@ -1348,7 +1348,7 @@
if (tags.length === 0) return;
try {
const response = await ApiClient.fetchUrl("/prompt_manager/prompts/tags", {
const response = await fetch("/prompt_manager/prompts/tags", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -1371,7 +1371,7 @@
async removeTag(promptId, tag) {
try {
const response = await ApiClient.fetchUrl(`/prompt_manager/prompts/${promptId}/tags`, {
const response = await fetch(`/prompt_manager/prompts/${promptId}/tags`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tag }),
@@ -1473,7 +1473,7 @@
if (tags.length === 0) return;
try {
const response = await ApiClient.fetchUrl("/prompt_manager/bulk/tags", {
const response = await fetch("/prompt_manager/bulk/tags", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -1499,7 +1499,7 @@
if (!category) return;
try {
const response = await ApiClient.fetchUrl("/prompt_manager/bulk/category", {
const response = await fetch("/prompt_manager/bulk/category", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -1524,7 +1524,7 @@
if (!confirm(`Are you sure you want to delete ${this.selectedPrompts.size} prompts?`)) return;
try {
const response = await ApiClient.fetchUrl("/prompt_manager/bulk/delete", {
const response = await fetch("/prompt_manager/bulk/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -1545,7 +1545,7 @@
async exportPrompts() {
try {
const response = await ApiClient.fetchUrl("/prompt_manager/export");
const response = await fetch("/prompt_manager/export");
if (response.ok) {
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
@@ -1578,7 +1578,7 @@
document.getElementById("galleryEmpty").classList.add("hidden");
try {
const response = await ApiClient.fetchUrl(`/prompt_manager/prompts/${promptId}/images`);
const response = await fetch(`/prompt_manager/prompts/${promptId}/images`);
if (response.ok) {
const data = await response.json();
if (data.success) {
@@ -1620,7 +1620,7 @@
content.innerHTML = images.map((image, index) => {
const generated = new Date(image.generation_time);
const when = escapeHtml(`${generated.toLocaleDateString()} ${generated.toLocaleTimeString()}`);
const fileUrl = ApiClient.resolveUrl(`/prompt_manager/images/${escapeHtml(image.id)}/file`);
const fileUrl = `/prompt_manager/images/${escapeHtml(image.id)}/file`;
const caption = ImageHelpers.formatImageCaption(image, { formatFileSize });
const details = ImageHelpers.formatImageCaption(image, { formatFileSize, separator: ' • ' });
return `
@@ -1656,7 +1656,6 @@
// Initialize ViewerJS for the gallery after DOM is updated
setTimeout(() => {
if (this.galleryViewer) {
this.invalidateMetadataRequest();
this.galleryViewer.destroy();
}
@@ -1700,7 +1699,6 @@
viewed: (event) => {
this.loadMetadataForImage(event.detail.image);
},
hide: () => this.invalidateMetadataRequest(),
hidden: () => {
const sidebar = document.getElementById('metadata-sidebar');
if (sidebar) sidebar.remove();
@@ -1713,13 +1711,7 @@
invalidateMetadataRequest() {
this.metadataRequest = null;
this.currentMetadata = null;
}
closeGallery() {
this.invalidateMetadataRequest();
this.hideModal("galleryModal");
}
@@ -1795,7 +1787,7 @@
`;
try {
const response = await ApiClient.fetchUrl('/prompt_manager/maintenance', {
const response = await fetch('/prompt_manager/maintenance', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ operations: selectedOps })
@@ -1928,7 +1920,7 @@
`;
try {
const response = await ApiClient.fetchUrl('/prompt_manager/diagnostics');
const response = await fetch('/prompt_manager/diagnostics');
if (response.ok) {
const data = await response.json();
if (data.success) {
@@ -2045,7 +2037,7 @@
const testPrompt = this.prompts[0];
try {
const response = await ApiClient.fetchUrl('/prompt_manager/diagnostics/test-link', {
const response = await fetch('/prompt_manager/diagnostics/test-link', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -2075,7 +2067,7 @@
// Database backup and restore functionality
async backupDatabase() {
try {
const response = await ApiClient.fetchUrl('/prompt_manager/backup');
const response = await fetch('/prompt_manager/backup');
if (response.ok) {
// Get the filename from the Content-Disposition header
@@ -2152,7 +2144,7 @@
const formData = new FormData();
formData.append('database_file', file);
const response = await ApiClient.fetchUrl('/prompt_manager/restore', {
const response = await fetch('/prompt_manager/restore', {
method: 'POST',
body: formData
});
@@ -2342,7 +2334,7 @@
async resumeRunningScan() {
let status;
try {
const response = await ApiClient.fetchUrl("/prompt_manager/scan/status");
const response = await fetch("/prompt_manager/scan/status");
if (!response.ok) return;
status = await response.json();
} catch (_) {
@@ -2390,7 +2382,7 @@
async quickBackup() {
try {
const response = await ApiClient.fetchUrl("/prompt_manager/backup");
const response = await fetch("/prompt_manager/backup");
if (response.ok) {
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
@@ -2453,7 +2445,7 @@
async loadLogStats() {
try {
const response = await ApiClient.fetchUrl('/prompt_manager/logs/stats');
const response = await fetch('/prompt_manager/logs/stats');
const data = await response.json();
if (data.success) {
@@ -2471,7 +2463,7 @@
async loadLogConfig() {
try {
const response = await ApiClient.fetchUrl('/prompt_manager/logs/config');
const response = await fetch('/prompt_manager/logs/config');
const data = await response.json();
if (data.success) {
@@ -2486,7 +2478,7 @@
async loadLogFiles() {
try {
const response = await ApiClient.fetchUrl('/prompt_manager/logs/files');
const response = await fetch('/prompt_manager/logs/files');
const data = await response.json();
if (data.success) {
@@ -2523,11 +2515,11 @@
const limit = parseInt(document.getElementById("logLimit").value);
try {
const url = new URL(ApiClient.resolveUrl('/prompt_manager/logs'), window.location.origin);
const url = new URL('/prompt_manager/logs', window.location.origin);
if (level) url.searchParams.set('level', level);
url.searchParams.set('limit', limit.toString());
const response = await ApiClient.fetchUrl(url);
const response = await fetch(url);
const data = await response.json();
if (data.success) {
@@ -2586,11 +2578,11 @@
const limit = parseInt(document.getElementById("logLimit").value);
try {
const url = new URL(ApiClient.resolveUrl('/prompt_manager/logs'), window.location.origin);
const url = new URL('/prompt_manager/logs', window.location.origin);
if (level) url.searchParams.set('level', level);
url.searchParams.set('limit', limit.toString());
const response = await ApiClient.fetchUrl(url);
const response = await fetch(url);
const data = await response.json();
if (data.success) {
@@ -2620,7 +2612,7 @@
async downloadLogFile(filename) {
try {
const response = await ApiClient.fetchUrl(`/prompt_manager/logs/download/${encodeURIComponent(filename)}`);
const response = await fetch(`/prompt_manager/logs/download/${encodeURIComponent(filename)}`);
if (response.ok) {
const blob = await response.blob();
@@ -2649,7 +2641,7 @@
}
try {
const response = await ApiClient.fetchUrl('/prompt_manager/logs/truncate', {
const response = await fetch('/prompt_manager/logs/truncate', {
method: 'POST'
});
const data = await response.json();
@@ -2673,7 +2665,7 @@
const consoleLogging = document.getElementById("consoleLogging").checked;
try {
const response = await ApiClient.fetchUrl('/prompt_manager/logs/config', {
const response = await fetch('/prompt_manager/logs/config', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
@@ -2747,12 +2739,12 @@
// Metadata functionality
openMetadataViewer() {
window.open(ApiClient.resolveUrl('/prompt_manager/gallery.html'), '_blank');
window.open('/prompt_manager/gallery.html', '_blank');
}
// Gallery functionality
openGallery() {
window.open(ApiClient.resolveUrl('/prompt_manager/gallery'), '_blank');
window.open('/prompt_manager/gallery', '_blank');
}
@@ -2817,7 +2809,7 @@
async extractImageMetadata(imageUrl) {
try {
// Fetch the image as an array buffer
const response = await ApiClient.fetchUrl(imageUrl);
const response = await fetch(imageUrl);
const arrayBuffer = await response.arrayBuffer();
// Parse PNG metadata
@@ -2904,11 +2896,6 @@
const metadataContent = document.getElementById('metadata-content');
if (!metadataContent) return;
const request = {};
this.metadataRequest = request;
this.currentMetadata = null;
const isCurrent = () => this.metadataRequest === request && metadataContent.isConnected !== false;
// Show loading state
metadataContent.innerHTML = `
<div class="metadata-loading">
@@ -2924,11 +2911,9 @@
try {
const metadata = await this.extractImageMetadata(imageUrl);
if (!isCurrent()) return;
this.currentMetadata = metadata;
this.updateMetadataSidebar(metadata);
} catch (error) {
if (!isCurrent()) return;
console.error('Failed to load metadata:', error);
metadataContent.innerHTML = `
<div class="text-center text-pm-error py-8">
@@ -3322,7 +3307,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
}
try {
const response = await ApiClient.fetchUrl('/prompt_manager/save', {
const response = await fetch('/prompt_manager/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -3360,7 +3345,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
getImageUrl(image) {
// If the image already has a url property, use it
if (image.url) return ApiClient.resolveUrl(image.url);
if (image.url) return image.url;
// Otherwise construct from image_path
if (image.image_path) {
@@ -3371,17 +3356,17 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
const filename = image.filename || path.split(/[/\\]/).pop();
// Use the image ID if available, otherwise use the path-based serve endpoint
if (image.id) {
return ApiClient.resolveUrl(`/prompt_manager/images/${image.id}/file`);
return `/prompt_manager/images/${image.id}/file`;
}
// Fallback: serve by path (encode the path)
return ApiClient.resolveUrl(`/prompt_manager/images/serve/${encodeURIComponent(path)}`);
return `/prompt_manager/images/serve/${encodeURIComponent(path)}`;
}
return null;
}
getThumbnailUrl(image) {
// If thumbnail_url already exists, use it
if (image.thumbnail_url) return ApiClient.resolveUrl(image.thumbnail_url);
if (image.thumbnail_url) return image.thumbnail_url;
// Try to construct thumbnail URL from filename and relative_path
// Thumbnails are stored as: thumbnails/{parent_dir}/{stem}_thumb{ext}
@@ -3404,7 +3389,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
// URL encode the path components (but preserve slashes)
const encodedParentDir = parentDir.split('/').map(p => encodeURIComponent(p)).join('/');
return ApiClient.resolveUrl(`/prompt_manager/images/serve/thumbnails/${encodedParentDir}${encodeURIComponent(stem)}_thumb${ext}`);
return `/prompt_manager/images/serve/thumbnails/${encodedParentDir}${encodeURIComponent(stem)}_thumb${ext}`;
}
}
@@ -3414,7 +3399,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
async loadFilmStripImages(promptId) {
try {
const response = await ApiClient.fetchUrl(`/prompt_manager/prompts/${promptId}/images`);
const response = await fetch(`/prompt_manager/prompts/${promptId}/images`);
if (response.ok) {
const data = await response.json();
if (data.success && data.images) {
@@ -3530,7 +3515,6 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
viewed: (event) => {
this.loadMetadataForImage(event.detail.image);
},
hide: () => this.invalidateMetadataRequest(),
hidden: () => {
const sidebar = document.getElementById('metadata-sidebar');
if (sidebar) sidebar.remove();
@@ -3588,7 +3572,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
statusEls.forEach(el => { el.textContent = 'Checking...'; el.className = 'flex items-center gap-1.5'; });
try {
const response = await ApiClient.fetchUrl('/prompt_manager/autotag/models');
const response = await fetch('/prompt_manager/autotag/models');
const data = await response.json();
if (data.success) {
@@ -3687,7 +3671,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
const skipTagged = document.querySelector('input[name="autoTagMode"]:checked').value === 'skip';
// Check if model is downloaded
const response = await ApiClient.fetchUrl('/prompt_manager/autotag/models');
const response = await fetch('/prompt_manager/autotag/models');
const data = await response.json();
if (data.success && !data.models[modelType].downloaded) {
@@ -3778,7 +3762,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
const modelType = document.querySelector('input[name="autoTagModel"]:checked').value;
// Check if model is downloaded
const response = await ApiClient.fetchUrl('/prompt_manager/autotag/models');
const response = await fetch('/prompt_manager/autotag/models');
const data = await response.json();
if (data.success && !data.models[modelType].downloaded) {
@@ -3920,7 +3904,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
// Review rows come from /images/all (database ids), so this resolves to image_id
const requestBody = ImageHelpers.autotagSingleBody(image, this.autoTagState);
const response = await ApiClient.fetchUrl('/prompt_manager/autotag/single', {
const response = await fetch('/prompt_manager/autotag/single', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody)
@@ -4074,7 +4058,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
}
try {
const response = await ApiClient.fetchUrl('/prompt_manager/autotag/apply', {
const response = await fetch('/prompt_manager/autotag/apply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
+2 -14
View File
@@ -25,18 +25,6 @@
/** Result of a latestOnly() call that was superseded by a newer one. */
const STALE = Symbol("stale response");
/** Keep Prompt Manager requests under the prefix that serves its page. */
function resolveUrl(url, pathname = root.location && root.location.pathname) {
if (typeof url !== "string" || !url.startsWith("/prompt_manager/")) return url;
const match = (pathname || "").match(/^(.*)\/prompt_manager(?:\/|$)/);
return match && match[1] ? match[1] + url : url;
}
/** Like fetch(), but keep Prompt Manager URLs under the page's proxy mount. */
function fetchUrl(url, init) {
return globalThis.fetch(resolveUrl(url), init);
}
/**
* @param {typeof fetch} [fetchImpl] - defaults to the global fetch (bound to globalThis)
*/
@@ -51,7 +39,7 @@
}
if (signal) init.signal = signal;
const response = await doFetch(resolveUrl(url), init);
const response = await doFetch(url, init);
let data = null;
let parsed = true;
try {
@@ -99,7 +87,7 @@
};
}
const api = { createApiClient, latestOnly, resolveUrl, fetchUrl, ApiError, STALE };
const api = { createApiClient, latestOnly, ApiError, STALE };
if (typeof module !== "undefined" && module.exports) module.exports = api;
else root.ApiClient = api;
})(typeof window !== "undefined" ? window : globalThis);
-14
View File
@@ -1,14 +0,0 @@
.pm-classic .pm-classic-workspace { min-block-size: 0; }
.pm-classic .pm-classic-toolbar { display: flex; flex-wrap: wrap; gap: 8px; flex-shrink: 0; }
.pm-classic-toolbar > button { flex: 1; }
.pm-classic .pm-classic-results { flex: 1; min-block-size: 100px; overflow: auto; padding: 8px; border: 1px solid var(--ca-line); border-radius: 5px; background: var(--ca-field); }
.pm-classic-message { color: var(--ca-muted); text-align: center; padding: 12px; margin: 0; overflow-wrap: anywhere; }
.pm-classic-error { color: var(--ca-error-text); background: var(--ca-error-bg); }
.pm-classic-count { color: var(--ca-text); text-align: center; margin: 0 0 10px; }
.pm-classic button.pm-classic-result { display: flex; flex-direction: column; align-items: stretch; text-align: start; inline-size: 100%; white-space: normal; margin-block-end: 5px; }
.pm-classic-result[aria-pressed="true"] { border-color: var(--ca-accent); box-shadow: inset 3px 0 var(--ca-accent); }
.pm-classic-preview { overflow-wrap: anywhere; }
.pm-classic-metadata { color: var(--ca-muted); font-size: 11px; overflow-wrap: anywhere; }
.pm-classic-restore { white-space: normal !important; }
.pm-classic-notification { position: fixed; inset-block-start: 20px; inset-inline-end: 20px; padding: 10px 15px; border: 1px solid var(--ca-border); border-radius: 5px; z-index: 10000; max-inline-size: min(300px, calc(100vw - 40px)); overflow-wrap: anywhere; }
.pm-classic-notification[data-kind="error"] { color: var(--ca-error-text); background: var(--ca-error-bg); border-color: var(--ca-error); }
-156
View File
@@ -1,156 +0,0 @@
import { createScope, createButton, mountTheme } from './shared/toolkit/core.js';
function loadStyles() {
const href = new URL('./classic-node.css', import.meta.url).href;
if ([...document.querySelectorAll('link[rel="stylesheet"]')].some(link => link.href === href)) return;
const link = document.createElement('link');
Object.assign(link, { rel: 'stylesheet', href });
document.head.append(link);
}
function message(node, text, error = false) {
node._classicResultsScope?.dispose();
const element = document.createElement('p');
element.className = error ? 'pm-classic-message pm-classic-error' : 'pm-classic-message';
element.textContent = text;
element.setAttribute('role', error ? 'alert' : 'status');
node.resultsSection.replaceChildren(element);
}
export function installClassicControls(nodeType, app, parseSearchQuery, api) {
const prototype = nodeType.prototype;
const onCreated = prototype.onNodeCreated;
const onRemoved = prototype.onRemoved;
prototype.onNodeCreated = function (...args) {
this._classicScope = createScope();
this._classicNotifications = new Set();
this._classicScope.cleanup(() => {
clearTimeout(this._textSyncTimer);
clearTimeout(this.resultHideTimer);
this._classicResultsScope?.dispose();
for (const item of this._classicNotifications) { clearTimeout(item.timer); item.element.remove(); }
this._classicNotifications.clear();
});
onCreated.apply(this, args);
};
prototype.onRemoved = function (...args) {
this._classicScope?.dispose();
return onRemoved?.apply(this, args);
};
prototype.createSearchUI = function (container) {
loadStyles();
container.classList.add('ca-tools', 'pm-classic');
mountTheme(container, app.ui?.settings, this._classicScope);
this._classicContainer = container;
const wrapper = document.createElement('div');
wrapper.className = 'pm-classic-workspace';
const buttons = document.createElement('div');
buttons.className = 'pm-classic-toolbar';
for (const [label, action, primary] of [
['🔍 Search', () => this.performSearch(), true],
['📋 Recent', () => this.loadRecentPrompts()],
['🌐 Web UI', () => this.openWebInterface()],
]) {
const button = createButton(label, primary ? 'ca-primary' : '');
this._classicScope.listen(button, 'click', action);
buttons.append(button);
}
wrapper.append(buttons);
if (this.properties.showTestButton) {
const test = createButton('🔧 Test API');
this._classicScope.listen(test, 'click', () => this.testApiConnection());
wrapper.append(test);
}
this.resultsSection = document.createElement('div');
this.resultsSection.className = 'pm-classic-results';
wrapper.append(this.resultsSection);
container.replaceChildren(wrapper);
message(this, '💡 Click Search or Recent to find prompts');
};
prototype.performSearch = async function () {
const { text, tags } = parseSearchQuery(this.widgets?.find(widget => widget.name === 'search_text')?.value || '');
if (!text && !tags.length) { message(this, '⚠️ Enter search text or tag:name'); return; }
await this._classicQuery('search_prompts', { search_text: text, tags }, '🔍 Searching database...');
};
prototype.loadRecentPrompts = async function () {
await this._classicQuery('get_recent_prompts', { limit: 20 }, '📋 Loading recent prompts...');
};
prototype._classicQuery = async function (method, params, pending) {
message(this, pending);
try {
const response = await this.callNodeMethod(method, params);
if (this._classicScope.signal.aborted) return;
this.displayResults(response.results || []);
this.showNotification(response.results?.length ? `Found ${response.results.length} prompts` : 'No recent prompts found');
} catch (error) {
if (this._classicScope.signal.aborted) return;
message(this, `❌ ${error.message}`, true);
this.showNotification(error.message, 'error');
}
};
prototype.displayResults = function (results) {
if (this._classicScope.signal.aborted) return;
this._searchResults = results || [];
this._selectedPromptIndex = -1;
if (!results?.length) { message(this, '📭 No prompts found'); return; }
this._classicResultsScope?.dispose();
this._classicResultsScope = createScope();
const header = document.createElement('p');
header.className = 'pm-classic-count';
header.textContent = `📚 Found ${results.length} prompt${results.length === 1 ? '' : 's'}`;
this.resultsSection.replaceChildren(header);
results.forEach((result, index) => {
const button = createButton('', 'pm-classic-result');
button.setAttribute('aria-pressed', 'false');
const preview = document.createElement('span');
preview.className = 'pm-classic-preview';
preview.textContent = result.text.length > 80 ? result.text.substring(0, 80) + '...' : result.text;
const metadata = document.createElement('span');
metadata.className = 'pm-classic-metadata';
metadata.textContent = `${result.category || 'No category'} · ${result.rating ? '⭐' + result.rating : 'No rating'} · ${result.created_at ? new Date(result.created_at).toLocaleDateString() : 'Unknown'} · ${Array.isArray(result.tags) ? result.tags.join(', ') : 'No tags'}`;
button.append(preview, metadata);
this._classicResultsScope.listen(button, 'click', () => this.usePrompt(result, index));
this.resultsSection.append(button);
});
this.startAutoHideTimer();
};
prototype.highlightSelectedResult = function (index) {
this._selectedPromptIndex = index;
this.resultsSection.querySelectorAll('.pm-classic-result').forEach((element, i) => element.setAttribute('aria-pressed', String(i === index)));
};
prototype.testApiConnection = async function () {
message(this, '🔧 Testing API connection...');
try {
const response = await fetch(api.fileURL('/prompt_manager/test'), { signal: this._classicScope.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
const data = await response.json();
if (!data.success) throw new Error(data.message || 'Test failed');
if (this._classicScope.signal.aborted) return;
message(this, `✅ API Connection Successful! ${data.message || ''} ${data.timestamp || ''}`);
this.showNotification('API connection test passed!', 'success');
} catch (error) {
if (this._classicScope.signal.aborted) return;
message(this, `❌ API Connection Failed: ${error.message}`, true);
this.showNotification(`API test failed: ${error.message}`, 'error');
}
};
prototype.hideResults = function () {
if (this._classicScope.signal.aborted || this.restoreButton) return;
this.resultsSection.style.display = 'none';
this.restoreButton = createButton('🔄 Results auto-hidden · Click to restore', 'pm-classic-restore');
this._classicScope.listen(this.restoreButton, 'click', () => this.showResults());
this.resultsSection.after(this.restoreButton);
};
prototype.showNotification = function (text, type = 'info') {
if (this._classicScope.signal.aborted) return;
const element = document.createElement('div');
element.className = 'ca-tools pm-classic-notification';
element.dataset.caTheme = this._classicContainer.dataset.caTheme;
element.dataset.kind = type;
element.setAttribute('role', type === 'error' ? 'alert' : 'status');
element.textContent = text;
document.body.append(element);
const item = { element, timer: setTimeout(() => { element.remove(); this._classicNotifications.delete(item); }, 3000) };
this._classicNotifications.add(item);
};
}
+166 -201
View File
@@ -205,7 +205,7 @@
async loadFolderTree() {
try {
const response = await ApiClient.fetchUrl('/prompt_manager/gallery/subfolders?include_ancestors=true');
const response = await fetch('/prompt_manager/gallery/subfolders?include_ancestors=true');
if (!response.ok) return;
const data = await response.json();
if (data.success && data.subfolders.length > 0) {
@@ -352,15 +352,9 @@
);
this.currentPage = next.page;
this.total = next.total;
const images = data.images.map(image => ({
...image,
url: ApiClient.resolveUrl(image.url),
thumbnail_url: ApiClient.resolveUrl(image.thumbnail_url),
}));
const from = append ? this.images.length : 0;
this.images = append ? this.images.concat(images) : images;
this.images = append ? this.images.concat(data.images) : data.images;
this.updateStats();
this.renderGallery({ from });
this.renderGallery();
this.updatePagination();
document.getElementById('loadingStatus').textContent = 'Loaded';
this.maybeLoadMore(); // a short page may not fill the viewport
@@ -446,8 +440,7 @@
this.renderGallery();
}
renderGallery({ from = 0 } = {}) {
if (from === 0) this.destroyViewer();
renderGallery() {
if (this.images.length === 0) {
this.showError('No images found in the output folder');
return;
@@ -457,15 +450,19 @@
document.getElementById('errorState').classList.add('hidden');
if (this.viewMode === 'grid') {
this.renderGridView({ from });
this.renderGridView();
} else {
this.renderListView({ from });
this.renderListView();
}
// Initialize ViewerJS after rendering
setTimeout(() => this.initializeViewer(), 100);
// Add simple fallback click handlers
this.addFallbackClickHandlers();
}
renderGridView({ from = 0 } = {}) {
renderGridView() {
const grid = document.getElementById('galleryGrid');
const list = document.getElementById('galleryList');
const showInfo = this.getSettings().showImageInfo;
@@ -473,8 +470,7 @@
list.classList.add('hidden');
grid.classList.remove('hidden');
const html = this.images.slice(from).map((image, offset) => {
const index = from + offset;
grid.innerHTML = this.images.map((image, index) => {
// Use thumbnail for display if available, fallback to original
const displayUrl = image.thumbnail_url || image.url;
const hasThumb = !!image.thumbnail_url;
@@ -482,7 +478,7 @@
const mediaType = image.media_type || 'image';
return `
<div data-image-index="${index}" class="image-item bg-pm-surface rounded-pm-md overflow-hidden border border-pm hover:border-pm cursor-pointer group">
<div class="image-item bg-pm-surface rounded-pm-md overflow-hidden border border-pm hover:border-pm cursor-pointer group">
<div class="aspect-square bg-pm-primary overflow-hidden relative">
<img src="${escapeHtml(displayUrl)}"
alt="${escapeHtml(image.filename)}"
@@ -524,11 +520,9 @@
</div>
`;
}).join('');
if (from === 0) grid.innerHTML = html;
else grid.insertAdjacentHTML('beforeend', html);
}
renderListView({ from = 0 } = {}) {
renderListView() {
const grid = document.getElementById('galleryGrid');
const list = document.getElementById('galleryList');
const showInfo = this.getSettings().showImageInfo;
@@ -536,8 +530,7 @@
grid.classList.add('hidden');
list.classList.remove('hidden');
const html = this.images.slice(from).map((image, offset) => {
const index = from + offset;
list.innerHTML = this.images.map((image, index) => {
// Use thumbnail for display if available, fallback to original
const displayUrl = image.thumbnail_url || image.url;
const hasThumb = !!image.thumbnail_url;
@@ -545,7 +538,7 @@
const mediaType = image.media_type || 'image';
return `
<div data-image-index="${index}" class="image-item bg-pm-surface rounded-pm-md border border-pm hover:border-pm cursor-pointer group flex items-center p-4">
<div class="image-item bg-pm-surface rounded-pm-md border border-pm hover:border-pm cursor-pointer group flex items-center p-4">
<div class="w-16 h-16 bg-pm-primary rounded-pm-md overflow-hidden flex-shrink-0 mr-4 relative">
<img src="${escapeHtml(displayUrl)}"
alt="${escapeHtml(image.filename)}"
@@ -578,37 +571,26 @@
</div>
`;
}).join('');
if (from === 0) list.innerHTML = html;
else list.insertAdjacentHTML('beforeend', html);
}
destroyViewer({ keepSlideshow = false } = {}) {
this.metadataRequest = null;
this.currentMetadata = null;
if (!keepSlideshow) this.stopViewerSlideshow();
initializeViewer() {
// Destroy existing viewer if it exists
if (this.viewer) {
this.viewer.clearEnforceFocus();
this.viewer.destroy();
}
this.viewer = null;
document.getElementById('metadata-sidebar')?.remove();
}
openViewer(index, { keepSlideshow = false } = {}) {
if (!Number.isInteger(index) || index < 0 || index >= this.images.length) return;
this.destroyViewer({ keepSlideshow });
const size = 100;
const start = Math.max(0, Math.min(index - size / 2, this.images.length - size));
const container = document.createElement('div');
container.innerHTML = this.images.slice(start, start + size).map(image =>
`<img src="${escapeHtml(image.thumbnail_url || image.url)}"
data-original="${escapeHtml(image.url)}"
alt="${escapeHtml(image.filename)}">`
).join('');
const container = this.viewMode === 'grid' ? document.getElementById('galleryGrid') : document.getElementById('galleryList');
// Find all images in the container
const images = container.querySelectorAll('img[data-original]');
if (images.length === 0) {
console.warn('No images found for viewer initialization');
return;
}
this.viewer = new Viewer(container, {
inline: false,
initialViewIndex: index - start,
button: true,
backdrop: true,
navbar: true,
@@ -637,22 +619,14 @@
// Add metadata sidebar when ViewerJS opens
// Use the original image URL from data-original attribute
const originalImg = event.detail.originalImage;
this.addMetadataSidebar(originalImg);
setTimeout(() => this.addMetadataSidebar(originalImg), 100);
},
play: (event) => {
event.preventDefault();
if (this.slideshowPlaying) this.stopViewerSlideshow();
else {
this.slideshowPlaying = true;
if (this.viewer.options.fullscreen) this.viewer.requestFullscreen();
this.scheduleViewerSlide();
}
show: function() {
},
hide: () => {
this.metadataRequest = null;
this.currentMetadata = null;
this.viewer?.clearEnforceFocus();
this.stopViewerSlideshow();
shown: function() {
},
hide: function() {
// Remove sidebar when viewer closes
const sidebar = document.getElementById('metadata-sidebar');
if (sidebar) {
sidebar.remove();
@@ -660,50 +634,12 @@
}
});
const viewer = this.viewer;
const navigate = (direction, loop = false) => {
if (viewer !== this.viewer || viewer.hiding || viewer.played) return viewer;
let target = start + viewer.index + direction;
if (loop) target = (target + this.images.length) % this.images.length;
else target = Math.max(0, Math.min(target, this.images.length - 1));
if (target >= start && target < start + viewer.length) {
viewer.view(target - start);
} else {
this.openViewer(target, { keepSlideshow: true });
}
return this.viewer;
};
viewer.prev = (loop) => navigate(-1, loop);
viewer.next = (loop) => navigate(1, loop);
viewer.show();
}
stopViewerSlideshow() {
clearTimeout(this.slideshowTimer);
this.slideshowTimer = null;
if (this.slideshowPlaying) this.viewer?.exitFullscreen();
this.slideshowPlaying = false;
}
scheduleViewerSlide() {
// Native Viewer.js slideshows preload every original and loop within the filmstrip.
this.slideshowTimer = setTimeout(() => {
this.slideshowTimer = null;
if (!this.slideshowPlaying || !this.viewer) return;
this.viewer.next(true);
if (this.slideshowPlaying) this.scheduleViewerSlide();
}, 5000);
// Don't add click handlers here - let ViewerJS handle them
}
addMetadataSidebar(originalImage) {
const viewerContainer = document.querySelector('.viewer-container');
if (!viewerContainer) return;
const existing = document.getElementById('metadata-sidebar');
if (existing) {
this.loadImageMetadata(originalImage.dataset.original || originalImage.src,
existing.querySelector('#metadata-content'));
return;
}
if (!viewerContainer || document.getElementById('metadata-sidebar')) return;
const sidebar = document.createElement('div');
sidebar.id = 'metadata-sidebar';
@@ -749,37 +685,81 @@
// Load metadata for the current image (ALWAYS use original for metadata)
const originalImageUrl = originalImage.dataset.original || originalImage.src;
this.loadImageMetadata(originalImageUrl, sidebar.querySelector('#metadata-content'));
// Listen for ViewerJS view changes to update metadata
this.setupViewerMetadataUpdates(sidebar);
}
addFallbackClickHandlers() {
for (const id of ['galleryGrid', 'galleryList']) {
const container = document.getElementById(id);
if (container.dataset.mediaClicksBound) continue;
container.dataset.mediaClicksBound = '1';
container.addEventListener('click', (event) => {
const item = event.target.closest('.image-item');
if (!item) return;
const img = item.querySelector('img[data-original]');
if (!img) return;
event.preventDefault();
event.stopPropagation();
const index = Number(item.dataset.imageIndex);
const { original, caption, isVideo } = img.dataset;
if (isVideo === 'true') {
this.showVideoModal(original, caption, index);
} else {
try {
this.openViewer(index);
} catch (error) {
console.warn('ViewerJS failed, using fallback:', error);
this.destroyViewer();
this.showSimpleImageModal(original, caption);
setupViewerMetadataUpdates(sidebar) {
// Listen for ViewerJS navigation events
const viewerContainer = document.querySelector('.viewer-container');
if (!viewerContainer) return;
// Override ViewerJS navigation to update metadata
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes' && mutation.attributeName === 'src') {
const img = mutation.target;
if (img.tagName === 'IMG' && img.closest('.viewer-canvas')) {
// Find the corresponding original image
const imageUrl = img.src;
this.loadImageMetadata(imageUrl, sidebar.querySelector('#metadata-content'));
}
}
});
});
const canvas = viewerContainer.querySelector('.viewer-canvas');
if (canvas) {
observer.observe(canvas, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['src']
});
}
}
addFallbackClickHandlers() {
// Add simple click handlers that work even if ViewerJS fails
const container = this.viewMode === 'grid' ? document.getElementById('galleryGrid') : document.getElementById('galleryList');
const imageItems = container.querySelectorAll('.image-item');
imageItems.forEach((item, index) => {
item.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const img = item.querySelector('img[data-original]');
if (img) {
// ALWAYS use the original media URL for viewing
const originalMediaUrl = img.dataset.original;
const caption = img.dataset.caption;
const isVideo = img.dataset.isVideo === 'true';
const mediaType = img.dataset.mediaType || 'image';
if (isVideo) {
// For videos, use our custom video modal
this.showVideoModal(originalMediaUrl, caption, index);
} else {
// For images, try ViewerJS first, fallback to simple modal
if (this.viewer) {
try {
this.viewer.view(index);
} catch (e) {
console.warn('ViewerJS failed, using fallback:', e);
this.showSimpleImageModal(originalMediaUrl, caption);
}
} else {
this.showSimpleImageModal(originalMediaUrl, caption);
}
}
}
});
});
}
async showSimpleImageModal(imageUrl, caption) {
// Create a modal that matches the screenshot design
const modal = document.createElement('div');
@@ -1193,18 +1173,15 @@
}
async loadImageMetadata(imageUrl, container) {
const request = {};
this.metadataRequest = request;
this.currentMetadata = null;
container.textContent = 'Loading metadata...';
const isCurrent = () => this.metadataRequest === request && container.isConnected !== false;
try {
// First try to get linked prompt from database
let databasePrompt = null;
try {
const metadataUrl = ImageHelpers.metadataLookupUrl(imageUrl);
if (metadataUrl) {
const response = await ApiClient.fetchUrl(metadataUrl);
// Extract relative path from the URL for the API call
const urlParts = imageUrl.split('/prompt_manager/images/serve/');
if (urlParts.length > 1) {
const relativePath = urlParts[1];
const response = await fetch(`/prompt_manager/images/prompt/${encodeURIComponent(relativePath)}`);
if (response.ok) {
const data = await response.json();
if (data.success && data.prompt) {
@@ -1263,9 +1240,8 @@
}
}
if (isCurrent()) this.displayMetadata(metadata, container);
this.displayMetadata(metadata, container);
} catch (error) {
if (!isCurrent()) return;
console.error('Error loading metadata:', error);
container.innerHTML = `
<div class="bg-pm-error-tint border border-pm-error rounded-pm-md p-4">
@@ -1613,7 +1589,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
async extractImageMetadata(imageUrl) {
try {
// Fetch the image as an array buffer
const response = await ApiClient.fetchUrl(imageUrl);
const response = await fetch(imageUrl);
const arrayBuffer = await response.arrayBuffer();
// Parse PNG metadata
@@ -1766,7 +1742,6 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
debugMode: document.getElementById('debugModeToggle').checked,
apiTimeout: parseInt(document.getElementById('apiTimeoutInput').value),
thumbnailsGenerated: this.getSettings().thumbnailsGenerated, // Preserve this
thumbnailPromptDismissed: this.getSettings().thumbnailPromptDismissed,
checkThumbnailsAtStartup: document.getElementById('checkThumbnailsAtStartup').checked,
// Video settings
videoAutoplay: document.getElementById('videoAutoplayToggle').checked,
@@ -2115,7 +2090,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
// Call backend to clear server-side thumbnails (only our generated thumbnails)
try {
const response = await ApiClient.fetchUrl('/prompt_manager/images/clear-thumbnails', {
const response = await fetch('/prompt_manager/images/clear-thumbnails', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
@@ -2206,7 +2181,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
async resumeDuplicateScan() {
let status;
try {
const response = await ApiClient.fetchUrl('/prompt_manager/scan_duplicates/status');
const response = await fetch('/prompt_manager/scan_duplicates/status');
if (!response.ok) return;
status = await response.json();
} catch (_) {
@@ -2344,7 +2319,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
${image.media_type === 'video' ? `
<div class="w-20 h-20 bg-pm-input rounded flex items-center justify-center relative">
${image.thumbnail_url ? `
<img src="${escapeHtml(ApiClient.resolveUrl(image.thumbnail_url))}" alt="Video thumbnail" class="w-full h-full object-cover rounded">
<img src="${escapeHtml(image.thumbnail_url)}" alt="Video thumbnail" class="w-full h-full object-cover rounded">
` : `
<svg class="w-8 h-8 text-pm-secondary" fill="currentColor" viewBox="0 0 20 20">
<path d="M2 6a2 2 0 012-2h6l2 2h6a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6zM14.553 7.106A1 1 0 0014 8v4a1 1 0 00.553.894l2 1A1 1 0 0018 13V7a1 1 0 00-1.447-.894l-2 1z"/>
@@ -2353,7 +2328,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
<div class="absolute bottom-0 right-0 bg-black/75 text-pm text-xs px-1 rounded">VIDEO</div>
</div>
` : `
<img src="${escapeHtml(ApiClient.resolveUrl(image.thumbnail_url || image.url))}" alt="${escapeHtml(image.filename)}"
<img src="${escapeHtml(image.thumbnail_url || image.url)}" alt="${escapeHtml(image.filename)}"
class="w-20 h-20 object-cover rounded cursor-pointer hover:opacity-80"
onclick="this.parentElement.parentElement.querySelector('.image-preview').click()">
`}
@@ -2372,7 +2347,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
<div class="flex items-center justify-between">
<div class="flex items-center space-x-2">
<button class="image-preview text-xs bg-pm-accent hover:bg-pm-accent-hover text-pm px-2 py-1 rounded"
data-action="open-url" data-url="${escapeHtml(ApiClient.resolveUrl(image.url))}">
data-action="open-url" data-url="${escapeHtml(image.url)}">
View Full
</button>
</div>
@@ -2432,7 +2407,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
try {
const imagePaths = Array.from(checkboxes).map(cb => cb.dataset.imagePath);
const response = await ApiClient.fetchUrl('/prompt_manager/delete_duplicate_images', {
const response = await fetch('/prompt_manager/delete_duplicate_images', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
@@ -2523,16 +2498,37 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
}
try {
const data = await this.api.get('/prompt_manager/images/thumbnails/status');
const total = data.total_images;
const pending = data.pending;
if (!data.success || !Number.isSafeInteger(total) ||
!Number.isSafeInteger(pending) || total <= 0 ||
pending <= 0 || pending > total) return;
const signature = JSON.stringify([data.collection_id, total, pending]);
if (settings.thumbnailPromptDismissed === signature) return;
if (pending / total > 0.1) {
this.showThumbnailGenerationPrompt(total, pending, signature);
// Check a sample of images to see if any are missing thumbnails
const response = await fetch('/prompt_manager/images/output?limit=50&offset=0');
const data = await response.json();
console.debug('Checking images for missing thumbnails:', data);
if (data.success && data.images && data.images.length > 0) {
// Count images missing thumbnails
let missingThumbnails = 0;
for (const image of data.images) {
if (!image.thumbnail_url) {
missingThumbnails++;
}
}
console.debug(`Found ${missingThumbnails} images without thumbnails in sample of ${data.images.length}`);
// If more than 10% of sampled images are missing thumbnails, show prompt
const missingPercentage = (missingThumbnails / data.images.length) * 100;
if (missingThumbnails > 0 && missingPercentage > 10) {
// Estimate total missing based on sample
const estimatedMissing = Math.round((missingThumbnails / data.images.length) * data.total);
console.debug(`Estimated ${estimatedMissing} total images missing thumbnails (${missingPercentage.toFixed(1)}% of sample)`);
this.showThumbnailGenerationPrompt(data.total, estimatedMissing);
} else if (missingThumbnails > 0) {
console.debug(`Only ${missingPercentage.toFixed(1)}% of images missing thumbnails, not showing prompt`);
} else {
console.debug('All sampled images have thumbnails');
}
} else {
console.debug('No images found or failed to fetch images');
}
} catch (error) {
console.debug('Could not check for missing thumbnails at startup:', error);
@@ -2540,7 +2536,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
}, 1000); // Wait 1 second for page to settle
}
showThumbnailGenerationPrompt(imageCount, missingCount = imageCount, signature = null) {
showThumbnailGenerationPrompt(imageCount, missingCount = imageCount) {
const modal = document.createElement('div');
modal.className = 'fixed inset-0 bg-black/50 flex items-center justify-center z-50';
modal.innerHTML = `
@@ -2551,10 +2547,10 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
Generate Thumbnails?
</h3>
<p class="text-pm-secondary mb-4">
${missingCount} images need new or updated thumbnails (${imageCount} total images). Generate them for faster loading?
Found ${missingCount} images without thumbnails (${imageCount} total images). Would you like to generate thumbnails for faster loading?
</p>
<p class="text-sm text-pm-muted mb-3">
Only missing or outdated thumbnails will be generated. Existing current thumbnails will be kept. You can change startup checks in Gallery Settings.
💡 Thumbnails speed up gallery loading significantly. You can change this setting in Gallery Settings.
</p>
<div class="flex space-x-3">
<button id="thumbnailPromptNo" class="flex-1 px-4 py-2 bg-pm-input hover:bg-pm-hover text-pm rounded-pm-md transition-colors">
@@ -2570,20 +2566,11 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
document.body.appendChild(modal);
const dismiss = () => {
if (signature) {
try {
const settings = this.getSettings();
settings.thumbnailPromptDismissed = signature;
localStorage.setItem('gallerySettings', JSON.stringify(settings));
} catch (error) {
console.warn('Could not save thumbnail prompt dismissal:', error);
}
}
// Handle button clicks
document.getElementById('thumbnailPromptNo').addEventListener('click', () => {
document.body.removeChild(modal);
};
document.getElementById('thumbnailPromptNo').addEventListener('click', dismiss);
});
document.getElementById('thumbnailPromptYes').addEventListener('click', () => {
// Don't remove modal yet - transform it to show progress
this.startThumbnailGenerationInModal(modal, imageCount, missingCount);
@@ -2592,7 +2579,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
// Close on outside click
modal.addEventListener('click', (e) => {
if (e.target === modal) {
dismiss();
document.body.removeChild(modal);
}
});
}
@@ -2988,7 +2975,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
async loadTagsAndCategories() {
try {
// Load tags
const tagsResponse = await ApiClient.fetchUrl('/prompt_manager/tags');
const tagsResponse = await fetch('/prompt_manager/tags');
const tagsData = await tagsResponse.json();
if (tagsData.success) {
// Filter out the __protected__ tag from suggestions
@@ -2996,7 +2983,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
}
// Load categories
const categoriesResponse = await ApiClient.fetchUrl('/prompt_manager/categories');
const categoriesResponse = await fetch('/prompt_manager/categories');
const categoriesData = await categoriesResponse.json();
if (categoriesData.success) {
this.addPromptState.allCategories = categoriesData.categories;
@@ -3190,7 +3177,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
saveBtn.textContent = 'Saving...';
try {
const response = await ApiClient.fetchUrl('/prompt_manager/save', {
const response = await fetch('/prompt_manager/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -3287,7 +3274,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
statusEls.forEach(el => { el.textContent = 'Checking...'; });
try {
const response = await ApiClient.fetchUrl('/prompt_manager/autotag/models');
const response = await fetch('/prompt_manager/autotag/models');
const data = await response.json();
if (data.success) {
@@ -3396,7 +3383,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
unloadBtn.textContent = 'Unloading...';
try {
const response = await ApiClient.fetchUrl('/prompt_manager/autotag/unload', {
const response = await fetch('/prompt_manager/autotag/unload', {
method: 'POST'
});
const data = await response.json();
@@ -3422,7 +3409,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
const keepInMemory = document.getElementById('keepModelInMemory').checked;
// Check if model is downloaded
const response = await ApiClient.fetchUrl('/prompt_manager/autotag/models');
const response = await fetch('/prompt_manager/autotag/models');
const data = await response.json();
if (data.success && !data.models[modelType].downloaded) {
@@ -3512,7 +3499,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
const modelType = document.querySelector('input[name="autoTagModel"]:checked').value;
// Check if model is downloaded
const response = await ApiClient.fetchUrl('/prompt_manager/autotag/models');
const response = await fetch('/prompt_manager/autotag/models');
const data = await response.json();
if (data.success && !data.models[modelType].downloaded) {
@@ -3542,10 +3529,6 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
}
async loadNextReviewImage() {
const request = {};
this.reviewRequest = request;
this.autoTagState.currentTags = [];
this.autoTagState.reviewPromptId = null;
if (this.autoTagState.reviewIndex >= this.autoTagState.reviewImages.length) {
this.hideModal("autoTagReviewModal");
this.showNotification('Review complete!', 'success');
@@ -3555,7 +3538,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
const image = this.autoTagState.reviewImages[this.autoTagState.reviewIndex];
document.getElementById('reviewCurrentIndex').textContent = this.autoTagState.reviewIndex + 1;
document.getElementById('reviewImage').src = ApiClient.resolveUrl(image.thumbnail_url || image.url);
document.getElementById('reviewImage').src = image.thumbnail_url || image.url;
document.getElementById('reviewImageName').textContent = image.filename || 'Unknown';
document.getElementById('reviewTagsContainer').innerHTML = '<div class="text-pm-secondary">Generating tags...</div>';
@@ -3563,7 +3546,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
// Gallery entries come from /images/output: id is a path digest, so address by path
const requestBody = ImageHelpers.autotagSingleBody(image, this.autoTagState);
const response = await ApiClient.fetchUrl('/prompt_manager/autotag/single', {
const response = await fetch('/prompt_manager/autotag/single', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody)
@@ -3571,9 +3554,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
const data = await response.json();
if (this.reviewRequest !== request) return;
if (data.success) {
this.autoTagState.reviewPromptId = data.prompt_id ?? null;
this.autoTagState.currentTags = data.tags;
this.renderReviewTags();
} else {
@@ -3581,7 +3562,6 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
`<div class="text-pm-error">Error: ${escapeHtml(data.error)}</div>`;
}
} catch (error) {
if (this.reviewRequest !== request) return;
console.error('Error generating tags:', error);
document.getElementById('reviewTagsContainer').innerHTML =
'<div class="text-pm-error">Failed to generate tags</div>';
@@ -3619,41 +3599,28 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
return;
}
const promptId = this.autoTagState.reviewPromptId;
if (!promptId) {
this.showNotification('This image has no linked prompt. Scan the output folder first.', 'warning');
return;
}
const request = this.reviewRequest;
if (this.reviewApplying === request) return;
this.reviewApplying = request;
const image = this.autoTagState.reviewImages[this.autoTagState.reviewIndex];
try {
const response = await ApiClient.fetchUrl('/prompt_manager/autotag/apply', {
const response = await fetch('/prompt_manager/autotag/apply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt_id: promptId,
image_path: image.path,
tags: this.autoTagState.currentTags
})
});
const data = await response.json();
if (this.reviewRequest !== request) return;
if (data.success) {
this.showNotification(`Applied ${this.autoTagState.currentTags.length} tags`, 'success');
} else {
this.showNotification(data.error || 'Failed to apply tags', 'warning');
return;
}
} catch (error) {
if (this.reviewRequest !== request) return;
console.error('Error applying tags:', error);
this.showNotification('Failed to apply tags', 'error');
return;
} finally {
if (this.reviewApplying === request) this.reviewApplying = null;
}
this.autoTagState.reviewIndex++;
@@ -3661,8 +3628,6 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
}
cancelReview() {
this.reviewRequest = null;
this.autoTagState.reviewPromptId = null;
this.autoTagState.reviewImages = [];
this.autoTagState.reviewIndex = 0;
this.autoTagState.currentTags = [];
+1 -20
View File
@@ -79,9 +79,6 @@
typeof entry.path === "string" && entry.path && !Number.isInteger(entry.id)
? { path: entry.path }
: { image_id: entry.id };
if ("path" in target && Number.isInteger(entry.root_index)) {
target.root_index = entry.root_index;
}
const modelType = String(settings.modelType || "");
const params = modelType.startsWith("wd14")
? {
@@ -106,23 +103,7 @@
return original;
}
function metadataLookupUrl(imageUrl) {
try {
const url = new URL(imageUrl, 'http://prompt-manager.invalid');
const marker = '/prompt_manager/images/serve/';
const index = url.pathname.indexOf(marker);
if (index < 0) return null;
const relativePath = decodeURIComponent(url.pathname.slice(index + marker.length));
const query = new URLSearchParams();
if (url.searchParams.has('root')) query.set('root', url.searchParams.get('root'));
const suffix = query.size ? `?${query}` : '';
return `/prompt_manager/images/prompt/${encodeURIComponent(relativePath)}${suffix}`;
} catch {
return null;
}
}
const api = { formatImageCaption, workflowDocumentHtml, autotagSingleBody, fallbackImageSource, metadataLookupUrl };
const api = { formatImageCaption, workflowDocumentHtml, autotagSingleBody, fallbackImageSource };
if (typeof module !== "undefined" && module.exports) module.exports = api;
else root.ImageHelpers = api;
})(typeof window !== "undefined" ? window : globalThis);
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2025 ComfyAssets
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-23
View File
@@ -1,23 +0,0 @@
# Initial source provenance
The initial browser controls are extracted from ComfyAssets' own
KikoTools modules at commit `1820fb19ea5d200e8cd2a09bcbbcc35926f13228`.
KikoTools remains unchanged by this extraction. Future toolkit changes belong
here and are validated in PromptManager before a later KikoTools adoption PR.
| Toolkit file | KikoTools source | Original source SHA-256 |
| --- | --- | --- |
| `ui/core.js` | `web/ui/core.js` | `cd10b700441a607cb1d56051cf0a430c0b36f901c0c351d8f4dae067090d8838` |
| `ui/controls.css` | `web/ui/controls.css` | `64d6b0f3059b0a21187796059fe14bfb5358a796a18f775543b7baef43874e80` |
Host orchestration and coverage helpers were extracted and generalized from
KikoTools' maintainer tooling, with isolated tracked-file installation and a
project preparation adapter proven against PromptManager. They have no runtime
imports from either node pack. All original code retains the ComfyAssets MIT
license. No Pixaroma implementation, styles or assets were copied.
`ui/core.js` is unchanged. `ui/controls.css` retains only the shared control,
range, touch-target and forced-colors rules (original lines 1–61, 97–99, 118,
170–172 and closing brace 174). Feature-view selectors remain in KikoTools.
The retained declarations are unchanged; the extracted stylesheet SHA-256 is
`3ae1ca39077ffc3621b86ae7ef77202841fa27061b0e4623f2bb0413b27ecd7b`.
-69
View File
@@ -1,69 +0,0 @@
.ca-tools {
color-scheme: dark;
--ca-surface: #2b2b2e;
--ca-field: #202023;
--ca-control: #37373b;
--ca-hover: #45454b;
--ca-text: #eeeef1;
--ca-muted: #b5b5bd;
--ca-line: #48484f;
--ca-border: #7d7d87;
--ca-accent: #0877bb;
--ca-focus: #5bc2ff;
--ca-error: #d52430;
--ca-error-text: #ffadb3;
--ca-error-bg: #391d23;
font: 13px/1.5 Inter, ui-sans-serif, system-ui, sans-serif;
background: var(--ca-surface);
color: var(--ca-text);
}
.ca-tools[data-ca-theme="light"] {
color-scheme: light;
--ca-surface: #ffffff;
--ca-field: #f5f5f7;
--ca-control: #eeeef1;
--ca-hover: #e2e2e8;
--ca-text: #23232b;
--ca-muted: #5c5c68;
--ca-line: #d4d4dc;
--ca-border: #85858f;
--ca-focus: #0066a6;
--ca-error-text: #a20d20;
--ca-error-bg: #fff0f1;
}
.ca-tools :where(div, span, label, input, textarea, button, select, fieldset) { box-sizing: border-box; }
.ca-tools :where(button, input, select, textarea) { font: inherit; color: inherit; accent-color: var(--ca-accent); }
.ca-tools :where(button, select, input:not([type="radio"], [role="switch"]), textarea) { border: 1px solid var(--ca-border); border-radius: 5px; min-block-size: 30px; }
.ca-tools button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 4px 10px; background: var(--ca-control); cursor: pointer; white-space: nowrap; }
.ca-tools button:hover:not(:disabled) { background: var(--ca-hover); }
.ca-tools button:disabled { opacity: .42; cursor: default; }
.ca-tools :where(input:not([type="radio"], [role="switch"]), select, textarea) { background: var(--ca-field); min-inline-size: 0; padding: 4px 8px; }
.ca-tools :where(button, input, select, textarea):focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 2px; }
.ca-tools :where(input, textarea)::placeholder { color: var(--ca-muted); opacity: 1; }
.ca-tools .ca-primary, .ca-tools button[aria-current="page"] { background: var(--ca-accent); border-color: var(--ca-accent); color: #fff; }
.ca-tools .ca-primary:hover:not(:disabled) { background: #096ba6; }
.ca-tools .ca-danger { background: var(--ca-error); border-color: var(--ca-error); color: #fff; }
.ca-tools .ca-danger:hover:not(:disabled) { background: #b51c28; }
.ca-tools .ca-icon-button { min-inline-size: 30px; padding-inline: 6px; }
.ca-tools .ca-field-label { display: block; font-size: 11px; color: var(--ca-muted); }
.ca-tools .ca-field { display: inline-flex; align-items: center; gap: 6px; min-inline-size: 0; }
.ca-tools .ca-switch { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; min-block-size: 30px; font-size: 12px; }
.ca-tools .ca-switch input { appearance: none; position: relative; inline-size: 36px; min-block-size: 24px; border: 1px solid var(--ca-border); border-radius: 20px; background: var(--ca-control); margin: 0; cursor: pointer; flex-shrink: 0; }
.ca-tools .ca-switch input::before { content: ""; position: absolute; inline-size: 16px; block-size: 16px; background: var(--ca-muted); inset-block-start: 3px; inset-inline-start: 3px; border-radius: 50%; }
.ca-tools .ca-switch input:checked { background: var(--ca-accent); border-color: var(--ca-accent); }
.ca-tools .ca-switch input:checked::before { background: #fff; inset-inline-start: 15px; }
.ca-tools .ca-segments { border: 0; padding: 0; margin: 0; display: inline-flex; gap: 2px; min-inline-size: 0; }
.ca-tools .ca-segments label { position: relative; display: inline-flex; min-inline-size: 24px; min-block-size: 30px; cursor: pointer; }
.ca-tools .ca-segments input { position: absolute; inset: 0; margin: 0; opacity: 0; inline-size: 100%; block-size: 100%; cursor: pointer; }
.ca-tools .ca-segments label > span { padding: 4px 12px; background: var(--ca-field); border: 1px solid var(--ca-border); border-radius: 4px; color: var(--ca-text); font-size: 13px; }
.ca-tools .ca-segments input:checked + span { background: var(--ca-accent); border-color: var(--ca-accent); color: #fff; }
.ca-tools .ca-segments input:focus-visible + span { outline: 2px solid var(--ca-focus); outline-offset: 2px; }
.ca-tools .ca-visually-hidden { position: absolute; inline-size: 1px; block-size: 1px; clip-path: inset(50%); overflow: hidden; white-space: nowrap; }
.ca-tools .ca-range { display: flex; align-items: center; gap: 10px; flex-shrink: 0; color: var(--ca-muted); font-size: 11px; }
.ca-tools .ca-range input[type="range"] { flex: 1; inline-size: 60px; min-inline-size: 0; margin: 0; padding: 0; border: 0; min-block-size: 30px; cursor: pointer; }
.ca-tools .ca-range output { min-inline-size: 44px; font-variant-numeric: tabular-nums; }
@media (pointer: coarse) { .ca-tools :where(button, select), .ca-tools .ca-segments label > span, .ca-tools .ca-switch { min-block-size: 40px; } }
@media (forced-colors: active) {
.ca-tools { --ca-accent: Highlight; --ca-focus: Highlight; --ca-border: ButtonText; --ca-line: CanvasText; }
.ca-tools .ca-switch input:checked::before { background: HighlightText; }
}
-138
View File
@@ -1,138 +0,0 @@
export function createScope() {
const controller = new AbortController();
const cleanups = [];
return {
signal: controller.signal,
listen(target, event, callback) {
target.addEventListener(event, callback, { signal: controller.signal });
},
cleanup(callback) { cleanups.push(callback); },
debounce(callback, delay) {
let timer;
cleanups.push(() => clearTimeout(timer));
return (...args) => {
clearTimeout(timer);
if (!controller.signal.aborted) timer = setTimeout(() => callback(...args), delay);
};
},
dispose() {
if (controller.signal.aborted) return;
controller.abort();
cleanups.splice(0).forEach(callback => callback());
},
};
}
export function themeFromColor(color) {
let values;
const hex = color.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i)?.[1];
if (hex) {
const expanded = hex.length === 3 ? [...hex].map(value => value + value).join('') : hex;
values = expanded.match(/../g).map(value => parseInt(value, 16));
} else if (/^rgb\(/.test(color)) {
values = color.match(/[\d.]+/g)?.slice(0, 3).map(Number);
}
if (!values || values.length !== 3) return null;
const linear = values.map(value => value / 255).map(value => value <= .04045 ? value / 12.92 : ((value + .055) / 1.055) ** 2.4);
return linear[0] * .2126 + linear[1] * .7152 + linear[2] * .0722 > .35 ? 'light' : 'dark';
}
export function mountTheme(element, settings, scope) {
const stylesheet = new URL('./controls.css', import.meta.url).href;
if (![...document.querySelectorAll('link[rel="stylesheet"]')].some(link => link.href === stylesheet)) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = stylesheet;
document.head.append(link);
}
const preference = matchMedia('(prefers-color-scheme: dark)');
const update = () => {
const background = getComputedStyle(document.body).getPropertyValue('--bg-color').trim();
element.dataset.caTheme = themeFromColor(background) || (preference.matches ? 'dark' : 'light');
};
const observer = new MutationObserver(update);
for (const target of [document.documentElement, document.body]) observer.observe(target, { attributes: true, attributeFilter: ['style', 'class', 'data-theme'] });
scope.cleanup(() => observer.disconnect());
scope.listen(preference, 'change', update);
if (settings?.addEventListener) scope.listen(settings, 'Comfy.ColorPalette.change', update);
update();
}
export function createButton(text, className = '') {
const element = document.createElement('button');
element.type = 'button';
element.className = className;
element.textContent = text;
return element;
}
export function createSlider(label, { id, min, max, step, value, unit }, scope, onInput) {
const field = document.createElement('div');
field.className = 'ca-range';
const caption = document.createElement('label');
caption.htmlFor = id;
caption.textContent = label;
const input = document.createElement('input');
Object.assign(input, { type: 'range', id, min, max, step, value });
const output = document.createElement('output');
output.setAttribute('for', id);
output.value = `${value} ${unit}`;
scope.listen(input, 'input', () => {
output.value = `${input.value} ${unit}`;
onInput(Number(input.value));
});
field.append(caption, input, output);
return field;
}
export function createField(label, input, id) {
const field = document.createElement('label');
field.className = 'ca-field';
field.htmlFor = id;
const caption = document.createElement('span');
caption.className = 'ca-field-label';
caption.textContent = label;
input.id = id;
field.append(caption, input);
return field;
}
export function createSwitch(label, className, id) {
const input = document.createElement('input');
input.type = 'checkbox';
input.role = 'switch';
input.checked = true;
input.className = className;
const field = createField(label, input, id);
field.className = 'ca-switch';
field.replaceChildren(input, document.createTextNode(label));
return field;
}
export function createSelect(options, className) {
const select = document.createElement('select');
select.className = className;
for (const [value, text] of options) select.add(new Option(text, value));
return select;
}
export function createSegments(label, options, name, scope, onChange) {
const fieldset = document.createElement('fieldset');
fieldset.className = 'ca-segments';
const legend = document.createElement('legend');
legend.className = 'ca-visually-hidden';
legend.textContent = label;
fieldset.append(legend);
options.forEach(([value, text], index) => {
const input = document.createElement('input');
input.type = 'radio';
input.name = name;
input.value = value;
input.checked = index === 0;
const field = createField(text, input, `${name}-${value}`);
field.replaceChildren(input, field.firstChild);
scope.listen(input, 'change', () => onChange(value));
fieldset.append(field);
});
return fieldset;
}
-24
View File
@@ -1,24 +0,0 @@
{
"name": "@comfyassets/toolkit",
"version": "0.1.0",
"resolved": "https://codeload.github.com/ComfyAssets/ComfyAssets-Toolkit/tar.gz/66644f5620e10efeaba090dca2d5be54baa2ee14",
"integrity": "sha512-jvDj3iYJHjRCH7nZimoF4KNLwEVdzKVQIOZrMfoLg9aLnHm98R8YvBUOvnlJ+JNrM8/LnD2m30u3ggSTnR0/sw==",
"files": {
"core.js": {
"source": "ui/core.js",
"sha256": "cd10b700441a607cb1d56051cf0a430c0b36f901c0c351d8f4dae067090d8838"
},
"controls.css": {
"source": "ui/controls.css",
"sha256": "3ae1ca39077ffc3621b86ae7ef77202841fa27061b0e4623f2bb0413b27ecd7b"
},
"LICENSE": {
"source": "LICENSE",
"sha256": "0c865294d0c580dbe365d9ff5ccd655bd2d3716ca3282cc485b5cf789ec8ea6f"
},
"SOURCE.md": {
"source": "SOURCE.md",
"sha256": "46c226483f1d0c01524b1363435ccb3b1e67c2b0f6ed9161f7f087e49e8ed5a9"
}
}
}
+1 -2
View File
@@ -101,8 +101,7 @@
try {
const init = { method, headers: { Accept: "text/event-stream" }, signal: controller.signal };
if (body !== undefined) init.body = body;
const requestUrl = root.ApiClient ? root.ApiClient.resolveUrl(url) : url;
const response = await doFetch(requestUrl, init);
const response = await doFetch(url, init);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
if (source.readyState === CLOSED) return;
source.readyState = OPEN;
+11 -27
View File
@@ -583,12 +583,8 @@ class TagsPageManager {
// ── Prompts Display ───────────────────────────────────────
async loadTagPrompts() {
if (this.selectedTags.length === 0) {
this.clearPrompts();
return;
}
const request = {};
this.promptRequest = request;
if (this.isLoadingPrompts) return;
if (this.selectedTags.length === 0) return;
this.isLoadingPrompts = true;
this.promptOffset = 0;
@@ -626,7 +622,6 @@ class TagsPageManager {
}
const data = await this.fetchJson(url);
if (this.promptRequest !== request) return;
if (data.success) {
this.prompts = data.prompts;
@@ -639,7 +634,6 @@ class TagsPageManager {
requestAnimationFrame(() => this.refreshPromptObserver());
}
} catch (err) {
if (this.promptRequest !== request) return;
console.error('Failed to load tag prompts:', err);
if (grid) {
grid.replaceChildren();
@@ -649,14 +643,12 @@ class TagsPageManager {
grid.appendChild(errDiv);
}
} finally {
if (this.promptRequest === request) this.isLoadingPrompts = false;
this.isLoadingPrompts = false;
}
}
async loadMoreTagPrompts() {
if (this.isLoadingPrompts || !this.hasMorePrompts) return;
const request = {};
this.promptRequest = request;
this.isLoadingPrompts = true;
const spinner = document.querySelector('#tagPromptsLoader .prompts-loader-spinner');
@@ -683,7 +675,6 @@ class TagsPageManager {
}
const data = await this.fetchJson(url);
if (this.promptRequest !== request) return;
if (data.success) {
const startIndex = this.prompts.length;
@@ -694,16 +685,13 @@ class TagsPageManager {
this.updatePromptCountBadge();
}
} catch (err) {
if (this.promptRequest !== request) return;
console.error('Failed to load more prompts:', err);
this.hasMorePrompts = false;
} finally {
if (this.promptRequest === request) {
this.isLoadingPrompts = false;
if (spinner) spinner.classList.add('hidden');
this.updateLoadMoreButton();
requestAnimationFrame(() => this.refreshPromptObserver());
}
this.isLoadingPrompts = false;
if (spinner) spinner.classList.add('hidden');
this.updateLoadMoreButton();
requestAnimationFrame(() => this.refreshPromptObserver());
}
}
@@ -999,9 +987,6 @@ class TagsPageManager {
}
clearPrompts() {
this.promptRequest = null;
this.isLoadingPrompts = false;
this.promptOffset = 0;
this.prompts = [];
this.promptTotal = 0;
this.hasMorePrompts = false;
@@ -1106,7 +1091,6 @@ class TagsPageManager {
}
destroy() {
this.promptRequest = null;
this.destroyInfiniteScroll();
this.hideContextMenu();
clearTimeout(this.searchTimer);
@@ -1123,7 +1107,7 @@ class TagsPageManager {
// ── Helpers ───────────────────────────────────────────────
async fetchJson(url, options = {}) {
const resp = await ApiClient.fetchUrl(url, options);
const resp = await fetch(url, options);
if (!resp.ok) {
let errorMsg;
try { errorMsg = (await resp.json()).error; } catch { errorMsg = resp.statusText; }
@@ -1133,9 +1117,9 @@ class TagsPageManager {
}
getThumbnailUrl(image) {
if (image.thumbnail_url) return ApiClient.resolveUrl(image.thumbnail_url);
if (image.url) return ApiClient.resolveUrl(image.url);
if (image.id) return ApiClient.resolveUrl(`/prompt_manager/images/${image.id}/file`);
if (image.thumbnail_url) return image.thumbnail_url;
if (image.url) return image.url;
if (image.id) return `/prompt_manager/images/${image.id}/file`;
return '';
}
}
+1 -2
View File
@@ -14,8 +14,7 @@
async function detectPalette() {
try {
const prefix = window.location.pathname.match(/^(.*)\/prompt_manager(?:\/|$)/)?.[1] || '';
const res = await fetch(`${prefix}/api/settings/Comfy.ColorPalette`);
const res = await fetch('/api/settings/Comfy.ColorPalette');
if (!res.ok) return 'dark';
const data = await res.json();
// data is the palette name string, or an object with value
-21
View File
@@ -1,21 +0,0 @@
The MIT License (MIT)
Copyright 2015-present Chen Fengyuan
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
-21
View File
@@ -1,21 +0,0 @@
{
"name": "viewerjs",
"version": "1.15.2",
"source": "https://github.com/fengyuanchen/viewerjs/tree/v1.15.2",
"resolved": "https://registry.npmjs.org/viewerjs/-/viewerjs-1.15.2.tgz",
"integrity": "sha512-CYEqSFfPUSnmn1PjrhYxXOScaUC7n8+NpSqI+MrOSM8wcKPe35MlwQYwksuD5rjFkYv/ePmY5NsUa51uRNN2jA==",
"files": {
"viewer.min.js": {
"source": "dist/viewer.min.js",
"sha256": "490580be4988dff2e05cd9c81c4161b82ce0c3715d268fadf8f7193853f40031"
},
"viewer.min.css": {
"source": "dist/viewer.min.css",
"sha256": "24a43eb0b188c824bb5b32e4d95c08b6162ec01eaa709ca8f0823d9512d0913d"
},
"LICENSE": {
"source": "LICENSE",
"sha256": "eb917650781dcf06a60b97cba9b05bf583931ec2d311d8a5548a8dca90a57e0f"
}
}
}
+3 -3
View File
File diff suppressed because one or more lines are too long
+3 -3
View File
File diff suppressed because one or more lines are too long
+4 -4
View File
@@ -4,10 +4,10 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Image Gallery - Generation Data</title>
<script src="js/theme-bridge.js"></script>
<script src="js/html-escape.js"></script>
<script src="js/png-metadata.js"></script>
<script src="js/comfy-metadata.js"></script>
<script src="/prompt_manager/js/theme-bridge.js"></script>
<script src="/prompt_manager/js/html-escape.js"></script>
<script src="/prompt_manager/js/png-metadata.js"></script>
<script src="/prompt_manager/js/comfy-metadata.js"></script>
<link rel="stylesheet" href="lib/tailwind/comfyui-theme.css">
<link rel="stylesheet" href="lib/tailwind/styles.css">
<style>