Compare commits
@@ -1,5 +1,6 @@
|
||||
# Local configuration and credentials
|
||||
config.json
|
||||
allow_remote_changes
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
@@ -107,8 +107,8 @@ ComfyUI-Manager 收录审核检查的就是这几类口子(2026-10 审 Anomalo
|
||||
|
||||
1. **不执行下载来的或用户文件里的代码。** 数据不用 `pickle`、`eval`、`exec`、动态导入读;配置文件当数据解析。模型文件只读 safetensors / GGUF 的文件头,`.ckpt` / `.pt` / `.pth` / `.bin` 不打开,界面提醒它们可能带代码。
|
||||
2. **下载要能核对。** 只从允许的 https 站点下载,先写 `.part`,核对 SHA-256 后才改名放入;没有可核对的指纹时在界面上说明。下载的东西只当数据用。
|
||||
3. **接口不接受任意本机路径。** 只收“类型 + 根目录序号 + 相对路径”,在后端用 `resolve_within` 校验;需要指向本机某个文件时,由服务端自己找到它,只给页面一个令牌。
|
||||
4. **会写入、移动、删除文件或替用户联网的接口只接受本机请求**(请求来自本机,没有经过代理,Host 也是本机)。用 `--listen` 开放到局域网时,别的设备只能查看。由 `api/local_guard.py` 的中间件统一把关:`/anomalous/` 下 GET 以外的请求默认只接受本机,确实只读的 POST 列进 `OPEN_POSTS`,不只是读的 GET(备份文件、联网、本机文件夹)列进 `LOCAL_GETS`;新接口要想清楚属于哪一类,不要为了方便放进 `OPEN_POSTS`。
|
||||
3. **接口不接受任意本机路径。** 只收“类型 + 根目录序号 + 相对路径”,在后端用 `resolve_within` 校验;需要指向本机某个文件时,由服务端自己找到它,只给页面一个令牌。模型文件本身可以是链接(云服务器上模型文件夹里全是指向平台模型盘的链接):文件夹部分照样用 `resolve_within`,文件用 `model_file_within` / `model_reference_within` 按名字取,不跟着链接走出去,封面、信息和改动都在链接旁边。
|
||||
4. **会写入、移动、删除文件或替用户联网的接口只接受本机请求**(请求来自本机,没有经过代理,Host 也是本机)。用 `--listen` 开放到局域网时,别的设备只能查看。由 `api/local_guard.py` 的中间件统一把关:`/anomalous/` 下 GET 以外的请求默认只接受本机,确实只读的 POST 列进 `OPEN_POSTS`,不只是读的 GET(备份文件、联网、本机文件夹)列进 `LOCAL_GETS`;新接口要想清楚属于哪一类,不要为了方便放进 `OPEN_POSTS`。唯一的例外是云服务器用户在服务器上的插件文件夹里放了 `allow_remote_changes` 文件(启动时读一次,网页建不了也删不掉):这时 `local_guard.may_change` 也放行别的设备;接口里要自己判断时用 `may_change`,只有“浏览器就在这台电脑上”才成立的事(比如在本机的下载文件夹里找原文件)才用 `is_local`。MCP 始终只接受本机。
|
||||
5. **用户的文件删除一律进回收站,没有回收站就拒绝;从不覆盖**(同名另存为“名字 (2)”或拒绝)。插件自己产生的临时文件(`.part`、暂存区)可以直接删。
|
||||
6. **只读的接口也按第 3 条来。** “只是读”不是例外;能按名字、编号取的,就不收路径,由服务端在自己登记的范围里找到文件。
|
||||
7. **读用户文件要有上限。** 限制读取的大小和条数;读不了或超大的文件跳过并在结果里说明,不让一个坏文件拖垮整个请求。读到的内容进页面只当纯文本(本节第二条)。
|
||||
|
||||
@@ -82,15 +82,18 @@ covered by the `styles.css` manifest.
|
||||
and no scan replaces or deletes it.
|
||||
- `api/model_catalog.py`, `api/model_resolution.py`, `api/model_metadata.py`, and
|
||||
`api/model_media.py` own model listing, identity recovery, mutation, and covers;
|
||||
`api/model_folder.py` shows a model file in Explorer / Finder (by type, folder index and
|
||||
file name, never a path; a POST, so only this computer may ask);
|
||||
`api/model_folder.py` shows a model file in Explorer / Finder, or without a file name opens
|
||||
the models folder (by type, folder index, subfolder and file name, never a path; a POST, so
|
||||
only this computer may ask);
|
||||
`api/models.py` is a compatibility facade; `api/model_constants.py` holds the
|
||||
model, media and sidecar extensions they share. `api/model_type_listing.py` lists a
|
||||
whole models folder with its subfolders for the models page's type chips.
|
||||
- `api/scanner.py` and `scraper.py` own scan orchestration and enrichment; both scan
|
||||
routes map request fields to scraper switches in one place (`_scraper_flags`).
|
||||
`civitai_client.py` is the scraper's network side (version by hash, model page, media),
|
||||
telling "Civitai does not know the file" from "Civitai could not be asked" (and why).
|
||||
telling "Civitai does not know the file" from "Civitai could not be asked" (and why); it
|
||||
asks Civitai's other address (civitai.red) when civitai.com does not answer (`api_hosts`,
|
||||
also for import, Model Check and the network test).
|
||||
`net_proxy.py` is the proxy every request of the plugin goes through (Settings → Network,
|
||||
`NETWORK_PROXY` in api/config.json; empty = urllib's own: HTTPS_PROXY or the Windows proxy),
|
||||
handed to the scan process as HTTP(S)_PROXY, and the names of why a request failed;
|
||||
@@ -103,7 +106,20 @@ covered by the `styles.css` manifest.
|
||||
`api/scan_summary.py` counts models matched on Civitai, unmatched and not yet scanned
|
||||
for the scan page and lists the last two, by the same sidecar rules the scraper uses
|
||||
(`sidecar_info`, `is_unmatched`, `unmatched_reason` in `model_identity.py`); it also
|
||||
lists the model files in other formats, which scans never read, so the page can say so.
|
||||
lists the model files in other formats, which scans never read, so the page can say so,
|
||||
the models set to "don't scan", and the new ones whose header names no drawing model kind.
|
||||
It keeps the last summary (memory and user/anomalous/scan_summary.json) for the page to
|
||||
show at once, shares one count among the requests meanwhile, and lists the models found on
|
||||
Civitai on request (`GET /anomalous/scan_matched`).
|
||||
`api/model_heads.py` keeps what each model file's header says (model_kind.inspect_file)
|
||||
in user/anomalous/model_heads.json by real path, size and time, so a header is read once per
|
||||
file version, also across restarts; get_metadata reads headers through it.
|
||||
Models in component folders (VAE, text encoders, CLIP, CLIP Vision, upscalers) are counted
|
||||
and listed apart as well (`components`, `component_models`): many are not on Civitai.
|
||||
It also lists every model a scan reads for the scan settings' picker
|
||||
(`/anomalous/scan_pickable`), with size, cover and the card badge's metadata fields.
|
||||
`api/scan_choice.py` saves that choice (`POST /anomalous/scan_choice`, this computer only)
|
||||
into each model's `<model>.anomalous.json`.
|
||||
- `api/mcp_server.py` serves `/anomalous/mcp`, the Model Context Protocol endpoint AI apps
|
||||
connect to (Streamable HTTP; the 2026-07-28 stateless protocol and the earlier
|
||||
`initialize`-based ones; this computer only, Host and Origin checked against DNS
|
||||
@@ -134,6 +150,26 @@ covered by the `styles.css` manifest.
|
||||
name in ComfyUI-Manager's model list (`api/manager_catalog.py`), checked by the hash.
|
||||
`api/hf_card.py` gives a model downloaded from Hugging Face its model card's example image
|
||||
as cover and its trigger words and example prompts as notes (never over the user's).
|
||||
- `api/model_import.py` puts model files the user drops on the models page into the right
|
||||
models folder (`/anomalous/import/*`): finds the dropped file in this computer's Downloads
|
||||
or Desktop and moves it (or uploads a copy), tells what it is, checks it against Civitai
|
||||
and the scanned models, never replaces a file (" (2)").
|
||||
- `api/model_placement.py` is the models page's Tidy check (`/anomalous/placement/*`): models
|
||||
whose header names another type than their folder, identical files (by SHA-256), and moving
|
||||
a model with its sidecars to where it belongs.
|
||||
- `api/model_usage.py` is Tidy's "not used lately" (`GET /anomalous/usage/check`, this computer
|
||||
only, on a press): when each model and node pack was last used, from the run record, the
|
||||
PNG outputs' prompts (image_search's cached records, which keep the node types too), FLAC /
|
||||
Ogg sounds' prompts and the saved workflows, recipes, combos and parameter sets; nothing that
|
||||
came in the last 30 days is judged. Node packs come from ComfyUI's loaded packs, with whether
|
||||
they add to the page and which other packs take what only they make. Read with limits,
|
||||
deletes nothing. `POST /anomalous/usage/keep` (this computer only) keeps the packs the user
|
||||
says they still use, which no record can show, in `user/anomalous/usage_kept.json`.
|
||||
- `api/usage_ledger.py` is the run record behind it: ComfyUI's on_prompt hook (registered in
|
||||
the plugin's `__init__.py`) notes each queued run's node types and model files with the time,
|
||||
and `POST /anomalous/usage/opened` (this computer only, capped lists) what an opened workflow
|
||||
holds, apart from the runs, in `user/anomalous/usage_ledger.json`, saved a few seconds later;
|
||||
it never stops a run.
|
||||
- `api/workflow_schema.py`, `api/recipe_schema.py`, `api/recipe_images.py`, and
|
||||
`api/recipe_store.py` own recipe validation/shaping, images, CRUD, history, and
|
||||
integrity receipts; `api/recipes.py` is the HTTP facade. `api/recipe_constants.py`
|
||||
@@ -147,13 +183,16 @@ covered by the `styles.css` manifest.
|
||||
`api/node_material.py` saves one canvas node's current values as a
|
||||
`node_parameter_selection` material (the current-node panel's "save").
|
||||
- `api/local_guard.py` decides which requests only this computer may make (every change under
|
||||
`/anomalous/`; other devices may look), as one middleware; see backend.md.
|
||||
`/anomalous/`; other devices may look), as one middleware, and lets other devices change
|
||||
things too when the server has an `allow_remote_changes` file (cloud servers); see backend.md.
|
||||
- `api/media_routes.py`, `api/gallery_routes.py`, `api/translation_routes.py`, and
|
||||
`api/folder_types.py` own the formerly mixed utility route families.
|
||||
- `model_policies.py` owns shared backend rename and protected-category policy.
|
||||
- `model_identity.py` owns file SHA-256 evidence and the base model a safetensors header
|
||||
tells, shared with the standalone scanner (which runs as its own process, so the API
|
||||
never imports `scraper.py`).
|
||||
never imports `scraper.py`). `model_kind.py` reads a .safetensors / .gguf header (never
|
||||
formats that can carry code) and says which models folder the file belongs in and its base
|
||||
model; model import and the scanner's offline base model use it.
|
||||
- `recycle_bin.py` is the only way the plugin deletes the user's files (models, covers,
|
||||
outputs, recipes, materials, notes, audio): to the system Recycle Bin, together, or not
|
||||
at all (`TrashUnavailable` on drives without one). `api/trash.py` is the API's import of it
|
||||
@@ -183,6 +222,32 @@ covered by the `styles.css` manifest.
|
||||
switch refuses to run over modified tracked files. `ui_version_manager.js` is
|
||||
the version line (shown in the update guide opened by the header "!" button) and panel; nothing goes online until "check for updates"
|
||||
is clicked, and restarts go through ComfyUI Manager's reboot route when present.
|
||||
Every function takes an optional `Checkout` (another folder and its own state
|
||||
file): `api/tts_version.py` uses it for the companion node pack Anomalous_TTS,
|
||||
found among the custom_nodes folders by its node class and accepted only as a
|
||||
git clone whose `origin` is the official repository (`/anomalous/tts_version`,
|
||||
`/releases` local-only, `/update`, `/undo`; last switch in the ignored
|
||||
`.anomalous_tts_version.local.json`). `ui_tts_version.js` is its group in the
|
||||
GPT-SoVITS settings dialog: installed version, "Check for updates", update to
|
||||
the newest stable release or back, then the restart question.
|
||||
- Generation presets (Anomalous_TTS interface 16): `tts_presets_api.js` holds the
|
||||
calls (`/anomalous_tts/presets`) and pure helpers (the language pairs the
|
||||
characters can meet, the presets file written back with only what differs from
|
||||
the built-in values plus the presets made here (`custom_<id>` with a label),
|
||||
where a preset is used and taking it out, a dialog row's numbers in effect
|
||||
next to the general ones, a character's `defaults` / `defaults.by_language` as
|
||||
rows and back, keeping every other field, where a mode sits between steady and
|
||||
lively, a line to listen with in each language). `ui_tts_preset_switch.js`
|
||||
draws the modes as choices and the picked one against one fixed mode (the
|
||||
default preset, or Auto): a steady-to-lively scale, a sentence, and folded, the
|
||||
four numbers as the Current node card's bars, each with its plain name and the
|
||||
node's own name (`top_k` …), editable in place. `ui_tts_mode_preview.js` makes
|
||||
and keeps the takes for listening (one line, one seed, only the mode changing,
|
||||
through Preview Audio). `ui_tts_presets.js` is the group in the GPT-SoVITS
|
||||
settings dialog (that switch with a character and a line to listen with, make
|
||||
default / reset / new / rename / delete, rules per pair), saving each change at once; `ui_tts_tuning.js` is the
|
||||
per-character dialog behind the card's Generation settings button, shown only
|
||||
with interface 16.
|
||||
|
||||
- `web/main.js` coordinates extension registration (with `?v=...` versioned module imports busting aggressive browser ES Module caching and unconditional legacy storage key purging). `browser.js` owns the shared
|
||||
browser class and extracted-method wiring; `browser_entry.js` owns the single
|
||||
@@ -219,8 +284,11 @@ covered by the `styles.css` manifest.
|
||||
and `invalidateEngineCache({ rescan: true })` (Refresh button) also makes the node
|
||||
re-read its folders (`?refresh=1`).
|
||||
`ui_audio_tts_editor.js` edits a character's emotion references through that API:
|
||||
it opens at once, loads the character's audio list in the background (save waits
|
||||
for it), and keeps settings fields it does not know.
|
||||
it opens at once, loads the character's audio list and their lines
|
||||
(`/anomalous_tts/reference_lines`, interface 14; optional) in the background (save
|
||||
waits for the list), and keeps settings fields it does not know.
|
||||
`ui_tts_clip_search.js` is its clip search: by file name or line, the first 30
|
||||
matches with listen, use as main voice and add as emotion.
|
||||
`tts_setup_api.js` holds the other calls (pretrained downloads, chunked
|
||||
upload, inspect, commit, discard) and the pure import-form rules (`importKind`,
|
||||
`pickWeights`, `nameConflict`, `textFromFile`, `buildImportBody`, `importProblem`,
|
||||
@@ -302,13 +370,19 @@ covered by the `styles.css` manifest.
|
||||
dragged onto one (writing the character and script widgets together, one Ctrl+Z
|
||||
step) or copied. The page feeds it the voice groups after each fetch.
|
||||
It puts the cards on one side and generating on the other: beside
|
||||
them once the page is wide, below them when it is narrow (docked browser).
|
||||
them once the page is wide, below them when it is narrow (docked browser); on a
|
||||
wide page `ui_split_bar.js` is the divider between them (dragged, remembered).
|
||||
A card is one row (number, text, buttons); "Into your own workflow" is folded.
|
||||
`ui_script_run.js` is the director's "Generate" section (the bar with the result and
|
||||
a way to the audio gallery; language and speed in view, the sampling numbers folded
|
||||
under "fine-tuning" with plain names): it runs
|
||||
a way to the audio gallery; language, speed and the mode: Auto, or one preset for
|
||||
every line through the node's `preset` input, in the same switch measured
|
||||
against Auto (compact: scale and numbers folded) and heard from the ▶ on a
|
||||
line card (the takes heard stay on the side to compare), a preset's numbers
|
||||
changed there saved into the preset; links to the presets and to the
|
||||
character's generation settings): it runs
|
||||
the script without the canvas, plays the result, retakes the whole script
|
||||
(new seed) or one line (`[take:N]`, the node caches the rest), and saves a
|
||||
character's language, speed and folded sampling parameters to its `defaults`
|
||||
character's language and speed to its `defaults` (keeping the rest of them)
|
||||
(only values that differ from the node's own defaults, which it reads from
|
||||
`/object_info`; needs Anomalous_TTS interface 11).
|
||||
`ui_tts_pronunciation.js` edits a GPT-SoVITS character's pronunciation table
|
||||
@@ -333,13 +407,14 @@ covered by the `styles.css` manifest.
|
||||
copied into `output/audio/`.
|
||||
- `ui_sidebar.js` assembles the browser window (rail, list column, header, page panels;
|
||||
`remote_view.js` adds the header's "view only" chip when opened from another device)
|
||||
and renders the model folder list. `ui_shell_rail.js` owns the left icon rail: one
|
||||
and puts the model folder column in the list column (`ui_folder_list.js`: the folders of the
|
||||
type the chips show, counts with subfolders, 📂 to open one in Explorer). `ui_shell_rail.js` owns the left icon rail: one
|
||||
entry per page, the tool slots and the settings slot. `ui_shell_nav.js` owns page
|
||||
navigation (`goTo`): the domain each page needs, which pages have a list and whether
|
||||
it is open (remembered per page; closed and overlaying below 760 px), the header's
|
||||
page title, and the page reopened next time. `ui_home.js` owns the home page (task
|
||||
cards, first steps). `ui_shell_frame.js` owns dragging, resizing and keeping the
|
||||
floating window on screen; `ui_scan_watch.js` polls scan status for the rail's scan
|
||||
floating window on screen, and the full-window button (double-click on the header too); `ui_scan_watch.js` polls scan status for the rail's scan
|
||||
button, the progress panel and the model reload afterwards.
|
||||
The activity log (docs/decisions AD-018): `api/activity_log.py` keeps the entries
|
||||
(newest first, bounded, in the ComfyUI user folder), records this plugin's and
|
||||
@@ -355,9 +430,11 @@ covered by the `styles.css` manifest.
|
||||
removed nodes or rewired existing ones are never offered).
|
||||
`ui_settings_hub.js` owns the display preferences (view mode, scale, atmosphere, window
|
||||
layout) and their application, the gear and language redraws; `ui_settings_page.js`
|
||||
is the settings page the gear opens (a tool page with Back): look and language, model
|
||||
cards and memory with the card image cache, folders, workflow fingerprints, downloads,
|
||||
backup (`ui_backup.js`), opening mode and window, help; `ui_feedback_dialog.js` is the feedback window (Home and the
|
||||
is the settings page the gear opens (a tool page with Back), in tabs: Look (look and
|
||||
language, model cards and memory with the card image cache, opening mode and window),
|
||||
Models (folders, where downloads and imports go), Workflows (fingerprints), Node packs
|
||||
(those not used lately, `ui_pack_usage.js`), Prompts
|
||||
(translation), Backup (`ui_backup.js`), Help; `ui_feedback_dialog.js` is the feedback window (Home and the
|
||||
settings page open it): one text box, then `feedback.js` opens a GitHub issue with it in
|
||||
the browser's language, the environment folded at the end when attached (versions and
|
||||
hardware only, never a path or ComfyUI's command line), or copies the environment, and
|
||||
@@ -366,18 +443,27 @@ covered by the `styles.css` manifest.
|
||||
`ui_browser_navigation.js` owns shared panel hiding/cleanup and workspace return,
|
||||
including Esc on the workspace panel (`nbPanel`, below the header and right of the
|
||||
rail; the list column steps aside while it is open).
|
||||
`ui_scan_page.js` is the scan page (a tool page like the doctor): counts, the scan
|
||||
button, a card for how the next scan goes (opening the scan settings page), the last
|
||||
scan's summary; each count and the summary open a
|
||||
`ui_scan_page.js` is the scan page (a tool page like the doctor), quieter the further
|
||||
down: the scan card (a band of covers with an empty frame per model not scanned yet, what is left, the scan button with "pick
|
||||
models to scan" beside it, online or offline, what the scan will do in one sentence), the
|
||||
models' state as a bar and chips (the components on a chip of their own), what needs a look,
|
||||
the settings (`ui_scan_settings.js`: the scan options as tiles, the two that change files
|
||||
folded under "more"), the last scan in one line; icons from `scan_icons.js`. Each state chip,
|
||||
the components and the last scan open a
|
||||
list page from `ui_scan_lists.js` (not scanned, unmatched, other formats, the last scan's
|
||||
models) with Back, a button for the whole list (scan these / look them up again) and rows
|
||||
that open a model, whose Back returns to the list, or scan it;
|
||||
`scan_results.js` holds the words for scan results, shared with the activity page;
|
||||
`net_failure.js` the words for why Civitai could not be reached (net_proxy.py's codes), for
|
||||
the scan results and Settings → Network's connection test.
|
||||
`ui_scan_picker.js` is the scan page's model picker: every model a scan reads, by folder,
|
||||
state (not scanned, not found, damaged, found, don't scan) and search, as a list or covers;
|
||||
the page keeps its picks as items (none: the button scans everything).
|
||||
`scan_runner.js` starts and follows scans (every folder, picked or listed models, or one
|
||||
model from its card), shows the result and refreshes node drop-downs, hashes and the
|
||||
grid afterwards; folder visibility/order lives in
|
||||
grid afterwards; before an online scan `scan_connection.js` checks whether Civitai can be
|
||||
reached and, when not, asks: scan from the files, set a proxy, or not now;
|
||||
folder visibility/order lives in
|
||||
`ui_folder_manager.js`.
|
||||
`scan_progress.js` owns the scan progress panel
|
||||
(`updateScanProgress` / `finishScanProgress` / `failScanProgress`): inside the scan
|
||||
@@ -386,16 +472,27 @@ covered by the `styles.css` manifest.
|
||||
shortcuts (the second keeps its old "materials" ids), their fallback when ComfyUI's keybinding does not fire, and the
|
||||
settings control that opens ComfyUI's keybinding editor.
|
||||
- `ui_model_sources.js` renders the Models page's Sources view (the last type chip): where each model is downloaded, for the open workflow or every model, with each link editable and saved in place; `model_source_links.js` owns its data and actions (collecting the workflow's models, resolving them here, saving a link to the model's information and the workflow, the canvas note and the clipboard list).
|
||||
- `ui_gallery_card.js` builds one output's gallery card (viewer, workbench, cover pick, drag, star, delete; a video's card
|
||||
- `ui_gallery_card.js` builds one output's gallery card (viewer, workbench, cover pick, drag, star, compare, delete; a video's card
|
||||
plays in the viewer and has no workbench, star or menu); `gallery_filter.js` holds what the gallery lists (All / Pictures /
|
||||
Videos and the formats hidden in Settings, per browser) and `ui_gallery_filter.js` draws both controls;
|
||||
`gallery_card_menu.js` is its right-click menu (insert as a Load Image node reading the output file, parameters);
|
||||
`gallery_card_menu.js` is its right-click menu (insert as a Load Image node reading the output file, parameters),
|
||||
drawn by `action_menu.js` (a small menu at the pointer or under a button);
|
||||
`ui_param_compare.js` compares parameters: a card's ⇄ picks an image, a second image's ⇄ or
|
||||
the canvas completes the pair (the image workbench's ⇄ takes the canvas; the Gallery's and
|
||||
Activity's ⇄ Canvas vs. newest picture pair the newest output PNG with it), and its window says
|
||||
whether both are the same node structure and lists each value that differs (prompts by the tags
|
||||
only one side has, seeds apart), writing ticked image values onto the canvas as one Ctrl+Z
|
||||
step with Undo; `param_compare.js` reads and compares the two sides without DOM.
|
||||
`key_params.js` keeps the starred key parameters (node type + widget name, a hidden ComfyUI
|
||||
setting); the window shows them first. `ui_param_set_keys.js` puts parameter sets in view: a
|
||||
recipe card's ▤ N chip (first in its specs line) (`GET /anomalous/parameters/signatures`; sets equal to the recipe not counted) opens its Parameters tab,
|
||||
and each set there shows its key values, how many values differ from the recipe, and ⇄.
|
||||
`ui_keep_menu.js` is the star's keep menu (also the workbench's Keep): the whole workflow, the combo or the prompts,
|
||||
each saying where it goes, opening what was kept already or removing it (to the Recycle Bin through the recipe,
|
||||
notebook or material delete route); `image_keep.js` does the keeping (an image as a combo,
|
||||
its prompts as a saved prompt, reading what was kept) without DOM.
|
||||
- `ui_combos.js` renders the Combos page's list (搭配: a main model, LoRAs and a prompt, formerly Prompt Notes): search,
|
||||
New, and a card per combo with its model's cover and Put on canvas; a card opens the combo's editor, and dragged onto the
|
||||
? (what a combo is), New, and a card per combo with its model's cover and Put on canvas; a card opens the combo's editor, and dragged onto the
|
||||
canvas (`material_drag.js`) becomes a new group of nodes where it is dropped. Combos keep the notes' files.
|
||||
A combo can instead hold a node structure (`data.kind === 'nodes'`): `combo_structure.js`
|
||||
captures the canvas's picked nodes (subgraphs aside) with the links between them and puts
|
||||
@@ -404,7 +501,11 @@ covered by the `styles.css` manifest.
|
||||
from unpicked nodes noted to wire);
|
||||
`combo_slots.js` holds its rules that need no canvas; `ui_combo_structure.js` is the save
|
||||
dialog, the slot editor, the slot model picker and "New"; `ui_translation_peek.js` is the
|
||||
read-only translation panel beside its prompts.
|
||||
read-only translation panel beside its prompts. `lora_nodes.js` knows the LoRA nodes a default
|
||||
combo puts its LoRAs in (ComfyUI's chain, rgthree's Power Lora Loader, Easy-Use's LoRA stack, the
|
||||
third-party ones only when installed) and a Power Lora Loader's rows (for structures, and as
|
||||
drop-down-like boxes for Model Check); `ui_lora_node_choice.js` is the default combo editor's
|
||||
choice among them.
|
||||
- `detached_graph.js` lays a workflow on a graph of its own, off the canvas, under an id of its own: ComfyUI keeps widget
|
||||
values by graph id, so a copy under the canvas's id would write its values onto the canvas.
|
||||
- `recipe_save.js` saves Workflow Recipes for the canvas save and for an output image's workflow (the gallery's keep menu,
|
||||
@@ -422,40 +523,69 @@ covered by the `styles.css` manifest.
|
||||
- `ui_model_types.js` owns the models page's type chips (one per models folder, with its count) and
|
||||
`owner.modelScope`, what the grid lists: a whole type, or one list folder shown as a crumb. The
|
||||
grid's cards set `currentType/PathIdx/Subfolder` to their own model's folder (`focusModel`),
|
||||
which the editor, the scanner and "add to canvas" read. `ui_model_search.js` is the models
|
||||
which the editor, the scanner and "add to canvas" read. Its Import chip and files dropped on
|
||||
the grid open `ui_model_import.js`, the import window (one card per file: type, base model,
|
||||
where it goes, warnings, progress); `model_import.js` reads each file's header and asks,
|
||||
moves or uploads it, then scans the new files. The page's tabs (All models, Tidy with how
|
||||
many things it found, Sources) head the grid; under All models the type chips and the base
|
||||
model chips (`owner.modelBase`; `model_bases.js` names each model's family from its scanned
|
||||
base model or its header's guess). The Tidy tab is `ui_model_tidy.js` (`owner.modelView ===
|
||||
'tidy'`): cards with covers in two steps — models in a folder whose loader cannot read them
|
||||
(from → to, Move there), identical copies (pick the one to keep, recycle the rest);
|
||||
`model_placement.js` does its requests and keeps the tab's count. Under the two steps
|
||||
`ui_model_usage.js` lists the models not used for 30 / 90 days or never (picked ones go to
|
||||
the Recycle Bin) and leads to Settings → Node packs, where `ui_pack_usage.js` lists the node
|
||||
packs: each opens ComfyUI Manager's node list searched for it (Manager 3; its name is copied
|
||||
for the rest); packs that also add to the page are set apart, those marked "I still use it"
|
||||
listed last. `usage_view.js` is what both share: the check, kept in `owner.modelUsage`, and
|
||||
each page's head (groups, what the run record vouches for). `usage_notes.js` posts what each workflow ComfyUI opens holds (its own
|
||||
`loadGraphData` wrapper, once per content and page load) and the open tabs just before a
|
||||
check, so opening a workflow or putting a node on the canvas counts as use. `ui_model_search.js` is the models
|
||||
search in the header (shown on the models page through the shell's `data-page`): the grid
|
||||
lists the models of its type matching every word (`owner.modelQuery`).
|
||||
- `model_source.js` shows where a model's information came from: the card badge (marked only
|
||||
when inferred from the file, ≈, or not scanned yet) and the detail header's source line with
|
||||
the fields the user set.
|
||||
when inferred from the file, "推断", or not scanned yet), the details' source line with
|
||||
the fields the user set, its links (source link, Civitai) and, for a model Civitai
|
||||
does not know, the note on what was read from the file.
|
||||
- `ui_grid.js` and model-detail modules own model presentation: `ui_grid.js` manages chunked card rendering,
|
||||
card placeholder ergonomics (eliminating misleading unclickable text in favor of pure centered icon and status),
|
||||
card action buttons (one-click canvas addition with plus icon, model metadata editor, direct precision scanner without wizard modal popups)
|
||||
with absolute positioning cascades immune to tooltip target conflicts, vibrant hover affordance,
|
||||
safe docked sidebar preservation upon node addition, and multi-type node dispatch; `ui_detail.js`
|
||||
coordinates detail display (the file card with Show in folder in `model_location.js`), `ui_model_editor.js` owns metadata editing, and
|
||||
`ui_model_selector.js` owns advanced selection. `ui_gallery.js` (cards in
|
||||
coordinates detail display (cover column, then the head from `model_detail_head.js`: back crumb, name, source line,
|
||||
Add node / Edit / Folder / Outputs; the foot with the path, Copy and Delete, and Show in folder, in
|
||||
`model_location.js`), `ui_model_editor.js` owns metadata editing (cover, name, source link, notes;
|
||||
styles `styles/31-model-info.css`), and
|
||||
`model_widget_path.js` puts a model path into a loader's widget. `ui_gallery.js` (cards in
|
||||
`ui_gallery_card.js`, filters in `gallery_filter.js` / `ui_gallery_filter.js`) and `ui_gallery_detail.js` own generated-output browsing and workbench lifecycle,
|
||||
with stage interaction in `ui_image_stage.js` and metadata tabs in
|
||||
`ui_image_inspector.js`.
|
||||
- `ui_recipes.js` / `ui_recipe_detail.js` and `ui_notebooks.js`
|
||||
own their respective workspace surfaces and persistence flows. `ui_recipes.js` owns
|
||||
the Workflow Recipe studio catalog workspace with search/filter tags, grid/list layout toggle,
|
||||
dedicated top-right modal close anchor (permanently decoupled from the tool button row to prevent wrapping displacement),
|
||||
streamlined action header (preserving active workflow saving while pruning unfinished package
|
||||
import entrypoints), and card browsing. `ui_notebooks.js` owns the Combos workspace (its list or one combo's editor,
|
||||
own their respective workspace surfaces and persistence flows. `ui_recipes.js` hands the
|
||||
Workflows page (its catalog) the recipe services (save, edit, fetch) and opens a recipe by name. `ui_notebooks.js` owns the Combos workspace (filling the page; its list or one combo's editor,
|
||||
with Back to the list) and combo persistence; `ui_notebook_editor.js` owns unfolded card editing (modularized into single-responsibility
|
||||
sub-functions adhering to the 50-line rule: sticky top action toolbar with floating More popover dropdown and timed two-step delete safety guard,
|
||||
unfolded companion models card with unconstrained multi-column tile flow eliminating nested gallery scrollbars,
|
||||
prompt composer with dynamic field-sizing and compact inline find & replace toolbar, and unified dark slim scrollbar ergonomics with complete bilingual dictionary coverage in `locales.js`), and
|
||||
`notebook_canvas.js` puts a combo on the canvas as a new wired group of nodes (following the pointer, or at a dropped
|
||||
card's position; Esc takes a following group off again); it never changes nodes already there. Combos are a rail page. `ui_recipe_detail.js`
|
||||
coordinates the Workflow Recipe detail session and model composition. `ui_recipe_overview.js`
|
||||
owns the Overview prompt showcase (with `entry.text` fallback, guarded non-shrinking primary action CTA, and floating Popover More dropdown menu), and `ui_recipe_parameters.js`
|
||||
owns the responsive Parameter Presets workspace (featuring default-expanded raw node parameter inspection,
|
||||
a `clamp(230px, 24vw, 290px)` sidebar with guarded card actions, uncluttered console action bars with deferred status feedback,
|
||||
`minmax(130px, 1fr)` Bento Grid with universal click-to-copy, LoRA cards with flexbox truncation guards,
|
||||
and sticky editor headers).
|
||||
is a workflow's details, built around its values (styles `styles/29-workflow-detail.css`): a bar
|
||||
(Back, the name, the models' state, Open, ⋯ edit and export), then `ui_recipe_sets.js`'s left
|
||||
column (the workflow's own values, the saved sets unlike them with their key values, Save the
|
||||
canvas, New; then all pictures and versions) and the chosen view on the right.
|
||||
`ui_recipe_set_view.js` is one set: Apply to canvas, ⇄ (with the workflow's own or the canvas),
|
||||
⋯ (copy as a new set, rename, delete); the cover, notes and tags for the workflow's own
|
||||
(`ui_recipe_info.js`, which also holds the name and the models' state); the workflow's focus
|
||||
values (重点参数: `recipe_focus.js` keeps them in the recipe's params and tells comparisons which
|
||||
values they are, `ui_recipe_focus.js` draws them with their notes and the ☆ on each node
|
||||
value); the main values; the
|
||||
prompts; the pictures made with these values; the models (the fold says whether all are here)
|
||||
and every node's values, folded, each opening as the viewer last left it (this browser's
|
||||
storage); a tip while the workflow has no set, and one pointing at ⇄ on a set until it is used.
|
||||
Narrow (docked) details turn the left column into one row of pills (a container query). A new
|
||||
set is named after the workflow with the next number (`nextSetName`). `ui_recipe_parameters.js`
|
||||
holds what the views share: prompt roles (automatic or set by hand) and their boxes, every node's
|
||||
values (unnamed ones labelled by their node type's widgets, `comfyWidgetLabels`), the new set's editor.
|
||||
`prompt_boxes.js` is the one place that finds prompt boxes on the live canvas: a
|
||||
multiline STRING input by the node's ComfyUI definition, whatever it is called; its
|
||||
role is the box's own name (positive / negative) or else the wiring (outputs followed
|
||||
@@ -467,20 +597,31 @@ covered by the `styles.css` manifest.
|
||||
extracts prompt envelopes without model file paths and writes them (`fillPrompt`);
|
||||
text never crosses roles unless the user picked the box; writes are one undo step.
|
||||
Within recipe detail, `ui_recipe_versions.js` owns history comparison/restore,
|
||||
`ui_recipe_gallery.js` owns result cards and direct Image Detail Workbench handoff,
|
||||
`ui_recipe_gallery.js` owns the picture grid (thumbnails, 参数, ⇄ to compare) and the all-pictures view,
|
||||
`ui_recipe_models.js` owns the overview's model list (preview, presence under the saved name,
|
||||
note, download page; finding missing models is Model Check's) and the recipe cover,
|
||||
`ui_recipe_metadata.js` owns inline persistence, and `ui_recipe_detail_dom.js` owns
|
||||
the DOM/copy helpers shared by detail subviews. `recipe_identity.js` derives model
|
||||
references from native loaders plus a table of verified all-in-one loader layouts
|
||||
(`ALL_IN_ONE_LOADER_SPECS`, mirrored in `api/recipe_schema.py`); other third-party
|
||||
widgets stay parameters. References are keyed by
|
||||
(`ALL_IN_ONE_LOADER_SPECS`) and two stack nodes by their rows (rgthree's Power Lora Loader,
|
||||
Easy-Use's LoRA stack; `nodeModelValues`), all mirrored in `api/recipe_schema.py`; other
|
||||
third-party widgets stay parameters. References are keyed by
|
||||
`(node_id, widget_index, category, saved_value)`. `recipe_parser.js` summarizes
|
||||
models, LoRAs, samplers and prompts (linked prompt nodes before embedded loader
|
||||
prompts) and keeps the summary in step with widget edits using the same node rules.
|
||||
`ui_recipe_catalog.js` owns recipe
|
||||
filters, navigation, dismissible topbar drag guidance strip with localStorage persistence, the 3-step empty-state onboarding blueprint (`renderRecipeEmptyGuide`), and background catalog-wide model readiness resolution (`resolveCatalogRecipeReadiness`), `ui_recipe_cards.js` owns cards and card actions
|
||||
(including `grab` drag affordance, cover `可拖拽` badge, harmonized multi-state model readiness pill with `getRecipeReadiness` synchronizing available, missing, and pending matches with detail overview, and direct canvas drag-and-drop), `ui_recipe_dialogs.js` owns save/edit dialogs, and `ui_recipe_media.js` owns shared cover helpers. Detail sessions synchronize detected model availability back to `owner.recipeRecords` via `syncRecipeReferencesToCatalog`.
|
||||
prompts) and keeps the summary in step with widget edits using the same node rules; which
|
||||
LoRAs a node loads is `recipe_loras.js`, and reading a node's boxes `recipe_widgets.js`.
|
||||
`ui_recipe_catalog.js` is the Workflows page: one bar (search, the filter menu with kind and tag,
|
||||
the count, grid or list, share and import, save the canvas), the list, the empty page's three-step
|
||||
guide (`renderRecipeEmptyGuide`) and the catalog-wide model readiness check
|
||||
(`resolveCatalogRecipeReadiness`). `ui_recipe_cards.js` is one card: the cover with the models'
|
||||
state (`getRecipeReadiness`: a dot, or words when some are missing) and 功能模块 for a partial
|
||||
workflow, the name, a line of steps, sampler, LoRAs and size, then its model; Open and ⋯ (edit, export, delete)
|
||||
as two small buttons in the cover's corner on hover; a press anywhere else opens the details, a drag opens it on
|
||||
the canvas. Opening a workflow on the canvas hides the browser and leaves the Workflows list in place. Its styles are
|
||||
`styles/28-workflows.css`. `ui_popup_menu.js` is the small menu under a button they share,
|
||||
`ui_recipe_save_dialog.js` is saving the canvas as a workflow (the cover chosen on the left from the canvas,
|
||||
the current cover or recent pictures as thumbnails; name, tags, notes and the fingerprint choice on the right;
|
||||
the name defaults to the canvas's saved workflow name), `ui_recipe_dialogs.js` the edit dialog, and `ui_recipe_media.js` owns shared cover helpers. Detail sessions synchronize detected model availability back to `owner.recipeRecords` via `syncRecipeReferencesToCatalog`.
|
||||
- `ui_prompt_composer.js` owns the standalone Prompt Studio drawer (dock side, width, Esc);
|
||||
`ui_prompt_workbench.js` fills it: the top bar (tags or text view, the meanings' language) and
|
||||
saving a box as a card. `ui_prompt_target.js` decides what is edited, the prompt boxes of
|
||||
@@ -491,7 +632,9 @@ covered by the `styles.css` manifest.
|
||||
`prompt_gloss.js` looks up and keeps the tags' meanings in the picked language. `ui_prompt_source_deck.js`
|
||||
owns the card list (three built-in cards and the saved prompts), search, new-card form,
|
||||
library sync and renaming / deleting a saved prompt; `prompt_material_source.js` reads
|
||||
saved prompts as cards and saves one; `ui_prompt_card_popover.js` owns the card preview (hover
|
||||
saved prompts as cards and saves one; `ui_prompt_reference.js` is the reference column a
|
||||
clicked card opens in, between the cards and the boxes (its tags taken one by one or all, ticks
|
||||
on those the box has; it widens the drawer while open, and takes the cards' place where three columns do not fit); `ui_prompt_card_popover.js` owns the card preview (hover
|
||||
corridor, pin, Copy / Add / Rename / Delete); `prompt_card_drag.js` lets a card or a box be
|
||||
dragged out of the drawer onto a canvas prompt box or empty canvas (through `material_drag.js`
|
||||
and `prompt_drop.js`), passing through inside the drawer so the boxes still take it.
|
||||
@@ -501,7 +644,8 @@ covered by the `styles.css` manifest.
|
||||
cancellation and resize cleanup. Translation requests go through `translation_service.js`.
|
||||
- `ui_dialog.js` owns the plugin's own alert / confirm / prompt dialogs
|
||||
(`anomalousAlert`, `anomalousConfirm`, `anomalousPrompt`), used instead of the
|
||||
browser's native ones. `ui_prompt_toast.js` is the short toast (optionally with an
|
||||
browser's native ones; an alert can carry a line to copy (`copy`), and `copyText`
|
||||
also works on a page served over plain http. `ui_prompt_toast.js` is the short toast (optionally with an
|
||||
action, such as Undo after a drop) shared across the plugin.
|
||||
- `canvas_history.js` asks ComfyUI's Ctrl+Z history for a step after each canvas write
|
||||
Anomalous makes (applied values, drops, created or inserted nodes, recipes): ComfyUI
|
||||
@@ -511,7 +655,11 @@ covered by the `styles.css` manifest.
|
||||
need a workflow-carried hash before Model Check recovers them.
|
||||
- `ui_dom.js` provides small DOM/JSON helpers; `material_inspector.js` owns the image
|
||||
workbench's metadata and node-parameter rendering.
|
||||
- `model_check.js` checks the open workflow's models against this computer (Model
|
||||
- `ui_first_run.js` is Home's first-run setup while nothing is scanned (check that Civitai answers
|
||||
through `api/network_check.py`, the optional key, a scan of every model, the tour); a card, never
|
||||
a popup, hidden per browser.
|
||||
- `model_check.js` checks the open workflow's models (drop-downs naming model files, and
|
||||
switched-on Power Lora Loader rows through `lora_nodes.js`) against this computer (Model
|
||||
Check's verdicts, no DOM; the feature was called Model Doctor, and code and CSS still
|
||||
say "doctor") and puts a found file into its node; `ui_doctor.js` is the
|
||||
Model Check page and `ui_doctor_banner.js` the bar over the canvas when an opened workflow
|
||||
@@ -523,10 +671,15 @@ covered by the `styles.css` manifest.
|
||||
is the page's Download buttons, progress and the dialog that shows and changes where
|
||||
each file goes. `ui_node_assistant.js` owns the current-node panel (model actions, LoRA
|
||||
insertion, model cards and history), `ui_node_model_picker.js` owns native combo
|
||||
replacement, `ui_node_parameters.js` renders the panel's parameters section, and
|
||||
replacement, `ui_node_parameters.js` renders the panel's parameters section (each saved
|
||||
set as a card with the picture made with it and where it came from, values as bars or
|
||||
old → new, or all sets side by side), and
|
||||
`node_parameter_sets.js` merges the node type's saved values (material files and recipe
|
||||
parameter sets) and works out what each would change on the node, keeping seeds,
|
||||
model files and missing choices; saved values (not a recipe's) are deleted there. `model_picker.js`, `node_material_actions.js`, and `graph_splice.js`
|
||||
parameter sets, each source with its origin) and works out what each would change on the node, keeping seeds,
|
||||
model files and missing choices, and asks `/anomalous/parameter_pictures` for the recent pictures made with
|
||||
the sets' values; saved values (not a recipe's) are deleted there. It also keeps, in this
|
||||
browser's storage (`anomalous_applied_sets`, the newest 100 by workflow and node), which set
|
||||
was last put into a node, for the panel's line on where the values came from. `model_picker.js`, `node_material_actions.js`, and `graph_splice.js`
|
||||
own the remaining explicit graph changes.
|
||||
- A model's download link lives in its user layer (`source_url` in `<model>.anomalous.json`, via
|
||||
`/anomalous/update_metadata`), the same field the model editor edits; for the open workflow's
|
||||
|
||||
@@ -1,5 +1,153 @@
|
||||
# 📈 Anomalous Model Browser Changelog
|
||||
|
||||
## v1.60.2 (Fix: the browser opens whatever the plugin folder is called) — 2026-10-25
|
||||
|
||||
### 💬 From a Bilibili comment (来自 B 站评论)
|
||||
- **The browser opens when the plugin folder has another name (插件文件夹改了名也能打开)**: a plugin unpacked from a ZIP of GitHub sits in `Anomalous_Model_Browser-main`; the browser's look did not load there, so a click on **Model Browser** seemed to do nothing (the browser lay under the canvas, without its look) and that button showed in the top bar although the floating button was chosen. The plugin now finds its files under whatever name its folder has. Updates to the plugin also reach the page at once there again, as with the usual name.
|
||||
|
||||
## v1.60.1 (Cloud Servers, Generation Presets, Full Window, Faster Scan Page) — 2026-10-22
|
||||
|
||||
### 🔍 Scanning (扫描)
|
||||
- **The scan page opens at once, also with many models**: it shows the last counts right away, says it is updating them, and shows the fresh ones when they come. What each model file's header says is now kept on disk (`user/anomalous/model_heads.json`, by file, read again only when the file changes), so a big library or a network drive is not read again after every restart either; requests that come while the page counts share that one count.
|
||||
- **See the models found on Civitai**: the scan page's "Found on Civitai" count opens a list of them, with each model's Civitai name and base model, a box to find one by name, and a way to open each.
|
||||
- **Before an online scan, the plugin checks whether Civitai can be reached**, on both its addresses. When it cannot, it asks instead of waiting on every model: **Scan from the files** (base models found, no covers or trigger words; a later scan fills them in), **Set a proxy** (opens Settings → Models → Network), or Cancel. A check that reached Civitai is trusted for five minutes.
|
||||
- **Civitai's other address (civitai.red) when civitai.com does not answer**: some networks reach only the second, a cloud server in mainland China among them (tested on AutoDL: civitai.com timed out, civitai.red answered in about a second). Scans, import and Model Check's lookups and **Settings → Network → Test** ask civitai.red then, so models still get their names, trigger words and base models; the test says which address answered. The covers are on Civitai's picture server, which such networks often cannot reach either: after two covers in a row that would not download, a scan fetches no more covers instead of waiting on each.
|
||||
|
||||
### 🧩 Combos (搭配)
|
||||
- **A combo's models are cover cards**, as in the model library: the cover on top, the name below. In a combo saved from picked nodes, the slot (Main model, LoRA…) is written on the cover and a click on the card changes the model; in a default combo, the main models and LoRAs to pick from are cover cards side by side. They used to be rows as wide as the window, with **Change** far from the name (模型卡片不再横向拉满整个窗口).
|
||||
|
||||
### 🎙️ Character voices (角色语音)
|
||||
- **Update Anomalous_TTS from GPT-SoVITS Settings (在 GPT-SoVITS 设置里更新 Anomalous_TTS)**: a new *Anomalous_TTS version* line shows the installed version; **Check for updates** looks up its published stable releases (only then does it go online), **Update to v…** switches to the newest one and offers to restart ComfyUI, and **Back to v…** undoes it. Anomalous_TTS now publishes numbered stable releases (the first is v1.0.0), so an update never lands on unfinished work. Only an Anomalous_TTS installed with git from its GitHub repository is changed (as ComfyUI Manager installs it), only from the computer ComfyUI runs on, and never over files you changed. When an import needs a newer Anomalous_TTS, the message points here.
|
||||
|
||||
- **Generation presets (生成预设)**: GPT-SoVITS Settings has three presets, **Steady** (fewer slips, flatter), **Standard** (the old default) and **Lively** (livelier, slips now and then): pick the default one for every character, and one for each pair of languages, such as a Japanese character speaking Chinese (until now every such line was made steadier without asking; that is the rule you start with). With nothing changed, voices sound exactly as before.
|
||||
- **Presets you can hear and compare (听得到、比得出的预设)**: pick a preset and see where it sits between *steadier* and *livelier* next to your default (a ring for the default, a dot for the one picked), with a sentence such as "a little livelier than your default (Standard)". **▶ Listen to this one** has a character read a line with that preset; every take uses the same seed, so only the preset changes, and the presets already heard stay as buttons to click back and forth. Folded under it are the numbers as bars against your default, the way the Current node card shows a change (old → new, ↑ ↓); each has its plain name and the usual one beside it (`top_k`, `top_p`, `temperature`, `repetition_penalty`), so values from the GPT-SoVITS WebUI are easy to match. Click a number to change it; a changed built-in number shows its built-in value, and **Reset** puts it back. **Make default** sets the preset every character uses. **+ New** makes your own preset from a copy of the one picked, with a name of your own (such as 念旁白 for narration); it can be picked everywhere the built-in ones can, renamed, and deleted (the question says what uses it; those places go back to the general setting).
|
||||
- **Modes on Voice-over (配音时直接选模式)**: under *How it is read*, **Auto** (the presets and the character's own settings decide, line by line) or any preset for the whole script, shown against Auto the same way and heard from the **▶** on each line: it reads that line in the mode picked, and the modes already heard on it stay on the side as buttons to compare. The scale and the numbers fold away there, so the narrow side stays short. A preset's numbers can be changed right there and are saved into that preset. Links lead to the presets and to the character's generation settings. This replaces the folded fine-tuning numbers, which showed the node's defaults rather than what the presets used; **Save as default** now keeps the language and speed only.
|
||||
- **A character's own generation settings (角色特调)**: a new **Generation settings** button on each character card sets the preset and speed for the character as a whole and for each language it speaks, with the numbers themselves when a preset is not enough. Under each row are the numbers its lines will get, blue where they differ from the general presets. The node follows these settings too, so a workflow in ComfyUI sounds like generating here; numbers changed on the node itself still go first. Needs Anomalous_TTS v1.1.0 (update it from the same dialog).
|
||||
|
||||
- **More room on Voice-over (配音页更宽敞)**: each line is one row (number, text, its buttons), so more lines fit; a bar between the lines and the generating side can be dragged to share the width (remembered; double-click for the default, about half each); *Into your own workflow* is folded to one line; who reads and their voices share one row, and the how-to line shows only until there are lines. When the side is wide enough, the modes' scale and numbers are shown open.
|
||||
|
||||
### 🪟 The window (窗口)
|
||||
- **Full window (全屏)**: a new button beside *Dock* makes the plugin fill the whole ComfyUI page, and turns it back; a double-click on the title bar does the same. It is remembered.
|
||||
|
||||
### ☁️ ComfyUI on a cloud server (云服务器上使用)
|
||||
- **How far it goes**: fully supported on overseas cloud servers (RunPod…). On servers in mainland China (AutoDL…) only in part: browsing, offline scans and tidying work, but Civitai is mostly out of reach there, so lookups are unreliable and covers rarely download (海外服务器完整支持;国内服务器只能部分支持). Not fully tested yet: one AutoDL server so far, no overseas one. The cloud guide ends with a section for AI assistants (what is going on, the steps, how to check, what to do when it did not work, what to tell the user about safety), so a user can hand the link to their AI.
|
||||
- On a rented cloud server (AutoDL, RunPod…) the page is always opened from another device, so since v1.58.1 the plugin only let it look: scans, downloads, moves and saves were refused. Now there are two ways: open ComfyUI through an **SSH tunnel** (the address starts with 127.0.0.1; it counts as the computer ComfyUI runs on, nothing to change), or make an empty file named **`allow_remote_changes`** in the plugin folder on the server and restart ComfyUI. With the file, every device that can open the page may change things; the page can neither make nor remove it, and the MCP endpoint stays for this computer only. How, and the risk: [docs/guides/cloud-server.md](docs/guides/cloud-server.md).
|
||||
- The **View only** chip beside the page title can be pressed: it says what is refused and the two ways above, with a **Copy** button for the one line to paste into the server's terminal (the plugin's real path on that server already filled in), so there is no folder to find. With the file there it reads **Remote changes on**, in yellow, and its dialog gives the line that turns it off again.
|
||||
- **Models that are links (模型是快捷方式/链接时)**: on a cloud server such as AutoDL every model in the models folder is a link into the platform's shared model disk. Scans wrote the info beside the link, but the cards read it from the disk behind it: a scanned model showed as not scanned, under the disk's file name (`6b4679…`) instead of its own, and editing, deleting, opening its folder or finding it from a saved workflow could be refused as "outside the models folder". A linked model is now taken as named, with its covers and info beside the link; edits, renames and deletes act on the link, never on the shared disk; moving one in Tidy moves the link instead of copying the model disk's file onto the server. Names leaving the folder (`..`) are refused as before.
|
||||
- **Models linked from another tool keep their info**: a model that is a link with no info beside it shows the info beside the file it links to (A1111's Civitai Helper leaves `.civitai.info` there). Only read there; covers, edits and scans stay beside the link.
|
||||
|
||||
## v1.60.0 (Model Import, Tidy, Parameter Compare, Workflows Rebuilt, Base Model Filter, Settings Tabs) — 2026-10-22
|
||||
|
||||
### 📥 Drop a model in, it goes to the right folder (拖进来自动归位)
|
||||
- **Models page → + Import**, or drop model files anywhere on the models page: each file gets a card saying what it is — Checkpoint, LoRA, VAE, text encoder, diffusion model, ControlNet, Embedding, CLIP Vision, upscale model — with its base model, how sure that is, and one line on what such a model is for. It is read from the file's header only; .ckpt/.pt files, which can carry code, are never opened (you pick their type).
|
||||
- Where it will go is shown and can be changed; by default it follows the download setting (by base model, such as loras/Illustrious). A file found in your Downloads folder or on the Desktop is **moved** there (instant on the same drive; tick "Keep the original" to copy); any other file is sent as a copy.
|
||||
- While you look, the whole file is checked against Civitai: SDXL-family LoRAs get their exact base model (Pony, Illustrious, NoobAI cannot be told apart from the file alone) and the model and version name. **A file you already have** (identical content) is pointed out and left out unless you tick it; a different file of the same name is saved as "name (2)". Nothing is ever replaced. The new files are scanned right after.
|
||||
|
||||
### 🧹 Tidy: models in the wrong folder, files you have twice (整理模型)
|
||||
- **The models page has tabs: All models, Tidy and Sources** (Sources was a chip among the model types). Tidy shows a count when it found something.
|
||||
- **Tidy** reads the "table of contents" inside every model file and shows, as cards with the model's cover and name, two steps. **1. In the wrong folder**: what the file really is and where it belongs, drawn as *Checkpoint → UNet / Anima*, with one plain reason (for example an Anima, Flux or Wan model that is only the main part, which "Load Checkpoint" cannot read — Civitai labels all of these "Checkpoint"). **Move there** takes the model with its cover, info and your notes, keeping its subfolder ("Change place" to pick another); nothing is replaced, and it says which loader node your workflows need instead. **2. Identical files**: the copies side by side, the one in the right folder marked to keep (click another cover to keep that one), and one button sends the extra copies to the Recycle Bin with their covers and info. Scanned models are compared at once; same-size files never scanned when you press **Compare**.
|
||||
- Models that load where they are anyway (a whole checkpoint in the UNet folder) are not listed.
|
||||
- **Not used lately (很久没用的模型和节点包)**, under the two steps, when you press **Look for them**, for models under Tidy and for node packs in **Settings → Node packs** (a line under Tidy leads there), from *not used for 30 days*, *for 90 days* to *never used*. AMB now notes what each run uses and what each workflow you open holds (nodes and models, kept on this computer), and counts the tabs open when you look, so a node pack making sounds or videos, a workflow never saved, or a node only put on the canvas counts too; before that note began, your images and sounds (each records its models and nodes) and your saved workflows, recipes and combos tell. Nothing that came in the last 30 days is judged, and while the note is younger than the group you look at, the page says so.
|
||||
- **Models**, biggest first, with the day they were last used and a warning when a saved workflow, recipe or combo still names one. Tick the ones to drop and send them to the Recycle Bin with their covers and info.
|
||||
- **Node packs** with how many nodes and how long installed (from the day its folder was made), and a warning when another pack's nodes take what only this one makes. Packs that work only through nodes come first; those that also add buttons, menus or panels to the page are listed apart, since their nodes going unused does not say the rest is. A pack used only through its panels or buttons leaves no record: press **I still use it** and it is listed apart from then on (**Unmark** undoes it). **Find it in the Manager** opens ComfyUI Manager's node list already searched for that pack (its name is also copied), where you disable or remove it. Packs without nodes, which only work on the page or the server, are listed apart: no record can tell whether they are used.
|
||||
- Importing, uploading and Tidy's moves only work on the computer ComfyUI runs on; opened from another device (ComfyUI started with `--listen`), they say so instead of writing into your model folders.
|
||||
|
||||
### 🧬 Filter models by base model (按底模筛选)
|
||||
- Under the type chips, one chip per base model the listed models have, with counts: **All · Illustrious 21 · Anima 7 · SDXL 9 · Unknown 2**. Spellings of one family count as one (SDXL 1.0 and SDXL). Models not scanned yet are listed by what their file's header tells. Handy for LoRAs: pick the base model of your checkpoint and only the LoRAs that fit are left. It works together with the search.
|
||||
- The scanner's offline guess of a base model (for models Civitai does not know) now also recognizes LoRAs, Qwen-Image, Wan, Chroma and Hunyuan Video files.
|
||||
|
||||
### ✏️ A new model editor, with a source link (模型编辑页重做,能填来源链接)
|
||||
- **Edit** on a model opens a cleaner editor: the cover large on the left with the choices under it (the current one, Civitai's own cover when you replaced it, your newest pictures, upload, or the whole gallery), and on the right **Name**, **Source link** and **Notes**, each saying what it does. Nothing is written until **Save**; Esc or a click outside cancels. Renaming the file on disk is a tick under the name that says what it means.
|
||||
- **Source link (来源链接)**: where you downloaded a model (LiblibAI, HuggingFace…), its site named as you type. The details get a button that opens it, beside Civitai's; shared workflows and Model Check point to it when someone misses that model. Only web addresses are kept.
|
||||
- **Reset** clears the name and notes you set and brings Civitai's cover back; the source link stays.
|
||||
- Saving no longer marks the name or notes as yours when you did not touch them.
|
||||
|
||||
### 🪪 Model details, rearranged (模型详情页重排)
|
||||
- From a Bilibili comment: the buttons were small and spread over five places, and the file name showed twice. Now the title bar is gone and everything sits beside the cover: a small **← Models / LoRA** to go back (Esc does the same; from Model Check or the assistant it says where it leads), the model's **name**, the size / base model / source line with the Civitai and source links, and one row of buttons: **Add node** (the one coloured button), **Edit**, **Folder** (shows the file in Explorer; pointing at it shows the path) and **Outputs** (pictures made with it).
|
||||
- The file's full path shows once, at the foot of the details with **Copy** and **Delete model**. Delete is no longer a red button next to Add node; it asks first in AMB's own dialog, and the files still go to the Recycle Bin. The ⬇ (jump to the bottom) button is gone.
|
||||
- In a narrow window (the browser docked beside the canvas) the cover column is hidden as before, so a small cover now sits beside the name.
|
||||
- A model's description (Civitai's own text) keeps its headings a little above the text instead of poster size, and a prompt quoted in it wraps instead of making the details scroll sideways.
|
||||
|
||||
### 🗂️ The folder column follows the type; open folders in Explorer (文件夹栏跟着类型走,一键打开文件夹)
|
||||
- The column left of the models used to list every type's folders (raw names like `checkpoints (1)`, `[Root]` rows with 0, nothing marking where you were), repeating the chips above it. Now it shows the folders of the type you picked: **All**, then its subfolders as a tree whose numbers include their subfolders (empty ones dimmed, ▾ folds one), and **In the top folder** for models lying directly in the type's folder. The folder you are looking at is marked.
|
||||
- **📂 Open** next to the type's name opens that type's folder in Explorer, and a 📂 on any folder you point at opens that one, so you can drop files in or tidy up by hand. Like the details' Folder button, it works only on the computer ComfyUI runs on.
|
||||
- In a narrow window (docked beside the canvas) the column opens as a drawer over the page; closing it no longer squeezes the page for a moment as it slides away.
|
||||
|
||||
### 🎨 The Combos page looks like the others (搭配页和其他页面统一)
|
||||
- It no longer sits in a lighter, inset box with a shadow, a second "Combos" title and a red ✕: it fills the page in the same colours as Models and Workflows. The explanation of what a combo is folds behind **?** (shown by itself while you have no combos), and the editor's Back reads **← All combos**.
|
||||
|
||||
### 🧩 Combos with rgthree's and Easy-Use's LoRA nodes (搭配支持常用的第三方 LoRA 节点)
|
||||
- Stacking many LoRAs used to mean a long chain of Load LoRA nodes. **New** on the Combos page now also offers **Default combo, LoRAs in Power Lora Loader** (rgthree: every LoRA in one node, each row with its own switch and strength) and **…in Easy-Use LoRA stack** (up to ten per stack node, put on the model by Easy Apply LoraStack), each only when that node pack is installed. You still just pick the main model and the LoRAs; the combo's editor can switch between them later.
|
||||
- A combo set to a node pack this ComfyUI does not have is put down with ComfyUI's own LoRA loaders, and says so.
|
||||
- **Save as combo** on canvas nodes keeps a Power Lora Loader's LoRAs: before, its rows were lost and it came back empty. Each row's LoRA is now a slot you can change in the editor, its strength and switch shown beside it.
|
||||
- **The LoRAs inside a Power Lora Loader are seen everywhere else too (Power Lora Loader 里的 LoRA 也认得了)**: before, AMB looked only at ordinary drop-downs, so a downloaded workflow using one never said its LoRAs were missing. Now **Model Check** lists each switched-on row's LoRA (missing, found by fingerprint, download, put back in), saving a workflow records their fingerprints, workflow cards and a picture's parameters list them with their strengths, a workflow's **models used** include them, and a LoRA's **Outputs** finds the pictures made with it. A workflow's models used also include Easy-Use LoRA stack rows. Only these widely used nodes are read this way; ComfyUI's own loaders remain the reference.
|
||||
|
||||
### 🚀 First-run setup on Home (新手一键准备)
|
||||
- Until your models have been scanned once, Home opens with **New here? Let's get set up**: four steps and **Set up for me**. It checks that Civitai answers (many need a proxy for it; if not, **Retry** or **Scan offline**), offers the optional Civitai key, scans every model with a progress bar in the card (it renames no files and runs in the background), then starts the 30-second tour. It stops to ask when the network is down. **Later** hides it; Home's **New here?** line has **Set up** to bring it back. It is a card on Home, never a popup.
|
||||
|
||||
### ≈ What was read from the file says so (离线推断标清楚)
|
||||
- A model Civitai does not know has its base model inferred from the file, as before; now it says so. Its card reads **推断 SDXL** (it was ≈), and its details open with a note: why Civitai did not have it, that the base model was read from the file and may be off, and **Add a name and source link**, instead of an English placeholder description.
|
||||
- The scan page says it too: an online scan "looks up 5 on Civitai and infers the base model from the file for any it does not find", and the state chip reads **Not found · inferred from file**.
|
||||
|
||||
### 🏷️ Version labels on model cards (卡片显示版本号)
|
||||
- A scanned model's card shows its Civitai version ("v2.0", "Illustrious", "fp8"…) as a small label above the name, so versions of one model that share a file name can be told apart.
|
||||
|
||||
### ⇄ Compare parameters (参数对比)
|
||||
- Two pictures from the same workflow often differ in only a few values. Press ⇄ on one picture in the Gallery, then on another (or **Compare with the canvas**); the picture details have the same ⇄ for the canvas. The window says whether both use the same nodes and lists only what differs, node by node, A and B each in its own colour: prompts by the tags only A or only B has (marked positive or negative), long names one line per side with the differing part marked, seeds folded away, nodes only one side has listed apart. Two workflows numbered differently still compare the nodes each has one of, such as their only KSampler.
|
||||
- Against the canvas, tick the values to take from the picture and put them on the canvas in one go (seeds stay); one Ctrl+Z or the toast's Undo takes them back.
|
||||
- **What changed since the last picture (和最新出图对比)**: **⇄ Canvas vs. newest picture** at the top of the Gallery and of Activity compares the canvas with the newest picture in the output folder, edits made by hand on the canvas included, and can put the picture's values back.
|
||||
- **Key parameters (关键参数)**: press ☆ before a value's name (tick **Show equal values too** to star one that matches) and it stays at the top of every comparison, for every workflow, kept with your ComfyUI settings.
|
||||
- **Current node shows where saved values came from (当前节点的参数方案:出处、出图、并排)**: each saved set for the selected node is a card with the picture made with those values (or the picture they were kept from; click to see it large) and a line on where it came from, with the day: **a parameter set of workflow X** (with how many pictures it made, and **Open** to show that set in the workflow), **kept with picture Y** (**See it**), or **saved here**. The values it would change are drawn instead of written: numbers as two bars (now and then) with ↑ or ↓, choices as the old one struck through → the new one. What stays (seeds, models, choices this computer lacks) is said with why; **Show all** lists every value, equal ones too (there is no more "4 shown, N more"). **⇄ Compare** opens the comparison of the whole workflow with the canvas, not only this node.
|
||||
- **Where the node's values came from (上次套用的是哪套)**: a line on top of the parameters says which set was last put into this node and when ("上次套用「阿罗娜K4.0 · 02」· 今天 18:02"), and what changed since ("之后变了:cfg 5 → 6.5"), with **Put them back**; that set's card is marked. It is remembered in this browser, per workflow and node, and forgotten when the change is undone. Without it, the line says which saved set the values match, or come closest to ("只差:cfg 6 → 5").
|
||||
- **Side by side (并排)**: with two sets or more, switch to a table: the current values and each set in columns (its picture on top), the values a set would change marked, **Use N** under each. The choice is remembered.
|
||||
- **Parameter sets in view (参数方案更好找)**: a workflow card with saved sets shows **▤ N** (how many), which opens them directly. Each set there shows its key values (those unlike the workflow's own in colour), how many values differ, and ⇄ to compare it with the workflow.
|
||||
|
||||
### 🗂️ A tidier Workflows page (工作流页面重做)
|
||||
- One bar on top: search, **Filter** (kind and tag in one menu), grid or list, share and import, and **Save the canvas**. The drag tip strip and the red ✕ are gone (Esc still closes the page; the empty page explains dragging).
|
||||
- **A workflow opens on its values (打开工作流先看参数方案)**: the details are rebuilt around them. On the left: **默认参数** (the workflow's own), your saved sets (each with its key values and how many values differ), **+ 从画布保存** and **+ 新建参数方案**, then **全部出图** and **版本**. On the right, the chosen set: **应用到当前画布**, **⇄ 对比** (with the default values or the canvas), ⋯ (copy as a new set, rename, delete), its main values, its prompts (long ones folded, the role still chosen from a menu), **the pictures made with these values**, and, folded, the models and every node's values. The bar keeps the name (click to rename), the models' state, Open and ⋯ (edit, export). The old Overview tab is gone: cover, notes and tags sit at the top of 默认参数.
|
||||
- A new set is named after the workflow with a number (阿罗娜K4.0 2); values saved without names show their real names instead of 控件 1, 控件 2. A set equal to the workflow's own values (saving a workflow keeps one) is no longer listed twice.
|
||||
- **Compare pictures inside a workflow too**: every picture in 全部出图 and under a set has ⇄ — pick two, or one and the canvas or the default values. The pictures there now load as thumbnails.
|
||||
- Cards keep the picture, the name, a line of steps, sampler, LoRAs and size, and the model. The models' state is a dot on the cover, with words only when some are missing; 功能模块 marks a partial workflow. **▶ Open** and **⋯** (edit, export, delete) are two small round buttons in the cover's corner, shown when the pointer is on a card, so pressing a card to look inside no longer opens it on the canvas by mistake.
|
||||
- **Focus values (重点参数)**: mark what matters most in a workflow. Under 全部节点参数 every value has a ☆; marked values show at the top of 默认参数 and of every set as large tiles with the node they belong to, a note you can add (「0.6–0.8 最好」), and, on a set, the default value when it differs. Set rows list them first, and in ⇄ 对比 they lead the key values with their note. A star turns at once with a little bounce, the value fades in at the top, and marking or noting one saves only that (no new version of the workflow). They belong to that workflow (the ☆ in the comparison window still stars a value for every workflow), are kept when the workflow is updated from the canvas, and travel with it in an exported workflow package (share codes carry only the canvas workflow).
|
||||
- **Tips where they help (参数方案的引导)**: a workflow without sets says what they are for, with **+ 从画布保存** right there; on a set, a line points at **⇄ 对比** until you have used it (or pressed 知道了).
|
||||
- **Folds remember you (折叠记住你的选择)**: 用到的模型 and 全部节点参数 open as you last left them. 用到的模型 says on its title whether the models are all here or how many are missing, and opens by itself when some are.
|
||||
- **Docked, the details fit (侧边栏模式不再挤)**: in a narrow panel the left column becomes one row of pills above the values (默认参数, your sets, new, 全部出图, 版本).
|
||||
- **Saving a workflow, redone (保存工作流的窗口重做)**: the cover is large on the left, chosen from the canvas, the current cover or your recent pictures (as thumbnails); name, tags and notes on the right; Cancel and Save always in view. The name starts as the canvas's workflow name when ComfyUI has saved it under one. Esc cancels, Enter in the name saves.
|
||||
|
||||
### 🔇 Models you never want scanned (不需要扫描的模型)
|
||||
- A model whose header looks like no drawing model (a voice model such as F5-TTS kept in checkpoints, say) is no longer just "not scanned" forever. The scan page asks about them: **都不扫**, **都照常扫**, or **逐个看**. Set to not be scanned, a model shows **不扫描** instead of 未扫描, a scan of everything skips it (picked by hand it is still scanned), and the scan page lists these models to change it back. A model's details have the same button. Nothing is decided for you: scanning still keeps a fingerprint that repairs workflows.
|
||||
- **Not on Civitai is no fault (Civitai 上没有不再像出错)**: the count of models Civitai did not find warns only when some file is damaged or a lookup is still to do; each such model says its fingerprint is kept for repairing workflows.
|
||||
|
||||
### 🎯 The scan page, rearranged: pick models beside the button, settings in place, components apart (扫描页重排)
|
||||
- **Quieter the further down.** On top one card answers "what now": two rows of your models' covers (an empty dashed frame for each model not scanned yet, gently pulsing while a scan runs), what is left in one line ("3 models still to look up"), the scan button, **Pick models to scan…**, and **Online / Offline** right beside them. Then the models' state as a coloured bar with a chip per state; then, only when there is something, what needs a look (damaged files, voice-like models, models set to never be scanned, other formats), one row each with its icon; then the settings as tiles with an icon and a switch each; the last scan is one line at the foot. "How scanning works" is folded under the title.
|
||||
- **Pick models to scan… sits beside the scan button.** The picks are named under it (each with a ✕, with their count and total size) and the button scans just them; **Clear, scan everything again** goes back. There is no "all / only ones I pick" switch any more. When some picks are already found, it says so: a scan only reads their record again unless Fetch again is on.
|
||||
- **Under the button, what this scan will do**, in one sentence; **Details** unfolds the numbers: how many will be looked up on Civitai (not scanned yet, and those last scanned without a connection; how many of them are components), how many are already found and keep their record, how many Civitai does not have and are not looked up again, how many are set to never be scanned. Turning on offline or Fetch again changes the lines at once.
|
||||
- **The scan settings are on the page** instead of a page of their own: Civitai names on cards, fixing the workflow afterwards and the Civitai key as tiles; renaming files and fetching everything again are folded under **More**, marked as changing files, and when one is on the scan card says so in yellow.
|
||||
- **Components on their own (基础组件)**: VAE, text encoders, CLIP, CLIP Vision and upscalers get a chip of their own, "7 components · not found is fine", whose list (found, not found, not scanned yet) says that many of them are not on Civitai at all and that this does not matter (the fingerprint is still kept for Model Check). The counts above, and the Not found and Not scanned yet lists, leave them out; their own list shows all of them.
|
||||
- **The model picker is new**: every model a scan reads, as a list (name, folder, state, base model, size) or as covers. Chips by state, the same words as the scan page — **Not scanned yet, Not found, Damaged, Found, Never scan** — with counts; a search; the folders on the left with how many are picked in each, two folders of one type told apart by where they are. **Pick these N** picks everything shown (so "the 12 not scanned yet in LoRA" is two clicks), shift-click picks a run, **Picked only** shows the picks. The footer says how many are picked and how big they are.
|
||||
- Only files a scan can read (.safetensors) are listed; the old picker also offered .ckpt and other files a scan never opens.
|
||||
- **Quick to open with many models, or models on a network drive** (a NAS, a cloud server's model disk): the page reads the headers of models not scanned yet 16 at a time and remembers them until the file changes, so it opens again at once; while it counts, it says **Counting your models…** instead of staying blank. On a cloud server with 1041 unscanned models it had taken 79 seconds each time.
|
||||
|
||||
### ⚙️ Settings in tabs (设置分组)
|
||||
- The settings page is split into tabs: **Look, Models, Workflows, Node packs, Prompts, Backup, Help**. "Change where downloads go" opens Models directly.
|
||||
- **Where downloads (and now imports) go is a choice**: by base model (the new default), in Downloads then by base model, all in a Downloads folder, straight in the type's folder, or your own folder typed by hand; an example path shows the result. Without a saved setting, downloads now go by base model instead of into a Downloads folder.
|
||||
|
||||
### 🔖 Take single tags from a saved prompt (从存过的提示词里挑词)
|
||||
- **In Prompt Studio a clicked card opens as a reference** between the cards and the prompt you are editing, as its tags (with their meanings when a language is picked). Click one tag and only that tag goes into the prompt; tags the prompt has already are ticked. **Add all** puts the whole card in, as a click on a card used to; the preview's Add and dragging a card work as before. The ✕ closes the reference; beside the canvas the drawer widens by the column while it is open.
|
||||
- **It does not squeeze a narrow window**: where three columns do not fit (a side-panel browser window, a narrow drawer), the reference takes the card list's place and **‹ Cards** brings the list back, so the reference and the prompt you edit each get half the width.
|
||||
|
||||
### Fixes
|
||||
- **The browser no longer opens blank after Put on canvas (搭配放到画布后再打开不再空白)**: putting a combo on the canvas hid the combo page before closing the browser, so the next time it opened the title said Combos and nothing was below. It now opens again on the combo list, or on the combo you put down.
|
||||
- **Edit emotions: every clip in the list, and the line follows the clip (编辑情绪:下拉列出全部音频,换音频台词跟着换)**: a box that already held a file listed only the files whose names contain it (the browser filters by the box's text), so a character's other clips seemed missing. Pressing the box now lists all of them, with the current file shown greyed; leaving without a pick keeps it. Picking another clip no longer keeps the old clip's line beside it: the line the node already reads for that clip is filled in, otherwise the line the node finds for the clip (its .txt / .lab or the annotation list) is filled in, with Anomalous_TTS from interface 14 on (older versions leave the box blank and the node reads the line when it speaks); going back to the first clip brings its line back. A blank line box shows, greyed, the line the node uses for its clip.
|
||||
- **Search a character's clips by line (按台词找参考音频)**: Edit emotions has a search box above the clips: type part of a line or a file name and the matches list with their lines, to hear (▶), **Use as main voice** or **Add as emotion** (a new emotion row with that clip and its line, ready for its name). Searching lines needs Anomalous_TTS interface 14; with an older version it searches file names.
|
||||
|
||||
## v1.59.1 Beta (GPT-SoVITS v3 / v4 voices) — 2026-10-17
|
||||
|
||||
### 🎙️ Character voices: v3 and v4 models (角色语音支持 v3 / v4 模型)
|
||||
- **Anomalous_TTS now runs GPT-SoVITS v3 and v4 models** (its first stable release, [v1.0.0](https://github.com/DemonGatanjieu/Anomalous_TTS/releases/tag/v1.0.0); update it in ComfyUI Manager), and this version of the plugin goes with it: v3 / v4 files are imported instead of refused, and a file whose version cannot be read is refused with a clearer reason. With an Anomalous_TTS too old for v3 / v4, the import says to update it.
|
||||
- **Each character card shows its GPT-SoVITS model version** (v2, v2ProPlus, v4…) after the name (角色卡片显示模型版本).
|
||||
- **v3 and v4 need their own official base model and vocoder** (v4 about 0.8 GB, v3 about 1.2 GB). **GPT-SoVITS Settings** lists them, with their own labels, only when one of your characters uses that version, and the import card says what is still missing for the model you dropped in. They download on first use too; a GPT-SoVITS package already on the computer is used as it is.
|
||||
|
||||
## v1.59.0 Beta (Model Downloads, Backup, Share Codes, Node Combos, MCP, Light Theme) — 2026-10-17
|
||||
|
||||
### ⬇️ Download missing models from Model Check (模型检查一键下载缺失模型)
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
[](https://github.com/ltdrdata/ComfyUI-Manager)
|
||||
[](LICENSE)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://www.bilibili.com/video/BV1boHx61Enc/)
|
||||
[](https://www.bilibili.com/video/BV1bkpZ6uEZQ/)
|
||||
[](https://www.reddit.com/r/comfyui/s/n38ihf3DLf)
|
||||
@@ -40,7 +40,7 @@
|
||||
|
||||
<p align="center"><img src="docs/images/en-scan.webp" width="520" alt="Scan results: each file matched on Civitai"></p>
|
||||
|
||||
**2. Someone else's workflow opens with red nodes.** Model Check says which models are missing. The ones you already have under another name or in another folder go back in with one press; the rest can be downloaded from Civitai or Hugging Face, and each download is checked against the model's fingerprint.
|
||||
**2. Someone else's workflow opens with red nodes.** Model Check says which models are missing. When the workflow carries model fingerprints (the plugin writes them into the workflows and images of everyone who has it, unless they turn it off), the ones you already have under another name or in another folder go back in with one press, and the rest can be downloaded from Civitai or Hugging Face, each download checked against its fingerprint. A workflow without fingerprints can only be matched by file name: a file of the same name elsewhere on your computer is offered for you to confirm.
|
||||
|
||||
<p align="center"><img src="docs/images/en-model-check.webp" width="760" alt="Model Check puts back three missing models with one press"></p>
|
||||
|
||||
@@ -97,7 +97,7 @@ Every page has an icon on the rail at the left of the window.
|
||||
Tools on the rail:
|
||||
|
||||
* **Scan** reads your model folders and fetches covers, trigger words and base models from Civitai (by file hash, no extra Python packages).
|
||||
* **Model Check** (formerly Model Doctor) checks each workflow you open: when models are missing, a bar over the canvas says so and puts back, with one press, the ones you have under another name or folder (recognised by hash and file size); its page lists the rest with what you can do.
|
||||
* **Model Check** (formerly Model Doctor) checks each workflow you open: when models are missing, a bar over the canvas says so and puts back, with one press, the ones you have under another name or folder, recognised by the fingerprints (hash and file size) the plugin records in workflows and images (**Settings → Record model fingerprints in workflows**, on by default); without them, a file of the same name is offered for you to confirm. Its page lists the rest with what you can do.
|
||||
* **Current node** (formerly Node Assistant) shows the model of the node you select, lets you swap it from the covers or insert a LoRA, and lists every set of values saved for that kind of node with exactly what applying it would change; saved values can be deleted there.
|
||||
* **Each tool on its page**: share and import on Workflows (⇅: a short share code of the canvas workflow, in Chinese characters or letters; paste a code, or drop a workflow file, an image or a backup .zip), Model Sources (where each model can be downloaded) on Models and in Model Check, translation on the prompt boxes in Current node.
|
||||
* **Settings** (at the bottom): language, font size, thumbnails, video covers, which model folders to show, and feedback. Home and Settings have **Report a problem**, which opens a GitHub issue with your versions and GPU already filled in.
|
||||
@@ -118,7 +118,9 @@ Open the browser with **Shift + B** (again to close), the floating button on the
|
||||
<details>
|
||||
<summary><b>🌐 Network and backups</b></summary>
|
||||
|
||||
**Network:** Scan looks models up on Civitai and prompt translation uses Google Translate, so both need to reach those sites (from mainland China usually through a proxy). Without them, pick **Offline scan**: base models are read from the files, without covers or trigger words, and a later online scan fills in the rest. Downloading missing models needs the network too; for Hugging Face, **Settings → Model downloads** can use the hf-mirror.com mirror (Civitai has none). Browsing models, outputs, workflows and combos needs no network.
|
||||
**On a cloud server (AutoDL, RunPod…):** fully supported on overseas servers; on servers in mainland China only in part (Civitai is mostly out of reach there: lookups are unreliable, covers rarely download). Not fully tested yet (one AutoDL server so far). The guide has a section for AI assistants to walk you through it. Opened from another computer, the plugin is view only by default. Open ComfyUI through an SSH tunnel, or see [docs/guides/cloud-server.md](docs/guides/cloud-server.md).
|
||||
|
||||
**Network:** Scan looks models up on Civitai and prompt translation uses Google Translate, so both need to reach those sites (from mainland China usually through a proxy; when civitai.com cannot be reached the plugin tries Civitai's other address, civitai.red, which some networks reach, a cloud server in mainland China among them: names and trigger words come, covers usually not). Without them, pick **Offline** on the scan page (an online scan does the same by itself when it cannot connect): base models are read from the files, without covers or trigger words, and a later online scan fills in the rest. Downloading missing models needs the network too; for Hugging Face, **Settings → Model downloads** can use the hf-mirror.com mirror (Civitai has none). Browsing models, outputs, workflows and combos needs no network.
|
||||
|
||||
**Keep a copy of your own data:** **Settings → Backup → Export** packs Workflow Recipes, combos, saved prompts and node values, parameter presets, and the names, notes and covers you gave your models into one .zip; keep it on a cloud drive or a USB stick, and **Import** puts it back, also on another computer. (They live in `workflows/anomalous_recipes`, `workflows/anomalous_notebooks`, `workflows/anomalous_materials` and `workflows/anomalous_parameters` in your ComfyUI user folder.)
|
||||
|
||||
@@ -147,7 +149,7 @@ Open the browser with **Shift + B** (again to close), the floating button on the
|
||||
|
||||
<p align="center"><img src="docs/images/scan.webp" width="520" alt="扫描结果:每个文件都在 C 站认出来了"></p>
|
||||
|
||||
**2. 打开别人的工作流,一片红框。** 模型检查会告诉你缺哪些模型。你电脑里其实有、只是改了名或换了文件夹的,点一下就换上;真没有的可以从 C 站或 Hugging Face 下载,下载完按模型指纹核对过才用。
|
||||
**2. 打开别人的工作流,一片红框。** 模型检查会告诉你缺哪些模型。如果工作流里带着模型指纹(装了本插件的人出图、保存工作流时会自动记下,除非他在设置里关掉),你电脑里其实有、只是改了名或换了文件夹的,点一下就换上;真没有的可以从 C 站或 Hugging Face 下载,下载完按指纹核对过才用。没有指纹的工作流只能按文件名找:别处有同名文件,会列出来让你确认。
|
||||
|
||||
<p align="center"><img src="docs/images/model-check.webp" width="760" alt="模型检查一键换上 3 个缺失的模型"></p>
|
||||
|
||||
@@ -155,11 +157,11 @@ Open the browser with **Shift + B** (again to close), the floating button on the
|
||||
|
||||
<p align="center"><img src="docs/images/gallery.webp" width="760" alt="图库搜索后把图拖到画布"></p>
|
||||
|
||||
**4. 每次都要重新配一遍模型、LoRA 和提示词。** 存成一个搭配。把卡片拖到画布空白处,就放出一组连好线的节点,画布上原有的东西不动。
|
||||
**4. 每次都要重新配一遍模型、LoRA 和提示词。** 在画布上选中这几个节点,右键 **存为搭配**,它们就成了画布旁的一张卡片。下次点 **放到画布**(或者直接拖卡片),点哪里,那组连好线的节点就出现在哪里,画布上原有的东西不动。搭配里的模型和提示词以后还能换。
|
||||
|
||||
<p align="center"><img src="docs/images/combo.webp" width="760" alt="搭配卡片拖到画布变成连好线的节点"></p>
|
||||
<p align="center"><img src="docs/images/combo.webp" width="760" alt="选中的节点存为搭配,再放回画布变成连好线的节点"></p>
|
||||
|
||||
**5. 提示词太长,改起来麻烦。** 选中提示词节点,提示词工坊把它拆成词块:点一下调权重,拖动换位置,输入就加(中文会自动译成英文)。
|
||||
**5. 提示词太长,改起来麻烦。** 选中提示词节点,提示词工坊把它拆成词块:点一下调权重,拖动换位置,输入就加(中文会自动译成英文,比如输入“蓝宝石”加进去的是 sapphire)。
|
||||
|
||||
<p align="center"><img src="docs/images/prompt-studio.webp" width="760" alt="提示词工坊按词块编辑提示词节点"></p>
|
||||
|
||||
@@ -207,7 +209,7 @@ Open the browser with **Shift + B** (again to close), the floating button on the
|
||||
图标栏上的工具:
|
||||
|
||||
* **扫描**:读取模型文件夹,按文件哈希从 C 站获取封面、触发词和底模。不需要额外安装 Python 包。
|
||||
* **模型检查**(原“模型医生”):打开工作流时自动检查,缺模型就在画布上方提示;本地改过名、换过文件夹的同一个文件(按哈希和文件大小认)一键换上,其余的在检查页里自己挑或去 Civitai 找。
|
||||
* **模型检查**(原“模型医生”):打开工作流时自动检查,缺模型就在画布上方提示;本地改过名、换过文件夹的同一个文件一键换上,靠的是插件记在工作流和图片里的模型指纹(哈希和文件大小;**设置 → 在工作流里记下模型指纹**,默认打开);没有指纹时,同名文件会列出来让你确认。其余的在检查页里自己挑或去 Civitai 找。
|
||||
* **当前节点**(原“节点助手”):选中画布上的节点,查看它用的模型。可以看图换模型、插入 LoRA;存过的同类节点参数列在一起,每条写明会改哪几项,一键套用;存下的参数也在这里删除。
|
||||
* **工具回到各自的页面**:分享与导入在“工作流”页(⇅:把画布上的工作流变成一段短短的分享码,有汉字码和字母码;也能粘贴分享码,或拖入工作流文件、图片、备份压缩包),模型来源(每个模型去哪下载)在“模型”页和模型检查里,翻译在“当前节点”的提示词框上。
|
||||
* **设置**(最下面):语言、字号、缩略图、视频封面、显示哪些模型文件夹,以及提交反馈。主页和设置里的 **报告问题** 会打开一个已经填好版本和显卡信息的 GitHub 页面。
|
||||
@@ -231,7 +233,9 @@ Open the browser with **Shift + B** (again to close), the floating button on the
|
||||
<details>
|
||||
<summary><b>🌐 网络和备份</b></summary>
|
||||
|
||||
**关于网络:** 扫描模型要连 Civitai(C 站)查封面和触发词,提示词翻译默认用谷歌翻译,这两样在国内一般要开网络代理才能用。不开代理也行:扫描时选 **离线扫描**,只从模型文件推断底模,没有封面和触发词,以后能连上时再扫一次会自动补上。下载缺失的模型也要联网:Hugging Face 的模型可以在 **设置 → 模型下载** 里打开“Hugging Face 用国内镜像”,不用代理;C 站的没有镜像。浏览模型、图库、工作流和搭配都不用联网。
|
||||
**在云服务器上(AutoDL 等):** 海外的云服务器完整支持;国内的云服务器只能部分支持(多半连不上 Civitai,查模型不稳定,封面基本下不来)。目前还没有完整测过(只实测过一台 AutoDL 服务器),用的时候请留意。说明里有一节写给 AI 看的步骤,把链接丢给 AI 就能让它带你做。从别的电脑打开时插件默认只能浏览。用 SSH 隧道打开就能照常用,或者见 [docs/guides/cloud-server.md](docs/guides/cloud-server.md)。
|
||||
|
||||
**关于网络:** 扫描模型要连 Civitai(C 站)查封面和触发词,提示词翻译默认用谷歌翻译,这两样在国内一般要开网络代理才能用。不开代理也行:扫描页选 **离线**(联网扫描连不上时也会自动改成这样),只从模型文件推断底模,没有封面和触发词,以后能连上时再扫一次会自动补上。下载缺失的模型也要联网:Hugging Face 的模型可以在 **设置 → 模型下载** 里打开“Hugging Face 用国内镜像”,不用代理;C 站的没有镜像。(civitai.com 连不上时插件会自动试 Civitai 的另一个地址 civitai.red,有的网络能连上,比如国内的云服务器,这时能认出模型名字和触发词,封面多半还是下不来。)浏览模型、图库、工作流和搭配都不用联网。
|
||||
|
||||
**备份自己的数据:** **设置 → 备份 → 导出**,把工作流配方、搭配、存下的提示词和节点参数、参数预设,以及你给模型改的名字、备注和封面打成一个压缩包,放到云端硬盘或 U 盘里;换电脑或重装后用 **导入** 恢复。(它们存在 ComfyUI 用户目录下的 `workflows/anomalous_recipes`、`workflows/anomalous_notebooks`、`workflows/anomalous_materials` 与 `workflows/anomalous_parameters`。)
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import os
|
||||
from .api import setup_routes
|
||||
from .api.usage_ledger import note_prompt
|
||||
from server import PromptServer
|
||||
|
||||
WEB_DIRECTORY = "./web"
|
||||
@@ -7,6 +8,8 @@ WEB_DIRECTORY = "./web"
|
||||
# 注册 API 路由
|
||||
if hasattr(PromptServer, "instance") and PromptServer.instance is not None:
|
||||
setup_routes(PromptServer.instance.app)
|
||||
# Each queued run's nodes and models, for the Tidy tab's "Not used lately" (api/usage_ledger.py).
|
||||
PromptServer.instance.add_on_prompt_handler(note_prompt)
|
||||
|
||||
NODE_CLASS_MAPPINGS = {}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {}
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
from . import (
|
||||
activity_log, audio_catalog, folder_types, gallery_routes, materials, media_routes, model_catalog,
|
||||
model_folder, model_media, model_metadata, model_resolution, model_type_listing, kept_images, node_material, recipe_packages, recipes, scan_report, scan_summary,
|
||||
local_guard, lora_info, mcp_server, model_download, translation_routes, version_manager, backup,
|
||||
network_check,
|
||||
model_folder, model_media, model_metadata, model_resolution, model_type_listing, kept_images, node_material, recipe_packages, recipes, scan_choice, scan_report, scan_summary,
|
||||
local_guard, lora_info, mcp_server, model_download, model_import, model_placement, translation_routes, version_manager, tts_version, backup,
|
||||
network_check, model_heads, model_usage, usage_ledger,
|
||||
)
|
||||
from .scanner import *
|
||||
from .config import *
|
||||
from .notebooks import *
|
||||
from .parameters import *
|
||||
|
||||
import os
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
EXTENSION_STATIC_PREFIX = '/extensions/Anomalous_Model_Browser/'
|
||||
# ComfyUI serves the web folder under the plugin folder's own name, whatever it is called.
|
||||
EXTENSION_STATIC_PREFIX = f'/extensions/{os.path.basename(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))}/'
|
||||
|
||||
|
||||
@web.middleware
|
||||
@@ -28,6 +31,7 @@ async def no_cache_extension_middleware(request, handler):
|
||||
return response
|
||||
|
||||
def setup_routes(app):
|
||||
model_heads.keep_on_disk()
|
||||
if hasattr(app, 'middlewares') and no_cache_extension_middleware not in app.middlewares:
|
||||
app.middlewares.append(no_cache_extension_middleware)
|
||||
# Other devices on the network may look; only this computer may change things (local_guard.py).
|
||||
@@ -43,7 +47,6 @@ def setup_routes(app):
|
||||
app.router.add_get('/anomalous/models', model_catalog.api_get_models)
|
||||
app.router.add_get('/anomalous/type_models', model_type_listing.api_get_type_models)
|
||||
app.router.add_get('/anomalous/all_scan_models', model_catalog.api_get_all_scan_models)
|
||||
app.router.add_get('/anomalous/batch_select', model_catalog.api_batch_select)
|
||||
app.router.add_get('/anomalous/image', media_routes.api_serve_image)
|
||||
app.router.add_get('/anomalous/card_cache', media_routes.api_card_cache)
|
||||
app.router.add_post('/anomalous/card_cache/clear', media_routes.api_clear_card_cache)
|
||||
@@ -62,10 +65,12 @@ def setup_routes(app):
|
||||
# Parameter Routes
|
||||
app.router.add_get('/anomalous/parameters', api_get_parameters)
|
||||
app.router.add_get('/anomalous/parameters/by_node_type', api_get_parameters_by_type)
|
||||
app.router.add_get('/anomalous/parameters/signatures', api_get_parameter_set_signatures)
|
||||
app.router.add_post('/anomalous/save_parameter', api_save_parameter)
|
||||
app.router.add_post('/anomalous/rename_parameter', api_rename_parameter)
|
||||
app.router.add_post('/anomalous/delete_parameter', api_delete_parameter)
|
||||
app.router.add_get('/anomalous/parameter_gallery', api_get_parameter_gallery)
|
||||
app.router.add_get('/anomalous/parameter_pictures', api_get_parameter_pictures)
|
||||
|
||||
# Recipe Routes
|
||||
app.router.add_get('/anomalous/recipes', recipes.api_get_recipes)
|
||||
@@ -76,6 +81,7 @@ def setup_routes(app):
|
||||
app.router.add_get('/anomalous/recipe_gallery_compare', recipes.api_get_recipe_gallery_compare)
|
||||
app.router.add_post('/anomalous/save_recipe', recipes.api_save_recipe)
|
||||
app.router.add_post('/anomalous/update_recipe', recipes.api_update_recipe)
|
||||
app.router.add_post('/anomalous/recipe_focus', recipes.api_set_recipe_focus)
|
||||
app.router.add_post('/anomalous/set_recipe_gallery_cover', recipes.api_set_recipe_gallery_cover)
|
||||
app.router.add_post('/anomalous/delete_recipe', recipes.api_delete_recipe)
|
||||
app.router.add_get('/anomalous/recipe_history', recipes.api_get_recipe_history)
|
||||
@@ -114,11 +120,15 @@ def setup_routes(app):
|
||||
app.router.add_post('/anomalous/update_metadata', model_metadata.api_update_metadata)
|
||||
app.router.add_post('/anomalous/set_custom_cover', model_media.api_set_custom_cover)
|
||||
app.router.add_post('/anomalous/upload_custom_cover', model_media.api_upload_custom_cover)
|
||||
app.router.add_get('/anomalous/model_covers', model_media.api_model_covers)
|
||||
app.router.add_get('/anomalous/model_images', media_routes.api_get_model_images)
|
||||
app.router.add_post('/anomalous/resolve_paths_to_previews', model_catalog.api_resolve_paths_to_previews)
|
||||
app.router.add_get('/anomalous/scan_summary', scan_summary.api_scan_summary)
|
||||
app.router.add_get('/anomalous/scan_matched', scan_summary.api_scan_matched)
|
||||
app.router.add_get('/anomalous/scan_pickable', scan_summary.api_scan_pickable)
|
||||
app.router.add_get('/anomalous/last_scan', scan_report.api_last_scan)
|
||||
app.router.add_get('/anomalous/scan_model', scan_report.api_scan_model)
|
||||
app.router.add_post('/anomalous/scan_choice', scan_choice.api_set_scan_choice)
|
||||
app.router.add_get('/anomalous/lora_info', lora_info.api_lora_info)
|
||||
|
||||
# Audio & Voice Studio Routes
|
||||
@@ -128,8 +138,13 @@ def setup_routes(app):
|
||||
app.router.add_get('/anomalous/audio_previews', audio_catalog.api_get_audio_previews)
|
||||
app.router.add_post('/anomalous/save_audio_preview', audio_catalog.api_save_audio_preview)
|
||||
version_manager.register_routes(app)
|
||||
tts_version.register_routes(app)
|
||||
activity_log.register_routes(app)
|
||||
mcp_server.register_routes(app)
|
||||
model_download.register_routes(app)
|
||||
network_check.register_routes(app)
|
||||
model_usage.register_routes(app)
|
||||
usage_ledger.register_routes(app)
|
||||
model_import.register_routes(app)
|
||||
model_placement.register_routes(app)
|
||||
backup.register_routes(app)
|
||||
|
||||
@@ -17,10 +17,10 @@ import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
try:
|
||||
from ..civitai_client import USER_AGENT, _load_api_key
|
||||
from ..civitai_client import USER_AGENT, _load_api_key, answered, api_hosts
|
||||
from ..net_proxy import urlopen
|
||||
except ImportError: # loaded outside the package (tests)
|
||||
from civitai_client import USER_AGENT, _load_api_key
|
||||
from civitai_client import USER_AGENT, _load_api_key, answered, api_hosts
|
||||
from net_proxy import urlopen
|
||||
from . import manager_catalog
|
||||
|
||||
@@ -79,6 +79,23 @@ def _get_json(url, headers):
|
||||
raise SourceUnreachable(str(error)) from error
|
||||
|
||||
|
||||
def _civitai_json(path):
|
||||
"""Civitai's API answer at `path` (/api/v1/…), from whichever of its addresses answers
|
||||
(civitai_client.api_hosts); None on 404."""
|
||||
unreachable = None
|
||||
for host in api_hosts():
|
||||
try:
|
||||
data = _get_json(f"https://{host}{path}", civitai_headers())
|
||||
except SourceUnreachable as error:
|
||||
if isinstance(error.__cause__, urllib.error.HTTPError):
|
||||
raise # Civitai answered with an error: the other address is the same Civitai
|
||||
unreachable = error
|
||||
continue
|
||||
answered(host)
|
||||
return data
|
||||
raise unreachable
|
||||
|
||||
|
||||
def _civitai_page(version):
|
||||
domain = "civitai.red" if (version.get("model") or {}).get("nsfw") or (version.get("nsfwLevel") or 1) > 1 else "civitai.com"
|
||||
model_id = version.get("modelId")
|
||||
@@ -127,7 +144,7 @@ def _from_civitai_version(version, file_hash="", name=""):
|
||||
|
||||
|
||||
def civitai_by_hash(file_hash):
|
||||
version = _get_json(f"https://civitai.com/api/v1/model-versions/by-hash/{urllib.parse.quote(file_hash)}", civitai_headers())
|
||||
version = _civitai_json(f"/api/v1/model-versions/by-hash/{urllib.parse.quote(file_hash)}")
|
||||
return _from_civitai_version(version, file_hash) if version else None
|
||||
|
||||
|
||||
@@ -145,7 +162,7 @@ def civitai_by_link(url, name, file_hash=""):
|
||||
version_id = _civitai_version_id(url)
|
||||
if not version_id.isdigit():
|
||||
return None
|
||||
version = _get_json(f"https://civitai.com/api/v1/model-versions/{version_id}", civitai_headers())
|
||||
version = _civitai_json(f"/api/v1/model-versions/{version_id}")
|
||||
return _from_civitai_version(version, file_hash, name) if version else None
|
||||
|
||||
|
||||
|
||||
@@ -76,11 +76,12 @@ def _normalise_model_ref(value):
|
||||
|
||||
|
||||
def build_record(path):
|
||||
"""Searchable text, recorded model hashes and referenced model files for one image."""
|
||||
"""Searchable text, recorded model hashes, referenced model files and node types for one image."""
|
||||
name = os.path.basename(path)
|
||||
texts = [name]
|
||||
hashes = set()
|
||||
models = set()
|
||||
types = set()
|
||||
if path.lower().endswith(".png"):
|
||||
try:
|
||||
chunks = read_png_text(path)
|
||||
@@ -92,7 +93,11 @@ def build_record(path):
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
texts.append(str(node.get("class_type", "")))
|
||||
if isinstance(node.get("class_type"), str):
|
||||
types.add(node["class_type"])
|
||||
for key, value in (node.get("inputs") or {}).items():
|
||||
if isinstance(value, dict) and isinstance(value.get("lora"), str):
|
||||
value = value["lora"] # a row of rgthree's Power Lora Loader
|
||||
if isinstance(value, str):
|
||||
texts.append(value)
|
||||
if MODEL_FILE.search(value):
|
||||
@@ -111,7 +116,7 @@ def build_record(path):
|
||||
models |= _normalise_model_ref(model_name)
|
||||
if chunks.get("parameters"):
|
||||
texts.append(chunks["parameters"])
|
||||
return {"text": "\n".join(texts).lower(), "hashes": hashes, "models": models}
|
||||
return {"text": "\n".join(texts).lower(), "hashes": hashes, "models": models, "types": types}
|
||||
|
||||
|
||||
def _load_json(raw):
|
||||
|
||||
@@ -6,9 +6,16 @@ version switches, saved settings) answers only requests from the computer ComfyU
|
||||
GET is local-only unless it is a read sent as POST (OPEN_POSTS); a GET is open unless it is not
|
||||
a plain read (LOCAL_GETS). A request is local when it comes from a loopback address, through no
|
||||
proxy, naming a loopback host; checking the Host also keeps out a page that reached this server
|
||||
under another name (DNS rebinding)."""
|
||||
under another name (DNS rebinding).
|
||||
|
||||
ComfyUI on a rented cloud server is only ever opened from another device. An empty file named
|
||||
`allow_remote_changes` in the plugin's folder, made on that server, lets every device that can
|
||||
open the page change things too (may_change); it is read once, when ComfyUI starts, and the
|
||||
page cannot make or remove it. MCP keeps to this computer either way."""
|
||||
|
||||
import ipaddress
|
||||
import os
|
||||
import shlex
|
||||
import urllib.parse
|
||||
|
||||
from aiohttp import web
|
||||
@@ -26,12 +33,19 @@ OPEN_POSTS = frozenset({
|
||||
LOCAL_GETS = frozenset({
|
||||
"/anomalous/backup/file",
|
||||
"/anomalous/version/releases",
|
||||
"/anomalous/tts_version/releases",
|
||||
"/anomalous/import/folders",
|
||||
"/anomalous/civitai_check",
|
||||
"/anomalous/usage/check", # reads every output and saved workflow
|
||||
})
|
||||
# Routes that answer other devices themselves (MCP replies in JSON-RPC).
|
||||
OWN_CHECK = frozenset({"/anomalous/mcp"})
|
||||
READS = ("GET", "HEAD", "OPTIONS")
|
||||
REMOTE_FILE = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "allow_remote_changes")
|
||||
REMOTE_CHANGES = os.path.isfile(REMOTE_FILE)
|
||||
if REMOTE_CHANGES:
|
||||
print("[Anomalous Browser] allow_remote_changes 文件存在:任何能打开页面的设备都能扫描、移动、删除模型和改设置。"
|
||||
" The allow_remote_changes file is there: every device that can open the page may change things.", flush=True)
|
||||
MESSAGE = ("只能在运行 ComfyUI 的电脑上操作,别的设备只能浏览。"
|
||||
" Only the computer ComfyUI runs on can do this; other devices can only look.")
|
||||
|
||||
@@ -57,6 +71,11 @@ def is_local(request):
|
||||
return loopback_host(urllib.parse.urlsplit("//" + request.headers.get("Host", "")).hostname)
|
||||
|
||||
|
||||
def may_change(request):
|
||||
"""The request may change things: it comes from this computer, or remote changes are allowed."""
|
||||
return REMOTE_CHANGES or is_local(request)
|
||||
|
||||
|
||||
def needs_local(method, path):
|
||||
"""Whether a request to `path` with `method` must come from this computer."""
|
||||
if not path.startswith(PREFIX) or path in OWN_CHECK:
|
||||
@@ -73,11 +92,26 @@ def refusal():
|
||||
|
||||
@web.middleware
|
||||
async def local_only_middleware(request, handler):
|
||||
if needs_local(request.method, request.path) and not is_local(request):
|
||||
if needs_local(request.method, request.path) and not may_change(request):
|
||||
return refusal()
|
||||
return await handler(request)
|
||||
|
||||
|
||||
def switch_command():
|
||||
"""The line to paste into the server's terminal that makes the allow_remote_changes file, or
|
||||
removes it when it is there. On Windows `echo off >` makes the file in cmd and PowerShell
|
||||
alike (only that it exists counts)."""
|
||||
if os.name == "nt":
|
||||
return f'del "{REMOTE_FILE}"' if REMOTE_CHANGES else f'echo off > "{REMOTE_FILE}"'
|
||||
return ("rm -f " if REMOTE_CHANGES else "touch ") + shlex.quote(REMOTE_FILE)
|
||||
|
||||
|
||||
async def api_access(request):
|
||||
"""GET /anomalous/access - whether this page may change things: {"local": bool}."""
|
||||
return web.json_response({"local": is_local(request)})
|
||||
"""GET /anomalous/access - whether this page is on this computer and whether it may change
|
||||
things: {"local": bool, "changes": bool}; for another device also "command", the line that
|
||||
turns remote changes on or off (it only runs where someone types it, on the server)."""
|
||||
local = is_local(request)
|
||||
answer = {"local": local, "changes": may_change(request)}
|
||||
if not local:
|
||||
answer["command"] = switch_command()
|
||||
return web.json_response(answer)
|
||||
|
||||
@@ -399,6 +399,10 @@ async def api_get_materials_by_node_type(request):
|
||||
continue
|
||||
blocks = [block for block in _material_node_blocks(material, include_values=True) if block["type"] == node_type]
|
||||
if blocks:
|
||||
# Where the values came from, for Current node: a kept picture, or a node saved there.
|
||||
source = material.get("source") if isinstance(material.get("source"), dict) else {}
|
||||
image = source.get("image") if isinstance(source.get("image"), dict) else {}
|
||||
preview = str((material.get("image") if isinstance(material.get("image"), dict) else {}).get("preview_asset_id") or "")
|
||||
matches.append({
|
||||
"filename": summary["filename"],
|
||||
"name": summary["name"],
|
||||
@@ -406,6 +410,10 @@ async def api_get_materials_by_node_type(request):
|
||||
"timestamp": summary["timestamp"],
|
||||
"blocks": blocks,
|
||||
"workflow_hashes": _workflow_hashes_for_blocks(material["workflow"], blocks),
|
||||
"origin": str(source.get("type") or ""),
|
||||
"source_image": {"filename": str(image["filename"]), "subfolder": str(image.get("subfolder") or "")}
|
||||
if isinstance(image.get("filename"), str) and image["filename"] else None,
|
||||
"preview": preview if preview.lower().endswith((".png", ".webp")) else "",
|
||||
})
|
||||
return matches
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ from .metadata import get_metadata
|
||||
from .model_catalog import _iter_search_models
|
||||
from .model_constants import MEDIA_EXTENSIONS, MODEL_EXTENSIONS, PREVIEW_SUFFIXES
|
||||
from .notebooks import _list_notebooks
|
||||
from .path_utils import resolve_folder_subdir, resolve_within
|
||||
from .path_utils import model_file_within, resolve_folder_subdir, resolve_within
|
||||
from .recipe_store import _list_recipes, get_recipes_dir
|
||||
from .scan_report import last_scan_path
|
||||
from .scan_summary import _summarize
|
||||
@@ -94,7 +94,7 @@ def _resolve_model(model_id):
|
||||
try:
|
||||
path_idx = int(path_idx)
|
||||
base_dir, target_dir = resolve_folder_subdir(folder_type, path_idx, "/" + subfolder)
|
||||
file_path = resolve_within(target_dir, filename)
|
||||
file_path = model_file_within(target_dir, filename)
|
||||
except (ValueError, KeyError, TypeError):
|
||||
raise ToolError(f"No models folder for {model_id}.")
|
||||
if not filename.lower().endswith(MODEL_EXTENSIONS) or not os.path.isfile(file_path):
|
||||
|
||||
@@ -14,6 +14,7 @@ try:
|
||||
from ..model_identity import USER_INFO_SUFFIX, is_unmatched, read_json, sidecar_file_hash, sidecar_info, unmatched_reason
|
||||
except ImportError:
|
||||
from model_identity import USER_INFO_SUFFIX, is_unmatched, read_json, sidecar_file_hash, sidecar_info, unmatched_reason
|
||||
from .model_heads import inspect as inspect_file # the header, read once per file version
|
||||
|
||||
# Fields the model editor sets: <model>.anomalous.json key -> metadata key.
|
||||
USER_FIELDS = {"custom_name": "custom_name", "custom_notes": "custom_notes", "source_url": "source_url"}
|
||||
@@ -85,8 +86,18 @@ def _positive_civitai_id(value):
|
||||
return None
|
||||
return parsed if parsed > 0 else None
|
||||
|
||||
def _info_base(file_path, base_path):
|
||||
"""Where the model's info files are read: beside the model as named; for a link with none
|
||||
beside it, beside the file it links to, where another tool (A1111's Civitai Helper) may
|
||||
have left them. Only read there: covers, edits and scans stay beside the link."""
|
||||
if os.path.islink(file_path) and not any(os.path.exists(base_path + suffix) for suffix in (".info", ".civitai.info")):
|
||||
return os.path.splitext(os.path.realpath(file_path))[0]
|
||||
return base_path
|
||||
|
||||
|
||||
def _read_metadata(file_path):
|
||||
base_path = os.path.splitext(file_path)[0]
|
||||
info_base = _info_base(file_path, base_path)
|
||||
metadata = {
|
||||
"name": os.path.basename(base_path),
|
||||
"description": "",
|
||||
@@ -101,13 +112,17 @@ def _read_metadata(file_path):
|
||||
"hash_source": "",
|
||||
"custom_name": "",
|
||||
"custom_notes": "",
|
||||
"version_name": "", # the Civitai version ("v2.0", "Illustrious"…), told apart on the card
|
||||
"base_guess": "", # no base model known yet (not scanned): what the file's header tells
|
||||
# "civitai" (matched), "local" (only what the file tells: no match, or offline) or "" (not scanned)
|
||||
"info_source": "",
|
||||
"info_source": "",
|
||||
"unmatched_reason": "", # for "local": not_found | network | offline | "" (scanned before this was kept)
|
||||
"user_fields": [],
|
||||
"scan_choice": "", # the user's "skip" / "scan" (model_identity.scan_choice)
|
||||
"not_drawing": False, # not scanned yet, and its header looks like no drawing model
|
||||
}
|
||||
|
||||
info_files = [f"{base_path}.info", f"{base_path}.civitai.info"]
|
||||
info_files = [f"{info_base}.info", f"{info_base}.civitai.info"]
|
||||
for info_file in info_files:
|
||||
if os.path.exists(info_file):
|
||||
try:
|
||||
@@ -156,6 +171,8 @@ def _read_metadata(file_path):
|
||||
if civitai_url: metadata["civitai_url"] = civitai_url
|
||||
if model_id is not None: metadata["model_id"] = model_id
|
||||
if version_id is not None: metadata["version_id"] = version_id
|
||||
if version_id is not None and isinstance(data.get("model"), dict) and isinstance(data.get("name"), str):
|
||||
metadata["version_name"] = data["name"].strip()
|
||||
if hash_val: metadata["hash"] = hash_val
|
||||
if "anomalous_custom_name" in data and data["anomalous_custom_name"]: metadata["custom_name"] = data["anomalous_custom_name"]
|
||||
if "anomalous_custom_notes" in data and data["anomalous_custom_notes"]: metadata["custom_notes"] = data["anomalous_custom_notes"]
|
||||
@@ -163,12 +180,20 @@ def _read_metadata(file_path):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
scan = sidecar_info(base_path)
|
||||
scan = sidecar_info(info_base)
|
||||
if scan is not None:
|
||||
metadata["info_source"] = "local" if is_unmatched(scan) else "civitai"
|
||||
metadata["info_source"] = "local" if is_unmatched(scan) else "civitai"
|
||||
metadata["unmatched_reason"] = unmatched_reason(scan)
|
||||
if scan is None:
|
||||
found = inspect_file(file_path)
|
||||
if not metadata["baseModel"]:
|
||||
metadata["base_guess"] = found.get("base") or ""
|
||||
# A header that reads but names no drawing model kind (a voice model, say).
|
||||
metadata["not_drawing"] = found["format"] == "safetensors" and not found["kind"]
|
||||
# The user's own layer comes last and wins, also when a field was cleared on purpose ("").
|
||||
user = read_json(base_path + USER_INFO_SUFFIX) or {}
|
||||
if user.get("scan") in ("skip", "scan"):
|
||||
metadata["scan_choice"] = user["scan"]
|
||||
for key, field in USER_FIELDS.items():
|
||||
if isinstance(user.get(key), str):
|
||||
metadata[field] = user[key]
|
||||
@@ -204,7 +229,8 @@ def _get_metadata_cached(file_path, signature):
|
||||
|
||||
|
||||
def get_metadata(file_path):
|
||||
normalized_path = os.path.realpath(file_path)
|
||||
# As named, not resolved: a model that is a link has its covers and info beside the link.
|
||||
normalized_path = os.path.abspath(file_path)
|
||||
signature = _metadata_signature(normalized_path)
|
||||
# Metadata contains mutable lists. Never expose the cached object directly.
|
||||
return copy.deepcopy(_get_metadata_cached(normalized_path, signature))
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Model folder discovery, listing, search, and presentation lookup."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import urllib.parse
|
||||
|
||||
@@ -15,7 +14,7 @@ from .folder_types import (
|
||||
get_active_folder_types, get_active_model_roots,
|
||||
get_active_physical_basenames, get_folder_view_mode,
|
||||
)
|
||||
from .path_utils import resolve_folder_subdir
|
||||
from .path_utils import model_reference_within, resolve_folder_subdir
|
||||
|
||||
def _collect_folder_models(target_dir, folder_type, path_idx, rel_subfolder, page, limit):
|
||||
"""Collect one folder in a worker thread with a single directory listing."""
|
||||
@@ -448,11 +447,8 @@ def _resolve_paths_to_model_info_sync(paths, folder_types=None, exact_only=False
|
||||
real_base_dir = os.path.realpath(base_dir)
|
||||
roots.append((folder_type, path_idx, real_base_dir))
|
||||
for original, _, relative_path in requested:
|
||||
relative = relative_path.replace('/', os.sep)
|
||||
candidate = os.path.realpath(os.path.join(real_base_dir, relative))
|
||||
try:
|
||||
if os.path.commonpath((real_base_dir, candidate)) != real_base_dir:
|
||||
continue
|
||||
candidate = model_reference_within(real_base_dir, relative_path.replace('/', os.sep))
|
||||
except ValueError:
|
||||
continue
|
||||
if os.path.isfile(candidate) and is_model_file(candidate):
|
||||
@@ -601,91 +597,3 @@ async def api_get_all_scan_models(request):
|
||||
limit = int(request.query.get('limit', 0))
|
||||
payload = await asyncio.to_thread(_collect_all_scan_models, page, limit)
|
||||
return web.json_response(payload)
|
||||
|
||||
|
||||
async def api_batch_select(request):
|
||||
folder_key = request.query.get('folderKey', 'ALL')
|
||||
action = request.query.get('action', 'all')
|
||||
|
||||
def matches_condition(file_path, root, base_name):
|
||||
if action == 'all':
|
||||
return True
|
||||
elif action == 'no_preview':
|
||||
for ext in PREVIEW_SUFFIXES + MEDIA_EXTENSIONS:
|
||||
if os.path.exists(os.path.join(root, base_name + ext)):
|
||||
return False
|
||||
return True
|
||||
elif action == 'no_desc':
|
||||
info_file = file_path + '.info'
|
||||
civitai_info = os.path.join(root, base_name + '.civitai.info')
|
||||
if os.path.exists(civitai_info):
|
||||
try:
|
||||
with open(civitai_info, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
if data.get('description', '').strip():
|
||||
return False
|
||||
except: pass
|
||||
if os.path.exists(info_file):
|
||||
try:
|
||||
with open(info_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
if data.get('description', '').strip():
|
||||
return False
|
||||
except: pass
|
||||
return True
|
||||
return False
|
||||
|
||||
results = {}
|
||||
|
||||
if folder_key == 'ALL':
|
||||
target_types = get_active_folder_types()
|
||||
seen_dirs = set()
|
||||
for t in target_types:
|
||||
try: paths = folder_paths.get_folder_paths(t)
|
||||
except Exception: continue
|
||||
if not paths: continue
|
||||
for path_idx, base_dir in enumerate(paths):
|
||||
if base_dir in seen_dirs: continue
|
||||
seen_dirs.add(base_dir)
|
||||
if not os.path.exists(base_dir): continue
|
||||
for root, dirs, files in os.walk(base_dir):
|
||||
for f in files:
|
||||
if is_model_file(f):
|
||||
file_path = os.path.join(root, f)
|
||||
base_name = os.path.splitext(f)[0]
|
||||
if matches_condition(file_path, root, base_name):
|
||||
rel_subfolder = os.path.relpath(root, base_dir)
|
||||
if rel_subfolder == '.': rel_subfolder = ''
|
||||
else: rel_subfolder = rel_subfolder.replace('\\', '/')
|
||||
fkey = f"{t}|{path_idx}|{rel_subfolder}"
|
||||
if fkey not in results: results[fkey] = []
|
||||
results[fkey].append(f)
|
||||
else:
|
||||
parts = folder_key.split('|')
|
||||
if len(parts) >= 3:
|
||||
t = parts[0]
|
||||
path_idx = int(parts[1])
|
||||
subfolder = parts[2]
|
||||
|
||||
try: paths = folder_paths.get_folder_paths(t)
|
||||
except Exception: paths = []
|
||||
|
||||
if paths and path_idx < len(paths):
|
||||
try:
|
||||
base_dir, target_dir = resolve_folder_subdir(t, path_idx, subfolder)
|
||||
except ValueError:
|
||||
return web.json_response({"selected": {}})
|
||||
|
||||
if os.path.exists(target_dir):
|
||||
try: entries = os.listdir(target_dir)
|
||||
except: entries = []
|
||||
for f in entries:
|
||||
if is_model_file(f):
|
||||
file_path = os.path.join(target_dir, f)
|
||||
if os.path.isfile(file_path):
|
||||
base_name = os.path.splitext(f)[0]
|
||||
if matches_condition(file_path, target_dir, base_name):
|
||||
if folder_key not in results: results[folder_key] = []
|
||||
results[folder_key].append(f)
|
||||
|
||||
return web.json_response({"selected": results})
|
||||
|
||||
@@ -7,7 +7,7 @@ POST /anomalous/download/start {download_url, type, root, rel, sha256, size, .
|
||||
GET /anomalous/download/status -> {jobs}
|
||||
POST /anomalous/download/cancel {id}
|
||||
GET/POST /anomalous/download/settings -> where downloads go ({place, folder}), kept in
|
||||
user/anomalous/download_settings.json.
|
||||
user/anomalous/download_settings.json; imported models (model_import.py) follow the same folder.
|
||||
|
||||
One download runs at a time, into "<file>.part" next to where the file goes; an interrupted one
|
||||
continues from there. The finished file is checked against the SHA-256 the workflow or the site
|
||||
@@ -47,7 +47,7 @@ SPACE_MARGIN = 200 * 1024 * 1024
|
||||
MAX_SUBFOLDERS = 300
|
||||
KEEP_FINISHED = 50
|
||||
# hf_mirror None: not chosen yet (the page takes the mirror for a Chinese interface).
|
||||
DEFAULT_SETTINGS = {"place": "workflow", "folder": "Downloads", "hf_mirror": None}
|
||||
DEFAULT_SETTINGS = {"place": "workflow", "folder": "{base}", "hf_mirror": None}
|
||||
|
||||
_lock = threading.Lock()
|
||||
_jobs = {} # id -> job dict, in the order started
|
||||
@@ -323,7 +323,12 @@ def _finish(job, part, sha256):
|
||||
job["card"] = hf_card.enrich(job["dest"], job["url"])
|
||||
except Exception as error:
|
||||
print(f"[Anomalous Browser] Download: the Hugging Face model card was not read: {error}")
|
||||
# ComfyUI's file lists and the browser's model metadata see the new file.
|
||||
forget_file_lists()
|
||||
|
||||
|
||||
def forget_file_lists():
|
||||
"""ComfyUI's file lists and the browser's model metadata see a file that just arrived
|
||||
(a download, an import)."""
|
||||
for cache in (getattr(folder_paths, "filename_list_cache", None), getattr(folder_paths, "cache_helper", None)):
|
||||
try:
|
||||
cache.clear()
|
||||
@@ -333,7 +338,7 @@ def _finish(job, part, sha256):
|
||||
from .metadata import clear_metadata_cache
|
||||
clear_metadata_cache()
|
||||
except Exception as error:
|
||||
print(f"[Anomalous Browser] Download: could not clear the model cache: {error}")
|
||||
print(f"[Anomalous Browser] Could not clear the model cache: {error}")
|
||||
|
||||
|
||||
def _record(job, sha256):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Shows a model file in this computer's file manager: Explorer or Finder with the file
|
||||
selected, the folder on Linux. The request names the model the way the browser lists it
|
||||
(type, folder index, subfolder, file name), never a path, and local_guard lets only this
|
||||
computer ask (it is a POST)."""
|
||||
selected, the folder on Linux; or, without a file name, opens a models folder (a type's root
|
||||
or a subfolder). The request names it the way the browser lists it (type, folder index,
|
||||
subfolder, file name), never a path, and local_guard lets only this computer ask (a POST)."""
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -10,7 +10,7 @@ import time
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from .utils import require_filename, resolve_folder_subdir, resolve_within
|
||||
from .utils import model_file_within, resolve_folder_subdir
|
||||
|
||||
|
||||
def _explorer_windows():
|
||||
@@ -90,7 +90,20 @@ def _reveal(path):
|
||||
subprocess.Popen(["xdg-open", os.path.dirname(path)])
|
||||
|
||||
|
||||
def _open_folder(folder):
|
||||
if sys.platform == "win32":
|
||||
before = set(_explorer_windows())
|
||||
subprocess.Popen(f'explorer "{folder}"')
|
||||
threading.Thread(target=_raise_explorer, args=(folder, before), daemon=True).start()
|
||||
elif sys.platform == "darwin":
|
||||
subprocess.Popen(["open", folder])
|
||||
else:
|
||||
subprocess.Popen(["xdg-open", folder])
|
||||
|
||||
|
||||
async def api_open_model_folder(request):
|
||||
"""{type, path_idx, subfolder, filename}: Explorer with the model selected; without a
|
||||
filename, the folder (type root or subfolder) itself."""
|
||||
try:
|
||||
data = await request.json()
|
||||
except ValueError:
|
||||
@@ -98,15 +111,15 @@ async def api_open_model_folder(request):
|
||||
if not isinstance(data, dict):
|
||||
return web.json_response({"status": "error", "message": "Invalid request body"}, status=400)
|
||||
try:
|
||||
filename = require_filename(data.get("filename", ""))
|
||||
_, folder = resolve_folder_subdir(data.get("type", ""), int(data.get("path_idx", 0)), data.get("subfolder", "/"))
|
||||
path = resolve_within(folder, filename)
|
||||
filename = data.get("filename")
|
||||
path = model_file_within(folder, filename) if filename else None
|
||||
except (ValueError, KeyError, TypeError):
|
||||
return web.json_response({"status": "error", "message": "Invalid request parameters"}, status=400)
|
||||
if not os.path.isfile(path):
|
||||
return web.json_response({"status": "error", "message": "Model file not found"}, status=404)
|
||||
if not os.path.isfile(path) if path else not os.path.isdir(folder):
|
||||
return web.json_response({"status": "error", "message": "Not found"}, status=404)
|
||||
try:
|
||||
_reveal(path)
|
||||
_reveal(path) if path else _open_folder(folder)
|
||||
except OSError as error:
|
||||
return web.json_response({"status": "error", "message": str(error)}, status=500)
|
||||
return web.json_response({"status": "success"})
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""What a model file's header says (model_kind.inspect_file: kind, base model, format), kept on
|
||||
disk so each file version is read once, also across restarts. With many models, or models on a
|
||||
network drive (a NAS, a cloud server's model disk), reading every header again each time the
|
||||
scan page or a models folder opens takes minutes; the header only changes with the file.
|
||||
|
||||
Kept in user/anomalous/model_heads.json by the file's real path, with its size and time: a
|
||||
changed file is read again. At most MAX_ENTRIES files, the longest unused dropped first, and
|
||||
written a few seconds after the last change (one write for a whole folder). Only this plugin
|
||||
reads it; it is a cache, not data to back up. It is kept on disk once the plugin's routes are
|
||||
set up (keep_on_disk); before that (tests, tools) it lives in memory only."""
|
||||
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
|
||||
from .activity_log import log_path
|
||||
from .path_utils import atomic_write_json
|
||||
|
||||
try:
|
||||
from ..model_identity import read_json
|
||||
from ..model_kind import inspect_file
|
||||
except ImportError: # loaded outside the package (tests)
|
||||
from model_identity import read_json
|
||||
from model_kind import inspect_file
|
||||
|
||||
MAX_ENTRIES = 50000 # about 6 MB on disk; a library has far fewer models
|
||||
MAX_BYTES = 64 * 1024 * 1024 # a cache file larger than this is not read (nor written)
|
||||
SAVE_AFTER_S = 5
|
||||
FIELDS = ("kind", "base", "sure", "format")
|
||||
|
||||
_lock = threading.Lock()
|
||||
_heads = None # {real path: [size, mtime_ns, last used (days), kind, base, sure, format]}
|
||||
_timer = None
|
||||
_on_disk = False
|
||||
|
||||
|
||||
def keep_on_disk():
|
||||
"""From now on the cache is read from and written to cache_path() (the plugin's start)."""
|
||||
global _on_disk
|
||||
_on_disk = True
|
||||
|
||||
|
||||
def cache_path():
|
||||
return os.path.join(os.path.dirname(log_path()), "model_heads.json")
|
||||
|
||||
|
||||
def _load():
|
||||
global _heads
|
||||
if _heads is None:
|
||||
try:
|
||||
data = read_json(cache_path()) if _on_disk and os.path.getsize(cache_path()) <= MAX_BYTES else None
|
||||
except OSError:
|
||||
data = None
|
||||
_heads = {key: value for key, value in (data or {}).items()
|
||||
if isinstance(key, str) and isinstance(value, list) and len(value) == 3 + len(FIELDS)}
|
||||
return _heads
|
||||
|
||||
|
||||
def save():
|
||||
"""Writes the cache now (also what the timer does)."""
|
||||
global _timer
|
||||
with _lock:
|
||||
_timer = None
|
||||
heads = _load()
|
||||
if len(heads) > MAX_ENTRIES:
|
||||
for key in sorted(heads, key=lambda name: heads[name][2])[:len(heads) - MAX_ENTRIES]:
|
||||
del heads[key]
|
||||
snapshot = dict(heads)
|
||||
try:
|
||||
atomic_write_json(cache_path(), snapshot, max_bytes=MAX_BYTES)
|
||||
except (OSError, ValueError) as error:
|
||||
print(f"[Anomalous Browser] Model header cache not saved: {error}")
|
||||
|
||||
|
||||
def _save_soon():
|
||||
global _timer
|
||||
if _on_disk and _timer is None:
|
||||
_timer = threading.Timer(SAVE_AFTER_S, save)
|
||||
_timer.daemon = True
|
||||
_timer.start()
|
||||
|
||||
|
||||
def inspect(path):
|
||||
"""model_kind.inspect_file(path), from the cache while the file is unchanged."""
|
||||
try:
|
||||
stat = os.stat(path)
|
||||
except OSError:
|
||||
return inspect_file(path)
|
||||
real, today = os.path.realpath(path), int(time.time() // 86400)
|
||||
with _lock:
|
||||
known = _load().get(real)
|
||||
if known and known[0] == stat.st_size and known[1] == stat.st_mtime_ns:
|
||||
if known[2] != today:
|
||||
known[2] = today
|
||||
_save_soon()
|
||||
return dict(zip(FIELDS, known[3:]))
|
||||
found = inspect_file(path)
|
||||
with _lock:
|
||||
_load()[real] = [stat.st_size, stat.st_mtime_ns, today, *(found.get(field) for field in FIELDS)]
|
||||
_save_soon()
|
||||
return found
|
||||
@@ -0,0 +1,399 @@
|
||||
"""Putting a model file the user drops on the models page into the right models folder.
|
||||
|
||||
A browser gives a dropped file's name, size, time and bytes, never its path. So:
|
||||
GET /anomalous/import/folders -> {types: {type: roots}, settings}: the folders a model can go to
|
||||
(model_download.roots_of) and the download settings, whose folder rule ({base}) imports share.
|
||||
POST /anomalous/import/inspect?name&size&mtime body: the file's first bytes (its header)
|
||||
-> what it is (model_kind.classify), files of the same name already there, and, when this
|
||||
computer's Downloads or Desktop holds that very file (same name, size and time; asked only
|
||||
by the computer ComfyUI runs on), a `token` for it.
|
||||
POST /anomalous/import/identify {token} -> its SHA-256, what Civitai knows of it (base model,
|
||||
model and version name) and a scanned model with the same SHA-256, if any.
|
||||
POST /anomalous/import/place {token, type, root, rel, keep} -> moves that file there (a copy
|
||||
when `keep`); same drive = a rename, another drive = copy, then the original goes.
|
||||
PUT /anomalous/import/upload?type&root&rel&size body: the whole file -> written there.
|
||||
identify, place and upload answer only the computer ComfyUI runs on, unless remote changes
|
||||
are allowed (local_guard.may_change).
|
||||
|
||||
A file already at the destination is never replaced: the new one gets " (2)". Each import is
|
||||
one activity log entry (model_import).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from aiohttp import web
|
||||
import folder_paths
|
||||
|
||||
try:
|
||||
from ..model_identity import file_sha256
|
||||
from ..model_kind import classify, inspect_file, parse_gguf, parse_safetensors
|
||||
except ImportError: # loaded outside the package (tests)
|
||||
from model_identity import file_sha256
|
||||
from model_kind import classify, inspect_file, parse_gguf, parse_safetensors
|
||||
from .activity_log import add_entry
|
||||
from .download_sources import SourceUnreachable, civitai_by_hash
|
||||
from .local_guard import is_local as _is_local, may_change
|
||||
from .model_download import (
|
||||
CHUNK, MODEL_EXTENSIONS, SPACE_MARGIN, clean_folder, folder_type_for, forget_file_lists, read_settings, roots_of,
|
||||
)
|
||||
from .path_utils import resolve_within
|
||||
|
||||
KINDS = ("checkpoints", "loras", "vae", "text_encoders", "diffusion_models", "controlnet", "embeddings",
|
||||
"clip_vision", "upscale_models", "ipadapter")
|
||||
SEARCH_DEPTH = 2
|
||||
SEARCH_LIMIT = 20000
|
||||
TIME_SLACK_MS = 2000
|
||||
KEEP_TOKENS = 200
|
||||
_FOLDER_IDS = { # Windows known folders: Downloads, Desktop
|
||||
"downloads": "{374DE290-123F-4565-9164-39C4925E467B}",
|
||||
"desktop": "{B4BFCC3A-DB2C-424C-B029-7FE99A87C641}",
|
||||
}
|
||||
|
||||
_lock = threading.Lock()
|
||||
_tokens = {} # token -> {path, name, size}
|
||||
|
||||
|
||||
class Refused(Exception):
|
||||
"""An import that cannot be done; `code` says why (the page has a sentence for each)."""
|
||||
|
||||
def __init__(self, code):
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
|
||||
|
||||
def _error(code, status=400):
|
||||
return web.json_response({"error": code}, status=status)
|
||||
|
||||
|
||||
# --- where the dropped file is on this computer --------------------------------------------
|
||||
|
||||
def _known_folder(name):
|
||||
if os.name == "nt":
|
||||
try:
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
folder_id = uuid.UUID(_FOLDER_IDS[name]).bytes_le
|
||||
path = ctypes.c_wchar_p()
|
||||
shell = ctypes.windll.shell32
|
||||
shell.SHGetKnownFolderPath.argtypes = [ctypes.c_char_p, wintypes.DWORD, wintypes.HANDLE, ctypes.POINTER(ctypes.c_wchar_p)]
|
||||
if shell.SHGetKnownFolderPath(folder_id, 0, None, ctypes.byref(path)) == 0:
|
||||
found = path.value
|
||||
ctypes.windll.ole32.CoTaskMemFree(path)
|
||||
if found and os.path.isdir(found):
|
||||
return found
|
||||
except Exception:
|
||||
pass
|
||||
fallback = os.path.expanduser("~/" + name.capitalize())
|
||||
return fallback if os.path.isdir(fallback) else ""
|
||||
|
||||
|
||||
def search_folders():
|
||||
"""[(label, path)] where dropped files usually come from: Downloads, then Desktop."""
|
||||
found = []
|
||||
for label in ("downloads", "desktop"):
|
||||
path = _known_folder(label)
|
||||
if path and all(os.path.realpath(path) != os.path.realpath(other) for _, other in found):
|
||||
found.append((label, path))
|
||||
return found
|
||||
|
||||
|
||||
def find_original(name, size, mtime_ms, folders=None):
|
||||
"""(label, path) of the file with this name, size and time in `folders` (and their
|
||||
subfolders two deep), else None."""
|
||||
wanted = name.lower()
|
||||
seen = 0
|
||||
for label, base in folders if folders is not None else search_folders():
|
||||
for current, dirs, files in os.walk(base):
|
||||
depth = 0 if current == base else os.path.relpath(current, base).count(os.sep) + 1
|
||||
dirs[:] = sorted(item for item in dirs if not item.startswith(".")) if depth < SEARCH_DEPTH else []
|
||||
for file in files:
|
||||
seen += 1
|
||||
if seen > SEARCH_LIMIT:
|
||||
return None
|
||||
if file.lower() != wanted:
|
||||
continue
|
||||
path = os.path.join(current, file)
|
||||
try:
|
||||
stat = os.stat(path)
|
||||
except OSError:
|
||||
continue
|
||||
if stat.st_size == size and (not mtime_ms or abs(stat.st_mtime * 1000 - mtime_ms) <= TIME_SLACK_MS):
|
||||
return label, path
|
||||
return None
|
||||
|
||||
|
||||
def _remember(path, name, size):
|
||||
token = uuid.uuid4().hex
|
||||
with _lock:
|
||||
_tokens[token] = {"path": path, "name": name, "size": size}
|
||||
while len(_tokens) > KEEP_TOKENS:
|
||||
_tokens.pop(next(iter(_tokens)))
|
||||
return token
|
||||
|
||||
|
||||
def _original(token):
|
||||
with _lock:
|
||||
item = _tokens.get(str(token or ""))
|
||||
if not item or not os.path.isfile(item["path"]) or os.path.getsize(item["path"]) != item["size"]:
|
||||
raise Refused("gone")
|
||||
return item
|
||||
|
||||
|
||||
# --- what it is ----------------------------------------------------------------------------
|
||||
|
||||
def import_types():
|
||||
known = getattr(folder_paths, "folder_names_and_paths", {})
|
||||
return [kind for kind in KINDS if kind in known]
|
||||
|
||||
|
||||
def classify_head(name, head):
|
||||
"""classify() from a file's first bytes, by its extension."""
|
||||
lower = name.lower()
|
||||
if lower.endswith((".safetensors", ".sft")):
|
||||
return classify("safetensors", parse_safetensors(head))
|
||||
if lower.endswith(".gguf"):
|
||||
return classify("gguf", parse_gguf(head))
|
||||
return classify(None, None)
|
||||
|
||||
|
||||
def same_name(kind, name):
|
||||
"""Files of this name already in the type's folders: [{root, rel, size}]."""
|
||||
if not kind:
|
||||
return []
|
||||
wanted, found = name.lower(), []
|
||||
for index, base in enumerate(folder_paths.get_folder_paths(kind) or []):
|
||||
if not os.path.isdir(base):
|
||||
continue
|
||||
for current, _dirs, files in os.walk(base):
|
||||
for file in files:
|
||||
if file.lower() == wanted:
|
||||
path = os.path.join(current, file)
|
||||
found.append({"root": index, "rel": os.path.relpath(path, base).replace(os.sep, "/"),
|
||||
"size": os.path.getsize(path)})
|
||||
return found[:10]
|
||||
|
||||
|
||||
def inspect(name, size, mtime_ms, head, local):
|
||||
found = find_original(name, size, mtime_ms) if local else None
|
||||
if found:
|
||||
result = inspect_file(found[1])
|
||||
result.pop("format", None)
|
||||
result.update(token=_remember(found[1], name, size), where=found[0])
|
||||
else:
|
||||
result = classify_head(name, head)
|
||||
if result["kind"] not in import_types():
|
||||
result.update(kind="", sure=False)
|
||||
result["same_name"] = same_name(result["kind"], name)
|
||||
return result
|
||||
|
||||
|
||||
def _same_hash(sha256):
|
||||
"""Where a scanned model with this SHA-256 is ("sub/name.safetensors"), else None."""
|
||||
from .model_resolution import collect_model_hash_index
|
||||
names = [key for key, value in collect_model_hash_index().items() if str(value.get("hash") or "").lower() == sha256]
|
||||
return next((key for key in names if "/" in key), names[0] if names else None)
|
||||
|
||||
|
||||
def identify(token):
|
||||
"""SHA-256, Civitai's record, and an already scanned copy, of a found file."""
|
||||
item = _original(token)
|
||||
sha256 = file_sha256(item["path"])
|
||||
result = {"sha256": sha256, "civitai": None, "reason": "", "duplicate": _same_hash(sha256)}
|
||||
try:
|
||||
found = civitai_by_hash(sha256)
|
||||
if found:
|
||||
result["civitai"] = {key: found.get(key, "") for key in ("base_model", "model_name", "version_name", "page")}
|
||||
else:
|
||||
result["reason"] = "not_found"
|
||||
except SourceUnreachable:
|
||||
result["reason"] = "network"
|
||||
return result
|
||||
|
||||
|
||||
# --- putting it there ----------------------------------------------------------------------
|
||||
|
||||
def free_path(path):
|
||||
"""`path`, or "name (2).ext", "name (3).ext"… when it is taken."""
|
||||
if not os.path.exists(path) and not os.path.exists(path + ".part"):
|
||||
return path
|
||||
stem, ext = os.path.splitext(path)
|
||||
number = 2
|
||||
while os.path.exists(f"{stem} ({number}){ext}") or os.path.exists(f"{stem} ({number}){ext}.part"):
|
||||
number += 1
|
||||
return f"{stem} ({number}){ext}"
|
||||
|
||||
|
||||
def destination(data, size=0):
|
||||
"""(type, root index, root path, dest) for a place/upload request; Refused if refused."""
|
||||
folder_type = folder_type_for([data.get("type")])
|
||||
if folder_type not in import_types():
|
||||
raise Refused("bad_type")
|
||||
paths = folder_paths.get_folder_paths(folder_type) or []
|
||||
try:
|
||||
root_index = int(data.get("root", 0))
|
||||
except (TypeError, ValueError):
|
||||
raise Refused("bad_root")
|
||||
if not 0 <= root_index < len(paths):
|
||||
raise Refused("bad_root")
|
||||
try:
|
||||
rel = clean_folder(data.get("rel"))
|
||||
root = os.path.realpath(paths[root_index])
|
||||
dest = resolve_within(root, *rel.split("/"))
|
||||
except ValueError:
|
||||
raise Refused("bad_path")
|
||||
if not rel.lower().endswith(MODEL_EXTENSIONS):
|
||||
raise Refused("bad_name")
|
||||
probe = root
|
||||
while probe and not os.path.isdir(probe):
|
||||
probe = os.path.dirname(probe)
|
||||
if size and probe and shutil.disk_usage(probe).free < size + SPACE_MARGIN:
|
||||
raise Refused("no_space")
|
||||
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
||||
return folder_type, root_index, root, free_path(dest)
|
||||
|
||||
|
||||
def _same_drive(first, second):
|
||||
"""Whether `first` can be renamed to `second`; a link counts where the link is (moving
|
||||
it moves the link, not the model disk behind it)."""
|
||||
try:
|
||||
return os.lstat(first).st_dev == os.stat(os.path.dirname(second)).st_dev
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def move_into(source, dest, keep):
|
||||
"""Moves (or with `keep` copies) `source` to `dest`; a copy goes through "<dest>.part"."""
|
||||
if not keep and _same_drive(source, dest):
|
||||
os.replace(source, dest)
|
||||
return
|
||||
part = dest + ".part"
|
||||
try:
|
||||
shutil.copyfile(source, part)
|
||||
if os.path.getsize(part) != os.path.getsize(source):
|
||||
raise Refused("incomplete")
|
||||
os.replace(part, dest)
|
||||
except BaseException:
|
||||
if os.path.exists(part):
|
||||
os.remove(part) # our own unfinished copy
|
||||
raise
|
||||
if not keep:
|
||||
os.remove(source) # moved: the whole file is at dest now
|
||||
|
||||
|
||||
def _finish(folder_type, root_index, root, dest, how, size):
|
||||
forget_file_lists()
|
||||
rel = os.path.relpath(dest, root).replace(os.sep, "/")
|
||||
add_entry("file", "model_import", rel, {"import": {
|
||||
"type": folder_type, "path_idx": root_index, "rel": rel, "file": os.path.basename(dest), "size": size, "how": how,
|
||||
}})
|
||||
return {"type": folder_type, "root": root_index, "rel": rel, "file": os.path.basename(dest)}
|
||||
|
||||
|
||||
def place(data):
|
||||
item = _original(data.get("token"))
|
||||
keep = data.get("keep") is True
|
||||
folder_type, root_index, root, dest = destination(data, item["size"] if keep else 0)
|
||||
move_into(item["path"], dest, keep)
|
||||
if not keep:
|
||||
with _lock:
|
||||
_tokens.pop(str(data.get("token")), None)
|
||||
return _finish(folder_type, root_index, root, dest, "copy" if keep else "move", item["size"])
|
||||
|
||||
|
||||
# --- routes --------------------------------------------------------------------------------
|
||||
|
||||
async def api_folders(request):
|
||||
types = {}
|
||||
for kind in import_types():
|
||||
types[kind] = await asyncio.to_thread(roots_of, kind)
|
||||
return web.json_response({"types": types, "settings": read_settings()})
|
||||
|
||||
|
||||
async def api_inspect(request):
|
||||
name = os.path.basename(str(request.query.get("name") or "").replace("\\", "/"))
|
||||
if not name.lower().endswith(MODEL_EXTENSIONS):
|
||||
return _error("not_model")
|
||||
try:
|
||||
size = int(request.query.get("size") or 0)
|
||||
mtime_ms = float(request.query.get("mtime") or 0)
|
||||
except ValueError:
|
||||
return _error("bad_request")
|
||||
head = await request.read()
|
||||
result = await asyncio.to_thread(inspect, name, size, mtime_ms, head, _is_local(request))
|
||||
return web.json_response(result)
|
||||
|
||||
|
||||
async def api_identify(request):
|
||||
if not may_change(request):
|
||||
return _error("not_local", 403)
|
||||
try:
|
||||
data = await request.json()
|
||||
return web.json_response(await asyncio.to_thread(identify, data.get("token")))
|
||||
except Refused as error:
|
||||
return _error(error.code, 404)
|
||||
except (ValueError, AttributeError):
|
||||
return _error("bad_request")
|
||||
|
||||
|
||||
async def api_place(request):
|
||||
if not may_change(request):
|
||||
return _error("not_local", 403)
|
||||
try:
|
||||
data = await request.json()
|
||||
return web.json_response(await asyncio.to_thread(place, data))
|
||||
except Refused as error:
|
||||
return _error(error.code, 404 if error.code == "gone" else 400)
|
||||
except (ValueError, AttributeError):
|
||||
return _error("bad_request")
|
||||
except OSError as error:
|
||||
print(f"[Anomalous Browser] Import: could not put the file in place: {error}")
|
||||
return _error("io", 500)
|
||||
|
||||
|
||||
async def api_upload(request):
|
||||
if not may_change(request):
|
||||
return _error("not_local", 403)
|
||||
try:
|
||||
size = int(request.query.get("size") or 0)
|
||||
folder_type, root_index, root, dest = await asyncio.to_thread(destination, dict(request.query), size)
|
||||
except Refused as error:
|
||||
return _error(error.code)
|
||||
except ValueError:
|
||||
return _error("bad_request")
|
||||
part = dest + ".part"
|
||||
received = 0
|
||||
try:
|
||||
target = await asyncio.to_thread(open, part, "wb")
|
||||
try:
|
||||
async for chunk in request.content.iter_chunked(CHUNK):
|
||||
await asyncio.to_thread(target.write, chunk)
|
||||
received += len(chunk)
|
||||
finally:
|
||||
await asyncio.to_thread(target.close)
|
||||
if size and received != size:
|
||||
raise Refused("incomplete")
|
||||
dest = free_path(dest) if os.path.exists(dest) else dest
|
||||
await asyncio.to_thread(os.replace, part, dest)
|
||||
except BaseException as error:
|
||||
if os.path.exists(part):
|
||||
os.remove(part) # our own unfinished upload
|
||||
if isinstance(error, Refused):
|
||||
return _error(error.code)
|
||||
if isinstance(error, OSError):
|
||||
print(f"[Anomalous Browser] Import: upload failed: {error}")
|
||||
return _error("io", 500)
|
||||
raise
|
||||
return web.json_response(await asyncio.to_thread(_finish, folder_type, root_index, root, dest, "upload", received))
|
||||
|
||||
|
||||
def register_routes(app):
|
||||
app.router.add_get("/anomalous/import/folders", api_folders)
|
||||
app.router.add_post("/anomalous/import/inspect", api_inspect)
|
||||
app.router.add_post("/anomalous/import/identify", api_identify)
|
||||
app.router.add_post("/anomalous/import/place", api_place)
|
||||
app.router.add_put("/anomalous/import/upload", api_upload)
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Model preview lookup and bounded custom-cover operations."""
|
||||
|
||||
import asyncio
|
||||
import filecmp
|
||||
import os
|
||||
import shutil
|
||||
import urllib.parse
|
||||
@@ -8,9 +9,9 @@ import urllib.parse
|
||||
from aiohttp import web
|
||||
import folder_paths
|
||||
|
||||
from .model_constants import MEDIA_EXTENSIONS, PREVIEW_SUFFIXES
|
||||
from .model_constants import CIVITAI_BACKUP_SUFFIXES, MEDIA_EXTENSIONS, PREVIEW_SUFFIXES
|
||||
from .trash import move_to_trash
|
||||
from .utils import require_filename, resolve_folder_subdir, resolve_within
|
||||
from .utils import model_file_within, require_filename, resolve_folder_subdir, resolve_within
|
||||
|
||||
def _cache_token(file_path):
|
||||
try:
|
||||
@@ -132,12 +133,46 @@ def _preview_url_for_model(folder_type, path_idx, base_dir, file_path):
|
||||
)
|
||||
if not preview_file:
|
||||
return ""
|
||||
rel_subfolder = os.path.relpath(root, base_dir)
|
||||
return _media_url(folder_type, path_idx, base_dir, os.path.join(root, preview_file))
|
||||
|
||||
|
||||
def _media_url(folder_type, path_idx, base_dir, media_path):
|
||||
"""The /anomalous/image URL of a file beside a model, with a token that changes with it."""
|
||||
rel_subfolder = os.path.relpath(os.path.dirname(media_path), base_dir)
|
||||
if rel_subfolder == '.':
|
||||
rel_subfolder = '/'
|
||||
q_type = urllib.parse.quote(folder_type)
|
||||
q_idx = str(path_idx)
|
||||
q_sub = urllib.parse.quote(rel_subfolder)
|
||||
q_file = urllib.parse.quote(preview_file)
|
||||
version = _cache_token(os.path.join(root, preview_file))
|
||||
q_file = urllib.parse.quote(os.path.basename(media_path))
|
||||
version = _cache_token(media_path)
|
||||
return f"/anomalous/image?type={q_type}&path_idx={q_idx}&subfolder={q_sub}&filename={q_file}&t={version}"
|
||||
|
||||
|
||||
def _model_covers(folder_type, path_idx, base_dir, file_path):
|
||||
"""Civitai's own image a scan kept (<model>.civitai_bak.*) as a URL, or "", and whether the
|
||||
cover shown is another picture (the user's): a cover is Civitai's when it is a copy of it."""
|
||||
base = os.path.splitext(file_path)[0]
|
||||
saved = next((base + suffix for suffix in CIVITAI_BACKUP_SUFFIXES if os.path.isfile(base + suffix)), "")
|
||||
shown = next((base + suffix for suffix in PREVIEW_SUFFIXES + MEDIA_EXTENSIONS if os.path.isfile(base + suffix)), "")
|
||||
try:
|
||||
custom = bool(shown) and not (saved and filecmp.cmp(shown, saved, shallow=False))
|
||||
except OSError:
|
||||
custom = bool(shown)
|
||||
return {"civitai": _media_url(folder_type, path_idx, base_dir, saved) if saved else "", "custom": custom}
|
||||
|
||||
|
||||
async def api_model_covers(request):
|
||||
"""GET /anomalous/model_covers?type&path_idx&subfolder&filename - for the model editor: the
|
||||
Civitai cover it can bring back, and whether the one shown is the user's."""
|
||||
try:
|
||||
folder_type = request.query.get('type', '')
|
||||
path_idx = int(request.query.get('path_idx', 0))
|
||||
filename = require_filename(request.query.get('filename', ''))
|
||||
base_dir, target_dir = resolve_folder_subdir(folder_type, path_idx, request.query.get('subfolder', '/'))
|
||||
file_path = model_file_within(target_dir, filename)
|
||||
except (ValueError, KeyError, TypeError):
|
||||
return web.json_response({"error": "Invalid model location"}, status=400)
|
||||
if not os.path.isfile(file_path):
|
||||
return web.json_response({"error": "Model not found"}, status=404)
|
||||
return web.json_response(await asyncio.to_thread(_model_covers, folder_type, path_idx, base_dir, file_path))
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
|
||||
from aiohttp import web
|
||||
@@ -23,7 +24,7 @@ try:
|
||||
from ..model_identity import USER_INFO_SUFFIX, read_json
|
||||
except ImportError:
|
||||
from model_identity import USER_INFO_SUFFIX, read_json
|
||||
from .utils import require_filename, resolve_folder_subdir, resolve_within
|
||||
from .utils import model_file_within, require_filename, resolve_folder_subdir
|
||||
|
||||
def _first_existing_sidecar(base_path, suffixes):
|
||||
for suffix in suffixes:
|
||||
@@ -96,7 +97,7 @@ async def api_delete_model(request):
|
||||
try:
|
||||
filename = require_filename(filename)
|
||||
_, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder)
|
||||
model_path = resolve_within(target_dir, filename)
|
||||
model_path = model_file_within(target_dir, filename)
|
||||
except (ValueError, KeyError):
|
||||
return web.json_response({"status": "error", "message": "Invalid request parameters"}, status=400)
|
||||
if not os.path.exists(model_path):
|
||||
@@ -134,6 +135,10 @@ async def api_delete_model(request):
|
||||
return web.json_response({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
|
||||
def _is_web_link(text):
|
||||
return not text or (len(text) <= 2048 and re.fullmatch(r"https?://[^\s/]+\.\S+", text, re.IGNORECASE) is not None)
|
||||
|
||||
|
||||
async def api_update_metadata(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
@@ -162,7 +167,7 @@ async def api_update_metadata(request):
|
||||
try:
|
||||
filename = require_filename(extracted_filename)
|
||||
_, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder)
|
||||
file_path = resolve_within(target_dir, filename)
|
||||
file_path = model_file_within(target_dir, filename)
|
||||
except (ValueError, KeyError):
|
||||
pass
|
||||
|
||||
@@ -182,6 +187,9 @@ async def api_update_metadata(request):
|
||||
|
||||
if not file_path or not os.path.exists(file_path):
|
||||
return web.json_response({"status": "error", "message": "Model not found"})
|
||||
# A source link is opened from the page: a web address or nothing.
|
||||
if custom_source_url is not None and not _is_web_link(str(custom_source_url).strip()):
|
||||
return web.json_response({"status": "error", "message": "The source link must be an http(s) address."}, status=400)
|
||||
|
||||
physical_rename_skipped = physical_rename_requested and is_physical_rename_protected(
|
||||
folder_type=folder_type,
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
"""Models in the wrong models folder, and identical copies (the models page's Tidy view,
|
||||
ui_model_tidy.js).
|
||||
|
||||
GET /anomalous/placement/check[?deep=1] -> {misplaced, duplicates, unchecked}
|
||||
misplaced: models whose header (model_kind.py, only when sure) names another type than
|
||||
the folder they are in, with where they belong (the same subfolder in a folder of that
|
||||
type, on the same drive when there is one) and whether they still load where they are.
|
||||
duplicates: files with the same SHA-256 — known from scans, else read now for files of
|
||||
the same size (small ones always, large ones with deep). unchecked: the same-size large
|
||||
files a deep check would still read.
|
||||
POST /anomalous/placement/move {type, root, rel, to_type, to_root, to_rel} -> moves a model
|
||||
and its sidecars (covers, info, the user's notes); never over a file (" (2)"). Only for
|
||||
the computer ComfyUI runs on, unless remote changes are allowed (local_guard.may_change).
|
||||
|
||||
Removing a copy goes through /anomalous/delete_model (Recycle Bin). Headers and computed
|
||||
SHA-256s are kept in memory per file (path, size, time).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import threading
|
||||
import urllib.parse
|
||||
|
||||
from aiohttp import web
|
||||
import folder_paths
|
||||
|
||||
try:
|
||||
from ..model_identity import file_sha256
|
||||
from ..model_kind import inspect_file
|
||||
except ImportError: # loaded outside the package (tests)
|
||||
from model_identity import file_sha256
|
||||
from model_kind import inspect_file
|
||||
from .activity_log import add_entry
|
||||
from .local_guard import may_change
|
||||
from .metadata import get_metadata
|
||||
from .model_constants import MEDIA_EXTENSIONS, PREVIEW_SUFFIXES, SIDECAR_SUFFIXES
|
||||
from .model_download import MODEL_EXTENSIONS, clean_folder, forget_file_lists
|
||||
from .model_import import Refused, destination, import_types, move_into
|
||||
from .path_utils import model_reference_within
|
||||
|
||||
# (folder type, what the file is): it loads there all the same.
|
||||
WORKS_ANYWAY = {("diffusion_models", "checkpoints")}
|
||||
# Same-size files up to this are read at once (LoRAs, embeddings); larger ones on "Compare".
|
||||
AUTO_HASH = 256 * 1024 * 1024
|
||||
CACHE_LIMIT = 5000
|
||||
|
||||
_lock = threading.Lock()
|
||||
_heads = {} # path -> (size, mtime_ns, inspect_file result)
|
||||
_hashes = {} # path -> (size, mtime_ns, sha256)
|
||||
|
||||
|
||||
def _cached(store, path, stat, compute):
|
||||
key = (stat.st_size, stat.st_mtime_ns)
|
||||
with _lock:
|
||||
hit = store.get(path)
|
||||
if hit and hit[:2] == key:
|
||||
return hit[2]
|
||||
value = compute(path)
|
||||
with _lock:
|
||||
if len(store) >= CACHE_LIMIT:
|
||||
store.clear()
|
||||
store[path] = (*key, value)
|
||||
return value
|
||||
|
||||
|
||||
def library():
|
||||
"""Every model file once: [{type, root, rel, path, size, dev}] (a folder listed under two
|
||||
types counts for the first)."""
|
||||
seen, files = set(), []
|
||||
for folder_type in import_types():
|
||||
for index, base in enumerate(folder_paths.get_folder_paths(folder_type) or []):
|
||||
if not os.path.isdir(base):
|
||||
continue
|
||||
for current, dirs, names in os.walk(base):
|
||||
dirs[:] = sorted(name for name in dirs if not name.startswith("."))
|
||||
for name in sorted(names):
|
||||
if not name.lower().endswith(MODEL_EXTENSIONS):
|
||||
continue
|
||||
path = os.path.join(current, name)
|
||||
real = os.path.realpath(path)
|
||||
if real in seen:
|
||||
continue
|
||||
seen.add(real)
|
||||
try:
|
||||
stat = os.stat(path)
|
||||
except OSError:
|
||||
continue
|
||||
files.append({"type": folder_type, "root": index, "rel": os.path.relpath(path, base).replace(os.sep, "/"),
|
||||
"path": path, "size": stat.st_size, "dev": stat.st_dev, "stat": stat})
|
||||
return files
|
||||
|
||||
|
||||
def _card(item):
|
||||
"""What the page shows of a file: its name (the user's, else Civitai's, else the file's),
|
||||
Civitai version and cover URL."""
|
||||
try:
|
||||
meta = get_metadata(item["path"])
|
||||
except Exception:
|
||||
meta = {}
|
||||
folder, name = os.path.split(item["path"])
|
||||
stem = os.path.splitext(name)[0]
|
||||
cover = next((stem + suffix for suffix in PREVIEW_SUFFIXES + MEDIA_EXTENSIONS
|
||||
if os.path.isfile(os.path.join(folder, stem + suffix))), "")
|
||||
sub = item["rel"].rpartition("/")[0]
|
||||
url = ""
|
||||
if cover:
|
||||
url = (f"/anomalous/image?type={urllib.parse.quote(item['type'])}&path_idx={item['root']}"
|
||||
f"&subfolder={urllib.parse.quote(sub)}&filename={urllib.parse.quote(cover)}")
|
||||
version = meta.get("version_name") or ""
|
||||
name = meta.get("custom_name") or ""
|
||||
if not name and meta.get("model_id"): # Civitai's "Model - Version": the model's part
|
||||
full = meta.get("name") or ""
|
||||
name = full[:-len(" - " + version)] if version and full.endswith(" - " + version) else full
|
||||
return {"name": name or stem, "version": version, "preview_url": url}
|
||||
|
||||
|
||||
def _home_for(item, kind):
|
||||
"""Where a file of `kind` goes: its own subfolder and name, in a folder of that type,
|
||||
preferably on its drive (a move is then a rename)."""
|
||||
roots = []
|
||||
for index, base in enumerate(folder_paths.get_folder_paths(kind) or []):
|
||||
if os.path.isdir(base):
|
||||
roots.append((index, os.stat(base).st_dev))
|
||||
if not roots:
|
||||
return None
|
||||
index = next((index for index, dev in roots if dev == item["dev"]), roots[0][0])
|
||||
return {"type": kind, "root": index, "rel": item["rel"]}
|
||||
|
||||
|
||||
def misplaced(files):
|
||||
found = []
|
||||
for item in files:
|
||||
result = _cached(_heads, item["path"], item["stat"], inspect_file)
|
||||
kind = result.get("kind")
|
||||
if not kind or not result.get("sure") or kind == item["type"] or kind not in import_types():
|
||||
continue
|
||||
home = _home_for(item, kind)
|
||||
if home:
|
||||
found.append({"type": item["type"], "root": item["root"], "rel": item["rel"], "size": item["size"],
|
||||
"kind": kind, "base": result.get("base", ""), "works": (item["type"], kind) in WORKS_ANYWAY,
|
||||
"to": home, **_card(item)})
|
||||
return found
|
||||
|
||||
|
||||
def _known_hash(item):
|
||||
try:
|
||||
value = str(get_metadata(item["path"]).get("hash") or "").lower()
|
||||
except Exception:
|
||||
value = ""
|
||||
if len(value) == 64:
|
||||
return value
|
||||
with _lock:
|
||||
hit = _hashes.get(item["path"])
|
||||
return hit[2] if hit and hit[:2] == (item["stat"].st_size, item["stat"].st_mtime_ns) else ""
|
||||
|
||||
|
||||
def _copy(item):
|
||||
"""One of identical files: where it is, how it shows, whether its folder fits what it is
|
||||
(the copy to keep, when only one does)."""
|
||||
kind = _cached(_heads, item["path"], item["stat"], inspect_file).get("kind")
|
||||
return {"type": item["type"], "root": item["root"], "rel": item["rel"],
|
||||
"fits": not kind or kind == item["type"], **_card(item)}
|
||||
|
||||
|
||||
def duplicates(files, deep=False):
|
||||
"""(groups of identical files, {groups, bytes} still to read for a deep check)."""
|
||||
by_size = {}
|
||||
for item in files:
|
||||
if item["size"]:
|
||||
by_size.setdefault(item["size"], []).append(item)
|
||||
groups, unchecked = {}, {"groups": 0, "bytes": 0}
|
||||
for size, items in by_size.items():
|
||||
if len(items) < 2:
|
||||
continue
|
||||
unknown = [item for item in items if not _known_hash(item)]
|
||||
read = deep or size <= AUTO_HASH
|
||||
if unknown and not read:
|
||||
unchecked["groups"] += 1
|
||||
unchecked["bytes"] += size * len(unknown)
|
||||
for item in items:
|
||||
sha = _known_hash(item)
|
||||
if not sha and read:
|
||||
sha = _cached(_hashes, item["path"], item["stat"], file_sha256)
|
||||
if sha:
|
||||
groups.setdefault(sha, []).append(item)
|
||||
found = [{"sha256": sha, "size": items[0]["size"], "files": [_copy(item) for item in items]}
|
||||
for sha, items in groups.items() if len(items) > 1]
|
||||
return found, unchecked
|
||||
|
||||
|
||||
def check(deep=False):
|
||||
files = library()
|
||||
found, unchecked = duplicates(files, deep)
|
||||
return {"misplaced": misplaced(files), "duplicates": found, "unchecked": unchecked, "checked": len(files)}
|
||||
|
||||
|
||||
def _sidecars(path):
|
||||
"""The model's covers, info and notes: files named by its stem, unless another model in
|
||||
its folder has that stem too (then they are shared, and stay)."""
|
||||
folder, name = os.path.split(path)
|
||||
stem = os.path.splitext(name)[0]
|
||||
if any(os.path.isfile(os.path.join(folder, stem + ext)) for ext in MODEL_EXTENSIONS if stem + ext != name):
|
||||
return []
|
||||
return [(os.path.join(folder, stem + suffix), suffix) for suffix in SIDECAR_SUFFIXES
|
||||
if os.path.isfile(os.path.join(folder, stem + suffix))]
|
||||
|
||||
|
||||
def move(data):
|
||||
folder_type = data.get("type")
|
||||
if folder_type not in import_types():
|
||||
raise Refused("bad_type")
|
||||
paths = folder_paths.get_folder_paths(folder_type) or []
|
||||
try:
|
||||
index = int(data.get("root", 0))
|
||||
base = os.path.realpath(paths[index])
|
||||
rel = clean_folder(data.get("rel"))
|
||||
source = model_reference_within(base, rel.replace("/", os.sep))
|
||||
except (ValueError, TypeError, IndexError):
|
||||
raise Refused("bad_path")
|
||||
if not os.path.isfile(source) or not rel.lower().endswith(MODEL_EXTENSIONS):
|
||||
raise Refused("gone")
|
||||
to_type, to_root, root_path, dest = destination(
|
||||
{"type": data.get("to_type"), "root": data.get("to_root", 0), "rel": data.get("to_rel")}, os.path.getsize(source))
|
||||
sidecars = _sidecars(source)
|
||||
move_into(source, dest, keep=False)
|
||||
moved, left = [os.path.basename(dest)], []
|
||||
dest_stem = os.path.splitext(dest)[0]
|
||||
for path, suffix in sidecars:
|
||||
target = dest_stem + suffix
|
||||
try:
|
||||
if os.path.exists(target):
|
||||
raise FileExistsError(target)
|
||||
move_into(path, target, keep=False)
|
||||
moved.append(os.path.basename(target))
|
||||
except OSError as error:
|
||||
left.append(os.path.basename(path))
|
||||
print(f"[Anomalous Browser] Tidy: {os.path.basename(path)} stayed where it was: {error}")
|
||||
forget_file_lists()
|
||||
to_rel = os.path.relpath(dest, root_path).replace(os.sep, "/")
|
||||
add_entry("file", "model_move", to_rel, {"move": {
|
||||
"from": {"type": folder_type, "path_idx": index, "rel": rel},
|
||||
"to": {"type": to_type, "path_idx": to_root, "rel": to_rel}, "files": moved, "left": left,
|
||||
}})
|
||||
return {"type": to_type, "root": to_root, "rel": to_rel, "left": left}
|
||||
|
||||
|
||||
async def api_check(request):
|
||||
deep = request.query.get("deep") == "1"
|
||||
return web.json_response(await asyncio.to_thread(check, deep))
|
||||
|
||||
|
||||
async def api_move(request):
|
||||
if not may_change(request):
|
||||
return web.json_response({"error": "not_local"}, status=403)
|
||||
try:
|
||||
data = await request.json()
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError
|
||||
return web.json_response(await asyncio.to_thread(move, data))
|
||||
except Refused as error:
|
||||
return web.json_response({"error": error.code}, status=404 if error.code == "gone" else 400)
|
||||
except ValueError:
|
||||
return web.json_response({"error": "bad_request"}, status=400)
|
||||
except OSError as error:
|
||||
print(f"[Anomalous Browser] Tidy: could not move the model: {error}")
|
||||
return web.json_response({"error": "io"}, status=500)
|
||||
|
||||
|
||||
def register_routes(app):
|
||||
app.router.add_get("/anomalous/placement/check", api_check)
|
||||
app.router.add_post("/anomalous/placement/move", api_move)
|
||||
@@ -0,0 +1,365 @@
|
||||
"""When each model and node pack here was last used (the models page's Tidy tab, "Not used lately").
|
||||
|
||||
GET /anomalous/usage/check -> {models, packs, seen, now, ledger_since}
|
||||
Evidence, read only when the user asks:
|
||||
- the run record (usage_ledger.py): every run since it started, kept or not, and every
|
||||
workflow opened on the page (the open tabs are noted just before a check);
|
||||
- the outputs from before that: PNG images (their prompt names model files and node types;
|
||||
image_search.cached_record) and FLAC / Ogg sounds (audio_metadata), dated by the file;
|
||||
- the saved ComfyUI workflows, recipes, combos and parameter sets (any string naming a model
|
||||
file, any node type, any SHA-256).
|
||||
A model counts as used when its path, its file name (two files of one name: both count, the
|
||||
safe side) or its scanned SHA-256 appears.
|
||||
Nothing new is judged: a model or pack counts as idle since its last use or since it came
|
||||
here (`added`: the file's or folder's creation date), whichever is later.
|
||||
models: idle for 30 days or more, with {type, root, rel, size, name, version, preview_url,
|
||||
last_used (seconds or null), outputs, runs, opened, saved (saved files naming it), added}.
|
||||
packs: the node packs in custom_nodes but this plugin and ComfyUI Manager, {name, nodes,
|
||||
last_used, outputs, runs, opened, saved, added, web (it adds to ComfyUI's page too), feeds (packs
|
||||
whose nodes take a kind of thing only its nodes make), words (what to search the Manager
|
||||
for), kept (the user said they still use it)}; a pack of no nodes only works on the page or
|
||||
the server, nothing here can tell.
|
||||
seen: {outputs, saved, capped} what was read (capped: some outputs or files were not).
|
||||
ledger_since: when the run record started (null: nothing run since).
|
||||
|
||||
POST /anomalous/usage/keep {pack, kept} -> {kept: [names]}
|
||||
What no record can show (a pack used only through its panels or buttons) the user says:
|
||||
"I still use it", kept in user/anomalous/usage_kept.json ({packs: {name: since}}); only a
|
||||
pack ComfyUI loaded can be marked, any name unmarked.
|
||||
|
||||
What none of this can show (a workflow from before the record, dropped in and never saved) is
|
||||
said on the page; nothing here deletes or disables anything.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
|
||||
import folder_paths
|
||||
from aiohttp import web
|
||||
|
||||
from . import usage_ledger
|
||||
from .audio_metadata import read_audio_tags
|
||||
from .backup import part_dirs
|
||||
from .image_search import MODEL_FILE, cached_record
|
||||
from .model_placement import _card, _known_hash, library
|
||||
from .path_utils import atomic_write_json
|
||||
|
||||
RECENT_SECONDS = 30 * 24 * 3600 # used or added within this: not listed
|
||||
MAX_IMAGES = 100_000 # outputs read at most, images and sounds
|
||||
MAX_SAVED = 20_000
|
||||
MAX_JSON_BYTES = 20 * 1024 * 1024
|
||||
SAVED_PARTS = ("comfy_workflows", "recipes", "combos", "parameters")
|
||||
SOUNDS = (".flac", ".ogg", ".opus")
|
||||
SHA256 = re.compile(r"^[0-9a-fA-F]{64}$")
|
||||
PLUGIN_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
NOT_LISTED = {os.path.basename(PLUGIN_DIR).lower(), "comfyui-manager"} # the tools doing the tidying
|
||||
FEEDS_SHOWN = 3
|
||||
# Words left out of what the Manager is searched for: its search wants all words, in order, in
|
||||
# one of a pack's title, author or description, and pack folders carry these where titles do not.
|
||||
SEARCH_NOISE = {"comfyui", "comfy", "custom", "customnodes", "node", "nodes", "pack", "main", "master"}
|
||||
|
||||
_kept_lock = threading.Lock()
|
||||
|
||||
|
||||
def _refs(value):
|
||||
ref = str(value).replace("\\", "/").strip().lower()
|
||||
return {ref, ref.rsplit("/", 1)[-1]}
|
||||
|
||||
|
||||
def _arrived(stat, changed=True):
|
||||
"""When a file or folder came here: its creation date, or its last change when that is later
|
||||
(`changed`; a copied file keeps its old change date, a folder's changes with every update)."""
|
||||
born = getattr(stat, "st_birthtime", None) or stat.st_ctime
|
||||
return max(born, stat.st_mtime) if changed else born
|
||||
|
||||
|
||||
class Evidence:
|
||||
"""Per model reference / hash / node type: how many outputs, runs, openings and saved files,
|
||||
and the latest time."""
|
||||
|
||||
def __init__(self):
|
||||
self.outputs, self.runs, self.opened, self.saved, self.last = {}, {}, {}, {}, {}
|
||||
self.seen = {"outputs": 0, "saved": 0, "capped": False}
|
||||
|
||||
def add(self, keys, when=None):
|
||||
for key in keys:
|
||||
if when is None:
|
||||
self.saved[key] = self.saved.get(key, 0) + 1
|
||||
else:
|
||||
self.outputs[key] = self.outputs.get(key, 0) + 1
|
||||
self.last[key] = max(self.last.get(key, 0), when)
|
||||
|
||||
def add_noted(self, counts, key, when, count):
|
||||
"""A run record entry; `counts` is self.runs or self.opened."""
|
||||
counts[key] = counts.get(key, 0) + count
|
||||
self.last[key] = max(self.last.get(key, 0), when)
|
||||
|
||||
def of(self, keys):
|
||||
keys = [key for key in keys if key]
|
||||
last = max((self.last.get(key, 0) for key in keys), default=0)
|
||||
return {"last_used": last or None,
|
||||
**{kind: max((counts.get(key, 0) for key in keys), default=0)
|
||||
for kind, counts in (("outputs", self.outputs), ("runs", self.runs), ("opened", self.opened), ("saved", self.saved))}}
|
||||
|
||||
|
||||
def _walk_json(value, found):
|
||||
"""Model references ('m:'), SHA-256s ('h:') and node types ('t:') anywhere in saved JSON."""
|
||||
stack = [value]
|
||||
while stack:
|
||||
item = stack.pop()
|
||||
if isinstance(item, dict):
|
||||
for key in ("type", "class_type"):
|
||||
if isinstance(item.get(key), str) and item[key]:
|
||||
found.add("t:" + item[key])
|
||||
stack.extend(item.values())
|
||||
elif isinstance(item, list):
|
||||
stack.extend(item)
|
||||
elif isinstance(item, str):
|
||||
if MODEL_FILE.search(item):
|
||||
found.update("m:" + ref for ref in _refs(item))
|
||||
elif SHA256.match(item):
|
||||
found.add("h:" + item.upper())
|
||||
|
||||
|
||||
def _read_saved(evidence):
|
||||
dirs = part_dirs()
|
||||
for part in SAVED_PARTS:
|
||||
base = dirs.get(part)
|
||||
if not base or not os.path.isdir(base):
|
||||
continue
|
||||
for current, folders, names in os.walk(base):
|
||||
folders[:] = [name for name in folders if not name.startswith(".")]
|
||||
for name in names:
|
||||
if not name.lower().endswith(".json"):
|
||||
continue
|
||||
if evidence.seen["saved"] >= MAX_SAVED:
|
||||
evidence.seen["capped"] = True
|
||||
return
|
||||
path = os.path.join(current, name)
|
||||
try:
|
||||
if os.path.getsize(path) > MAX_JSON_BYTES:
|
||||
continue
|
||||
with open(path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
found = set()
|
||||
_walk_json(data, found)
|
||||
evidence.seen["saved"] += 1
|
||||
evidence.add(found)
|
||||
|
||||
|
||||
def _sound_keys(path):
|
||||
"""What a sound's embedded prompt names, as evidence keys; empty without one."""
|
||||
try:
|
||||
prompt = json.loads(read_audio_tags(path).get("prompt") or "null")
|
||||
except (OSError, ValueError):
|
||||
return set()
|
||||
if not isinstance(prompt, dict):
|
||||
return set()
|
||||
types, models = usage_ledger.prompt_uses(prompt)
|
||||
return {"t:" + node_type for node_type in types} | {"m:" + ref for model in models for ref in _refs(model)}
|
||||
|
||||
|
||||
def _read_outputs(evidence):
|
||||
base = folder_paths.get_output_directory()
|
||||
for current, folders, names in os.walk(base):
|
||||
folders[:] = [name for name in folders if not name.startswith(".")]
|
||||
for name in names:
|
||||
lower = name.lower()
|
||||
if not lower.endswith((".png",) + SOUNDS):
|
||||
continue
|
||||
if evidence.seen["outputs"] >= MAX_IMAGES:
|
||||
evidence.seen["capped"] = True
|
||||
return
|
||||
path = os.path.join(current, name)
|
||||
try:
|
||||
when = os.stat(path).st_mtime
|
||||
except OSError:
|
||||
continue
|
||||
if lower.endswith(SOUNDS):
|
||||
keys = _sound_keys(path)
|
||||
else:
|
||||
record = cached_record(path, when)
|
||||
if not record:
|
||||
continue
|
||||
keys = {"m:" + ref for ref in record["models"]} | {"h:" + sha for sha in record["hashes"]}
|
||||
keys |= {"t:" + node_type for node_type in record.get("types", ())}
|
||||
if not keys:
|
||||
continue
|
||||
evidence.seen["outputs"] += 1
|
||||
evidence.add(keys, when)
|
||||
|
||||
|
||||
def _read_ledger(evidence):
|
||||
"""Every run and opened workflow since the record started; its start, or None."""
|
||||
ledger = usage_ledger.read()
|
||||
for names, counts in ((ledger, evidence.runs), (ledger["opened"], evidence.opened)):
|
||||
for node_type, (when, count) in names["types"].items():
|
||||
evidence.add_noted(counts, "t:" + node_type, when, count)
|
||||
for model, (when, count) in names["models"].items():
|
||||
for ref in _refs(model):
|
||||
evidence.add_noted(counts, "m:" + ref, when, count)
|
||||
return ledger["started"]
|
||||
|
||||
|
||||
def _io_types(cls):
|
||||
"""(kinds of thing a node takes, kinds it makes); combo lists and '*' left out."""
|
||||
taken, made = set(), set()
|
||||
try:
|
||||
inputs = cls.INPUT_TYPES() or {}
|
||||
for group in ("required", "optional"):
|
||||
for spec in (inputs.get(group) or {}).values():
|
||||
kind = spec[0] if isinstance(spec, (list, tuple)) and spec else spec
|
||||
if isinstance(kind, str):
|
||||
taken.add(kind)
|
||||
except Exception: # noqa: BLE001 - a pack's own code; it only means "takes nothing known"
|
||||
pass
|
||||
try:
|
||||
made = {kind for kind in (cls.RETURN_TYPES or ()) if isinstance(kind, str)}
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return taken - {"*"}, made - {"*"}
|
||||
|
||||
|
||||
def _search_words(name):
|
||||
words = [word for word in re.split(r"[\s_.\-]+", name.lower()) if word and word not in SEARCH_NOISE]
|
||||
return " ".join(words) or name.lower()
|
||||
|
||||
|
||||
def _pack_dirs():
|
||||
"""{name: {name, path, dir, types: []}} for the node packs ComfyUI loaded from custom_nodes."""
|
||||
import nodes # ComfyUI's registry, filled once ComfyUI has loaded the packs
|
||||
|
||||
roots = []
|
||||
try:
|
||||
roots = [os.path.normcase(os.path.realpath(root)) for root in folder_paths.get_folder_paths("custom_nodes") or []]
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
packs = {}
|
||||
for module, directory in (getattr(nodes, "LOADED_MODULE_DIRS", {}) or {}).items():
|
||||
real = os.path.normcase(os.path.realpath(directory))
|
||||
name = os.path.splitext(os.path.basename(module))[0]
|
||||
if real in roots: # a single .py file in custom_nodes
|
||||
packs[name] = {"name": name, "path": os.path.join(directory, name + ".py"), "dir": None, "types": []}
|
||||
elif os.path.normcase(os.path.realpath(os.path.dirname(directory))) in roots:
|
||||
packs[name] = {"name": name, "path": directory, "dir": real, "types": []}
|
||||
return packs
|
||||
|
||||
|
||||
def _packs():
|
||||
"""[{name, path, dir, types, web, feeds}] for the node packs ComfyUI loaded from custom_nodes."""
|
||||
import nodes
|
||||
|
||||
packs = _pack_dirs()
|
||||
io, core = {}, set()
|
||||
for node_type, cls in (getattr(nodes, "NODE_CLASS_MAPPINGS", {}) or {}).items():
|
||||
module = str(getattr(cls, "RELATIVE_PYTHON_MODULE", "") or "")
|
||||
taken, made = _io_types(cls)
|
||||
pack = module.split(".", 2)[1] if module.startswith("custom_nodes.") else None
|
||||
if pack in packs:
|
||||
packs[pack]["types"].append(node_type)
|
||||
io.setdefault(pack, [set(), set()])
|
||||
io[pack][0] |= taken
|
||||
io[pack][1] |= made
|
||||
elif pack is None:
|
||||
core |= taken | made
|
||||
|
||||
web_dirs = [os.path.normcase(os.path.realpath(path)) for path in (getattr(nodes, "EXTENSION_WEB_DIRS", {}) or {}).values()]
|
||||
for name, pack in packs.items():
|
||||
pack["web"] = bool(pack["dir"]) and any(path == pack["dir"] or path.startswith(pack["dir"] + os.sep) for path in web_dirs)
|
||||
own = io.get(name, [set(), set()])[1] - core
|
||||
pack["feeds"] = sorted((other for other, (taken, _) in io.items() if other != name and own & taken), key=str.lower)
|
||||
return [pack for name, pack in packs.items() if name.lower() not in NOT_LISTED]
|
||||
|
||||
|
||||
def kept_path():
|
||||
return os.path.join(os.path.dirname(usage_ledger.ledger_path()), "usage_kept.json")
|
||||
|
||||
|
||||
def _kept():
|
||||
"""{pack name: since} the user still uses."""
|
||||
try:
|
||||
with open(kept_path(), encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
packs = data.get("packs") if isinstance(data, dict) else None
|
||||
return packs if isinstance(packs, dict) else {}
|
||||
|
||||
|
||||
def keep(name, kept):
|
||||
"""Marks pack `name` as still used (`kept`) or not; the names marked. KeyError: not a loaded pack."""
|
||||
with _kept_lock:
|
||||
packs = _kept()
|
||||
if kept:
|
||||
if name not in _pack_dirs() or name.lower() in NOT_LISTED:
|
||||
raise KeyError(name)
|
||||
packs[name] = time.time()
|
||||
else:
|
||||
packs.pop(name, None)
|
||||
atomic_write_json(kept_path(), {"packs": packs})
|
||||
return sorted(packs)
|
||||
|
||||
|
||||
def check(now=None):
|
||||
now = time.time() if now is None else now
|
||||
evidence = Evidence()
|
||||
ledger_since = _read_ledger(evidence)
|
||||
_read_saved(evidence)
|
||||
_read_outputs(evidence)
|
||||
|
||||
models = []
|
||||
for item in library():
|
||||
keys = ["m:" + ref for ref in _refs(item["rel"])]
|
||||
sha = _known_hash(item)
|
||||
if sha:
|
||||
keys.append("h:" + sha.upper())
|
||||
use = evidence.of(keys)
|
||||
added = _arrived(item["stat"]) if item.get("stat") else None
|
||||
if now - max(use["last_used"] or 0, added or 0) < RECENT_SECONDS:
|
||||
continue
|
||||
models.append({"type": item["type"], "root": item["root"], "rel": item["rel"], "size": item["size"],
|
||||
"added": added, **_card(item), **use})
|
||||
|
||||
packs, kept = [], _kept()
|
||||
for pack in sorted(_packs(), key=lambda entry: entry["name"].lower()):
|
||||
uses = [evidence.of(["t:" + node_type]) for node_type in pack["types"]]
|
||||
try:
|
||||
added = _arrived(os.stat(pack["path"]), changed=False)
|
||||
except OSError:
|
||||
added = None
|
||||
packs.append({"name": pack["name"], "nodes": len(pack["types"]), "added": added, "web": pack["web"],
|
||||
"feeds": pack["feeds"][:FEEDS_SHOWN], "words": _search_words(pack["name"]), "kept": pack["name"] in kept,
|
||||
"last_used": max((use["last_used"] or 0 for use in uses), default=0) or None,
|
||||
**{kind: max((use[kind] for use in uses), default=0) for kind in ("outputs", "runs", "opened", "saved")}})
|
||||
return {"models": models, "packs": packs, "seen": evidence.seen, "now": now, "ledger_since": ledger_since}
|
||||
|
||||
|
||||
async def api_check(request):
|
||||
return web.json_response(await asyncio.to_thread(check))
|
||||
|
||||
|
||||
async def api_keep(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
except ValueError:
|
||||
data = None
|
||||
name, kept = (data.get("pack"), data.get("kept")) if isinstance(data, dict) else (None, None)
|
||||
if not isinstance(name, str) or not name or not isinstance(kept, bool):
|
||||
return web.json_response({"error": "bad_request"}, status=400)
|
||||
try:
|
||||
return web.json_response({"kept": await asyncio.to_thread(keep, name, kept)})
|
||||
except KeyError:
|
||||
return web.json_response({"error": "unknown_pack"}, status=404)
|
||||
except (OSError, ValueError):
|
||||
return web.json_response({"error": "write_failed"}, status=500)
|
||||
|
||||
|
||||
def register_routes(app):
|
||||
app.router.add_get("/anomalous/usage/check", api_check)
|
||||
app.router.add_post("/anomalous/usage/keep", api_keep)
|
||||
@@ -10,7 +10,7 @@ from .model_catalog import (
|
||||
_allowed_folder_types, _collect_all_scan_models, _collect_folder_models,
|
||||
_collect_folders, _find_model_sync, _iter_search_models, _model_info_for_path,
|
||||
_resolve_paths_to_model_info_sync, _resolve_paths_to_previews_sync,
|
||||
api_base_models, api_batch_select, api_compatible_models, api_find_model,
|
||||
api_base_models, api_compatible_models, api_find_model,
|
||||
api_get_all_scan_models, api_get_folders, api_get_models,
|
||||
api_resolve_paths_to_previews,
|
||||
)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""GET /anomalous/civitai_check: whether this computer reaches Civitai now, through the proxy in
|
||||
Settings -> Network when one is set (net_proxy.py); for Settings' "Test" button and the first-run
|
||||
setup. One small request with the key, if one is set, so a key Civitai refuses is told apart.
|
||||
It goes online, so it answers this computer only (local_guard LOCAL_GETS)."""
|
||||
setup. One small request with the key, if one is set, so a key Civitai refuses is told apart;
|
||||
when civitai.com does not answer, Civitai's other address (civitai.red) is asked too, and the
|
||||
answer says which one answered (`host`). It goes online, so it answers this computer only
|
||||
(local_guard LOCAL_GETS)."""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
@@ -13,35 +15,43 @@ from aiohttp import web
|
||||
from .download_sources import civitai_headers
|
||||
|
||||
try:
|
||||
from ..civitai_client import API_HOSTS, answered
|
||||
from ..net_proxy import configured, failure, urlopen
|
||||
except ImportError: # loaded outside the package (tests)
|
||||
from civitai_client import API_HOSTS, answered
|
||||
from net_proxy import configured, failure, urlopen
|
||||
|
||||
PROBE_URL = "https://civitai.com/api/v1/models?limit=1"
|
||||
PROBE_PATH = "/api/v1/models?limit=1"
|
||||
TIMEOUT = 8
|
||||
|
||||
|
||||
def check_civitai():
|
||||
"""{reachable, key ('set' | 'none' | 'refused'), ms, error, failure (net_proxy.failure), proxy}."""
|
||||
"""{reachable, key ('set' | 'none' | 'refused'), ms, error, failure (net_proxy.failure), proxy,
|
||||
host (the address that answered, or '')}."""
|
||||
headers = civitai_headers()
|
||||
key = "set" if "Authorization" in headers else "none"
|
||||
started = time.monotonic()
|
||||
|
||||
def answer(reachable, key, error="", why=""):
|
||||
def answer(reachable, key, error="", why="", host=""):
|
||||
return {"reachable": reachable, "key": key, "ms": round((time.monotonic() - started) * 1000),
|
||||
"error": error, "failure": why, "proxy": configured()}
|
||||
try:
|
||||
with urlopen(urllib.request.Request(PROBE_URL, headers=headers), timeout=TIMEOUT) as response:
|
||||
response.read(1024)
|
||||
return answer(True, key)
|
||||
except urllib.error.HTTPError as error:
|
||||
# Civitai answered: the network is fine; 401/403 with a key means the key is refused.
|
||||
if error.code in (401, 403) and key == "set":
|
||||
return answer(True, "refused", f"HTTP {error.code}", failure(error))
|
||||
return answer(error.code < 500, key, f"HTTP {error.code}", failure(error))
|
||||
except (urllib.error.URLError, OSError, ValueError) as error:
|
||||
reason = getattr(error, "reason", None) or error
|
||||
return answer(False, key, str(reason)[:200], failure(error))
|
||||
"error": error, "failure": why, "proxy": configured(), "host": host}
|
||||
first = None
|
||||
for host in API_HOSTS:
|
||||
try:
|
||||
with urlopen(urllib.request.Request(f"https://{host}{PROBE_PATH}", headers=headers), timeout=TIMEOUT) as response:
|
||||
response.read(1024)
|
||||
answered(host)
|
||||
return answer(True, key, host=host)
|
||||
except urllib.error.HTTPError as error:
|
||||
# Civitai answered: the network is fine; 401/403 with a key means the key is refused.
|
||||
answered(host)
|
||||
if error.code in (401, 403) and key == "set":
|
||||
return answer(True, "refused", f"HTTP {error.code}", failure(error), host)
|
||||
return answer(error.code < 500, key, f"HTTP {error.code}", failure(error), host)
|
||||
except (urllib.error.URLError, OSError, ValueError) as error:
|
||||
reason = getattr(error, "reason", None) or error
|
||||
first = first or answer(False, key, str(reason)[:200], failure(error))
|
||||
return first
|
||||
|
||||
|
||||
async def api_civitai_check(request):
|
||||
|
||||
@@ -9,7 +9,7 @@ from aiohttp import web
|
||||
import folder_paths
|
||||
from .utils import require_filename, resolve_within
|
||||
from .recipe_constants import MAX_RECIPE_BYTES
|
||||
from .recipe_images import _parameter_gallery_images
|
||||
from .recipe_images import _parameter_gallery_images, _pictures_by_signature
|
||||
from .recipe_store import get_recipes_dir, _read_recipe
|
||||
from .workflow_schema import _parameter_signature
|
||||
from .trash import move_to_trash
|
||||
@@ -50,6 +50,42 @@ async def api_get_parameters(request):
|
||||
return web.json_response({"notebooks": notebooks})
|
||||
|
||||
|
||||
MAX_COUNTED_NOTEBOOKS = 5000
|
||||
|
||||
|
||||
def _parameter_set_signatures(parameters_dir):
|
||||
"""Each recipe's parameter sets by their values' signature: {recipe filename: [signature]},
|
||||
so the page can leave out the set saved with the recipe itself. Files over the save limit,
|
||||
unreadable ones and any past the first MAX_COUNTED_NOTEBOOKS are skipped."""
|
||||
sets = {}
|
||||
try:
|
||||
filenames = sorted(name for name in os.listdir(parameters_dir) if name.endswith(".json"))
|
||||
except OSError:
|
||||
return sets
|
||||
for filename in filenames[:MAX_COUNTED_NOTEBOOKS]:
|
||||
try:
|
||||
file_path = resolve_within(parameters_dir, filename)
|
||||
if os.path.getsize(file_path) > MAX_RECIPE_BYTES:
|
||||
continue
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
recipe_filename = data.get("recipe_filename")
|
||||
signature = data.get("parameter_signature")
|
||||
value = signature.get("value") if isinstance(signature, dict) else None
|
||||
if isinstance(recipe_filename, str) and recipe_filename:
|
||||
sets.setdefault(recipe_filename, []).append(value if isinstance(value, str) else "")
|
||||
return sets
|
||||
|
||||
|
||||
async def api_get_parameter_set_signatures(request):
|
||||
sets = await asyncio.to_thread(_parameter_set_signatures, get_parameters_dir())
|
||||
return web.json_response({"sets": sets})
|
||||
|
||||
|
||||
_parameter_notebooks_cache = None
|
||||
_parameter_notebooks_cache_time = 0
|
||||
|
||||
@@ -152,10 +188,12 @@ async def api_get_parameters_by_type(request):
|
||||
if recipe_fn not in grouped:
|
||||
grouped[recipe_fn] = []
|
||||
|
||||
signature = (nb.get("data", {}).get("parameter_signature") or {})
|
||||
grouped[recipe_fn].append({
|
||||
"filename": nb.get("filename"),
|
||||
"name": nb.get("name"),
|
||||
"timestamp": nb.get("timestamp"),
|
||||
"signature": signature.get("value") if isinstance(signature, dict) else "",
|
||||
"nodes": matched_nodes
|
||||
})
|
||||
|
||||
@@ -349,6 +387,23 @@ async def api_rename_parameter(request):
|
||||
|
||||
return web.json_response({"status": "success", "filename": filename, "name": new_name})
|
||||
|
||||
MAX_PICTURE_SIGNATURES = 64
|
||||
|
||||
|
||||
async def api_get_parameter_pictures(request):
|
||||
"""GET /anomalous/parameter_pictures?signatures=a,b - for Current node: how many recent
|
||||
output pictures were made with each parameter set's values, and the newest, in one pass."""
|
||||
signatures = {value.lower() for value in request.query.get("signatures", "").split(",") if value}
|
||||
if not signatures or len(signatures) > MAX_PICTURE_SIGNATURES \
|
||||
or any(not re.fullmatch(r"[0-9a-f]{64}", value) for value in signatures):
|
||||
return web.json_response({"status": "error", "message": "Invalid signatures"}, status=400)
|
||||
try:
|
||||
pictures, scanned = await asyncio.to_thread(_pictures_by_signature, signatures)
|
||||
except OSError:
|
||||
return web.json_response({"status": "error", "message": "Could not read the pictures"}, status=500)
|
||||
return web.json_response({"status": "success", "pictures": pictures, "scanned": scanned})
|
||||
|
||||
|
||||
async def api_get_parameter_gallery(request):
|
||||
"""Find recent output PNGs whose embedded parameters match one parameter notebook."""
|
||||
try:
|
||||
|
||||
@@ -32,16 +32,30 @@ def _model_roots():
|
||||
yield folder_type, path_index, os.path.realpath(base_dir)
|
||||
|
||||
|
||||
def model_file_within(directory, filename):
|
||||
"""The model file `filename` (a bare name) in `directory`, which was resolved inside its
|
||||
models folder already, as named: a model that is a link stays the link (on a cloud server
|
||||
the models folder links into a shared, read-only model disk). Its covers and info are the
|
||||
ones beside the link, edits are written there and a delete moves the link; only the folder
|
||||
part must stay inside the models folder, and the name cannot leave it."""
|
||||
return os.path.join(directory, require_filename(filename))
|
||||
|
||||
|
||||
def model_reference_within(base_dir, relative):
|
||||
"""model_file_within for a path relative to the models folder `base_dir` (subfolders and
|
||||
a file name): the subfolders must stay inside it, through links too."""
|
||||
head, name = os.path.split(relative)
|
||||
return model_file_within(resolve_within(base_dir, head), name)
|
||||
|
||||
|
||||
def _resolve_exact_model_reference(saved_value):
|
||||
"""Resolve one saved model value without recursive scanning or hashing."""
|
||||
if not isinstance(saved_value, str) or not saved_value.strip():
|
||||
return None
|
||||
relative_value = saved_value.replace("/", os.sep)
|
||||
for folder_type, path_index, base_dir in _model_roots():
|
||||
candidate = os.path.realpath(os.path.join(base_dir, relative_value))
|
||||
try:
|
||||
if os.path.commonpath((base_dir, candidate)) != base_dir:
|
||||
continue
|
||||
candidate = model_reference_within(base_dir, relative_value)
|
||||
except ValueError:
|
||||
continue
|
||||
if not os.path.isfile(candidate) or not candidate.lower().endswith(MODEL_FILE_SUFFIXES):
|
||||
@@ -68,7 +82,7 @@ def resolve_folder_subdir(folder_type, path_idx=0, subfolder='/'):
|
||||
|
||||
|
||||
def require_filename(filename):
|
||||
if not isinstance(filename, str) or not filename or os.path.basename(filename) != filename:
|
||||
if not isinstance(filename, str) or filename in ("", ".", "..") or os.path.basename(filename) != filename:
|
||||
raise ValueError("Invalid filename")
|
||||
return filename
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@ MAX_TAGS = 20
|
||||
MAX_TAG_LENGTH = 60
|
||||
MAX_NOTES_LENGTH = 3000
|
||||
MAX_MODEL_NOTE_LENGTH = 1000
|
||||
MAX_FOCUS_PARAMS = 12
|
||||
MAX_FOCUS_NOTE_LENGTH = 120
|
||||
MAX_FOCUS_NAME_LENGTH = 200
|
||||
MAX_THUMBNAIL_LENGTH = 1_500_000
|
||||
MAX_SOURCE_SUBFOLDER_LENGTH = 500
|
||||
MAX_RECIPE_BYTES = 12 * 1024 * 1024
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Contained Recipe image assets and bounded output-gallery inspection."""
|
||||
|
||||
from io import BytesIO
|
||||
from functools import lru_cache
|
||||
import hashlib
|
||||
import heapq
|
||||
import json
|
||||
@@ -192,6 +193,34 @@ def _recipe_gallery_images(node_signature):
|
||||
return matches, scanned
|
||||
|
||||
|
||||
@lru_cache(maxsize=4096)
|
||||
def _picture_parameter_signature(image_path, mtime_ns):
|
||||
"""A picture's parameter signature, read once per version of the file."""
|
||||
return _embedded_parameter_signature(image_path)
|
||||
|
||||
|
||||
def _pictures_by_signature(signatures):
|
||||
"""({signature: {"count", "newest": {filename, subfolder}}}, pictures read) for the recent
|
||||
output pictures made with each of `signatures`, newest first, in one pass."""
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
found = {}
|
||||
if not os.path.isdir(output_dir):
|
||||
return found, 0
|
||||
scanned = 0
|
||||
for mtime_ns, image_path in _recent_output_pngs(output_dir):
|
||||
scanned += 1
|
||||
signature = _picture_parameter_signature(image_path, mtime_ns)
|
||||
if signature not in signatures:
|
||||
continue
|
||||
entry = found.setdefault(signature, {"count": 0, "newest": None})
|
||||
entry["count"] += 1
|
||||
if entry["newest"] is None:
|
||||
relative_dir = os.path.relpath(os.path.dirname(image_path), output_dir)
|
||||
entry["newest"] = {"filename": os.path.basename(image_path),
|
||||
"subfolder": "" if relative_dir == "." else relative_dir.replace("\\", "/")}
|
||||
return found, scanned
|
||||
|
||||
|
||||
def _parameter_gallery_images(parameter_signature):
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
if not os.path.isdir(output_dir):
|
||||
@@ -200,7 +229,7 @@ def _parameter_gallery_images(parameter_signature):
|
||||
scanned = 0
|
||||
for mtime_ns, image_path in _recent_output_pngs(output_dir):
|
||||
scanned += 1
|
||||
if _embedded_parameter_signature(image_path) != parameter_signature:
|
||||
if _picture_parameter_signature(image_path, mtime_ns) != parameter_signature:
|
||||
continue
|
||||
relative_dir = os.path.relpath(os.path.dirname(image_path), output_dir)
|
||||
subfolder = "" if relative_dir == "." else relative_dir.replace("\\", "/")
|
||||
|
||||
@@ -29,6 +29,9 @@ ALL_IN_ONE_LOADER_SPECS = {
|
||||
"efficient loader": ((0, "checkpoint", "ckpt_name"), (1, "vae", "vae_name"), (3, "lora", "lora_name")),
|
||||
}
|
||||
PLACEHOLDER_MODEL_VALUES = {"none", "baked vae"}
|
||||
POWER_LORA_TYPE = "power lora loader (rgthree)"
|
||||
EASY_LORA_STACK_TYPE = "easy lorastack"
|
||||
EASY_STACK_ROWS = 10
|
||||
|
||||
|
||||
def _model_reference_specs(node):
|
||||
@@ -60,6 +63,29 @@ def _model_reference_specs(node):
|
||||
return specs
|
||||
|
||||
|
||||
def _model_reference_values(node):
|
||||
"""(widget_index, category, widget_name, saved_value) for every model a saved node names: known
|
||||
loaders by position, and two stack nodes by their rows: rgthree's Power Lora Loader (a row is a
|
||||
dict {on, lora, strength}; switched-off rows load nothing) and Easy-Use's LoRA stack
|
||||
(lora_N_name at 3 + 4*(N-1), up to num_loras). Must stay in sync with nodeModelValues in
|
||||
web/modules/recipe_identity.js."""
|
||||
values = _widget_values(node)
|
||||
lowered = _node_type(node).lower().strip()
|
||||
if lowered == POWER_LORA_TYPE:
|
||||
rows = [(index, value) for index, value in enumerate(values) if isinstance(value, dict) and "lora" in value]
|
||||
return [(index, "lora", f"lora_{row}", value.get("lora"))
|
||||
for row, (index, value) in enumerate(rows, 1) if value.get("on") is not False]
|
||||
if lowered == EASY_LORA_STACK_TYPE:
|
||||
try:
|
||||
count = max(0, min(int(values[2]), EASY_STACK_ROWS))
|
||||
except (IndexError, TypeError, ValueError):
|
||||
count = 0
|
||||
return [(3 + 4 * row, "lora", f"lora_{row + 1}_name", values[3 + 4 * row] if 3 + 4 * row < len(values) else None)
|
||||
for row in range(count)]
|
||||
return [(index, category, name, values[index] if index < len(values) else None)
|
||||
for index, category, name in _model_reference_specs(node)]
|
||||
|
||||
|
||||
def _identity_for_reference(saved_value):
|
||||
resolved = _resolve_exact_model_reference(saved_value)
|
||||
if not resolved:
|
||||
@@ -153,9 +179,7 @@ def _build_model_references(recipe, verify_identities=False):
|
||||
for node in workflow.get("nodes", []) if isinstance(workflow, dict) else []:
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
values = _widget_values(node)
|
||||
for widget_index, category, widget_name in _model_reference_specs(node):
|
||||
saved_value = values[widget_index] if widget_index < len(values) else None
|
||||
for widget_index, category, widget_name, saved_value in _model_reference_values(node):
|
||||
if not isinstance(saved_value, str) or not saved_value.strip():
|
||||
continue
|
||||
if saved_value.strip().lower() in PLACEHOLDER_MODEL_VALUES:
|
||||
@@ -277,6 +301,31 @@ def _normalise_source_image(value):
|
||||
}
|
||||
|
||||
|
||||
def _normalise_focus_params(entries):
|
||||
"""A workflow's focus values (重点参数): [{node, type, widget, note}], all short text; anything else is refused."""
|
||||
if not isinstance(entries, list) or len(entries) > MAX_FOCUS_PARAMS:
|
||||
raise ValueError("Invalid recipe focus values")
|
||||
kept = []
|
||||
seen = set()
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict):
|
||||
raise ValueError("Invalid recipe focus value")
|
||||
node, node_type, widget, note = (entry.get(key, "") for key in ("node", "type", "widget", "note"))
|
||||
if not all(isinstance(value, str) for value in (node, node_type, widget, note)):
|
||||
raise ValueError("Invalid recipe focus value")
|
||||
if (
|
||||
not node or not widget or len(node) > 32
|
||||
or max(len(node_type), len(widget)) > MAX_FOCUS_NAME_LENGTH
|
||||
or len(note) > MAX_FOCUS_NOTE_LENGTH
|
||||
):
|
||||
raise ValueError("Invalid recipe focus value")
|
||||
if (node, widget) in seen:
|
||||
continue
|
||||
seen.add((node, widget))
|
||||
kept.append({"node": node, "type": node_type, "widget": widget, "note": note.strip()})
|
||||
return kept
|
||||
|
||||
|
||||
def _normalise_recipe(payload):
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("Invalid recipe")
|
||||
@@ -324,6 +373,8 @@ def _normalise_recipe(payload):
|
||||
reference["user_note"] = user_note.strip()
|
||||
else:
|
||||
reference.pop("user_note", None)
|
||||
if "focusParams" in params:
|
||||
params["focusParams"] = _normalise_focus_params(params["focusParams"])
|
||||
_validate_workflow(workflow)
|
||||
workflow_scope = payload.get("workflow_scope", "complete")
|
||||
if workflow_scope not in {"partial", "complete"}:
|
||||
|
||||
@@ -26,13 +26,14 @@ from .recipe_schema import (
|
||||
_enrich_recipe as _schema_enrich_recipe, _normalise_recipe, _normalise_source_image,
|
||||
_identity_for_reference as _schema_identity_for_reference,
|
||||
_model_reference_key, _model_reference_specs,
|
||||
_model_roots, _preserve_model_reference_fields,
|
||||
_model_roots, _normalise_focus_params, _preserve_model_reference_fields,
|
||||
_resolve_exact_model_reference, _updated_recipe, _workflow_identity_for_reference,
|
||||
)
|
||||
from .recipe_store import (
|
||||
_archive_recipe, _delete_recipe_with_history, _history_dir, _list_recipe_history,
|
||||
_list_recipes, _read_recipe, _write_recipe, get_recipes_dir,
|
||||
)
|
||||
from .local_guard import may_change
|
||||
from .utils import require_filename, resolve_within
|
||||
from .workflow_schema import (
|
||||
_clean_volatile_params, _node_title, _node_type, _normalise_workflow_key,
|
||||
@@ -359,6 +360,40 @@ async def api_update_recipe(request):
|
||||
})
|
||||
|
||||
|
||||
def _set_recipe_focus(recipes_dir, filename, entries):
|
||||
"""Writes a recipe's focus values (重点参数) alone: no version kept, nothing else read again."""
|
||||
focus = _normalise_focus_params(entries)
|
||||
path = resolve_within(recipes_dir, filename)
|
||||
if os.path.getsize(path) > MAX_RECIPE_BYTES:
|
||||
raise ValueError("Recipe is too large")
|
||||
recipe = _read_recipe(path)
|
||||
if not isinstance(recipe, dict) or not isinstance(recipe.get("workflow"), dict):
|
||||
raise ValueError("Invalid recipe")
|
||||
params = recipe.get("params") if isinstance(recipe.get("params"), dict) else {}
|
||||
recipe["params"] = {**params, "focusParams": focus}
|
||||
_write_recipe(recipes_dir, filename, recipe)
|
||||
return focus
|
||||
|
||||
|
||||
async def api_set_recipe_focus(request):
|
||||
"""Marks or notes a recipe's focus values (a star in its details). Only from this computer (local_guard.may_change)."""
|
||||
if not may_change(request):
|
||||
return web.json_response({"status": "error", "message": "Only from this computer"}, status=403)
|
||||
try:
|
||||
payload = await request.json()
|
||||
filename = require_filename(payload.get("filename", ""))
|
||||
if not filename.endswith(".json"):
|
||||
raise ValueError("Invalid filename")
|
||||
focus = await asyncio.to_thread(_set_recipe_focus, get_recipes_dir(), filename, payload.get("focusParams"))
|
||||
except FileNotFoundError:
|
||||
return web.json_response({"status": "error", "message": "File not found"}, status=404)
|
||||
except (AttributeError, ValueError, json.JSONDecodeError):
|
||||
return web.json_response({"status": "error", "message": "Invalid focus values"}, status=400)
|
||||
except OSError:
|
||||
return web.json_response({"status": "error", "message": "Could not update recipe"}, status=500)
|
||||
return web.json_response({"status": "success", "focusParams": focus})
|
||||
|
||||
|
||||
async def api_get_recipe_history(request):
|
||||
try:
|
||||
filename = require_filename(request.query.get("filename", ""))
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""The user's choice to leave a model out of scans, or keep it in: POST /anomalous/scan_choice,
|
||||
kept in each model's <model>.anomalous.json ("scan"; model_identity.scan_choice reads it). The
|
||||
scan page asks it about new models that look like no drawing model; a model's details change it."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from .local_guard import may_change
|
||||
from .path_utils import atomic_write_json, resolve_folder_subdir
|
||||
from .utils import model_file_within
|
||||
|
||||
try:
|
||||
from ..model_identity import USER_INFO_SUFFIX, read_json
|
||||
except ImportError: # loaded outside the package (tests)
|
||||
from model_identity import USER_INFO_SUFFIX, read_json
|
||||
|
||||
MAX_CHOICE_FILES = 2000
|
||||
|
||||
|
||||
def _set_scan_choice(targets, choice):
|
||||
"""Writes the user's scan choice into each picked model's <model>.anomalous.json ("scan":
|
||||
"skip" or "scan"; "" removes it). `targets`: [{type, path_idx, subfolder, files}] as a scan
|
||||
takes them. Returns (models changed, file names left as they were: not found, not a
|
||||
.safetensors, or an unreadable .anomalous.json)."""
|
||||
if choice not in ("skip", "scan", "") or not isinstance(targets, list):
|
||||
raise ValueError("Invalid scan choice")
|
||||
picked = []
|
||||
for target in targets:
|
||||
names = target.get("files") if isinstance(target, dict) else None
|
||||
if not isinstance(names, list) or not all(isinstance(name, str) for name in names):
|
||||
raise ValueError("each target needs a list of file names")
|
||||
_, target_dir = resolve_folder_subdir(target.get("type"), int(target.get("path_idx", 0)), target.get("subfolder") or "/")
|
||||
picked += [(target_dir, name) for name in names]
|
||||
if len(picked) > MAX_CHOICE_FILES:
|
||||
raise ValueError("Too many models")
|
||||
changed, failed = 0, []
|
||||
for target_dir, name in picked:
|
||||
try:
|
||||
path = model_file_within(target_dir, name)
|
||||
except ValueError:
|
||||
failed.append(name)
|
||||
continue
|
||||
user_file = os.path.splitext(path)[0] + USER_INFO_SUFFIX
|
||||
user = read_json(user_file)
|
||||
if not name.endswith(".safetensors") or not os.path.isfile(path) or (user is None and os.path.exists(user_file)):
|
||||
failed.append(name)
|
||||
continue
|
||||
user = user or {"format": 1}
|
||||
if choice:
|
||||
user["scan"] = choice
|
||||
else:
|
||||
user.pop("scan", None)
|
||||
atomic_write_json(user_file, user)
|
||||
changed += 1
|
||||
return changed, failed
|
||||
|
||||
|
||||
async def api_set_scan_choice(request):
|
||||
"""POST /anomalous/scan_choice {targets, choice} - leave models out of scans ("skip"), keep
|
||||
them in ("scan"), or forget the choice (""). Only from this computer (local_guard.may_change)."""
|
||||
if not may_change(request):
|
||||
return web.json_response({"status": "error", "message": "Only from this computer"}, status=403)
|
||||
try:
|
||||
data = await request.json()
|
||||
changed, failed = await asyncio.to_thread(_set_scan_choice, data.get("targets"), data.get("choice", ""))
|
||||
except (AttributeError, KeyError, TypeError, ValueError):
|
||||
return web.json_response({"status": "error", "message": "Invalid scan choice"}, status=400)
|
||||
except OSError:
|
||||
return web.json_response({"status": "error", "message": "Could not save the choice"}, status=500)
|
||||
return web.json_response({"status": "success", "changed": changed, "failed": failed})
|
||||
@@ -1,34 +1,63 @@
|
||||
"""What the scan page shows before a scan: how many models are matched on Civitai, unmatched or
|
||||
new, and which ones the last two are (located like the scan report's models, so the page can
|
||||
open them); also the model files a scan never reads (other formats than .safetensors), so the
|
||||
page can say why they are not there."""
|
||||
page can say why they are not there, the models the user set to "don't scan", and the new ones
|
||||
whose header looks like no drawing model (a voice model, say), for the page to ask about. The
|
||||
components a drawing model loads (VAE, text encoders…) are counted and listed on their own too,
|
||||
since many are not on Civitai and that is no fault; and some covers of the scanned models, for
|
||||
the scan card's band of pictures. And for the scan page's picker, every model
|
||||
a scan reads with its name, size, cover and state.
|
||||
|
||||
Working the summary out reads every model folder, which takes a while with many models or on
|
||||
a network drive. So the last one is kept (in memory, and in user/anomalous/scan_summary.json
|
||||
for after a restart) and handed out at once (`?quick=1`) while a fresh one is worked out;
|
||||
requests that come meanwhile wait for that same one instead of reading the folders again. The
|
||||
models found on Civitai are kept with it and listed on request (GET /anomalous/scan_matched),
|
||||
so the page's summary stays small."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from .activity_log import log_path
|
||||
from .folder_types import get_active_scan_paths
|
||||
from .metadata import get_metadata
|
||||
from .path_utils import atomic_write_json
|
||||
from .model_media import _preview_url_for_model
|
||||
from .scan_report import locate_root
|
||||
|
||||
try:
|
||||
from ..model_identity import is_unmatched, sidecar_info, unmatched_reason
|
||||
from ..model_identity import is_unmatched, read_json, scan_choice, sidecar_info, unmatched_reason
|
||||
except ImportError: # loaded outside the package (tests)
|
||||
from model_identity import is_unmatched, sidecar_info, unmatched_reason
|
||||
from model_identity import is_unmatched, read_json, scan_choice, sidecar_info, unmatched_reason
|
||||
|
||||
MAX_LISTED = 2000 # models listed per kind; the counts stay exact
|
||||
MAX_PICKABLE = 5000 # models the picker lists; its total stays exact
|
||||
COVER_SAMPLE = 48 # covers the scan card's band gets (it shows as many as fit)
|
||||
# Model files in other formats: listed as "not scanned" (scans read only .safetensors).
|
||||
OTHER_MODEL_EXTENSIONS = (".ckpt", ".pt", ".pth", ".bin", ".gguf", ".onnx", ".sft", ".engine")
|
||||
# Folders of the parts a drawing model loads: many are not on Civitai, and that is no fault.
|
||||
COMPONENT_TYPES = ("vae", "clip", "text_encoders", "clip_vision", "upscale_models")
|
||||
# What the picker needs of a model's metadata: its card badge (model_source.js) and its name.
|
||||
PICKER_FIELDS = ("info_source", "unmatched_reason", "scan_choice", "not_drawing", "baseModel", "base_guess", "custom_name")
|
||||
# New models' headers are read this many at a time: on a network drive (a NAS, a cloud server's
|
||||
# model disk) each read waits on the network, and a thousand one after another take minutes.
|
||||
# They are read through get_metadata, whose cache keeps them until the file changes (the page
|
||||
# opened again reads none, and the picker after it finds them there).
|
||||
HEAD_READERS = 16
|
||||
MAX_SAVED_BYTES = 16 * 1024 * 1024 # the kept summary; MAX_LISTED keeps it far smaller
|
||||
_last = {} # the last summary worked out ("summary", "at") and its models found on Civitai ("matched")
|
||||
_working = None # the summary being worked out (an asyncio future), shared by the requests meanwhile
|
||||
|
||||
|
||||
def _summarize():
|
||||
"""Counts the files the scanner handles (.safetensors in the active scan folders), each once.
|
||||
`pending`: unmatched models Civitai was never asked about (offline, or unreachable then),
|
||||
which the next online scan looks up. `skipped`: model files in other formats, outside hidden
|
||||
folders (tools keep their working files there)."""
|
||||
def _walk():
|
||||
"""Each model file in the active scan folders once, as ((type, path_idx), base_dir, path,
|
||||
other): the .safetensors files scans read, and (`other`) model files in other formats outside
|
||||
hidden folders (tools keep their working files there)."""
|
||||
seen = set()
|
||||
total = matched = 0
|
||||
unmatched, new, skipped = [], [], []
|
||||
for base_dir in get_active_scan_paths():
|
||||
located = locate_root(base_dir) or ("", 0)
|
||||
for root, _dirs, files in os.walk(base_dir):
|
||||
@@ -42,31 +71,192 @@ def _summarize():
|
||||
if real in seen:
|
||||
continue
|
||||
seen.add(real)
|
||||
if other:
|
||||
skipped.append({"type": located[0], "path_idx": located[1], "filename": filename,
|
||||
"rel": os.path.relpath(path, base_dir).replace(os.sep, "/")})
|
||||
continue
|
||||
total += 1
|
||||
info = sidecar_info(os.path.splitext(path)[0])
|
||||
if info is not None and not is_unmatched(info):
|
||||
matched += 1
|
||||
continue
|
||||
item = {"type": located[0], "path_idx": located[1], "filename": filename,
|
||||
"rel": os.path.relpath(path, base_dir).replace(os.sep, "/")}
|
||||
if info is None:
|
||||
new.append(item)
|
||||
else:
|
||||
item.update(reason=unmatched_reason(info), base=info.get("baseModel") or "")
|
||||
unmatched.append(item)
|
||||
yield located, base_dir, path, other
|
||||
|
||||
|
||||
def _looks_not_drawing(path):
|
||||
"""Whether the header of a new model reads but names no drawing model kind."""
|
||||
return bool(get_metadata(path).get("not_drawing"))
|
||||
|
||||
|
||||
def _found_item(item, info):
|
||||
"""A model found on Civitai, for the list of them: where it is, Civitai's name, its base model."""
|
||||
model = info.get("model") if isinstance(info.get("model"), dict) else {}
|
||||
names = [str(model.get("name") or ""), str(info.get("name") or "")]
|
||||
return {**item, "name": " · ".join(name for name in names if name), "base": str(info.get("baseModel") or "")}
|
||||
|
||||
|
||||
def _summarize(with_matched=False):
|
||||
"""Counts the files the scanner handles (.safetensors in the active scan folders), each once.
|
||||
`pending`: unmatched models Civitai was never asked about (offline, or unreachable then),
|
||||
which the next online scan looks up. `skipped`: model files in other formats. `excluded`:
|
||||
models set to "don't scan", not in the other counts. A new model not chosen about whose
|
||||
header reads but names no drawing model kind carries `not_drawing` (counted too); only new
|
||||
models' headers are read. Models in a component folder carry `component` (counted too) and
|
||||
are listed again, matched ones as well, in `component_models` with their `state`
|
||||
(matched | unmatched | new); `components` counts them. `covers`: see `_covers`."""
|
||||
total = matched = 0
|
||||
unmatched, new, skipped, excluded, components, scanned, to_read, found = [], [], [], [], [], [], [], []
|
||||
for located, base_dir, path, other in _walk():
|
||||
item = {"type": located[0], "path_idx": located[1], "filename": os.path.basename(path),
|
||||
"rel": os.path.relpath(path, base_dir).replace(os.sep, "/")}
|
||||
if other:
|
||||
skipped.append(item)
|
||||
continue
|
||||
base = os.path.splitext(path)[0]
|
||||
choice = scan_choice(base)
|
||||
if choice == "skip":
|
||||
excluded.append(item)
|
||||
continue
|
||||
total += 1
|
||||
info = sidecar_info(base)
|
||||
if located[0] in COMPONENT_TYPES:
|
||||
item["component"] = True
|
||||
components.append(item)
|
||||
elif info is not None:
|
||||
scanned.append((located, base_dir, path))
|
||||
if info is not None and not is_unmatched(info):
|
||||
matched += 1
|
||||
item["state"] = "matched"
|
||||
if with_matched and not item.get("component"):
|
||||
found.append(_found_item(item, info))
|
||||
continue
|
||||
if info is None:
|
||||
if not choice:
|
||||
to_read.append((item, path))
|
||||
item["state"] = "new"
|
||||
new.append(item)
|
||||
else:
|
||||
item.update(state="unmatched", reason=unmatched_reason(info), base=info.get("baseModel") or "")
|
||||
unmatched.append(item)
|
||||
if to_read:
|
||||
with ThreadPoolExecutor(max_workers=HEAD_READERS) as pool:
|
||||
for (item, _path), not_drawing in zip(to_read, pool.map(_looks_not_drawing, [path for _item, path in to_read])):
|
||||
if not_drawing:
|
||||
item["not_drawing"] = True
|
||||
return {
|
||||
**({"matched_models": found} if with_matched else {}),
|
||||
"total": total, "matched": matched, "unmatched": len(unmatched), "new": len(new),
|
||||
"pending": sum(item["reason"] in ("offline", "network") for item in unmatched),
|
||||
"damaged": sum(item["reason"] == "damaged" for item in unmatched),
|
||||
"not_drawing": sum(bool(item.get("not_drawing")) for item in new),
|
||||
"excluded": len(excluded), "excluded_models": excluded[:MAX_LISTED],
|
||||
"unmatched_models": unmatched[:MAX_LISTED], "new_models": new[:MAX_LISTED],
|
||||
"skipped": len(skipped), "skipped_models": skipped[:MAX_LISTED],
|
||||
"components": {
|
||||
"total": len(components),
|
||||
**{state: sum(item["state"] == state for item in components) for state in ("matched", "unmatched", "new")},
|
||||
"pending": sum(item.get("reason") in ("offline", "network") for item in components),
|
||||
},
|
||||
"component_models": components[:MAX_LISTED],
|
||||
"covers": _covers(scanned),
|
||||
}
|
||||
|
||||
|
||||
def _modified(entry):
|
||||
try:
|
||||
return os.path.getmtime(entry[2])
|
||||
except OSError:
|
||||
return 0
|
||||
|
||||
|
||||
def _covers(scanned):
|
||||
"""Up to COVER_SAMPLE covers of the scanned models (components left out, they seldom have
|
||||
one), the newest model files first, as {name, preview_url}: the scan card's band of pictures."""
|
||||
covers = []
|
||||
for located, base_dir, path in sorted(scanned, key=_modified, reverse=True):
|
||||
url = _preview_url_for_model(located[0], located[1], base_dir, path)
|
||||
if url:
|
||||
covers.append({"name": os.path.splitext(os.path.basename(path))[0], "preview_url": url})
|
||||
if len(covers) >= COVER_SAMPLE:
|
||||
break
|
||||
return covers
|
||||
|
||||
|
||||
def _pickable():
|
||||
"""Every model a scan reads (the models set to "don't scan" too), located like the summary's,
|
||||
with its size, cover and what its card badge needs; `roots`: the name of each models folder
|
||||
they are in and of the folder holding it, to tell two folders of one type apart."""
|
||||
models, roots, total = [], {}, 0
|
||||
for located, base_dir, path, other in _walk():
|
||||
if other:
|
||||
continue
|
||||
total += 1
|
||||
if len(models) >= MAX_PICKABLE:
|
||||
continue
|
||||
key = f"{located[0]}|{located[1]}"
|
||||
if key not in roots:
|
||||
roots[key] = {"type": located[0], "path_idx": located[1], "name": os.path.basename(os.path.normpath(base_dir)),
|
||||
"parent": os.path.basename(os.path.dirname(os.path.normpath(base_dir)))}
|
||||
meta = get_metadata(path)
|
||||
try:
|
||||
size = os.path.getsize(path)
|
||||
except OSError:
|
||||
size = 0
|
||||
models.append({
|
||||
"type": located[0], "path_idx": located[1], "filename": os.path.basename(path),
|
||||
"rel": os.path.relpath(path, base_dir).replace(os.sep, "/"), "size": size,
|
||||
"preview_url": _preview_url_for_model(located[0], located[1], base_dir, path),
|
||||
"metadata": {field: meta.get(field) for field in PICKER_FIELDS},
|
||||
})
|
||||
return {"models": models, "total": total, "roots": list(roots.values())}
|
||||
|
||||
|
||||
def _summary_path():
|
||||
return os.path.join(os.path.dirname(log_path()), "scan_summary.json")
|
||||
|
||||
|
||||
def _work_out():
|
||||
"""A fresh summary, kept as the last one (and on disk when it changed)."""
|
||||
summary = _summarize(with_matched=True)
|
||||
matched = summary.pop("matched_models")
|
||||
changed = summary != _last.get("summary")
|
||||
_last.update(summary=summary, matched=matched[:MAX_PICKABLE], matched_total=len(matched), at=time.time())
|
||||
if changed:
|
||||
try:
|
||||
atomic_write_json(_summary_path(), {"at": _last["at"], "summary": summary}, max_bytes=MAX_SAVED_BYTES)
|
||||
except (OSError, ValueError) as error:
|
||||
print(f"[Anomalous Browser] Scan summary not kept: {error}")
|
||||
return summary
|
||||
|
||||
|
||||
async def _fresh():
|
||||
global _working
|
||||
if _working is None or _working.done():
|
||||
_working = asyncio.ensure_future(asyncio.to_thread(_work_out))
|
||||
return await asyncio.shield(_working)
|
||||
|
||||
|
||||
def _kept():
|
||||
"""The last summary ({"at", "summary"}), from memory or from disk; None before the first."""
|
||||
if _last.get("summary"):
|
||||
return {"at": _last["at"], "summary": _last["summary"]}
|
||||
try:
|
||||
if os.path.getsize(_summary_path()) > MAX_SAVED_BYTES:
|
||||
return None
|
||||
except OSError:
|
||||
return None
|
||||
data = read_json(_summary_path())
|
||||
return data if data and isinstance(data.get("summary"), dict) else None
|
||||
|
||||
|
||||
async def api_scan_summary(request):
|
||||
"""GET /anomalous/scan_summary - counts, and the unmatched and new models."""
|
||||
return web.json_response(await asyncio.to_thread(_summarize))
|
||||
"""GET /anomalous/scan_summary - counts, and the unmatched and new models. `?quick=1`: the
|
||||
last one at once ({"at", "summary"}, or {} before the first), for the page to show while it
|
||||
asks for a fresh one."""
|
||||
if request.query.get("quick"):
|
||||
return web.json_response(await asyncio.to_thread(_kept) or {})
|
||||
return web.json_response(await _fresh())
|
||||
|
||||
|
||||
async def api_scan_matched(request):
|
||||
"""GET /anomalous/scan_matched - the models found on Civitai (components apart), as the last
|
||||
summary saw them: {"models": [{type, path_idx, filename, rel, name, base}], "total"}."""
|
||||
if "matched" not in _last:
|
||||
await _fresh()
|
||||
return web.json_response({"models": _last.get("matched") or [], "total": _last.get("matched_total", 0)})
|
||||
|
||||
|
||||
async def api_scan_pickable(request):
|
||||
"""GET /anomalous/scan_pickable - the models the scan settings' picker lists."""
|
||||
return web.json_response(await asyncio.to_thread(_pickable))
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Version and updates of the companion node pack Anomalous_TTS (GPT-SoVITS Settings).
|
||||
|
||||
Only a git checkout of the official repository is touched, found among the
|
||||
custom_nodes folders by its node class and checked by its `origin` remote. The
|
||||
rules are those of the plugin's own version panel (api/version_manager.py):
|
||||
published releases are fetched only when asked, a switch checks out a
|
||||
published release tag, refuses over modified tracked files and can be undone.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
import folder_paths
|
||||
|
||||
from . import version_manager as vm
|
||||
|
||||
OFFICIAL = ("demongatanjieu", "anomalous_tts")
|
||||
NODE_CLASS = "AnomalousTTS_CharacterSpeech"
|
||||
STATE_FILE = os.path.join(vm.PLUGIN_DIR, ".anomalous_tts_version.local.json")
|
||||
READ_LIMIT = 200_000
|
||||
|
||||
|
||||
def _node_folders():
|
||||
"""custom_nodes folders whose nodes.py defines the Anomalous_TTS node (disabled ones left out)."""
|
||||
for base in folder_paths.get_folder_paths("custom_nodes"):
|
||||
try:
|
||||
names = sorted(os.listdir(base))
|
||||
except OSError:
|
||||
continue
|
||||
for name in names:
|
||||
folder = os.path.join(base, name)
|
||||
if name.endswith(".disabled") or not os.path.exists(os.path.join(folder, ".git")):
|
||||
continue
|
||||
try:
|
||||
with open(os.path.join(folder, "nodes.py"), encoding="utf-8", errors="replace") as handle:
|
||||
if NODE_CLASS in handle.read(READ_LIMIT):
|
||||
yield folder
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
|
||||
def find_checkout():
|
||||
"""The installed Anomalous_TTS as a version_manager.Checkout, or None (not installed, not a git
|
||||
clone, or cloned from somewhere else)."""
|
||||
for folder in _node_folders():
|
||||
repo = vm.Checkout(folder, STATE_FILE)
|
||||
owner_repo = vm._github_repo(vm._git_out("remote", "get-url", "origin", repo=repo))
|
||||
if owner_repo and tuple(part.lower() for part in owner_repo) == OFFICIAL:
|
||||
return repo
|
||||
return None
|
||||
|
||||
|
||||
def _checkout():
|
||||
repo = find_checkout()
|
||||
if repo is None:
|
||||
raise vm.VersionError("tts_missing", "Anomalous_TTS is not installed as a git clone of its GitHub repository")
|
||||
return repo
|
||||
|
||||
|
||||
def state():
|
||||
repo = find_checkout()
|
||||
return {"found": False} if repo is None else {"found": True, "state": vm.current_state(repo)}
|
||||
|
||||
|
||||
async def api_get_state(request):
|
||||
"""GET /anomalous/tts_version - Installed Anomalous_TTS version (local, no network)."""
|
||||
try:
|
||||
return web.json_response({"success": True, **(await asyncio.to_thread(state))})
|
||||
except vm.VersionError as error:
|
||||
return vm.error_response(error)
|
||||
|
||||
|
||||
async def api_get_releases(request):
|
||||
"""GET /anomalous/tts_version/releases?refresh=1 - Its published releases (network, only when asked)."""
|
||||
refresh = request.query.get("refresh") == "1"
|
||||
return await vm.run(lambda: vm.list_releases(refresh, _checkout()))
|
||||
|
||||
|
||||
async def api_update(request):
|
||||
"""POST /anomalous/tts_version/update {tag} - Check out a published release."""
|
||||
tag = str((await vm.json_body(request)).get("tag", ""))
|
||||
return await vm.run(lambda: vm.switch_to(tag, _checkout()))
|
||||
|
||||
|
||||
async def api_undo(request):
|
||||
"""POST /anomalous/tts_version/undo - Back to where the last update came from."""
|
||||
return await vm.run(lambda: vm.undo_last_switch(_checkout()))
|
||||
|
||||
|
||||
def register_routes(app):
|
||||
app.router.add_get('/anomalous/tts_version', api_get_state)
|
||||
app.router.add_get('/anomalous/tts_version/releases', api_get_releases)
|
||||
app.router.add_post('/anomalous/tts_version/update', api_update)
|
||||
app.router.add_post('/anomalous/tts_version/undo', api_undo)
|
||||
@@ -0,0 +1,173 @@
|
||||
"""What each run used and each opened workflow held, kept from the day this started (the Tidy
|
||||
tab's "Not used lately").
|
||||
|
||||
ComfyUI keeps no lasting note of which nodes and models a run used: its history is gone after a
|
||||
restart and its log names neither. So every prompt queued here passes `note_prompt` (ComfyUI's
|
||||
on_prompt hook) on its way in, whether it makes an image, a sound, a video or nothing kept, and
|
||||
the page posts what a workflow holds when it is opened (POST /anomalous/usage/opened {types,
|
||||
models}, usage_notes.js; this computer only). Node types and model files go into
|
||||
user/anomalous/usage_ledger.json with when each was last used and how often:
|
||||
{started, types: {type: [last, runs]}, models: {ref: [last, runs]},
|
||||
opened: {types: {type: [last, times]}, models: {ref: [last, times]}}}.
|
||||
It stays on this computer, is saved a few seconds after a note, and noting never stops a run.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
|
||||
import folder_paths
|
||||
from aiohttp import web
|
||||
|
||||
from .image_search import MODEL_FILE
|
||||
from .path_utils import atomic_write_json
|
||||
|
||||
SAVE_DELAY = 5.0 # seconds after a note: several in a row are saved once
|
||||
MAX_NAMES = 20_000 # per kind; the longest unused go first
|
||||
MAX_POSTED = 5_000 # names one opened workflow may post, per kind
|
||||
MAX_NAME_CHARS = 512
|
||||
|
||||
_lock = threading.Lock()
|
||||
_ledger = None
|
||||
_timer = None
|
||||
|
||||
|
||||
def ledger_path():
|
||||
user_dir = folder_paths.get_user_directory() if hasattr(folder_paths, "get_user_directory") else None
|
||||
if not user_dir:
|
||||
user_dir = os.path.join(folder_paths.base_path, "user")
|
||||
directory = os.path.join(user_dir, "anomalous")
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
return os.path.join(directory, "usage_ledger.json")
|
||||
|
||||
|
||||
def _names(data):
|
||||
"""{type or ref: [last, count]} from the saved file, {} when it is not one."""
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def _load():
|
||||
global _ledger
|
||||
if _ledger is None:
|
||||
data = {}
|
||||
try:
|
||||
with open(ledger_path(), encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
opened = data.get("opened") if isinstance(data.get("opened"), dict) else {}
|
||||
_ledger = {
|
||||
"started": data.get("started") if isinstance(data.get("started"), (int, float)) else None,
|
||||
"types": _names(data.get("types")),
|
||||
"models": _names(data.get("models")),
|
||||
"opened": {"types": _names(opened.get("types")), "models": _names(opened.get("models"))},
|
||||
}
|
||||
return _ledger
|
||||
|
||||
|
||||
def _copy(ledger):
|
||||
return {"started": ledger["started"], "types": dict(ledger["types"]), "models": dict(ledger["models"]),
|
||||
"opened": {kind: dict(ledger["opened"][kind]) for kind in ("types", "models")}}
|
||||
|
||||
|
||||
def _save():
|
||||
global _timer
|
||||
with _lock:
|
||||
_timer = None
|
||||
ledger = _load()
|
||||
for names in (ledger, ledger["opened"]):
|
||||
for kind in ("types", "models"):
|
||||
if len(names[kind]) > MAX_NAMES:
|
||||
names[kind] = dict(sorted(names[kind].items(), key=lambda entry: entry[1][0], reverse=True)[:MAX_NAMES])
|
||||
snapshot = _copy(ledger)
|
||||
try:
|
||||
atomic_write_json(ledger_path(), snapshot)
|
||||
except (OSError, ValueError) as error:
|
||||
logging.warning("[Anomalous Browser] Could not save the usage record: %s", error)
|
||||
|
||||
|
||||
def prompt_uses(prompt):
|
||||
"""(node types, model file references) a queued prompt names."""
|
||||
types, models = set(), set()
|
||||
for node in prompt.values():
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
if isinstance(node.get("class_type"), str) and node["class_type"]:
|
||||
types.add(node["class_type"])
|
||||
for value in (node.get("inputs") or {}).values():
|
||||
if isinstance(value, dict) and isinstance(value.get("lora"), str):
|
||||
value = value["lora"] # a row of rgthree's Power Lora Loader
|
||||
if isinstance(value, str) and MODEL_FILE.search(value):
|
||||
models.add(value.replace("\\", "/").strip().lower())
|
||||
return types, models
|
||||
|
||||
|
||||
def _note(types, models, when, opened):
|
||||
"""Counts `types` and `models` as used at `when`, among the runs or the opened workflows."""
|
||||
global _timer
|
||||
with _lock:
|
||||
ledger = _load()
|
||||
if ledger["started"] is None:
|
||||
ledger["started"] = when
|
||||
names = ledger["opened"] if opened else ledger
|
||||
for kind, found in (("types", types), ("models", models)):
|
||||
for name in found:
|
||||
count = names[kind].get(name, [0, 0])[1]
|
||||
names[kind][name] = [when, count + 1]
|
||||
if _timer is None:
|
||||
_timer = threading.Timer(SAVE_DELAY, _save)
|
||||
_timer.daemon = True
|
||||
_timer.start()
|
||||
|
||||
|
||||
def note(prompt, when=None):
|
||||
"""Note what `prompt` (ComfyUI's API prompt) uses, as of `when`; saved shortly after."""
|
||||
if not isinstance(prompt, dict):
|
||||
return
|
||||
types, models = prompt_uses(prompt)
|
||||
_note(types, models, time.time() if when is None else when, opened=False)
|
||||
|
||||
|
||||
def note_prompt(json_data):
|
||||
"""ComfyUI's on_prompt hook: notes the run and hands the request on unchanged."""
|
||||
try:
|
||||
note((json_data or {}).get("prompt"))
|
||||
except Exception as error: # noqa: BLE001 - a note must never stop a run
|
||||
logging.warning("[Anomalous Browser] Could not note a run's nodes and models: %s", error)
|
||||
return json_data
|
||||
|
||||
|
||||
def _posted(values):
|
||||
"""The names of a posted list: strings of a sane length, at most MAX_POSTED; None when not a list."""
|
||||
if not isinstance(values, list):
|
||||
return None
|
||||
return {value for value in values[:MAX_POSTED] if isinstance(value, str) and 0 < len(value) <= MAX_NAME_CHARS}
|
||||
|
||||
|
||||
async def api_opened(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
except ValueError:
|
||||
data = None
|
||||
if not isinstance(data, dict):
|
||||
return web.json_response({"error": "bad_request"}, status=400)
|
||||
types, models = _posted(data.get("types")), _posted(data.get("models"))
|
||||
if types is None or models is None:
|
||||
return web.json_response({"error": "bad_request"}, status=400)
|
||||
models = {model.replace("\\", "/").strip().lower() for model in models if MODEL_FILE.search(model)}
|
||||
_note(types, models, time.time(), opened=True)
|
||||
return web.json_response({"noted": len(types) + len(models)})
|
||||
|
||||
|
||||
def read():
|
||||
"""The record as noted so far (a copy), shaped as the module docstring says."""
|
||||
with _lock:
|
||||
return _copy(_load())
|
||||
|
||||
|
||||
def register_routes(app):
|
||||
app.router.add_post("/anomalous/usage/opened", api_opened)
|
||||
@@ -6,6 +6,9 @@ release tag. Nothing runs automatically: every network call starts from a
|
||||
button in the UI. Switching refuses when tracked files have local changes, and
|
||||
only tags that are published releases (or at least exist on the remote) can be
|
||||
checked out.
|
||||
|
||||
Every function acts on the plugin itself unless given a `Checkout`: the
|
||||
companion node pack Anomalous_TTS is updated the same way (api/tts_version.py).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -39,6 +42,21 @@ _lock = threading.Lock()
|
||||
_releases_cache = {"expires": 0, "data": None}
|
||||
|
||||
|
||||
class Checkout:
|
||||
"""Another git checkout to manage: its folder, where its last switch is noted, and the file
|
||||
that marks versions with a version panel (None: no such notion)."""
|
||||
|
||||
def __init__(self, root, state_file, marker=None):
|
||||
self.root = root
|
||||
self.state_file = state_file
|
||||
self.marker = marker
|
||||
self.cache = {"expires": 0, "data": None}
|
||||
|
||||
|
||||
def _root(repo):
|
||||
return repo.root if repo else PLUGIN_DIR
|
||||
|
||||
|
||||
class VersionError(Exception):
|
||||
"""A user-facing failure with a stable code for the UI."""
|
||||
|
||||
@@ -57,14 +75,14 @@ def _git_executable():
|
||||
return found
|
||||
|
||||
|
||||
def _git(*args, timeout=30, check=True):
|
||||
def _git(*args, timeout=30, check=True, repo=None):
|
||||
kwargs = {}
|
||||
if os.name == "nt":
|
||||
kwargs["creationflags"] = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
env = dict(os.environ, GIT_TERMINAL_PROMPT="0")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[_git_executable(), "-C", PLUGIN_DIR, *args],
|
||||
[_git_executable(), "-C", _root(repo), *args],
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
||||
timeout=timeout, env=env, **kwargs,
|
||||
)
|
||||
@@ -90,17 +108,17 @@ def version_key(tag):
|
||||
return (numbers, 1 if not suffix else 0, suffix)
|
||||
|
||||
|
||||
def current_state():
|
||||
def current_state(repo=None):
|
||||
"""Local only (no network): what is installed and whether it can be switched."""
|
||||
if not os.path.isdir(os.path.join(PLUGIN_DIR, ".git")) and _git_out("rev-parse", "--is-inside-work-tree") != "true":
|
||||
if not os.path.isdir(os.path.join(_root(repo), ".git")) and _git_out("rev-parse", "--is-inside-work-tree", repo=repo) != "true":
|
||||
return {"is_git": False}
|
||||
commit = _git_out("rev-parse", "HEAD")
|
||||
branch = _git_out("symbolic-ref", "--short", "-q", "HEAD") or None
|
||||
exact_tag = _git_out("describe", "--tags", "--exact-match", "HEAD") or None
|
||||
described = _git_out("describe", "--tags", "--always")
|
||||
base_tag = _git_out("describe", "--tags", "--abbrev=0") or None
|
||||
commit = _git_out("rev-parse", "HEAD", repo=repo)
|
||||
branch = _git_out("symbolic-ref", "--short", "-q", "HEAD", repo=repo) or None
|
||||
exact_tag = _git_out("describe", "--tags", "--exact-match", "HEAD", repo=repo) or None
|
||||
described = _git_out("describe", "--tags", "--always", repo=repo)
|
||||
base_tag = _git_out("describe", "--tags", "--abbrev=0", repo=repo) or None
|
||||
# Porcelain lines are "XY path"; keep the leading status column (no strip before slicing).
|
||||
status = _git("status", "--porcelain", "--untracked-files=no", check=False).stdout
|
||||
status = _git("status", "--porcelain", "--untracked-files=no", check=False, repo=repo).stdout
|
||||
changed = [line[3:] for line in status.splitlines() if line.strip()]
|
||||
return {
|
||||
"is_git": True,
|
||||
@@ -111,8 +129,8 @@ def current_state():
|
||||
"label": exact_tag or described or commit[:12],
|
||||
"dirty": bool(changed),
|
||||
"changed_files": changed[:20],
|
||||
"remote": _git_out("remote", "get-url", "origin") or None,
|
||||
"previous": _read_state().get("previous"),
|
||||
"remote": _git_out("remote", "get-url", "origin", repo=repo) or None,
|
||||
"previous": _read_state(repo).get("previous"),
|
||||
}
|
||||
|
||||
|
||||
@@ -146,36 +164,50 @@ def _fetch_github_releases(owner, repo):
|
||||
return releases
|
||||
|
||||
|
||||
def _remote_tags():
|
||||
output = _git_out("ls-remote", "--tags", "--refs", "origin", timeout=NETWORK_TIMEOUT * 3)
|
||||
def _remote_tags(repo=None):
|
||||
output = _git_out("ls-remote", "--tags", "--refs", "origin", timeout=NETWORK_TIMEOUT * 3, repo=repo)
|
||||
tags = [line.split("refs/tags/", 1)[1] for line in output.splitlines() if "refs/tags/" in line]
|
||||
return [{"tag": tag, "name": tag, "published_at": None, "prerelease": False, "notes": "", "url": None} for tag in tags]
|
||||
|
||||
|
||||
def list_releases(force=False):
|
||||
def _remote_tag_at(commit, repo=None):
|
||||
"""The remote tag on `commit`, for a checkout whose own tags were never fetched (a clone made
|
||||
before the release, then updated by a pull of its branch)."""
|
||||
output = _git_out("ls-remote", "--tags", "origin", timeout=NETWORK_TIMEOUT * 3, repo=repo)
|
||||
for line in output.splitlines():
|
||||
sha, _, ref = line.partition("\t")
|
||||
if commit and sha.startswith(commit) and ref.startswith("refs/tags/"):
|
||||
return ref[len("refs/tags/"):].removesuffix("^{}")
|
||||
return None
|
||||
|
||||
|
||||
def list_releases(force=False, repo=None):
|
||||
"""Published releases, newest first. GitHub API first, `git ls-remote` (uses git's proxy) as fallback."""
|
||||
state = current_state()
|
||||
state = current_state(repo)
|
||||
if not state.get("is_git"):
|
||||
raise VersionError("not_git", "The plugin is not a git checkout")
|
||||
cache = repo.cache if repo else _releases_cache
|
||||
now = time.monotonic()
|
||||
if not force and _releases_cache["data"] and now < _releases_cache["expires"]:
|
||||
releases, source = _releases_cache["data"]
|
||||
if not force and cache["data"] and now < cache["expires"]:
|
||||
releases, source = cache["data"]
|
||||
else:
|
||||
releases, source = None, None
|
||||
repo = _github_repo(state.get("remote"))
|
||||
if repo:
|
||||
github = _github_repo(state.get("remote"))
|
||||
if github:
|
||||
try:
|
||||
releases, source = _fetch_github_releases(*repo), "github"
|
||||
releases, source = _fetch_github_releases(*github), "github"
|
||||
except Exception:
|
||||
releases = None
|
||||
if releases is None:
|
||||
releases, source = _remote_tags(), "git"
|
||||
releases, source = _remote_tags(repo), "git"
|
||||
if not releases:
|
||||
raise VersionError("offline", "Could not reach GitHub or the git remote")
|
||||
releases.sort(key=lambda item: (item["published_at"] or "", version_key(item["tag"])), reverse=True)
|
||||
if source == "git":
|
||||
releases.sort(key=lambda item: version_key(item["tag"]), reverse=True)
|
||||
_releases_cache.update(expires=now + RELEASES_CACHE_SECONDS, data=(releases, source))
|
||||
cache.update(expires=now + RELEASES_CACHE_SECONDS, data=(releases, source))
|
||||
if not state.get("tag") and not state.get("dirty"):
|
||||
state["tag"] = _remote_tag_at(state.get("commit"), repo)
|
||||
|
||||
current_key = version_key(state.get("tag") or state.get("base_tag"))
|
||||
stable = [item for item in releases if not item["prerelease"]]
|
||||
@@ -193,24 +225,25 @@ def list_releases(force=False):
|
||||
|
||||
# ---------- switching ----------
|
||||
|
||||
def _read_state():
|
||||
def _read_state(repo=None):
|
||||
try:
|
||||
with open(STATE_FILE, "r", encoding="utf-8") as handle:
|
||||
with open(repo.state_file if repo else STATE_FILE, "r", encoding="utf-8") as handle:
|
||||
data = json.load(handle)
|
||||
return data if isinstance(data, dict) else {}
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def _write_state(data):
|
||||
temporary = STATE_FILE + ".tmp"
|
||||
def _write_state(data, repo=None):
|
||||
path = repo.state_file if repo else STATE_FILE
|
||||
temporary = path + ".tmp"
|
||||
with open(temporary, "w", encoding="utf-8") as handle:
|
||||
json.dump(data, handle, ensure_ascii=False, indent=2)
|
||||
os.replace(temporary, STATE_FILE)
|
||||
os.replace(temporary, path)
|
||||
|
||||
|
||||
def _require_switchable():
|
||||
state = current_state()
|
||||
def _require_switchable(repo=None):
|
||||
state = current_state(repo)
|
||||
if not state.get("is_git"):
|
||||
raise VersionError("not_git", "The plugin is not a git checkout")
|
||||
if state["dirty"]:
|
||||
@@ -218,25 +251,26 @@ def _require_switchable():
|
||||
return state
|
||||
|
||||
|
||||
def _known_tag(tag):
|
||||
def _known_tag(tag, repo=None):
|
||||
if not TAG_NAME.match(str(tag or "")):
|
||||
raise VersionError("bad_tag", "Invalid version name")
|
||||
tags = {item["tag"] for item in list_releases()["releases"]}
|
||||
tags = {item["tag"] for item in list_releases(repo=repo)["releases"]}
|
||||
if tag not in tags:
|
||||
raise VersionError("unknown_tag", f"{tag} is not a published version")
|
||||
return tag
|
||||
|
||||
|
||||
def _fetch_tag(tag):
|
||||
_git("fetch", "--no-tags", "--force", "origin", f"+refs/tags/{tag}:refs/tags/{tag}", timeout=FETCH_TIMEOUT)
|
||||
def _fetch_tag(tag, repo=None):
|
||||
_git("fetch", "--no-tags", "--force", "origin", f"+refs/tags/{tag}:refs/tags/{tag}", timeout=FETCH_TIMEOUT, repo=repo)
|
||||
|
||||
|
||||
def preview_switch(tag):
|
||||
def preview_switch(tag, repo=None):
|
||||
"""Fetch the target and report what switching would mean, without changing anything."""
|
||||
state = _require_switchable()
|
||||
_known_tag(tag)
|
||||
_fetch_tag(tag)
|
||||
has_switcher = _git("cat-file", "-e", f"refs/tags/{tag}:{SWITCHER_MARKER}", check=False).returncode == 0
|
||||
state = _require_switchable(repo)
|
||||
_known_tag(tag, repo)
|
||||
_fetch_tag(tag, repo)
|
||||
marker = repo.marker if repo else SWITCHER_MARKER
|
||||
has_switcher = not marker or _git("cat-file", "-e", f"refs/tags/{tag}:{marker}", check=False, repo=repo).returncode == 0
|
||||
target_key = version_key(tag)
|
||||
current_key = version_key(state.get("tag") or state.get("base_tag"))
|
||||
return {
|
||||
@@ -248,15 +282,15 @@ def preview_switch(tag):
|
||||
}
|
||||
|
||||
|
||||
def switch_to(tag):
|
||||
def switch_to(tag, repo=None):
|
||||
with _lock:
|
||||
state = _require_switchable()
|
||||
_known_tag(tag)
|
||||
_fetch_tag(tag)
|
||||
previous = {"commit": _git_out("rev-parse", "HEAD"), "branch": state["branch"], "label": state["label"]}
|
||||
_git("-c", "advice.detachedHead=false", "checkout", "--quiet", "--detach", f"refs/tags/{tag}")
|
||||
_write_state({"previous": previous, "switched_at": time.time()})
|
||||
return {"state": current_state(), "restart_required": True}
|
||||
state = _require_switchable(repo)
|
||||
_known_tag(tag, repo)
|
||||
_fetch_tag(tag, repo)
|
||||
previous = {"commit": _git_out("rev-parse", "HEAD", repo=repo), "branch": state["branch"], "label": state["label"]}
|
||||
_git("-c", "advice.detachedHead=false", "checkout", "--quiet", "--detach", f"refs/tags/{tag}", repo=repo)
|
||||
_write_state({"previous": previous, "switched_at": time.time()}, repo)
|
||||
return {"state": current_state(repo), "restart_required": True}
|
||||
|
||||
|
||||
def _default_branch():
|
||||
@@ -287,39 +321,39 @@ def return_to_latest():
|
||||
return {"state": current_state(), "restart_required": True}
|
||||
|
||||
|
||||
def undo_last_switch():
|
||||
def undo_last_switch(repo=None):
|
||||
with _lock:
|
||||
previous = _read_state().get("previous")
|
||||
previous = _read_state(repo).get("previous")
|
||||
if not previous or not previous.get("commit"):
|
||||
raise VersionError("no_previous", "There is no switch to undo")
|
||||
state = _require_switchable()
|
||||
here = {"commit": _git_out("rev-parse", "HEAD"), "branch": state["branch"], "label": state["label"]}
|
||||
state = _require_switchable(repo)
|
||||
here = {"commit": _git_out("rev-parse", "HEAD", repo=repo), "branch": state["branch"], "label": state["label"]}
|
||||
if previous.get("branch"):
|
||||
# Going back to a branch means its current tip, even if it moved since (e.g. a Manager update).
|
||||
_git("checkout", "--quiet", previous["branch"])
|
||||
_git("checkout", "--quiet", previous["branch"], repo=repo)
|
||||
else:
|
||||
_git("-c", "advice.detachedHead=false", "checkout", "--quiet", "--detach", previous["commit"])
|
||||
_write_state({"previous": here, "switched_at": time.time()})
|
||||
return {"state": current_state(), "restart_required": True}
|
||||
_git("-c", "advice.detachedHead=false", "checkout", "--quiet", "--detach", previous["commit"], repo=repo)
|
||||
_write_state({"previous": here, "switched_at": time.time()}, repo)
|
||||
return {"state": current_state(repo), "restart_required": True}
|
||||
|
||||
|
||||
# ---------- routes ----------
|
||||
|
||||
def _error_response(error):
|
||||
def error_response(error):
|
||||
status = 409 if error.code in {"dirty", "diverged"} else 400
|
||||
if error.code in {"offline", "git_timeout", "git_failed"}:
|
||||
status = 502
|
||||
return web.json_response({"success": False, "code": error.code, "error": str(error), **error.details}, status=status)
|
||||
|
||||
|
||||
async def _run(handler, *args):
|
||||
async def run(handler, *args):
|
||||
try:
|
||||
return web.json_response({"success": True, **(await asyncio.to_thread(handler, *args))})
|
||||
except VersionError as error:
|
||||
return _error_response(error)
|
||||
return error_response(error)
|
||||
|
||||
|
||||
async def _json_body(request):
|
||||
async def json_body(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
@@ -332,32 +366,32 @@ async def api_get_version(request):
|
||||
try:
|
||||
return web.json_response({"success": True, "state": await asyncio.to_thread(current_state)})
|
||||
except VersionError as error:
|
||||
return _error_response(error)
|
||||
return error_response(error)
|
||||
|
||||
|
||||
async def api_get_releases(request):
|
||||
"""GET /anomalous/version/releases?refresh=1 - Published releases (network, only when asked)."""
|
||||
return await _run(list_releases, request.query.get("refresh") == "1")
|
||||
return await run(list_releases, request.query.get("refresh") == "1")
|
||||
|
||||
|
||||
async def api_preview_switch(request):
|
||||
"""POST /anomalous/version/preview {tag}"""
|
||||
return await _run(preview_switch, str((await _json_body(request)).get("tag", "")))
|
||||
return await run(preview_switch, str((await json_body(request)).get("tag", "")))
|
||||
|
||||
|
||||
async def api_switch_version(request):
|
||||
"""POST /anomalous/version/switch {tag}"""
|
||||
return await _run(switch_to, str((await _json_body(request)).get("tag", "")))
|
||||
return await run(switch_to, str((await json_body(request)).get("tag", "")))
|
||||
|
||||
|
||||
async def api_return_to_latest(request):
|
||||
"""POST /anomalous/version/latest"""
|
||||
return await _run(return_to_latest)
|
||||
return await run(return_to_latest)
|
||||
|
||||
|
||||
async def api_undo_switch(request):
|
||||
"""POST /anomalous/version/undo"""
|
||||
return await _run(undo_last_switch)
|
||||
return await run(undo_last_switch)
|
||||
|
||||
|
||||
def register_routes(app):
|
||||
|
||||
@@ -4,6 +4,11 @@ description, and preview media. The API key comes from the plugin's config.json.
|
||||
fetch_civitai_info tells the two failures apart: None means Civitai answered that it does not
|
||||
know the file; CivitaiUnreachable means it could not be asked (no connection, timeouts, errors),
|
||||
and carries why (net_proxy.failure). Requests go through Settings' proxy (net_proxy.py).
|
||||
|
||||
Civitai answers its API on two addresses, civitai.com and civitai.red. Some networks reach only
|
||||
the second (mainland China's, a cloud server there), so when one does not answer the other is
|
||||
asked, and the one that answered is asked first next time (api_hosts). The pictures stay on
|
||||
image.civitai.com either way.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -19,6 +24,18 @@ except ImportError: # the scan process runs this as a top-level module
|
||||
from net_proxy import failure, urlopen
|
||||
|
||||
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
API_HOSTS = ("civitai.com", "civitai.red")
|
||||
_answered = [API_HOSTS[0]] # the address that answered last
|
||||
|
||||
|
||||
def api_hosts():
|
||||
"""Civitai's API addresses, the one that answered last first."""
|
||||
return [_answered[0]] + [host for host in API_HOSTS if host != _answered[0]]
|
||||
|
||||
|
||||
def answered(host):
|
||||
"""Notes that `host` answered (with data or a 404), so it is asked first from now on."""
|
||||
_answered[0] = host
|
||||
|
||||
|
||||
class CivitaiUnreachable(Exception):
|
||||
@@ -53,25 +70,31 @@ def _headers():
|
||||
|
||||
def fetch_civitai_info(file_hash: str, max_retries: int = 3) -> Optional[Dict]:
|
||||
"""Civitai's record of the model version with this file hash; None when Civitai does not
|
||||
know the file (404). Raises CivitaiUnreachable when it could not be asked."""
|
||||
url = f"https://civitai.com/api/v1/model-versions/by-hash/{file_hash}"
|
||||
know the file (404). Raises CivitaiUnreachable when it could not be asked. Each try asks
|
||||
the other address too when the first does not answer at all."""
|
||||
why = "other"
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
with urlopen(urllib.request.Request(url, headers=_headers()), timeout=15) as response:
|
||||
return json.loads(response.read().decode('utf-8'))
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 404:
|
||||
print(f"\033[93m[Skip] 模型 Hash {file_hash} 未在 Civitai 找到 (404)。\033[0m")
|
||||
return None
|
||||
why = failure(e)
|
||||
print(f"[-] 请求异常,状态码: {e.code} (尝试 {attempt+1}/{max_retries})")
|
||||
except urllib.error.URLError as e:
|
||||
why = failure(e)
|
||||
print(f"[-] 网络请求超时或异常: {e.reason} (尝试 {attempt+1}/{max_retries})")
|
||||
except Exception as e:
|
||||
why = failure(e)
|
||||
print(f"[-] 未知异常: {e} (尝试 {attempt+1}/{max_retries})")
|
||||
for host in api_hosts():
|
||||
url = f"https://{host}/api/v1/model-versions/by-hash/{file_hash}"
|
||||
try:
|
||||
with urlopen(urllib.request.Request(url, headers=_headers()), timeout=15) as response:
|
||||
data = json.loads(response.read().decode('utf-8'))
|
||||
answered(host)
|
||||
return data
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 404:
|
||||
answered(host)
|
||||
print(f"\033[93m[Skip] 模型 Hash {file_hash} 未在 Civitai 找到 (404)。\033[0m")
|
||||
return None
|
||||
why = failure(e)
|
||||
print(f"[-] 请求异常,状态码: {e.code} (尝试 {attempt+1}/{max_retries})")
|
||||
break # Civitai answered with an error: the other address is the same Civitai
|
||||
except urllib.error.URLError as e:
|
||||
why = failure(e)
|
||||
print(f"[-] {host} 网络请求超时或异常: {e.reason} (尝试 {attempt+1}/{max_retries})")
|
||||
except Exception as e:
|
||||
why = failure(e)
|
||||
print(f"[-] {host} 未知异常: {e} (尝试 {attempt+1}/{max_retries})")
|
||||
if attempt < max_retries - 1:
|
||||
time.sleep(2)
|
||||
print(f"\033[93m[Skip] 模型 Hash {file_hash} 网络重试失败。\033[0m")
|
||||
@@ -81,7 +104,7 @@ def fetch_civitai_info(file_hash: str, max_retries: int = 3) -> Optional[Dict]:
|
||||
def fetch_model_description(model_id) -> str:
|
||||
"""The model page's description (the version record only has the version's); '' when unavailable."""
|
||||
try:
|
||||
request = urllib.request.Request(f"https://civitai.com/api/v1/models/{model_id}", headers=_headers())
|
||||
request = urllib.request.Request(f"https://{api_hosts()[0]}/api/v1/models/{model_id}", headers=_headers())
|
||||
with urlopen(request, timeout=10) as response:
|
||||
return json.loads(response.read().decode('utf-8')).get("description") or ""
|
||||
except Exception as e:
|
||||
|
||||
@@ -19,7 +19,20 @@ small `api/utils.py` compatibility facade):
|
||||
|
||||
- `resolve_folder_subdir()` for configured model-folder boundaries;
|
||||
- `resolve_within()` for containment below an owned root;
|
||||
- `require_filename()` for single-file identifiers.
|
||||
- `require_filename()` for single-file identifiers (never `.` or `..`);
|
||||
- `model_file_within()` / `model_reference_within()` for a model file in a resolved folder.
|
||||
|
||||
A model file can be a link: on a cloud server (AutoDL) every model in the models folder links
|
||||
into the platform's shared, read-only model disk, under a name like `6b4679…` without an
|
||||
extension. Resolving the file would leave the models folder and lose its covers and info, which
|
||||
are written beside the link. So the folder part is resolved and must stay inside its root
|
||||
(through links too), and the file is taken as named: `model_file_within` is the folder joined
|
||||
with a bare `require_filename` name, `get_metadata` reads the sidecars beside the path it is
|
||||
given (`abspath`, not `realpath`), and edits, renames and deletes act on the link, never on the
|
||||
disk behind it. Only when a link has no info beside it are the info files beside the file it
|
||||
links to read (`_info_base`): another tool (A1111's Civitai Helper) may have left them there. Nothing in the plugin writes into a model file. Tidy's moves and the
|
||||
import's `move_into` tell a rename from a copy by where the link is (`lstat`), so moving a
|
||||
linked model renames the link instead of copying the model disk's file onto the server.
|
||||
|
||||
Never rely only on `..` rejection. Absolute Windows paths, UNC paths, alternate
|
||||
separators, device paths, and symlinks can bypass naïve string checks. Media
|
||||
@@ -46,13 +59,25 @@ look; every change under `/anomalous/` answers only the computer ComfyUI runs on
|
||||
middleware (`api/local_guard.py`, registered first in `setup_routes`) decides it for all routes:
|
||||
a request other than GET must be local unless its path is in `OPEN_POSTS` (reads sent as POST:
|
||||
`resolve_paths_to_previews`, `resolve_hash_batch`, `inspect_image_material`); a GET is open
|
||||
unless its path is in `LOCAL_GETS` (`backup/file`, `version/releases`, `import/folders`).
|
||||
unless its path is in `LOCAL_GETS` (`backup/file`, `version/releases`, `import/folders`,
|
||||
`civitai_check`: the first-run setup's one request to Civitai, `api/network_check.py`).
|
||||
`/anomalous/mcp` answers other devices itself. Local means: a loopback peer, no proxy headers
|
||||
(`Forwarded`, `X-Forwarded-For`, `X-Real-IP`, `X-Forwarded-Host`) and a loopback `Host`, so a
|
||||
page that reached the server under another name (DNS rebinding) is refused too. A refusal is
|
||||
HTTP 403 with `code: local_only` and a bilingual message. `GET /anomalous/access` tells the page
|
||||
whether it is local; `remote_view.js` then shows a "view only" chip in the header.
|
||||
|
||||
ComfyUI on a rented cloud server is only ever opened from another device. An empty
|
||||
`allow_remote_changes` file in the plugin folder, made on the server and read once at start
|
||||
(`REMOTE_CHANGES`), makes `local_guard.may_change` true for every request: the middleware and
|
||||
the routes that check for themselves (import, placement, scan choice, recipe focus) use
|
||||
`may_change`; `is_local` stays for what needs the browser on this computer (import's search of
|
||||
this computer's Downloads) and for MCP. `GET /anomalous/access` answers `{local, changes}`, and
|
||||
for another device also `command`: the line that makes the file (or, when it is there, removes
|
||||
it), with this server's real path, which the chip's dialog offers to copy; it only runs where
|
||||
someone pastes it, in the server's terminal. The chip reads "remote changes on" while the file
|
||||
is there. Usage and the risk: `docs/guides/cloud-server.md`.
|
||||
|
||||
## Storage ownership
|
||||
|
||||
Runtime UI settings and newly stored API keys live in `api/config.json`.
|
||||
@@ -146,7 +171,13 @@ polling after its UI has been reopened.
|
||||
`inferred` with its reason, `failed` with the error, renames and covers) and events
|
||||
(`civitai_down` with `reason`, net_proxy.py's name for the failure, and `done` with the
|
||||
unchanged count; the result carries the first reason as `civitai_error`). The scan process
|
||||
gets Settings' proxy as HTTP(S)_PROXY (`net_proxy.child_env`). One model's error is reported and
|
||||
gets Settings' proxy as HTTP(S)_PROXY (`net_proxy.child_env`). Civitai answers its API on
|
||||
civitai.com and civitai.red, and some networks (a cloud server in mainland China) reach only the
|
||||
second: `civitai_client.api_hosts` gives both, the one that answered last first, and every API
|
||||
request (the scan, import's and Model Check's lookups, `civitai_check`, which names the `host`)
|
||||
asks the other when the first does not answer at all; an HTTP error is Civitai's own answer and
|
||||
is not asked again. The pictures stay on image.civitai.com, so after `COVERS_DOWN_AFTER` (2)
|
||||
covers in a row that would not download the scan fetches no more covers (`covers_down`). One model's error is reported and
|
||||
the scan goes on. `ScanJob` in `api/scan_report.py` reads each folder's report when its
|
||||
worker exits and, before the scan's marker is released, writes the scan's result to
|
||||
`user/anomalous/last_scan.json` and the activity log, so whoever sees the scan end can
|
||||
@@ -164,6 +195,56 @@ changes the file's hash, so the file is to be downloaded again; the scan summary
|
||||
a scan stops asking (`--civitai-down` carries this to the next folders) and infers the
|
||||
rest from the files, instead of waiting for every timeout.
|
||||
|
||||
A model can be left out of scans: `"scan": "skip"` in its `<model>.anomalous.json` (the
|
||||
user's own layer, which scans never write; `model_identity.scan_choice`). A scan of a whole
|
||||
folder skips it and the scan summary counts it apart (`excluded`), not in the other counts;
|
||||
picked by hand (`targets`) it is scanned all the same. `"scan": "scan"` records that the user
|
||||
was asked and kept it. Nothing decides this alone: the summary only marks new models whose
|
||||
safetensors header reads but names no drawing model kind (`model_kind.inspect_file`, kind "",
|
||||
a voice model say) as `not_drawing`, and the scan page asks. Those headers are read
|
||||
`HEAD_READERS` (16) at a time, since on a network drive each read waits on the network (1041
|
||||
new models on a cloud server's model disk took 79 s one after another), through
|
||||
`get_metadata`, which reads headers through `api/model_heads.py`: each file's answer is kept
|
||||
by real path with its size and time in user/anomalous/model_heads.json (at most 50,000 files,
|
||||
the longest unused dropped first, written a few seconds after the last change, and only once
|
||||
`setup_routes` called `keep_on_disk`, so tests stay in memory), so a header is read once per
|
||||
file version, also after a restart. The last summary is kept too (`_last`, and
|
||||
user/anomalous/scan_summary.json when it changed): `?quick=1` hands it out at once for the page
|
||||
to show while a fresh one is worked out, and requests meanwhile await that same count
|
||||
(`_fresh`). The models found on Civitai are kept with it, components apart, and
|
||||
`GET /anomalous/scan_matched` lists them (file, Civitai's name, base model; at most 5000), so
|
||||
the summary the page reads each time stays small. `POST /anomalous/scan_choice`
|
||||
(`api/scan_choice.py`, this computer only) takes the picked models as scan `targets`, at most
|
||||
2000, and writes only `.safetensors` files' layers that are readable.
|
||||
|
||||
The summary also counts the models in component folders (`COMPONENT_TYPES`: VAE, text
|
||||
encoders, CLIP, CLIP Vision, upscalers) as `components` and lists them, found ones too, in
|
||||
`component_models` with their `state`; they stay in the other counts, and their list items carry
|
||||
`component`, so the page can show them apart. Folder type decides, not the file's header.
|
||||
`covers` holds up to 48 covers of scanned models, components left out, newest model file first
|
||||
(`{name, preview_url}`), for the pictures on top of the scan card.
|
||||
|
||||
The model editor writes through `POST /anomalous/update_metadata` (only the fields it sends:
|
||||
`custom_name`, `custom_notes`, `custom_source_url`, which must be empty or an http(s) address,
|
||||
else 400 and nothing changes) into the user layer, and covers through `set_custom_cover` /
|
||||
`upload_custom_cover` first. `GET /anomalous/model_covers?type&path_idx&subfolder&filename`
|
||||
(`api/model_media.py`, read-only) tells it Civitai's kept image (`<model>.civitai_bak.*`, as an
|
||||
image URL) and whether the cover shown is the user's, so it can offer Civitai's back.
|
||||
|
||||
Current node's saved values: `GET /anomalous/parameters/by_node_type` gives each recipe set's
|
||||
file and values' `signature`; `GET /anomalous/parameter_pictures?signatures=a,b`
|
||||
(`api/parameters.py`, at most 64 signatures, 64 hex characters each) counts, in one pass over
|
||||
the newest output pictures (`recipe_images._recent_output_pngs`), how many were made with each
|
||||
set's values and names the newest. A picture's signature is read once per file version
|
||||
(`lru_cache` of 4096 entries keyed by path and modification time), which the workflow page's
|
||||
`parameter_gallery` shares.
|
||||
|
||||
The scan page's picker reads `GET /anomalous/scan_pickable` (`api/scan_summary.py`): the
|
||||
same files the summary counts (the scan folders' `.safetensors`, each once), the "don't scan"
|
||||
ones included, up to 5000 with the exact `total`. Each carries its locator (`type`,
|
||||
`path_idx`, `rel`), size, cover URL and only the metadata fields its badge needs, not the
|
||||
descriptions. Its picks go to `/anomalous/scan_all` as `targets`, like a list page's.
|
||||
|
||||
## MCP endpoint
|
||||
|
||||
`POST /anomalous/mcp` (`api/mcp_server.py`) answers one JSON-RPC message per POST with
|
||||
@@ -268,6 +349,47 @@ without it on huggingface.co, whichever host the link names. The mirror sends ne
|
||||
outside China back to huggingface.co; the lookup follows such a redirect once for the size
|
||||
and SHA-256.
|
||||
|
||||
## Model import
|
||||
|
||||
`api/model_import.py` takes model files the user drops on the models page. A browser gives a
|
||||
dropped file's name, size, time and bytes but never its path, so `POST
|
||||
/anomalous/import/inspect` gets the name, size and time with the file's header bytes, and,
|
||||
asked from this computer (`local_guard.is_local`), looks for the same file (name, size, time
|
||||
within 2 s) in the Downloads and Desktop known folders and two subfolder levels below. A
|
||||
found file gets an in-memory token; the client never sends a path. What the file is comes
|
||||
from `model_kind.py`: the safetensors header's tensor names, shapes and kohya/modelspec
|
||||
metadata, or a GGUF's `general.architecture` and tensor names; `.ckpt/.pt/.pth/.bin` are never
|
||||
opened (the user picks the type). Types without a folder in this ComfyUI are not offered.
|
||||
Files of the same name already in that type's folders are listed. `POST
|
||||
/anomalous/import/identify` hashes a found file in a thread and asks Civitai by SHA-256 (base
|
||||
model, model and version name) and the scanned-model hash index for an identical copy.
|
||||
|
||||
`POST /anomalous/import/place` (local only) moves the found file into `{type, root, rel}`:
|
||||
a rename on the same drive, else a copy through `<dest>.part` checked by size, then the
|
||||
original is removed (or kept with `keep`). `PUT /anomalous/import/upload` (local only) streams the request
|
||||
body (not limited by ComfyUI's upload size) into `<dest>.part` and renames it when the size
|
||||
matches. Both check the destination like downloads (`resolve_within`, model extension, free
|
||||
space) and never replace a file: a taken name becomes `name (2).ext`. A failed copy or
|
||||
upload removes only its own `.part`. Each import clears the file-list caches and adds a
|
||||
`model_import` activity entry; the page then scans the new files. The destination the page
|
||||
proposes follows the download settings' folder (`{base}` = the base model's folder).
|
||||
|
||||
## Tidy check
|
||||
|
||||
`api/model_placement.py` serves the models page's Tidy view. `GET /anomalous/placement/check`
|
||||
walks every import type's folders once (a folder listed under two types counts for the first;
|
||||
real paths deduplicated) and reads each header through `model_kind.inspect_file`, kept in
|
||||
memory per path, size and time. A file whose header names another type than its folder, only
|
||||
when `sure`, is misplaced; it goes to the same relative path in a folder of that type, on the
|
||||
same drive when one exists. `works` marks a whole checkpoint in the diffusion models folder,
|
||||
which still loads there. Identical files are grouped by SHA-256: the scan's (metadata) hash, or
|
||||
one computed earlier this run; files of one size whose hash is unknown are only counted
|
||||
(`unchecked`) unless `deep=1` reads them. `POST /anomalous/placement/move` (local only) moves the model with
|
||||
`model_import.destination` and `move_into` (never over a file: " (2)") and then its sidecars
|
||||
(`SIDECAR_SUFFIXES`; none when another model in the folder shares the stem), reports those that
|
||||
could not follow (`left`), clears the file-list caches and logs `model_move`. Removing a copy is
|
||||
`/anomalous/delete_model` (Recycle Bin, sidecars along).
|
||||
|
||||
## Metadata and cache behavior
|
||||
|
||||
The output gallery keeps one ordered directory snapshot for at most ten seconds
|
||||
|
||||
@@ -45,7 +45,8 @@ Major UI panels live in `web/modules/ui_*.js`. Shared browser state remains on
|
||||
the `AnomalousBrowser` instance. Pure parsing, normalization, comparison, and
|
||||
transaction helpers remain in focused modules rather than acquiring DOM state.
|
||||
|
||||
`ui_sidebar.js` assembles the browser window and the model folder list; the left
|
||||
`ui_sidebar.js` assembles the browser window and places the model folder column
|
||||
(`ui_folder_list.js`, the current type's folders only); the left
|
||||
icon rail (`ui_shell_rail.js`), page navigation and the list column's open state
|
||||
(`ui_shell_nav.js`), the home page (`ui_home.js`) and the window frame
|
||||
(`ui_shell_frame.js`) are their own modules. Every page change goes through
|
||||
@@ -56,7 +57,7 @@ The Prompt Studio shortcut (its command and setting ids still say "materials") i
|
||||
native ComfyUI command/keybinding; a deferred window-key fallback invokes the same
|
||||
command path only when the host did not open the studio, so handled shortcuts are not executed twice.
|
||||
`ui_browser_navigation.js` owns shared panel hiding, recoverable detail cleanup,
|
||||
and workspace return. `ui_scan_page.js` owns the scan page and `scan_runner.js` scan launch and polling,
|
||||
and workspace return. `ui_scan_page.js` owns the scan page and `scan_runner.js` scan launch and polling (an online scan first goes through `scan_connection.js`: Civitai reachable, or the user picks scanning from the files),
|
||||
`ui_folder_manager.js` is the settings page's Model folders view (grouping mode,
|
||||
visibility and order, saved on each change). Public entry functions remain
|
||||
browser-instance methods so existing actions share current browser state.
|
||||
@@ -72,8 +73,9 @@ The main surfaces are:
|
||||
- Sidebar shell: navigation and persistent scan controls; focused child modules
|
||||
own the scan page, folder manager, and help dialog.
|
||||
- Grid and model detail: model browsing plus coordinated detail display in
|
||||
`ui_detail.js`, metadata editing in `ui_model_editor.js`, and advanced selection
|
||||
in `ui_model_selector.js`.
|
||||
`ui_detail.js` (its head with the actions in `model_detail_head.js`), metadata editing in
|
||||
`ui_model_editor.js`; `model_widget_path.js` puts a
|
||||
model path into a loader's widget. The scan page's model picker is `ui_scan_picker.js`, its settings card `ui_scan_settings.js`; opened afresh, the page shows the last counts at once (`?quick=1`) with "showing the last counts while they are updated", then the fresh ones; before any, a count slower than 400 ms shows "counting your models" instead of a blank page. "Found on Civitai" opens a list of those models with a box to find one by name (`/anomalous/scan_matched`, read when it opens).
|
||||
- Gallery: generated outputs (pictures and videos; All / Pictures / Videos beside the search
|
||||
and the hidden formats in Settings, `gallery_filter.js` + `ui_gallery_filter.js`) and the
|
||||
full-screen Image Detail Studio Workbench, for pictures only.
|
||||
@@ -84,7 +86,12 @@ The main surfaces are:
|
||||
owns header/zoom/filmstrip interaction, and `ui_image_inspector.js` owns metadata tabs.
|
||||
- Workspace: Combos (搭配, formerly Prompt Notes) and Workflow Recipes. The combo list, workspace/persistence, editing, and canvas use are separated
|
||||
across `ui_combos.js`, `ui_notebooks.js`, `ui_notebook_editor.js`, and
|
||||
`notebook_canvas.js`; Put on canvas lays a combo out as a new wired group of nodes (Esc while it
|
||||
`notebook_canvas.js`; a default combo's `data.loraNode` ('core', 'rgthree', 'easy'; none is 'core')
|
||||
says which LoRA node it is laid out with (`lora_nodes.js`; one this ComfyUI lacks falls back to
|
||||
'core' and says so after placing), chosen under New or in its editor (`ui_lora_node_choice.js`,
|
||||
shown only when there is a choice). A Power Lora Loader in a node structure keeps its rows as
|
||||
the node's `rows` and each row's LoRA as a slot with `field: 'lora'`; its rows are made again
|
||||
(`addNewLoraWidget`) before the slots are filled. Put on canvas lays a combo out as a new wired group of nodes (Esc while it
|
||||
follows the pointer removes it; the activity log holds back while `owner.placingCombo` is set) and
|
||||
never changes nodes already on the canvas (values of an existing node are changed in
|
||||
Current node). A combo with a node structure (`combo_structure.js`, `ui_combo_structure.js`)
|
||||
@@ -336,7 +343,10 @@ while the page is visible (scheduled after the previous request), on focus/visib
|
||||
and after a save; a newer request cancels the older one and a failed one keeps the
|
||||
current cards. List summaries are not prompt bodies: a prompt's text is read through
|
||||
`material_prompt_data.js` once per filename and timestamp, since a saved prompt's text
|
||||
never changes. A card's role decides its box: a positive card never goes into a
|
||||
never changes. A clicked card opens as a reference (`ui_prompt_reference.js`, read only,
|
||||
one at a time) between the cards and the boxes: a click on one of its tags adds that tag,
|
||||
"Add all" the whole card (as the preview's Add does); the ticks follow the box's text on the
|
||||
same 400 ms poll. A card's role decides its box: a positive card never goes into a
|
||||
negative box or the reverse; boxes of unknown or both roles take either. A saved
|
||||
card's preview renames it (`update_material`, tags kept) or deletes it
|
||||
(`delete_material`, to the Recycle Bin). A card, or a box's handle with the box's text
|
||||
|
||||
@@ -80,6 +80,11 @@ The route family is:
|
||||
- `POST /anomalous/update_material`
|
||||
- `POST /anomalous/delete_material`
|
||||
|
||||
`materials/by_node_type` (Current node's saved values) also says where each record's values
|
||||
came from: `origin` (the source type, `generated_image` or `canvas_node`), `source_image` (the
|
||||
kept picture's `filename` and `subfolder`, or null) and `preview` (the saved preview asset's id,
|
||||
for `material_asset`, or "").
|
||||
|
||||
`material_full?include_workflow=0` returns metadata and scoped node blocks (with
|
||||
`workflow_hashes` restricted to them) and no complete workflow; `include_workflow=1`
|
||||
returns the original workflow of a complete snapshot. Selected-node records never
|
||||
@@ -123,6 +128,35 @@ the star is filled when any exists, and a kept row opens it or removes it: what
|
||||
the Recycle Bin through its own delete route (`delete_recipe`, `delete_notebook`,
|
||||
`delete_material`) after a confirmation, and the star follows.
|
||||
|
||||
## Comparing parameters
|
||||
|
||||
`param_compare.js` reads two sides (`readParams`: each node's named widget values, mode, input
|
||||
wiring and prompt-box role from `prompt_boxes.js`) and compares them (`compareParams`): nodes
|
||||
pair by id and type, then leftover nodes by type when each side has exactly one of it; values
|
||||
equal by their text form are counted, not listed; seeds (`isVolatileWidget`) are flagged.
|
||||
`textDifference` shows a long text as the tags only one side has (each once), or as the
|
||||
differing span with 40 characters around it; the window gives each side its own line and colour,
|
||||
since neither side is older than the other. `ui_param_compare.js` is the window: a side is an output image (its
|
||||
workflow from `inspect_image_material`, laid on a detached graph) or the live canvas. With the
|
||||
canvas on one side, ticked image values are written through `applyNodeMaterialValues` (seeds,
|
||||
missing choices and vanished nodes skipped and counted), all as one Ctrl+Z step, with Undo in
|
||||
the toast. Nothing is stored; the gallery's pick (`owner.comparePick`) lasts until it is used
|
||||
or cancelled.
|
||||
|
||||
An image's workflow is always laid on its graph through `detached_graph.js`, which gives the
|
||||
copy a fresh id: ComfyUI's frontend keeps widget values by root graph id, node and name, and a
|
||||
copy under the open canvas's id (any workflow opened from that image) would share and overwrite
|
||||
the canvas's values.
|
||||
|
||||
Key parameters (`key_params.js`) are starred in the window by node type and widget name, so a
|
||||
star holds in every workflow; they live in the hidden ComfyUI setting
|
||||
`Anomalous.ModelBrowser.KeyParameters` (at most 40) and show first, equal values included.
|
||||
A side can also be a saved workflow (`{ workflow, label }`): a recipe's parameter set beside
|
||||
the recipe (`ui_param_set_keys.js`), whose card in the Parameters tab lists the set's key values
|
||||
and how many values differ. `GET /anomalous/parameters/signatures` gives each recipe's sets by
|
||||
their value signature (files over the save limit skipped, at most 5,000 read); a recipe card counts
|
||||
those unlike the recipe's own, since saving a recipe also keeps a set equal to it.
|
||||
|
||||
## Prompt plans
|
||||
|
||||
`prompt_plan` has no synthetic workflow. `POST save_prompt_plan` accepts a name, tags,
|
||||
|
||||
@@ -145,6 +145,12 @@ path only if it is present in the target widget's choices. Then it updates the
|
||||
dropdown through the node's widget hooks, which also clears ComfyUI's
|
||||
missing-model mark.
|
||||
|
||||
The check reads every drop-down naming a model file, and rgthree's Power Lora Loader's
|
||||
switched-on rows, which are no drop-downs (`powerLoraRowBoxes` in `lora_nodes.js` shows each
|
||||
as one: ComfyUI's LoRA list as its choices, a fix changing only the row's LoRA). The
|
||||
fingerprints a saved workflow carries (`hash_resolver.js`) and a picture's model search
|
||||
(`api/image_search.py`) read those rows too.
|
||||
|
||||
Checking never changes the workflow (`model_check.js`). Every opened workflow is
|
||||
checked without reloading ComfyUI's model lists; when models are missing, a bar
|
||||
over the canvas says how many and how many hash matches can be put in. Only a
|
||||
|
||||
@@ -132,7 +132,13 @@ the same normalization and enrichment boundaries as local recipes.
|
||||
native loaders plus `ALL_IN_ONE_LOADER_SPECS` (verified serialized layouts),
|
||||
mirrored by `_model_reference_specs` in `api/recipe_schema.py`. Add a loader
|
||||
to both tables with its real widget order; never infer a category from a
|
||||
file name, and skip placeholder values such as `None` / `Baked VAE`;
|
||||
file name, and skip placeholder values such as `None` / `Baked VAE`. Stack
|
||||
nodes whose rows are not fixed positions are read by `nodeModelValues` /
|
||||
`_model_reference_values` (the same two sides): rgthree's Power Lora Loader
|
||||
(rows are `{on, lora, strength}` objects; off rows are skipped, rows numbered
|
||||
as rgthree numbers them) and Easy-Use's LoRA stack (`lora_N_name` at
|
||||
3 + 4·(N−1), up to `num_loras`). Only widely used nodes are read this way;
|
||||
ComfyUI's own loaders stay the reference;
|
||||
- `web/modules/recipe_provenance.js`: pure Hash lookup/remap/replace helpers;
|
||||
- `web/modules/recipe_actions.js`: canvas transactions and append rollback;
|
||||
- `web/modules/ui_recipe_detail.js`: user actions that invoke the contracts;
|
||||
|
||||
@@ -87,19 +87,26 @@ Doctor requires their hash for automatic recovery and treats size-only evidence
|
||||
as a manual candidate at most.
|
||||
Inspection failure never blocks the workflow snapshot from being saved.
|
||||
|
||||
The detail view contains Overview, Parameters, Gallery, and Versions as
|
||||
applicable. Compact cards may ellipsize bounded values while preserving their
|
||||
The details are built around the recipe's values: a bar (Back, the name, the models'
|
||||
state, Open, ⋯ edit and export), a left column (the recipe's own values, then its saved sets
|
||||
whose value signature differs from the recipe's, newest first; all pictures; versions) and the
|
||||
chosen view on the right. The recipe's own values show its cover, notes and tags first; each set
|
||||
shows Apply to canvas, ⇄ (with the recipe's values or the canvas), its main values, prompts,
|
||||
the pictures made with those values, and, folded, the models (own values only) and every node. Compact cards may ellipsize bounded values while preserving their
|
||||
full copy value. Detail rows provide visible expand/collapse and copy controls
|
||||
for long values and prompts; they do not silently truncate authoritative data.
|
||||
|
||||
`ui_recipe_detail.js` coordinates the detail session, active tab, and model composition.
|
||||
`ui_recipe_overview.js` owns the overview; `ui_recipe_parameters.js` owns prompt roles,
|
||||
parameter editing, raw nodes, and preset saving; and `ui_recipe_models.js`
|
||||
`ui_recipe_detail.js` coordinates the detail session (the view: a set, all pictures or versions)
|
||||
and the bar; `ui_recipe_sets.js` the left column and the set actions (save the canvas, new,
|
||||
rename, delete); `ui_recipe_set_view.js` one set; `ui_recipe_info.js` the name, notes, tags and
|
||||
models' state (styles in `styles/29-workflow-detail.css`); `ui_recipe_parameters.js` prompt roles,
|
||||
raw nodes and the new set's editor; and `ui_recipe_models.js`
|
||||
lists the models with previews and whether each is here under its saved name. Inline persistence is centralized
|
||||
in `ui_recipe_metadata.js`, with pure ordering/value helpers in
|
||||
`ui_recipe_parameter_utils.js`. The catalog shell lives in `ui_recipe_catalog.js`,
|
||||
cards and card actions in `ui_recipe_cards.js`, save/edit dialogs in
|
||||
`ui_recipe_dialogs.js`, and shared cover helpers in `ui_recipe_media.js`.
|
||||
cards and card actions in `ui_recipe_cards.js` (styles in `styles/28-workflows.css`; the ⋯
|
||||
and filter menus are `ui_popup_menu.js`), the save dialog in `ui_recipe_save_dialog.js`, the edit
|
||||
dialog in `ui_recipe_dialogs.js`, and shared cover helpers in `ui_recipe_media.js`.
|
||||
`ui_recipe_versions.js` owns version comparison and restore, while
|
||||
`ui_recipe_gallery.js` owns result rendering and opens `ui_gallery_detail.js`
|
||||
directly for image inspection. Shared detail DOM/copy primitives live in
|
||||
@@ -167,8 +174,8 @@ position across manual refresh.
|
||||
## Parameter Notebooks
|
||||
|
||||
Parameter Notebooks are immutable generation-value snapshots owned by a recipe.
|
||||
Saving or updating a recipe creates a new snapshot. The Parameters tab uses a
|
||||
two-pane history browser: the left pane selects a notebook and the right pane
|
||||
Saving or updating a recipe creates a new snapshot, equal to the recipe's own values, so
|
||||
the details leave it out of the list. The details are a two-pane browser: the left pane selects a notebook and the right pane
|
||||
prominently identifies the active name and timestamp. Selection requests carry a
|
||||
token so an older gallery response cannot overwrite a newer selection. Parameter
|
||||
result matching uses the separate `sha256-params-v1` signature.
|
||||
@@ -213,6 +220,21 @@ ID and guarded by saved node type. Values are `positive`, `negative`, `both`,
|
||||
it restores the saved automatic result. Updates retain it only while ID and type
|
||||
still match.
|
||||
|
||||
## Focus values
|
||||
|
||||
A workflow's focus values (重点参数) live in `params.focusParams`: at most 12
|
||||
`{ node, type, widget, note }`, all strings (node ID, saved node type, widget
|
||||
name, a note of at most 120 characters). `api/recipe_schema.py` refuses any other
|
||||
shape and drops repeats. A star or a note saves them alone through
|
||||
`POST /anomalous/recipe_focus` (`api/recipes.py`, from this computer only): the
|
||||
recipe file is read (up to its size limit), only `focusParams` changes, and no
|
||||
version is kept, unlike a recipe update. They are shown, compared and kept only while a node of
|
||||
that ID and type is still in the workflow (`recipe_focus.js focusEntries`); an
|
||||
update from the canvas keeps the ones still true. Its parameter sets, the canvas
|
||||
it opens and its pictures share the node IDs, so comparisons among them
|
||||
(`compareParams`' `isKey(type, widget, bId, aId)`) list the focus values first
|
||||
with the starred key parameters, which hold for every workflow instead.
|
||||
|
||||
## Recipe-to-model navigation
|
||||
|
||||
Opening a resolved local model from Recipe Overview keeps the outer browser open
|
||||
@@ -226,7 +248,7 @@ to another main panel or closing Workspace abandons the transition and clears
|
||||
stale media/DOM/return state. Opening Workspace during the transition must not
|
||||
overwrite the earlier outer-panel return state.
|
||||
|
||||
Workflow Recipes and recipe-powered Assistant parameter presets remain beta
|
||||
surfaces until an explicit stability decision changes that status. Localized
|
||||
Workflow Recipes and recipe-powered Assistant parameter presets were beta
|
||||
surfaces until v1.60.0, the first release not marked beta (2026-10-09). Localized
|
||||
notices identify their data directories and distinguish single-node preset apply
|
||||
from full-skeleton Parameter Notebook apply.
|
||||
|
||||
@@ -100,6 +100,9 @@ Node Assistant model replacement and LoRA insertion are outside that warning.
|
||||
Notices distinguish single-node preset application from full-skeleton Parameter
|
||||
Notebook application.
|
||||
|
||||
Status (2026-10-09): ended with v1.60.0, the first release not marked beta. The
|
||||
in-app beta notices had already been removed.
|
||||
|
||||
## AD-014 — Architecture documentation is state, not history
|
||||
|
||||
Architecture documents change only when an owner, data flow, interface contract,
|
||||
@@ -149,3 +152,14 @@ user's own edits are never attributed to the plugin. Opening another workflow is
|
||||
logged as that, not as removed nodes. The log is bounded and never fails the action
|
||||
it records. Undo comes later and only for changes that can be reversed safely
|
||||
(canvas values, renames); deletions go through their own confirmations instead.
|
||||
|
||||
## AD-019 — No Civitai browser inside Anomalous; imported files are put in place
|
||||
|
||||
New users' trouble is not finding models but knowing which folder a downloaded file goes
|
||||
in. A Civitai browser inside the plugin would not reach many users (Civitai is often
|
||||
unreachable from mainland China, where most models come from LiblibAI, cloud drives and
|
||||
chat groups), would mirror a large site's search, filters and content rating, and would
|
||||
show its picture feed inside the plugin. Instead the plugin takes a file from wherever it
|
||||
came from: dropped on the models page, it is told apart by its header (never by running
|
||||
it), checked against Civitai when that answers, and moved into the folder its type and
|
||||
base model call for, with every guess shown and changeable before anything moves.
|
||||
|
||||
@@ -198,9 +198,11 @@
|
||||
|
||||
点角色卡片上的 **编辑情绪**:
|
||||
|
||||
最上面是搜索框:输入一句台词里的几个字或文件名,下面列出找到的音频和它的台词,可以试听(▶),点 **设为主声音** 或 **加为情绪**(新加一行情绪,音频和台词都填好,再写个情绪名)。搜台词需要 Anomalous_TTS 接口版本 14,旧版本只搜文件名。
|
||||
|
||||
1. **主声音 `{main}`**:选一条 3~10 秒的参考音频。留空的话,节点会自己挑一条。
|
||||
2. **情绪**:每行填一个情绪名(例如“开心”),再从角色文件夹里选一条参考音频。输入框可以直接打字搜索文件名。
|
||||
3. **参考台词**:可以不填,节点会自己去找同名 `.txt` 或 GPT-SoVITS 的标注文件。
|
||||
2. **情绪**:每行填一个情绪名(例如“开心”),再从角色文件夹里选一条参考音频。点一下音频框会列出全部音频(现在用的那条灰着显示,没选新的就还原);也可以直接打字搜索文件名。
|
||||
3. **参考台词**:换了音频,台词框会自动填上这条音频的台词(节点从同名 `.txt` / `.lab` 或 GPT-SoVITS 的标注文件里找到的,需要 Anomalous_TTS 接口版本 14)。找不到就留空,节点说话时会再找一遍。也可以自己改。
|
||||
4. 点 **保存**。设置会写进角色文件夹里的 `anomalous_tts.json`,由节点检查后保存。选了不存在的文件时,会提示原因,什么都不会改。
|
||||
|
||||
文件名本身带情绪的音频(例如 `Arona_Talk.开心.wav`)会被自动识别,不用再填一遍。只是扩展名的部分不算情绪,例如 `Arona_Talk.ogg.wav`、`Arona_Talk.ogg (1).ogg`。
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# 在云服务器上使用(AutoDL 等)
|
||||
|
||||
> **先说清楚支持到什么程度:**
|
||||
> - **海外的云服务器**(RunPod 等):完整支持。能连上 Civitai,扫描能拿到名字、触发词和封面。
|
||||
> - **国内的云服务器**(AutoDL 等):只能部分支持。浏览、离线扫描、整理模型都能用,但服务器多半连不上 Civitai:查模型不稳定,封面基本下不来。
|
||||
>
|
||||
> 下面的开关两种服务器都要用。
|
||||
>
|
||||
> **还没有完整测过:** 作者只在一台 AutoDL 服务器上实测过,海外服务器还没测过。用的时候请自己留意,遇到问题欢迎反馈。
|
||||
>
|
||||
> **想让 AI 帮你弄?** 把这页的链接发给 ChatGPT、Claude 之类的 AI,说“照着这页最后的 For AI assistants 一节带我做”就行。
|
||||
|
||||
ComfyUI 装在租来的云服务器上(AutoDL、RunPod 之类),你在自己电脑的浏览器里打开它时,对插件来说页面是从“别的设备”打开的。为了不让别人远程改动你的文件,插件默认只让这种页面浏览:标题旁会显示 **仅浏览**,扫描、下载、移动、删除、保存这类改动会被拒绝。
|
||||
|
||||
想在云服务器上照常使用,有两个办法,选一个就行。
|
||||
|
||||
## 办法一:用 SSH 隧道打开(推荐,什么都不用改)
|
||||
|
||||
SSH 隧道把服务器上 ComfyUI 的端口接到你自己电脑上,浏览器打开的是 `http://127.0.0.1:端口`。请求在服务器上也是从本机发出的,插件就当成本机,所有功能都能用,也不会把页面开放给别人。
|
||||
|
||||
AutoDL 控制台里每台实例都有 SSH 登录指令和密码,平台也提供了现成的隧道小工具;按平台说明把 ComfyUI 的端口(常见是 6006 或 8188)映射到本地,再用浏览器打开映射出来的 127.0.0.1 地址即可。
|
||||
|
||||
## 办法二:在服务器上放一个开关文件
|
||||
|
||||
如果你只能用平台给的网址打开 ComfyUI,最简单的做法:
|
||||
|
||||
1. 点标题旁的 **仅浏览**,在弹出的说明里点 **复制**。复制的是一行命令,已经填好了你服务器上插件的真实位置。
|
||||
2. 打开服务器的终端(AutoDL:JupyterLab → 终端),粘贴,按回车。
|
||||
3. 重启 ComfyUI。启动日志里会出现一行提示,标题旁的标签变成黄色的 **远程可修改**。
|
||||
|
||||
想自己动手也可以:
|
||||
|
||||
1. 在服务器上打开插件文件夹 `ComfyUI/custom_nodes/Anomalous_Model_Browser`(AutoDL 可以用 JupyterLab 的文件浏览器,或在终端里操作)。
|
||||
2. 新建一个空文件,名字是 `allow_remote_changes`(没有扩展名)。终端里可以这样做:
|
||||
|
||||
```bash
|
||||
touch ComfyUI/custom_nodes/Anomalous_Model_Browser/allow_remote_changes
|
||||
```
|
||||
|
||||
3. 重启 ComfyUI。启动日志里会出现一行提示,标题旁的标签变成黄色的 **远程可修改**。
|
||||
|
||||
**风险:** 打开之后,任何能打开这个网址的人都能扫描、移动、删除(进回收站)你的模型,改插件设置,也能用你的 Civitai Key。只在网址不外传时这样做;不需要了就删掉这个文件(点黄色的 **远程可修改**,里面也有一行可以复制的命令),再重启 ComfyUI。这个文件只能在服务器上建,网页里打不开也关不掉。
|
||||
|
||||
AI 软件用的 MCP 接口不受这个开关影响,始终只接受本机。
|
||||
|
||||
## 国内服务器连不上 Civitai
|
||||
|
||||
国内机房的服务器常常连不上 civitai.com。插件这时会自动改用 Civitai 的另一个地址 civitai.red(在 AutoDL 上测过能连上),照样能认出模型的名字、触发词和底模;封面图放在 civitai.com 那边,多半下不来,连续两张下不了,这次扫描就不再下载封面。**设置 → 网络 → 测试连接** 会告诉你连上的是哪一个。
|
||||
|
||||
两个都连不上时,扫描会自动改成只看文件(没有封面和触发词);也可以在 **设置 → 网络** 里填代理,或者在扫描页选 **离线**。
|
||||
|
||||
---
|
||||
|
||||
## For AI assistants
|
||||
|
||||
You are helping someone use Anomalous Model Browser (a ComfyUI plugin) on a rented cloud server. Read this section, then guide them one step at a time; ask for a screenshot or the terminal output after each step. Support status: tested on one AutoDL server only, never on an overseas one; say so if something behaves differently.
|
||||
|
||||
**What is going on.** The plugin lets only the computer ComfyUI runs on change things. On a cloud server the browser is always another computer, so the page is view only: a chip beside the page title reads 仅浏览 / View only, and scans, downloads, moves, deletes and saves are refused (HTTP 403, `code: local_only`).
|
||||
|
||||
**Two ways to fix it, pick one.**
|
||||
1. SSH tunnel (safest): forward the ComfyUI port to their own computer and open `http://127.0.0.1:<port>`. Then nothing else is needed. Platforms document their own tunnel tool.
|
||||
2. The switch file: an empty file named exactly `allow_remote_changes` (no extension) in the plugin folder, then a restart of ComfyUI. Easiest: in the page, press the 仅浏览 chip, press 复制 / Copy in its dialog, paste that line into the server's terminal, press Enter. The line already holds the right path. By hand: `touch <ComfyUI>/custom_nodes/Anomalous_Model_Browser/allow_remote_changes`.
|
||||
|
||||
**Check it worked.** After the restart the chip reads 远程可修改 / Remote changes on, in yellow, and ComfyUI's start log has a line `[Anomalous Browser] allow_remote_changes 文件存在…`. Opening `<their ComfyUI address>/anomalous/access` in the browser shows `{"local": false, "changes": true}`.
|
||||
|
||||
**When it did not work.**
|
||||
- Chip still View only: ComfyUI was not restarted (many cloud images restart it from their own launcher page, not the terminal); or the file is in the wrong folder; or it got an extension (`allow_remote_changes.txt`). `ls -la` in the plugin folder shows it.
|
||||
- The plugin folder has another name (installed under a different name): `find / -maxdepth 6 -type d -name 'Anomalous_Model_Browser*' 2>/dev/null`.
|
||||
- Updating a test branch with `git pull` says there is no tracking branch: `git branch --set-upstream-to=origin/<branch>` once.
|
||||
- Scans find nothing on Civitai: Settings → Models → Network → Test shows whether Civitai answers. Servers in mainland China mostly cannot reach it; the plugin then asks whether to scan from the files (offline). Do not tell the user to install proxy or VPN software on the server; if they already have an HTTP proxy, it goes into Settings → Network.
|
||||
- The scan page is slow the first time: it is counting many models, often on a network disk. Later visits show the last counts at once.
|
||||
- On some platforms every model is a link into a shared read-only disk; the plugin handles that (from 1.60.1). Edits and scan results are kept beside the link.
|
||||
|
||||
**Safety, tell the user.** With the switch file, anyone who has the page's address can change their models and settings and use their Civitai key: keep the address private, delete the file (the yellow chip's dialog has the line) and restart when done. Never ask them to paste API keys, passwords or the full server address into the chat. Deletes go to the system's trash, never permanently.
|
||||
|
||||
## English summary
|
||||
|
||||
How far it goes: on an **overseas** cloud server (RunPod…) everything works, Civitai included. On a server in **mainland China** (AutoDL…) it works only in part: browsing, offline scans and tidying work, but Civitai is mostly out of reach, so lookups are unreliable and covers rarely download.
|
||||
|
||||
When civitai.com cannot be reached (often so from mainland China), the plugin asks Civitai's other address, civitai.red, which gives names and trigger words; the covers may not download. On a rented cloud server the page is always opened from another device, so the plugin is **view only** there by default. Either open ComfyUI through an **SSH tunnel** (the browser address starts with `127.0.0.1`; counts as this computer, nothing to change), or create an empty file named `allow_remote_changes` in the plugin folder on the server and restart ComfyUI (press **View only**: its dialog has the line to copy into the server's terminal, with the real path filled in). With that file, anyone who can open the address may scan, move, delete and save, and use your Civitai key, so only do it while the address stays private; delete the file and restart to go back. The page cannot create or remove the file, and the MCP endpoint stays local-only either way.
|
||||
@@ -36,6 +36,8 @@ node tools/check_structure.mjs
|
||||
|
||||
# 一个前端测试
|
||||
node tests/combo_slots.mjs
|
||||
node tests/lora_nodes.mjs
|
||||
node tests/third_party_loras.mjs
|
||||
|
||||
# 全部 Python 测试(便携版 ComfyUI 的写法;其他安装方式换成自己的 python)
|
||||
../../../python_embeded/python.exe -B -m unittest discover -s tests -p "test_*.py"
|
||||
|
||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 384 KiB |
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 296 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 19 KiB |
@@ -14,6 +14,11 @@ import os
|
||||
import re
|
||||
import struct
|
||||
|
||||
try:
|
||||
from .model_kind import MAX_HEADER, inspect_file
|
||||
except ImportError: # the standalone scanner imports this file on its own
|
||||
from model_kind import MAX_HEADER, inspect_file
|
||||
|
||||
|
||||
USER_INFO_SUFFIX = ".anomalous.json"
|
||||
|
||||
@@ -28,6 +33,17 @@ def read_json(path):
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
SCAN_CHOICES = ("skip", "scan")
|
||||
|
||||
|
||||
def scan_choice(base):
|
||||
"""What the user chose about scanning a model, kept in <model>.anomalous.json ("scan"):
|
||||
"skip" (a scan of its folder leaves it out; picking it by hand still scans it), "scan"
|
||||
(asked once, scanned like any other) or "" (not chosen). ``base``: the path without extension."""
|
||||
choice = (read_json(base + USER_INFO_SUFFIX) or {}).get("scan")
|
||||
return choice if choice in SCAN_CHOICES else ""
|
||||
|
||||
|
||||
def scan_info_path(base):
|
||||
"""The sidecar holding a scan result: <model>.info, else another tool's <model>.civitai.info.
|
||||
A .civitai.info with only edits made in older versions (no Civitai ids) does not count,
|
||||
@@ -55,7 +71,6 @@ def is_unmatched(info):
|
||||
# not be reached; the scan was offline; the file's header is damaged (safetensors_damage), so
|
||||
# neither Civitai nor the header can tell what it is. Scans before this was recorded leave it empty.
|
||||
UNMATCHED_REASONS = ("not_found", "network", "offline", "damaged")
|
||||
MAX_HEADER_BYTES = 100 * 1024 * 1024
|
||||
|
||||
|
||||
def safetensors_damage(file_path):
|
||||
@@ -70,7 +85,7 @@ def safetensors_damage(file_path):
|
||||
if len(head) < 8:
|
||||
return "short"
|
||||
header_size = struct.unpack("<Q", head)[0]
|
||||
if not header_size or header_size > MAX_HEADER_BYTES or 8 + header_size > size:
|
||||
if not header_size or header_size > MAX_HEADER or 8 + header_size > size:
|
||||
return "header_size"
|
||||
header = json.loads(handle.read(header_size).decode("utf-8"))
|
||||
except OSError:
|
||||
@@ -143,38 +158,5 @@ def file_sha256(path):
|
||||
|
||||
|
||||
def infer_base_model_from_header(file_path: str) -> str:
|
||||
"""从 safetensors 头文件的张量键名推断底层 Base Model (用于脱机/HuggingFace 兼容)"""
|
||||
try:
|
||||
with open(file_path, "rb") as f:
|
||||
header_size_bytes = f.read(8)
|
||||
if len(header_size_bytes) < 8: return 'Unknown'
|
||||
header_size = struct.unpack('<Q', header_size_bytes)[0]
|
||||
if header_size > 100 * 1024 * 1024: return 'Unknown'
|
||||
|
||||
header_json = json.loads(f.read(header_size).decode('utf-8'))
|
||||
|
||||
# 1. 尝试从 __metadata__ 提取
|
||||
metadata = header_json.get('__metadata__', {})
|
||||
arch = metadata.get('modelspec.architecture', '')
|
||||
if 'stable-diffusion-xl' in arch.lower(): return 'SDXL'
|
||||
if 'stable-diffusion-v1' in arch.lower() or 'runwayml/stable-diffusion-v1-5' in arch.lower(): return 'SD 1.5'
|
||||
if 'flux' in arch.lower(): return 'Flux.1 D'
|
||||
if 'sd3' in arch.lower(): return 'SD3'
|
||||
|
||||
# 2. 暴力张量键名指纹匹配 (Tensor Fingerprinting)
|
||||
# 把前 500 个键拼接成字符串以提高检索效率,大部分核心键都在前面
|
||||
keys_str = " ".join(list(header_json.keys())[:500])
|
||||
|
||||
# Flux 指纹
|
||||
if 'double_blocks.0.img_attn' in keys_str or 'img_in.weight' in keys_str: return 'Flux.1 D'
|
||||
# SD3 指纹
|
||||
if 'joint_blocks.0.x_block' in keys_str: return 'SD3'
|
||||
# SDXL 指纹 (包含两套 text encoder)
|
||||
if 'conditioner.embedders.1.model' in keys_str or 'label_emb.0.0.weight' in keys_str: return 'SDXL'
|
||||
# SD 1.5 指纹
|
||||
if 'cond_stage_model.transformer.text_model' in keys_str or 'model.diffusion_model.input_blocks.0.0.weight' in keys_str: return 'SD 1.5'
|
||||
|
||||
return 'Unknown'
|
||||
except Exception as e:
|
||||
print(f"[-] 离线底模推断失败: {e}")
|
||||
return 'Unknown'
|
||||
"""The base model a .safetensors / .gguf header tells (model_kind.py), else 'Unknown'."""
|
||||
return inspect_file(file_path).get("base") or "Unknown"
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
"""What a model file is, from its header alone: which models folder it belongs in (LoRA,
|
||||
checkpoint, VAE…) and its base model. Reads a .safetensors header (JSON) or a .gguf header
|
||||
(key/value metadata and tensor names); never loads tensors and never opens formats that can
|
||||
carry code (.ckpt/.pt/.pth/.bin). Shared by model import (api/model_import.py) and the
|
||||
scanner's offline base model (model_identity.infer_base_model_from_header). No imports from
|
||||
ComfyUI, so it is tested on its own.
|
||||
|
||||
`classify(fmt, header)` -> {"kind": a ComfyUI folder name or "", "base": base model or "",
|
||||
"sure": True when the header names both outright, False when it is a reasoned guess}.
|
||||
"""
|
||||
|
||||
import json
|
||||
import struct
|
||||
|
||||
MAX_HEADER = 100 * 1024 * 1024
|
||||
GGUF_READ = 16 * 1024 * 1024
|
||||
|
||||
# Cross-attention width (the text encoder's output) -> base model, for the SD family.
|
||||
CONTEXT_WIDTH = {768: "SD 1.5", 1024: "SD 2.1", 2048: "SDXL"}
|
||||
# ComfyUI-GGUF's architecture names.
|
||||
GGUF_DIFFUSION = {
|
||||
"flux": "Flux.1 D", "sd1": "SD 1.5", "sdxl": "SDXL", "sd3": "SD3", "aura": "AuraFlow",
|
||||
"hidream": "HiDream", "cosmos": "Cosmos", "ltxv": "LTXV", "hyvid": "Hunyuan Video",
|
||||
"wan": "Wan Video", "lumina2": "Lumina", "qwen_image": "Qwen", "chroma": "Chroma",
|
||||
}
|
||||
GGUF_TEXT = ("t5", "t5encoder", "umt5", "llama", "qwen2", "qwen2vl", "qwen3", "gemma", "clip", "mistral")
|
||||
EMBEDDING_KEYS = {"emb_params", "clip_l", "clip_g", "string_to_param", "string_to_token", "name", "step",
|
||||
"sd_checkpoint", "sd_checkpoint_name"}
|
||||
LORA_MARKS = ("lora_up.", "lora_down.", "lora_a.", "lora_b.", "lora.up.", "lora.down.", "hada_w1_a",
|
||||
"lokr_w1", ".lora_linear_layer.", "dora_scale")
|
||||
CONTROL_MARKS = ("control_model_", "input_hint_block", "controlnet_cond_embedding", "controlnet_blocks",
|
||||
"controlnet_x_embedder", "zero_convs", "controlnet_mid_block")
|
||||
DIFFUSION_STARTS = ("double_blocks.", "single_blocks.", "joint_blocks.", "transformer_blocks.",
|
||||
"single_transformer_blocks.", "input_blocks.", "blocks.0.self_attn", "net.blocks.", "net.x_embedder.")
|
||||
|
||||
|
||||
def parse_safetensors(data):
|
||||
"""The header dict from a file's first bytes (8-byte length + JSON); None if not one."""
|
||||
if len(data) < 8:
|
||||
return None
|
||||
size = struct.unpack("<Q", data[:8])[0]
|
||||
if not 2 <= size <= MAX_HEADER or len(data) < 8 + size:
|
||||
return None
|
||||
try:
|
||||
header = json.loads(data[8:8 + size].decode("utf-8"))
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
return None
|
||||
return header if isinstance(header, dict) else None
|
||||
|
||||
|
||||
class _Reader:
|
||||
def __init__(self, data):
|
||||
self.data, self.at = data, 0
|
||||
|
||||
def take(self, count):
|
||||
if self.at + count > len(self.data):
|
||||
raise EOFError
|
||||
part = self.data[self.at:self.at + count]
|
||||
self.at += count
|
||||
return part
|
||||
|
||||
def number(self, fmt):
|
||||
return struct.unpack("<" + fmt, self.take(struct.calcsize(fmt)))[0]
|
||||
|
||||
def text(self):
|
||||
return self.take(self.number("Q")).decode("utf-8", "replace")
|
||||
|
||||
|
||||
_GGUF_SCALARS = {0: "B", 1: "b", 2: "H", 3: "h", 4: "I", 5: "i", 6: "f", 7: "?", 10: "Q", 11: "q", 12: "d"}
|
||||
|
||||
|
||||
def _gguf_value(reader, kind):
|
||||
if kind in _GGUF_SCALARS:
|
||||
return reader.number(_GGUF_SCALARS[kind])
|
||||
if kind == 8:
|
||||
return reader.text()
|
||||
if kind == 9: # an array: skipped (tokenizer word lists are long)
|
||||
item, count = reader.number("I"), reader.number("Q")
|
||||
for _ in range(count):
|
||||
_gguf_value(reader, item)
|
||||
return None
|
||||
raise ValueError("unknown gguf value type")
|
||||
|
||||
|
||||
def parse_gguf(data):
|
||||
"""{"meta": general.* values, "keys": tensor names} from a .gguf's first bytes; None if
|
||||
not one. What did not fit in `data` is left out."""
|
||||
if data[:4] != b"GGUF":
|
||||
return None
|
||||
reader = _Reader(data)
|
||||
reader.take(4)
|
||||
meta, keys = {}, []
|
||||
try:
|
||||
if reader.number("I") < 2:
|
||||
return None
|
||||
tensors, values = reader.number("Q"), reader.number("Q")
|
||||
for _ in range(values):
|
||||
key = reader.text()
|
||||
value = _gguf_value(reader, reader.number("I"))
|
||||
if key.startswith("general.") and isinstance(value, (str, int, float)):
|
||||
meta[key] = value
|
||||
for _ in range(tensors):
|
||||
keys.append(reader.text())
|
||||
dims = reader.number("I")
|
||||
reader.take(8 * dims + 4 + 8)
|
||||
except (EOFError, ValueError, struct.error):
|
||||
pass
|
||||
return {"meta": meta, "keys": keys}
|
||||
|
||||
|
||||
def read_head(path):
|
||||
"""(fmt, header) of a model file: ("safetensors", dict), ("gguf", dict), or (None, None)
|
||||
for other formats and unreadable files."""
|
||||
lower = path.lower()
|
||||
try:
|
||||
with open(path, "rb") as source:
|
||||
if lower.endswith((".safetensors", ".sft")):
|
||||
start = source.read(8)
|
||||
if len(start) < 8:
|
||||
return None, None
|
||||
size = struct.unpack("<Q", start)[0]
|
||||
if size > MAX_HEADER:
|
||||
return None, None
|
||||
return "safetensors", parse_safetensors(start + source.read(size))
|
||||
if lower.endswith(".gguf"):
|
||||
return "gguf", parse_gguf(source.read(GGUF_READ))
|
||||
except OSError:
|
||||
pass
|
||||
return None, None
|
||||
|
||||
|
||||
def _context_width(header):
|
||||
"""The cross-attention input width a SD-family file shows (768 / 1024 / 2048), else 0."""
|
||||
for key, value in header.items():
|
||||
low = key.lower().replace(".", "_")
|
||||
if "attn2_to_k" not in low or not isinstance(value, dict):
|
||||
continue
|
||||
shape = value.get("shape") or []
|
||||
if len(shape) == 2 and ("lora_down" in low or "lora_a" in low or low.endswith("to_k_weight")):
|
||||
return shape[1]
|
||||
return 0
|
||||
|
||||
|
||||
def _family(meta):
|
||||
"""An SDXL file's family from the name of the model it was trained on (kohya's metadata)."""
|
||||
trained_on = " ".join(str(meta.get(key, "")) for key in ("ss_sd_model_name", "ss_base_model", "modelspec.title")).lower()
|
||||
for mark, name in (("pony", "Pony"), ("illustrious", "Illustrious"), ("noob", "NoobAI")):
|
||||
if mark in trained_on:
|
||||
return name
|
||||
return ""
|
||||
|
||||
|
||||
def _base_from_meta(meta):
|
||||
arch = str(meta.get("modelspec.architecture", "")).lower()
|
||||
trained = str(meta.get("ss_base_model_version", "")).lower()
|
||||
pairs = (("flux", "Flux.1 D"), ("chroma", "Chroma"), ("stable-diffusion-xl", "SDXL"), ("sdxl", "SDXL"),
|
||||
("stable-diffusion-v1", "SD 1.5"), ("sd_v1", "SD 1.5"), ("stable-diffusion-v2", "SD 2.1"),
|
||||
("sd_v2", "SD 2.1"), ("stable-diffusion-3", "SD3"), ("sd3", "SD3"), ("wan", "Wan Video"),
|
||||
("qwen", "Qwen"), ("hunyuan", "Hunyuan Video"))
|
||||
for mark, name in pairs:
|
||||
if (arch and mark in arch) or (trained and trained.startswith(mark)):
|
||||
return name
|
||||
return ""
|
||||
|
||||
|
||||
def _base_from_keys(header, text):
|
||||
"""(base, sure) from tensor names; ("", False) when they do not tell."""
|
||||
if "individual_token_refiner" in text:
|
||||
return "Hunyuan Video", True
|
||||
# Cosmos-Predict2 (with or without "net."); Anima adds an adapter for its Qwen3 text
|
||||
# encoder. Before Wan: their blocks' attention names look alike.
|
||||
if "llm_adapter" in text:
|
||||
return "Anima", True
|
||||
if "adaln_modulation_self_attn" in text:
|
||||
return "Cosmos", True
|
||||
if "distilled_guidance_layer" in text:
|
||||
return "Chroma", True
|
||||
if "img_mod_1" in text or "txt_norm" in text or "img_mlp_net" in text:
|
||||
return "Qwen", True
|
||||
if "double_blocks_0_img_attn" in text or "single_blocks_0_linear1" in text:
|
||||
return "Flux.1 D", True
|
||||
if "single_transformer_blocks_0_attn" in text or ("single_transformer_blocks" in text and "x_embedder" in text):
|
||||
return "Flux.1 D", False
|
||||
if "joint_blocks_0_x_block" in text:
|
||||
return "SD3", True
|
||||
if "blocks_0_self_attn_q" in text and "blocks_0_cross_attn_k" in text:
|
||||
return "Wan Video", True
|
||||
if any(mark in text for mark in ("conditioner_embedders_1", "label_emb_0_0", "lora_te1_", "lora_te2_",
|
||||
"lora_unet_input_blocks", "lora_unet_output_blocks")):
|
||||
return "SDXL", True
|
||||
width = _context_width(header)
|
||||
if width in CONTEXT_WIDTH:
|
||||
return CONTEXT_WIDTH[width], True
|
||||
if "cond_stage_model_transformer_text_model" in text:
|
||||
return "SD 1.5", True
|
||||
if "input_blocks_1_1_transformer_blocks" in text or "lora_unet_down_blocks" in text:
|
||||
return "SD 1.5", False
|
||||
return "", False
|
||||
|
||||
|
||||
def _embedding_base(header):
|
||||
if "clip_g" in header:
|
||||
return "SDXL"
|
||||
shape = (header.get("emb_params") or {}).get("shape") or []
|
||||
return CONTEXT_WIDTH.get(shape[-1], "") if shape else ""
|
||||
|
||||
|
||||
def _kind(keys, raw, text):
|
||||
"""The models folder from tensor names: (kind, sure)."""
|
||||
names = set(keys)
|
||||
if names and names <= EMBEDDING_KEYS and names & {"emb_params", "clip_l", "clip_g", "string_to_param"}:
|
||||
return "embeddings", True
|
||||
if any(mark in text for mark in CONTROL_MARKS):
|
||||
return "controlnet", True
|
||||
diffusion = any(key.startswith(("model.diffusion_model.", "diffusion_model.") + DIFFUSION_STARTS) for key in keys)
|
||||
# A whole model first: some merged checkpoints still carry a LoRA's keys.
|
||||
if diffusion and any(key.startswith(("first_stage_model.", "vae.")) for key in keys):
|
||||
return "checkpoints", True
|
||||
if any(mark in raw for mark in LORA_MARKS):
|
||||
return "loras", True
|
||||
if diffusion:
|
||||
return "diffusion_models", True
|
||||
if any(key.startswith(("image_proj.", "ip_adapter.")) for key in keys):
|
||||
return "ipadapter", True
|
||||
if any(mark in text for mark in ("encoder_down_0_block", "decoder_up_0_block", "encoder_down_blocks_0",
|
||||
"encoder_downsamples", "first_stage_model_encoder")):
|
||||
return "vae", True
|
||||
if "vision_model_encoder_layers" in text and "text_model" not in text:
|
||||
return "clip_vision", True
|
||||
if "text_model_encoder_layers" in text or "encoder_block_0_layer_0_selfattention" in text \
|
||||
or ("model_layers_0_self_attn" in text and "embed_tokens" in text):
|
||||
return "text_encoders", True
|
||||
if "conv_first_weight" in text or "body_0_rdb1" in text or ("model_0_weight" in text and "model_1_sub" in text):
|
||||
return "upscale_models", False
|
||||
return "", False
|
||||
|
||||
|
||||
def classify(fmt, header):
|
||||
"""What a parsed header says: {"kind", "base", "sure"} (see the module text)."""
|
||||
if not header:
|
||||
return {"kind": "", "base": "", "sure": False}
|
||||
if fmt == "gguf":
|
||||
meta, keys = header.get("meta") or {}, header.get("keys") or []
|
||||
arch = str(meta.get("general.architecture", "")).lower()
|
||||
if arch in GGUF_DIFFUSION:
|
||||
return {"kind": "diffusion_models", "base": GGUF_DIFFUSION[arch], "sure": True}
|
||||
if arch.startswith(GGUF_TEXT):
|
||||
return {"kind": "text_encoders", "base": "", "sure": True}
|
||||
header = {key: {} for key in keys}
|
||||
meta = header.get("__metadata__") if isinstance(header.get("__metadata__"), dict) else {}
|
||||
keys = [key for key in header if key != "__metadata__"]
|
||||
raw = "\n".join(keys).lower()
|
||||
text = raw.replace(".", "_")
|
||||
kind, sure = _kind(keys, raw, text)
|
||||
if kind in ("vae", "text_encoders", "clip_vision", "upscale_models", "ipadapter"):
|
||||
base, base_sure = "", True # shared by several base models: the type's own folder
|
||||
elif kind == "embeddings":
|
||||
base, base_sure = _embedding_base(header), True
|
||||
else:
|
||||
base = _base_from_meta(meta)
|
||||
base_sure = bool(base)
|
||||
if not base:
|
||||
base, base_sure = _base_from_keys(header, text)
|
||||
if base == "SDXL":
|
||||
base = _family(meta) or base
|
||||
return {"kind": kind, "base": base, "sure": bool(kind) and sure and (base_sure or not base)}
|
||||
|
||||
|
||||
def inspect_file(path):
|
||||
"""classify() of a file on disk, plus its "format" (None when its header cannot be read)."""
|
||||
fmt, header = read_head(path)
|
||||
found = classify(fmt, header)
|
||||
found["format"] = fmt if header else None
|
||||
return found
|
||||
@@ -19,7 +19,7 @@ import argparse
|
||||
import shutil
|
||||
from model_policies import is_physical_rename_protected
|
||||
from recycle_bin import move_to_trash
|
||||
from model_identity import USER_INFO_SUFFIX, computed_file_identity, file_sha256, infer_base_model_from_header, is_unmatched, safetensors_damage, scan_info_path, sidecar_file_hash, sidecar_info as read_local_info, unmatched_reason
|
||||
from model_identity import USER_INFO_SUFFIX, computed_file_identity, file_sha256, infer_base_model_from_header, is_unmatched, safetensors_damage, scan_choice, scan_info_path, sidecar_file_hash, sidecar_info as read_local_info, unmatched_reason
|
||||
from civitai_client import CIVITAI_API_KEY, CivitaiUnreachable, download_media, fetch_civitai_info, fetch_model_description
|
||||
|
||||
|
||||
@@ -75,6 +75,11 @@ def write_scan_progress(progress_file, phase, total=0, current=0, filename=""):
|
||||
pass
|
||||
|
||||
|
||||
def left_out(root, filename, selected):
|
||||
"""A model the user set to "don't scan" (model_identity.scan_choice), in a scan of its whole folder."""
|
||||
return not selected and scan_choice(os.path.join(root, os.path.splitext(filename)[0])) == "skip"
|
||||
|
||||
|
||||
def count_scan_files(target_folder, target_files_basenames, unmatched_only=False):
|
||||
total = 0
|
||||
selected = set(target_files_basenames)
|
||||
@@ -82,6 +87,7 @@ def count_scan_files(target_folder, target_files_basenames, unmatched_only=False
|
||||
total += sum(
|
||||
1 for filename in files
|
||||
if filename.endswith(".safetensors") and (not selected or filename in selected)
|
||||
and not left_out(root, filename, selected)
|
||||
and (not unmatched_only or is_unmatched(read_local_info(os.path.join(root, os.path.splitext(filename)[0]))))
|
||||
)
|
||||
return total
|
||||
@@ -98,6 +104,9 @@ def sanitize_filename(name: str) -> str:
|
||||
return name.strip(' .')
|
||||
|
||||
CIVITAI_DOWN_AFTER = 2 # models in a row Civitai did not answer for before the rest of the run stays offline
|
||||
# Covers in a row that would not download before the rest of the run fetches none: the API can
|
||||
# answer on civitai.red where the pictures (image.civitai.com) cannot be reached, and each try waits.
|
||||
COVERS_DOWN_AFTER = 2
|
||||
|
||||
|
||||
def write_report(args, **entry):
|
||||
@@ -242,10 +251,14 @@ def scan_one(args, state, rename_log, root, filename, previous):
|
||||
media_url = next((img.get("url") for img in civitai_data.get("images") or [] if img.get("url")), None)
|
||||
civitai_covers, user_covers = cover_owners(old_base)
|
||||
# Download when the data is fresh, or when the model has no cover at all.
|
||||
if media_url and not state["civitai_down"] and (fresh or not (civitai_covers or user_covers)):
|
||||
if media_url and not state["civitai_down"] and not state["covers_down"] and (fresh or not (civitai_covers or user_covers)):
|
||||
if not args.dry_run:
|
||||
print(f"[*] 正在下载预览媒体...")
|
||||
saved_path = download_media(media_url, old_base + ".civitai_bak")
|
||||
state["cover_failures"] = 0 if saved_path else state["cover_failures"] + 1
|
||||
if state["cover_failures"] >= COVERS_DOWN_AFTER:
|
||||
state["covers_down"] = True
|
||||
print("\033[93m[!] 连续几张封面都下载不了,本次其余模型不再下载封面(名字和触发词照常获取)。\033[0m")
|
||||
if saved_path:
|
||||
print(f"[+] 媒体下载成功 -> {os.path.basename(saved_path)}")
|
||||
entry["cover"] = "kept" if user_covers else "new"
|
||||
@@ -414,7 +427,7 @@ def main():
|
||||
write_scan_progress(args.progress_file, "scanning", total_files)
|
||||
current_file = 0
|
||||
last_progress_write = 0.0
|
||||
state = {"network_failures": 0, "civitai_down": args.civitai_down}
|
||||
state = {"network_failures": 0, "civitai_down": args.civitai_down, "cover_failures": 0, "covers_down": False}
|
||||
counts = {"success": 0, "fail": 0, "unchanged": 0}
|
||||
|
||||
for root, _, files in os.walk(target_folder):
|
||||
@@ -423,6 +436,9 @@ def main():
|
||||
continue
|
||||
if target_files_basenames and filename not in target_files_basenames:
|
||||
continue
|
||||
if left_out(root, filename, target_files_basenames):
|
||||
print(f"[*] 已设为不扫描,跳过: {filename}")
|
||||
continue
|
||||
previous = read_local_info(os.path.join(root, os.path.splitext(filename)[0]))
|
||||
if args.retry_unmatched and not is_unmatched(previous):
|
||||
continue
|
||||
|
||||
@@ -7,7 +7,7 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const calls = [];
|
||||
const character = {
|
||||
name: '阿罗娜/日配', aliases: ['阿罗娜'], language: 'ja', settings: { format: 1, keep: 1 },
|
||||
counts: { gpt: 1, sovits: 1, audio: 2 },
|
||||
counts: { gpt: 1, sovits: 1, audio: 2 }, version: 'v4', needs: ['v4'],
|
||||
reference: { audio: 'ref/a.wav', text: 'こんにちは', language: 'ja', source: 'auto' },
|
||||
emotions: { 开心: { audio: 'ref/a.开心.wav', text: '', language: 'ja', source: 'filename' } },
|
||||
};
|
||||
@@ -34,6 +34,9 @@ assert.equal(first.error, '');
|
||||
const arona = first.groups.find(g => g.character === '阿罗娜/日配');
|
||||
assert.deepEqual(arona.slices.map(s => s.emotion), ['main', '开心']);
|
||||
assert.equal(arona.has_main, true);
|
||||
assert.equal(arona.model_version, 'v4'); // interface 15; older nodes give ''
|
||||
assert.deepEqual(arona.needs, ['v4']);
|
||||
assert.deepEqual(first.groups.find(g => g.character === 'broken').needs, []);
|
||||
assert.equal(first.groups.find(g => g.character === 'broken').has_main, false);
|
||||
|
||||
// Re-rendering (sidebar click, studio + sidebar together) does not hit the server again.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Where a downloaded model goes (web/modules/download_places.js).
|
||||
import assert from 'node:assert/strict';
|
||||
import { baseFolder, destinationFor, expandFolder, unsafeFormat } from '../web/modules/download_places.js';
|
||||
import { DEFAULT_FOLDER, FOLDER_PRESETS, baseFolder, destinationFor, expandFolder, unsafeFormat } from '../web/modules/download_places.js';
|
||||
|
||||
const entry = (value) => ({ value });
|
||||
const workflow = { place: 'workflow', folder: 'Downloads' };
|
||||
@@ -30,6 +30,13 @@ assert.equal(expandFolder('{base}', 'NoobAI', subfolders), 'NoobAI');
|
||||
assert.equal(expandFolder('{base}/loras', '', subfolders), 'loras'); // unknown base: the part goes
|
||||
assert.equal(destinationFor(entry('a.safetensors'), { base_model: 'Pony' }, { place: 'folder', folder: '{base}' }), 'Pony/a.safetensors');
|
||||
|
||||
// Without settings: by base model, the first of the settings' ready-made folders.
|
||||
assert.equal(DEFAULT_FOLDER, FOLDER_PRESETS[0]);
|
||||
assert.equal(destinationFor(entry('a.safetensors'), { base_model: 'Illustrious' }, { place: 'workflow' }), 'Illustrious/a.safetensors');
|
||||
assert.equal(destinationFor(entry('a.safetensors'), {}, { place: 'workflow' }), 'a.safetensors');
|
||||
assert.equal(baseFolder('SD 2.1'), 'SD2');
|
||||
assert.ok(FOLDER_PRESETS.every(folder => !folder.includes('*'))); // '*' marks "typed by hand" in the settings
|
||||
|
||||
assert.equal(unsafeFormat('x.ckpt'), true);
|
||||
assert.equal(unsafeFormat('x.PT'), true);
|
||||
assert.equal(unsafeFormat('x.safetensors'), false);
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// The LoRA nodes a combo puts its LoRAs in (web/modules/lora_nodes.js), on fake nodes.
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
POWER_LORA, addPowerLoraRows, chainLoras, installedLoraNodes, loraNodeKind, nativeLora, powerLoraRows,
|
||||
} from '../web/modules/lora_nodes.js';
|
||||
|
||||
globalThis.LiteGraph = {
|
||||
registered_node_types: {
|
||||
LoraLoader: { nodeData: { input: { required: { lora_name: [['Flux\\a.safetensors', 'b.safetensors']] } } } },
|
||||
[POWER_LORA]: {},
|
||||
},
|
||||
};
|
||||
|
||||
// Offered only where installed: rgthree is here, Easy-Use is not.
|
||||
assert.deepEqual(installedLoraNodes().map(kind => kind.id), ['core', 'rgthree']);
|
||||
assert.equal(loraNodeKind(undefined).id, 'core');
|
||||
assert.equal(loraNodeKind('nonsense').id, 'core');
|
||||
assert.equal(nativeLora('Flux/a.safetensors'), 'Flux\\a.safetensors'); // ComfyUI's own spelling
|
||||
assert.equal(nativeLora('elsewhere/c.safetensors'), 'elsewhere/c.safetensors');
|
||||
|
||||
const INPUTS = {
|
||||
LoraLoader: ['model', 'clip'], LoraLoaderModelOnly: ['model'], [POWER_LORA]: ['model', 'clip'],
|
||||
'easy loraStack': ['optional_lora_stack'], 'easy loraStackApply': ['lora_stack', 'model', 'optional_clip'],
|
||||
};
|
||||
function fakeGraph() {
|
||||
const links = [];
|
||||
const made = [];
|
||||
const node = (type) => {
|
||||
const item = {
|
||||
type, size: [300, 100], widgets: [], inputs: (INPUTS[type] || []).map(name => ({ name })),
|
||||
connect(out, target, input) { links.push([this.type, out, target.type, target.inputs[input]?.name]); return true; },
|
||||
};
|
||||
if (type === POWER_LORA) {
|
||||
item.addNewLoraWidget = (lora) => {
|
||||
const widget = { name: `lora_${item.widgets.length + 1}`, value: { on: true, lora: lora ?? null, strength: 1, strengthTwo: null } };
|
||||
item.widgets.push(widget);
|
||||
return widget;
|
||||
};
|
||||
}
|
||||
if (type === 'easy loraStack') {
|
||||
item.widgets = ['toggle', 'mode', 'num_loras', ...Array.from({ length: 10 }, (_, i) => `lora_${i + 1}_name`)].map(name => ({ name, value: null }));
|
||||
}
|
||||
return item;
|
||||
};
|
||||
const add = (type, relX, relY) => {
|
||||
const item = node(type);
|
||||
made.push({ type, relX, relY, node: item });
|
||||
return item;
|
||||
};
|
||||
return { links, made, add, source: node('CheckpointLoaderSimple') };
|
||||
}
|
||||
const options = (graph, loras, clipSlot = 1) => ({ add: graph.add, source: graph.source, clipSlot, loras, relX: 350, step: 350,
|
||||
setPath: (node, path) => node.widgets.push({ name: 'lora_name', value: path }) });
|
||||
|
||||
// ComfyUI's own: a chain, each LoRA's MODEL and CLIP into the next.
|
||||
let graph = fakeGraph();
|
||||
let result = chainLoras(loraNodeKind('core'), options(graph, ['a.safetensors', 'b.safetensors']));
|
||||
assert.deepEqual(graph.made.map(item => [item.type, item.relX]), [['LoraLoader', 350], ['LoraLoader', 700]]);
|
||||
assert.equal(result.relX, 700);
|
||||
assert.equal(result.clipSlot, 1);
|
||||
assert.equal(graph.links.length, 4);
|
||||
// A UNet: model-only loaders, no CLIP line.
|
||||
graph = fakeGraph();
|
||||
result = chainLoras(loraNodeKind('core'), options(graph, ['a.safetensors'], null));
|
||||
assert.deepEqual(graph.made.map(item => item.type), ['LoraLoaderModelOnly']);
|
||||
assert.equal(result.clipSlot, null);
|
||||
// No LoRAs: the loader itself feeds the prompts.
|
||||
graph = fakeGraph();
|
||||
result = chainLoras(loraNodeKind('rgthree'), options(graph, []));
|
||||
assert.equal(result.last, graph.source);
|
||||
assert.equal(graph.made.length, 0);
|
||||
|
||||
// Power Lora Loader: one node, a row per LoRA in ComfyUI's spelling, model and CLIP by name.
|
||||
graph = fakeGraph();
|
||||
result = chainLoras(loraNodeKind('rgthree'), options(graph, ['Flux/a.safetensors', 'b.safetensors']));
|
||||
assert.equal(graph.made.length, 1);
|
||||
assert.deepEqual(result.last.widgets.map(widget => widget.value.lora), ['Flux\\a.safetensors', 'b.safetensors']);
|
||||
assert.deepEqual(graph.links, [['CheckpointLoaderSimple', 0, POWER_LORA, 'model'], ['CheckpointLoaderSimple', 1, POWER_LORA, 'clip']]);
|
||||
assert.equal(result.clipSlot, 1);
|
||||
|
||||
// Easy-Use: twelve LoRAs make two stacks (ten and two), the second feeding the first, applied on the model.
|
||||
graph = fakeGraph();
|
||||
const twelve = Array.from({ length: 12 }, (_, i) => `l${i + 1}.safetensors`);
|
||||
result = chainLoras(loraNodeKind('easy'), options(graph, twelve));
|
||||
assert.deepEqual(graph.made.map(item => item.type), ['easy loraStack', 'easy loraStack', 'easy loraStackApply']);
|
||||
const [first, second] = graph.made.map(item => item.node);
|
||||
assert.equal(first.widgets.find(w => w.name === 'num_loras').value, 10);
|
||||
assert.equal(second.widgets.find(w => w.name === 'num_loras').value, 2);
|
||||
assert.equal(second.widgets.find(w => w.name === 'lora_2_name').value, 'l12.safetensors');
|
||||
assert.ok(graph.made[1].relY > graph.made[0].relY); // stacked, not on top of each other
|
||||
assert.deepEqual(graph.links, [
|
||||
['easy loraStack', 0, 'easy loraStack', 'optional_lora_stack'],
|
||||
['easy loraStack', 0, 'easy loraStackApply', 'lora_stack'],
|
||||
['CheckpointLoaderSimple', 0, 'easy loraStackApply', 'model'],
|
||||
['CheckpointLoaderSimple', 1, 'easy loraStackApply', 'optional_clip'],
|
||||
]);
|
||||
assert.equal(result.last.type, 'easy loraStackApply');
|
||||
|
||||
// A Power Lora Loader's rows read in order (a row with no LoRA kept as null) and made again.
|
||||
const saved = { type: POWER_LORA, widgets: [
|
||||
{ name: 'lora_1', value: { on: true, lora: 'a.safetensors', strength: 0.7, strengthTwo: null } },
|
||||
{ name: 'header', value: { toggled: true } },
|
||||
{ name: 'lora_3', value: { on: false, lora: 'b.safetensors', strength: 1, strengthTwo: 0.5 } },
|
||||
{ name: 'lora_4', value: { on: true, lora: null, strength: 1 } },
|
||||
{ name: '➕ Add Lora', value: undefined },
|
||||
] };
|
||||
const rows = powerLoraRows(saved);
|
||||
assert.deepEqual(rows, [
|
||||
{ on: true, lora: 'a.safetensors', strength: 0.7, strengthTwo: null },
|
||||
{ on: false, lora: 'b.safetensors', strength: 1, strengthTwo: 0.5 },
|
||||
{ on: true, lora: null, strength: 1, strengthTwo: null },
|
||||
]);
|
||||
assert.deepEqual(powerLoraRows({ type: 'LoraLoader', widgets: saved.widgets }), []);
|
||||
graph = fakeGraph();
|
||||
const fresh = graph.add(POWER_LORA, 0, 0);
|
||||
addPowerLoraRows(fresh, rows);
|
||||
assert.deepEqual(fresh.widgets.map(widget => widget.name), ['lora_1', 'lora_2', 'lora_3']); // renumbered as rgthree does
|
||||
assert.deepEqual(fresh.widgets.map(widget => widget.value), rows);
|
||||
|
||||
console.log('lora_nodes: ok');
|
||||
@@ -0,0 +1,21 @@
|
||||
// The models page's base model filter (web/modules/model_bases.js).
|
||||
import assert from 'node:assert/strict';
|
||||
import { baseFamily, countBases, inFamily } from '../web/modules/model_bases.js';
|
||||
|
||||
const model = (baseModel, base_guess = '') => ({ metadata: { baseModel, base_guess } });
|
||||
|
||||
// Spellings of one family are one; the header's guess counts when nothing was scanned.
|
||||
assert.equal(baseFamily(model('SDXL 1.0')), 'SDXL');
|
||||
assert.equal(baseFamily(model('', 'SDXL')), 'SDXL');
|
||||
assert.equal(baseFamily(model('Flux.1 D')), 'Flux');
|
||||
assert.equal(baseFamily(model('Illustrious')), 'Illustrious');
|
||||
assert.equal(baseFamily(model('Unknown')), '');
|
||||
assert.equal(baseFamily({}), '');
|
||||
|
||||
const list = [model('SDXL 1.0'), model('SDXL'), model('Illustrious'), model(''), model('Pony'), model('Pony')];
|
||||
assert.deepEqual(countBases(list), [['Pony', 2], ['SDXL', 2], ['Illustrious', 1], ['', 1]]);
|
||||
assert.equal(list.filter(item => inFamily(item, 'SDXL')).length, 2);
|
||||
assert.equal(list.filter(item => inFamily(item, '')).length, 1);
|
||||
assert.equal(list.filter(item => inFamily(item, null)).length, 6);
|
||||
|
||||
console.log('model_bases: ok');
|
||||
@@ -1,17 +1,24 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyParameterChanges, loadParameterSets, parameterChanges } from '../web/modules/node_parameter_sets.js';
|
||||
import {
|
||||
appliedState, applyParameterChanges, closestSet, isAppliedEntry, loadParameterSets, loadSetPictures, parameterChanges, parameterRows,
|
||||
rememberApplied, restoreApplied,
|
||||
} from '../web/modules/node_parameter_sets.js';
|
||||
|
||||
// Both sources, with the same values saved in a material and in a recipe parameter set.
|
||||
const shared = [123, 'fixed', 30, 5.5, 'euler', 'karras', 1];
|
||||
const asked = [];
|
||||
globalThis.fetch = async (url) => ({
|
||||
ok: true,
|
||||
json: async () => (String(url).includes('/materials/')
|
||||
json: async () => (asked.push(String(url)), String(url).includes('/parameter_pictures')
|
||||
? { pictures: { ['a'.repeat(64)]: { count: 2, newest: { filename: 'p.png', subfolder: '' } } } }
|
||||
: String(url).includes('/materials/')
|
||||
? { materials: [
|
||||
{ name: 'Slow', timestamp: 10, blocks: [{ title: 'KSampler', widgets_values: shared }] },
|
||||
{ name: 'Slow', filename: 'm.json', timestamp: 10, origin: 'generated_image', source_image: { filename: 'kept.png', subfolder: '' }, preview: 'preview-1.webp',
|
||||
blocks: [{ title: 'KSampler', widgets_values: shared }] },
|
||||
{ name: 'Fast', timestamp: 30, blocks: [{ title: 'KSampler', widgets_values: [1, 'fixed', 8, 2, 'lcm', 'sgm_uniform', 1] }] },
|
||||
] }
|
||||
: { groups: [
|
||||
{ recipe_filename: 'r.json', recipe_name: 'Portrait', notebooks: [{ name: 'Slow set', timestamp: 20, nodes: [{ title: 'KSampler', widgets_values: shared }] }] },
|
||||
{ recipe_filename: 'r.json', recipe_name: 'Portrait', notebooks: [{ name: 'Slow set', filename: 'params_1.json', signature: 'a'.repeat(64), timestamp: 20, nodes: [{ title: 'KSampler', widgets_values: shared }] }] },
|
||||
{ recipe_filename: 'unbound', notebooks: [{ name: 'Loose', timestamp: 99, nodes: [{ widgets_values: [9] }] }] },
|
||||
{ recipe_filename: 'gone.json', recipe_name: 'gone.json', notebooks: [{ name: 'Old', timestamp: 98, nodes: [{ widgets_values: [8] }] }] },
|
||||
] }),
|
||||
@@ -22,6 +29,16 @@ globalThis.fetch = async (url) => ({
|
||||
assert.deepEqual(entries.map(entry => entry.name), ['Fast', 'Slow']);
|
||||
assert.deepEqual(entries[1].sources.map(source => [source.kind, source.label]), [['material', 'Slow'], ['recipe', 'Portrait']]);
|
||||
assert.equal(entries[1].timestamp, 20);
|
||||
// Where each came from: the kept picture and its saved preview, the recipe's set file and signature.
|
||||
const [kept, set] = entries[1].sources;
|
||||
assert.deepEqual([kept.origin, kept.image, kept.preview], ['generated_image', { filename: 'kept.png', subfolder: '' }, '/anomalous/material_asset?filename=m.json&asset=preview-1.webp']);
|
||||
assert.deepEqual([set.recipeFile, set.setFile, set.setName, set.signature], ['r.json', 'params_1.json', 'Slow set', 'a'.repeat(64)]);
|
||||
assert.equal(entries[0].sources[0].image, null);
|
||||
// The pictures made with the sets' values: one request for all of them.
|
||||
const pictures = await loadSetPictures(entries);
|
||||
assert.equal(pictures['a'.repeat(64)].count, 2);
|
||||
assert.equal(asked.filter(url => url.includes('/parameter_pictures')).length, 1);
|
||||
assert.deepEqual(await loadSetPictures([entries[0]]), {}); // no recipe set, nothing asked
|
||||
}
|
||||
|
||||
// What would change: seeds, model files and missing choices stay.
|
||||
@@ -42,6 +59,9 @@ function sampler() {
|
||||
const loader = { id: 4, type: 'CheckpointLoaderSimple', widgets: [widget('ckpt_name', 'a.safetensors', ['a.safetensors', 'b.safetensors'])] };
|
||||
assert.equal(parameterChanges(loader, ['b.safetensors'])[0].keep, 'model');
|
||||
assert.deepEqual(parameterChanges(node, ['not a number', 'fixed', 20]), []); // a value of another type is not this input
|
||||
// Every value, the equal ones too, for "show all" and the side-by-side table.
|
||||
assert.deepEqual(parameterRows(node, [5, 'fixed', 30, 7, 'dpmpp_2m']).map(row => [row.label, row.status]),
|
||||
[['seed', 'same'], ['control_after_generate', 'same'], ['steps', 'change'], ['cfg', 'same'], ['sampler_name', 'change']]);
|
||||
}
|
||||
|
||||
// Applying writes only the changes that are made, as one undoable step.
|
||||
@@ -56,4 +76,49 @@ function sampler() {
|
||||
assert.deepEqual(node.widgets.map(w => w.value), [5, 'fixed', 20, 7, 'euler', 'normal', 1]);
|
||||
}
|
||||
|
||||
console.log('node parameter sets: merged sources, kept values and undoable apply OK');
|
||||
// What was last put into a node, per workflow and node; what changed since; undone, forgotten.
|
||||
{
|
||||
const saved = new Map();
|
||||
globalThis.localStorage = { getItem: key => saved.get(key) ?? null, setItem: (key, value) => saved.set(key, String(value)) };
|
||||
const app = { extensionManager: { workflow: { activeWorkflow: { key: 'workflows/a.json' } } } };
|
||||
const node = sampler();
|
||||
const entry = { name: 'Slow', values: [123, 'fixed', 30, 5.5, 'euler', 'normal', 1] };
|
||||
const rows = parameterRows(node, entry.values);
|
||||
assert.equal(appliedState(app, node), null);
|
||||
const previous = rememberApplied(app, node, entry, rows, 1000);
|
||||
assert.equal(previous, null);
|
||||
for (const change of parameterChanges(node, entry.values).filter(change => !change.keep)) node.widgets[change.index].value = change.to;
|
||||
assert.deepEqual(appliedState(app, node).changed, []);
|
||||
assert.equal(isAppliedEntry(entry, appliedState(app, node).record), true);
|
||||
assert.equal(isAppliedEntry({ ...entry, values: [123, 'fixed', 31] }, appliedState(app, node).record), false);
|
||||
node.widgets[3].value = 6; // cfg by hand
|
||||
assert.deepEqual(appliedState(app, node).changed.map(change => [change.label, change.from, change.to]), [['cfg', 5.5, 6]]);
|
||||
const other = { ...sampler(), id: 9 };
|
||||
assert.equal(appliedState(app, other), null); // another node
|
||||
app.extensionManager.workflow.activeWorkflow.key = 'workflows/b.json';
|
||||
assert.equal(appliedState(app, node), null); // another workflow
|
||||
app.extensionManager.workflow.activeWorkflow.key = 'workflows/a.json';
|
||||
node.widgets[2].value = 20; // steps and cfg back as before: nothing it changed is left
|
||||
node.widgets[3].value = 7;
|
||||
assert.equal(appliedState(app, node), null);
|
||||
restoreApplied(app, node, previous); // undone: forgotten
|
||||
assert.equal(JSON.parse(saved.get('anomalous_applied_sets'))['workflows/a.json||3'], undefined);
|
||||
}
|
||||
|
||||
// Without a record: the set the values are now, or the closest one.
|
||||
{
|
||||
const node = sampler();
|
||||
const now = node.widgets.map(w => w.value);
|
||||
const entries = [
|
||||
{ name: 'Far', values: [1, 'fixed', 40, 2, 'dpmpp_2m', 'karras', 0.5] },
|
||||
{ name: 'Near', values: [1, 'fixed', 20, 7.5, 'euler', 'normal', 1] },
|
||||
{ name: 'Same', values: now },
|
||||
{ name: 'Same too', values: [...now] },
|
||||
];
|
||||
assert.deepEqual([closestSet(node, entries).entry.name, closestSet(node, entries).others], ['Same', 1]);
|
||||
const near = closestSet(node, entries.slice(0, 2));
|
||||
assert.deepEqual([near.entry.name, near.changes.map(change => change.label)], ['Near', ['cfg']]);
|
||||
assert.equal(closestSet(node, entries.slice(0, 1)), null); // too far to speak of
|
||||
}
|
||||
|
||||
console.log('node parameter sets: merged sources, kept values, undoable apply, last applied OK');
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// node tests/param_compare.mjs — two sets of workflow parameters side by side.
|
||||
import assert from 'node:assert/strict';
|
||||
import { compareParams, readParams, shownValue, textDifference } from '../web/modules/param_compare.js';
|
||||
|
||||
const node = (id, type, widgets, extra = {}) => ({
|
||||
id, type, title: type, mode: 0, inputs: [],
|
||||
widgets: Object.entries(widgets).map(([name, value]) => ({ name, value })), ...extra,
|
||||
});
|
||||
const graph = (nodes, links = {}) => ({ _nodes: nodes, links });
|
||||
|
||||
const imageA = graph([
|
||||
node(3, 'KSampler', { seed: 11, steps: 20, cfg: 7 }, { inputs: [{ name: 'model', link: 1 }] }),
|
||||
node(4, 'CheckpointLoaderSimple', { ckpt_name: 'a.safetensors' }),
|
||||
node(6, 'CLIPTextEncode', { text: '1girl, red dress, smile, outdoors' }),
|
||||
], { 1: [1, 4, 0, 3, 0, 'MODEL'] });
|
||||
const imageB = graph([
|
||||
node(3, 'KSampler', { seed: 99, steps: 30, cfg: 7 }, { inputs: [{ name: 'model', link: 1 }] }),
|
||||
node(4, 'CheckpointLoaderSimple', { ckpt_name: 'a.safetensors' }),
|
||||
node(6, 'CLIPTextEncode', { text: '1girl, white dress, smile, outdoors' }),
|
||||
], { 1: [1, 4, 0, 3, 0, 'MODEL'] });
|
||||
|
||||
const same = compareParams(readParams(imageA), readParams(imageB));
|
||||
assert.equal(same.sameStructure, true);
|
||||
assert.equal(same.sameCount, 2); // cfg and ckpt_name
|
||||
assert.deepEqual(same.nodes.map(n => [n.id, n.rows.map(r => [r.name, r.aText, r.bText, r.volatile])]), [
|
||||
['3', [['seed', '11', '99', true], ['steps', '20', '30', false]]],
|
||||
['6', [['text', '1girl, red dress, smile, outdoors', '1girl, white dress, smile, outdoors', false]]],
|
||||
]);
|
||||
assert.equal(same.nodes[0].rows[1].index, 1); // the widget's place on the B side, to write it
|
||||
|
||||
// A node bypassed on one side, one only on one side, and an input wired elsewhere.
|
||||
const imageC = graph([
|
||||
node(3, 'KSampler', { seed: 11, steps: 20, cfg: 7 }, { mode: 4, inputs: [{ name: 'model', link: 2 }] }),
|
||||
node(4, 'CheckpointLoaderSimple', { ckpt_name: 'a.safetensors' }),
|
||||
node(9, 'LoraLoader', { lora_name: 'x.safetensors' }),
|
||||
], new Map([[2, { origin_id: 9, origin_slot: 0 }]]));
|
||||
const changed = compareParams(readParams(imageA), readParams(imageC));
|
||||
assert.equal(changed.sameStructure, false);
|
||||
assert.deepEqual(changed.onlyA.map(n => n.id), ['6']);
|
||||
assert.deepEqual(changed.onlyB.map(n => n.id), ['9']);
|
||||
assert.deepEqual(changed.rewired.map(n => n.id), ['3']);
|
||||
assert.deepEqual(changed.nodes[0].rows[0], { name: '', mode: true, a: 0, b: 4 });
|
||||
|
||||
// A node of another type under the same id is not compared value by value.
|
||||
const retyped = compareParams(readParams(graph([node(5, 'A', { x: 1 })])), readParams(graph([node(5, 'B', { x: 2 })])));
|
||||
assert.deepEqual([retyped.onlyA.map(n => n.type), retyped.onlyB.map(n => n.type), retyped.nodes], [['A'], ['B'], []]);
|
||||
|
||||
// Starred values stand apart, equal ones too, and count as compared like the rest.
|
||||
const starred = compareParams(readParams(imageA), readParams(imageB), { isKey: (type, widget) => type === 'KSampler' && ['cfg', 'steps'].includes(widget) });
|
||||
assert.deepEqual(starred.keys.map(({ node, row }) => [node.id, row.name, row.same]), [['3', 'steps', false], ['3', 'cfg', true]]);
|
||||
assert.deepEqual(starred.nodes.map(n => [n.id, n.rows.map(r => r.name)]), [['3', ['seed']], ['6', ['text']]]);
|
||||
assert.deepEqual([starred.sameCount, starred.compared], [2, 5]);
|
||||
|
||||
// Equal values listed on request, marked same, counted the same way.
|
||||
const all = compareParams(readParams(imageA), readParams(imageB), { includeSame: true });
|
||||
assert.deepEqual(all.nodes.map(n => [n.id, n.rows.map(r => [r.name, r.same])]), [
|
||||
['3', [['seed', false], ['steps', false], ['cfg', true]]],
|
||||
['4', [['ckpt_name', true]]],
|
||||
['6', [['text', false]]],
|
||||
]);
|
||||
assert.deepEqual([all.sameCount, all.compared], [2, 5]);
|
||||
|
||||
// Two workflows numbered differently: a type found once on each side still pairs, by type.
|
||||
const other = compareParams(
|
||||
readParams(graph([node(3, 'KSampler', { steps: 20 }), node(6, 'CLIPTextEncode', { text: 'a' }), node(7, 'CLIPTextEncode', { text: 'b' })])),
|
||||
readParams(graph([node(70, 'KSampler', { steps: 30 }), node(71, 'CLIPTextEncode', { text: 'c' }), node(72, 'CLIPTextEncode', { text: 'd' })])),
|
||||
);
|
||||
assert.equal(other.sameStructure, false);
|
||||
assert.deepEqual(other.nodes.map(n => [n.aId, n.bId, n.id, n.rows[0].aText, n.rows[0].bText]), [['3', '70', '70', '20', '30']]);
|
||||
assert.deepEqual([other.onlyA.map(n => n.id), other.onlyB.map(n => n.id), other.compared], [['6', '7'], ['71', '72'], 1]);
|
||||
assert.equal(compareParams(readParams(graph([node(1, 'A', {})])), readParams(graph([node(2, 'B', {})]))).compared, 0);
|
||||
|
||||
// Prompts as tags: the tags only one side has, each once.
|
||||
assert.deepEqual(textDifference('1girl, red dress, smile, red dress', '1girl, smile, white dress, hat'),
|
||||
{ tags: true, onlyA: ['red dress'], onlyB: ['white dress', 'hat'] });
|
||||
// Other text: the changed part with a little around it.
|
||||
const long = `${'a'.repeat(50)} cat ${'b'.repeat(50)}`;
|
||||
assert.deepEqual(textDifference(long, long.replace('cat', 'dog')), {
|
||||
tags: false, before: `…${'a'.repeat(39)} `, aPart: 'cat', bPart: 'dog', after: ` ${'b'.repeat(39)}…`,
|
||||
});
|
||||
assert.deepEqual(textDifference('steps 20', 'steps 30'), { tags: false, before: 'steps ', aPart: '2', bPart: '3', after: '0' });
|
||||
|
||||
assert.equal(shownValue(0.30000000000000004), '0.3');
|
||||
assert.equal(shownValue(''), '—');
|
||||
assert.equal(shownValue(['x', 1]), '["x",1]');
|
||||
|
||||
console.log('param_compare ok');
|
||||
@@ -0,0 +1,36 @@
|
||||
// node tests/recipe_focus.mjs — a workflow's focus values (重点参数) and how comparisons find them.
|
||||
import assert from 'node:assert/strict';
|
||||
import { MAX_FOCUS, focusEntries, focusTest, notedFocus, toggledFocus } from '../web/modules/recipe_focus.js';
|
||||
import { compareParams, readParams } from '../web/modules/param_compare.js';
|
||||
|
||||
const workflow = { nodes: [{ id: 3, type: 'KSampler' }, { id: 7, type: 'LoraLoader' }, { id: 8, type: 'LoraLoader' }] };
|
||||
const lora = { id: 7, type: 'LoraLoader' };
|
||||
|
||||
// Marking, unmarking, the note.
|
||||
let params = toggledFocus({ steps: 20 }, lora, 'strength_model');
|
||||
assert.deepEqual(params, { steps: 20, focusParams: [{ node: '7', type: 'LoraLoader', widget: 'strength_model', note: '' }] });
|
||||
params = notedFocus(params, params.focusParams[0], ` ${'x'.repeat(200)} `);
|
||||
assert.equal(params.focusParams[0].note.length, 120);
|
||||
assert.deepEqual(toggledFocus(params, lora, 'strength_model').focusParams, []);
|
||||
|
||||
// A full list takes no more; a mark whose node is gone or another type now is not shown.
|
||||
const full = { focusParams: Array.from({ length: MAX_FOCUS }, (_, i) => ({ node: '3', type: 'KSampler', widget: `w${i}`, note: '' })) };
|
||||
assert.equal(toggledFocus(full, lora, 'strength_clip'), null);
|
||||
const stale = { focusParams: [
|
||||
{ node: '7', type: 'LoraLoader', widget: 'strength_model', note: '' },
|
||||
{ node: '9', type: 'LoraLoader', widget: 'strength_model', note: '' },
|
||||
{ node: '3', type: 'LoraLoader', widget: 'strength_model', note: '' },
|
||||
] };
|
||||
assert.deepEqual(focusEntries(stale, workflow).map(entry => entry.node), ['7']);
|
||||
|
||||
// Comparing: the marked value of node 7 is a key value, the same widget of the other LoRA is not.
|
||||
const recipe = { params: stale, workflow };
|
||||
const test = focusTest(recipe);
|
||||
assert.ok(test('LoraLoader', 'strength_model', '7'));
|
||||
assert.equal(test('LoraLoader', 'strength_model', '8'), null);
|
||||
const graph = strengths => ({ _nodes: [7, 8].map((id, i) => ({ id, type: 'LoraLoader', title: 'Lora', mode: 0, inputs: [],
|
||||
widgets: [{ name: 'strength_model', value: strengths[i] }] })) });
|
||||
const result = compareParams(readParams(graph([0.7, 1])), readParams(graph([0.7, 0.5])), { isKey: test });
|
||||
assert.deepEqual(result.keys.map(({ node, row }) => [node.id, row.same]), [['7', true]]);
|
||||
assert.deepEqual(result.nodes.map(node => node.id), ['8']);
|
||||
console.log('recipe_focus: ok');
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Civitai answers its API on civitai.com and civitai.red; some networks reach only the second."""
|
||||
import io
|
||||
import sys
|
||||
import unittest
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
PLUGIN = Path(__file__).resolve().parents[1]
|
||||
sys.path[:0] = [str(PLUGIN), str(PLUGIN.parents[1])]
|
||||
|
||||
import civitai_client
|
||||
from api import download_sources
|
||||
|
||||
|
||||
class _Answer(io.BytesIO):
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
|
||||
def _network(asked, blocked=("civitai.com",), body=b'{"id": 7}'):
|
||||
"""An urlopen where the `blocked` hosts time out and the others answer `body`."""
|
||||
def opened(request, timeout):
|
||||
host = urllib.parse.urlsplit(request.full_url).hostname
|
||||
asked.append(host)
|
||||
if host in blocked:
|
||||
raise urllib.error.URLError(TimeoutError("timed out"))
|
||||
return _Answer(body)
|
||||
return opened
|
||||
|
||||
|
||||
class CivitaiHostsTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
patcher = mock.patch.object(civitai_client, "_answered", ["civitai.com"])
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
def test_the_scan_asks_civitai_red_when_civitai_com_does_not_answer(self):
|
||||
asked = []
|
||||
with mock.patch.object(civitai_client, "urlopen", side_effect=_network(asked)):
|
||||
self.assertEqual(civitai_client.fetch_civitai_info("ab" * 32), {"id": 7})
|
||||
self.assertEqual(civitai_client.fetch_civitai_info("cd" * 32), {"id": 7})
|
||||
self.assertEqual(asked, ["civitai.com", "civitai.red", "civitai.red"]) # then the one that answered first
|
||||
|
||||
def test_an_answer_from_civitai_com_is_not_asked_again_elsewhere(self):
|
||||
asked = []
|
||||
missing = urllib.error.HTTPError("https://civitai.com/x", 404, "x", {}, io.BytesIO(b""))
|
||||
def opened(request, timeout):
|
||||
asked.append(urllib.parse.urlsplit(request.full_url).hostname)
|
||||
raise missing
|
||||
with mock.patch.object(civitai_client, "urlopen", side_effect=opened):
|
||||
self.assertIsNone(civitai_client.fetch_civitai_info("ab" * 32))
|
||||
self.assertEqual(asked, ["civitai.com"])
|
||||
|
||||
def test_neither_answering_is_unreachable(self):
|
||||
asked = []
|
||||
with mock.patch.object(civitai_client, "urlopen", side_effect=_network(asked, ("civitai.com", "civitai.red"))), \
|
||||
mock.patch.object(civitai_client.time, "sleep"):
|
||||
with self.assertRaises(civitai_client.CivitaiUnreachable) as raised:
|
||||
civitai_client.fetch_civitai_info("ab" * 32, max_retries=2)
|
||||
self.assertEqual(raised.exception.args[0], "timeout")
|
||||
self.assertEqual(asked, ["civitai.com", "civitai.red"] * 2)
|
||||
|
||||
def test_import_and_model_check_ask_civitai_red_too(self):
|
||||
asked = []
|
||||
with mock.patch.object(download_sources, "urlopen", side_effect=_network(asked, body=b'{"files": []}')):
|
||||
self.assertIsNone(download_sources.civitai_by_hash("ab" * 32)) # answered, no such file
|
||||
self.assertEqual(asked, ["civitai.com", "civitai.red"])
|
||||
self.assertEqual(civitai_client.api_hosts()[0], "civitai.red")
|
||||
with mock.patch.object(download_sources, "urlopen", side_effect=_network([], ("civitai.com", "civitai.red"))):
|
||||
with self.assertRaises(download_sources.SourceUnreachable):
|
||||
download_sources.civitai_by_hash("ab" * 32)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -58,6 +58,16 @@ class ImageSearchTests(unittest.TestCase):
|
||||
def names(self, query):
|
||||
return [image['filename'] for image in image_search.filter_images(str(self.root), self.images, query)]
|
||||
|
||||
def test_power_lora_loader_rows_name_their_loras(self):
|
||||
prompt = json.dumps({'9': {'class_type': 'Power Lora Loader (rgthree)', 'inputs': {
|
||||
'PowerLoraLoaderHeaderWidget': {'type': 'PowerLoraLoaderHeaderWidget'},
|
||||
'lora_1': {'on': True, 'lora': 'Style\\ink wash.safetensors', 'strength': 0.8},
|
||||
'model': ['4', 0]}}})
|
||||
_png(self.root / 'd.png', prompt=prompt)
|
||||
record = image_search.build_record(str(self.root / 'd.png'))
|
||||
self.assertIn('style/ink wash.safetensors', record['models'])
|
||||
self.assertIn('ink wash.safetensors', record['models'])
|
||||
|
||||
def test_prompt_text_models_and_seed(self):
|
||||
self.assertEqual(self.names('赛博朋克'), ['a.png'])
|
||||
self.assertEqual(self.names('miaomiao realskin'), ['a.png'], 'all terms must match')
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
"""Only this computer may change things: one middleware in front of every /anomalous/ route."""
|
||||
"""Only this computer may change things: one middleware in front of every /anomalous/ route;
|
||||
other devices too when the server has the allow_remote_changes file (cloud servers)."""
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import make_mocked_request
|
||||
@@ -66,8 +70,45 @@ class LocalGuardTests(unittest.TestCase):
|
||||
request = make_mocked_request("GET", "/anomalous/access", headers={"Host": host}, transport=_Transport(remote))
|
||||
return json.loads((await local_guard.api_access(request)).text)
|
||||
|
||||
self.assertEqual(asyncio.run(run("127.0.0.1", "127.0.0.1:8188")), {"local": True})
|
||||
self.assertEqual(asyncio.run(run("192.168.1.20", "192.168.1.5:8188")), {"local": False})
|
||||
self.assertEqual(asyncio.run(run("127.0.0.1", "127.0.0.1:8188")), {"local": True, "changes": True})
|
||||
lan = asyncio.run(run("192.168.1.20", "192.168.1.5:8188"))
|
||||
self.assertEqual((lan["local"], lan["changes"]), (False, False))
|
||||
self.assertIn(local_guard.REMOTE_FILE, lan["command"])
|
||||
self.assertTrue(lan["command"].startswith("echo off >" if os.name == "nt" else "touch "))
|
||||
with mock.patch.object(local_guard, "REMOTE_CHANGES", True):
|
||||
cloud = asyncio.run(run("10.0.0.7", "u123.cloud.example:8443"))
|
||||
self.assertEqual((cloud["local"], cloud["changes"]), (False, True))
|
||||
self.assertTrue(cloud["command"].startswith("del " if os.name == "nt" else "rm -f "))
|
||||
|
||||
def test_the_command_survives_a_folder_with_spaces(self):
|
||||
with mock.patch.object(local_guard, "REMOTE_FILE", "/root/my comfy/custom_nodes/AMB/allow_remote_changes"), mock.patch.object(local_guard.os, "name", "posix"):
|
||||
self.assertEqual(local_guard.switch_command(), "touch '/root/my comfy/custom_nodes/AMB/allow_remote_changes'")
|
||||
|
||||
|
||||
class RemoteChangesTests(unittest.TestCase):
|
||||
def test_the_file_on_the_server_lets_other_devices_change_things(self):
|
||||
proxied = {"remote": "10.0.0.7", "host": "u123.cloud.example:8443", "X-Forwarded-For": "203.0.113.9"}
|
||||
self.assertEqual(ask("POST", "/anomalous/scan_all", **proxied)[0], 403)
|
||||
with mock.patch.object(local_guard, "REMOTE_CHANGES", True):
|
||||
for path in ("/anomalous/scan_all", "/anomalous/delete_model", "/anomalous/save_config"):
|
||||
self.assertEqual(ask("POST", path, **proxied)[0], 200, path)
|
||||
self.assertEqual(ask("GET", "/anomalous/backup/file", **proxied)[0], 200)
|
||||
request = make_mocked_request("POST", "/anomalous/mcp", headers={"Host": "u123.cloud.example:8443"},
|
||||
transport=_Transport("10.0.0.7"))
|
||||
self.assertFalse(local_guard.is_local(request)) # MCP checks is_local itself, unchanged
|
||||
|
||||
def test_it_is_read_when_the_plugin_starts(self):
|
||||
def load(): # a copy of the module, so the one the routes use stays as it is
|
||||
spec = importlib.util.spec_from_file_location("local_guard_copy", local_guard.__file__)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
self.assertEqual(Path(local_guard.REMOTE_FILE).parent, PLUGIN)
|
||||
self.assertFalse(load().REMOTE_CHANGES)
|
||||
real = os.path.isfile
|
||||
with mock.patch("os.path.isfile", lambda path: path == local_guard.REMOTE_FILE or real(path)):
|
||||
self.assertTrue(load().REMOTE_CHANGES)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -73,3 +73,20 @@ class MaterialsByNodeTypeTests(unittest.IsolatedAsyncioTestCase):
|
||||
response = await materials.api_get_materials_by_node_type(SimpleNamespace(query={'type': 'KSampler'}))
|
||||
self.assertEqual([item['name'] for item in json.loads(response.text)['materials']], ['kept'])
|
||||
|
||||
async def test_each_says_where_its_values_came_from(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
workflow = {'nodes': [{'id': 1, 'type': 'KSampler', 'widgets_values': [123, 'fixed', 20]}]}
|
||||
Path(directory, 'kept.json').write_text(json.dumps({
|
||||
'name': 'kept', 'workflow': workflow, 'kind': 'image_node_selection',
|
||||
'source': {'type': 'generated_image', 'image': {'filename': 'ComfyUI_00001_.png', 'subfolder': 'day', 'type': 'output'}},
|
||||
'image': {'preview_asset_id': 'preview-1.webp'}}))
|
||||
Path(directory, 'here.json').write_text(json.dumps({
|
||||
'name': 'here', 'workflow': workflow, 'kind': 'node_parameter_selection',
|
||||
'source': {'type': 'canvas_node'}, 'image': {'preview_asset_id': '../escape.txt'}}))
|
||||
with mock.patch.object(materials, 'get_materials_dir', lambda: directory):
|
||||
response = await materials.api_get_materials_by_node_type(SimpleNamespace(query={'type': 'KSampler'}))
|
||||
found = {item['name']: item for item in json.loads(response.text)['materials']}
|
||||
self.assertEqual((found['kept']['origin'], found['kept']['source_image'], found['kept']['preview']),
|
||||
('generated_image', {'filename': 'ComfyUI_00001_.png', 'subfolder': 'day'}, 'preview-1.webp'))
|
||||
self.assertEqual((found['here']['origin'], found['here']['source_image'], found['here']['preview']), ('canvas_node', None, ''))
|
||||
|
||||
|
||||
@@ -255,7 +255,7 @@ class DownloadTests(unittest.TestCase):
|
||||
self.assertFalse(Path(self.root, "Flux", "cool.safetensors.part").exists())
|
||||
|
||||
def test_settings(self):
|
||||
self.assertEqual(model_download.read_settings(), {"place": "workflow", "folder": "Downloads", "hf_mirror": None})
|
||||
self.assertEqual(model_download.read_settings(), {"place": "workflow", "folder": "{base}", "hf_mirror": None})
|
||||
def call(body):
|
||||
request = make_mocked_request("POST", "/anomalous/download/settings")
|
||||
request.json = lambda: asyncio.sleep(0, body)
|
||||
@@ -284,7 +284,8 @@ class DownloadTests(unittest.TestCase):
|
||||
mock.patch.object(download_sources.manager_catalog, "candidates",
|
||||
side_effect=lambda name, folder="": listed if name == "ae.safetensors" else []):
|
||||
body = json.loads(asyncio.run(model_download.api_lookup(request)).text)
|
||||
self.assertEqual(len(civitai), 1) # Civitai asked once, not once per model
|
||||
# Civitai asked once (on each of its two addresses), not once per model.
|
||||
self.assertEqual([url.split("/")[2] for url in civitai], ["civitai.com", "civitai.red"])
|
||||
reasons = [r.get("reason") for r in body["results"]]
|
||||
self.assertEqual(reasons[:3], ["network"] * 3)
|
||||
self.assertTrue(body["results"][3]["found"]) # Hugging Face (mirror) through the Manager's list
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""POST /anomalous/open_model_folder: a model file shown selected, or a models folder opened."""
|
||||
import asyncio
|
||||
import json
|
||||
import shutil
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
PLUGIN = Path(__file__).resolve().parents[1]
|
||||
sys.path[:0] = [str(PLUGIN), str(PLUGIN.parents[1])]
|
||||
|
||||
from api import model_folder, path_utils
|
||||
|
||||
|
||||
class _Request:
|
||||
def __init__(self, body):
|
||||
self.body = body
|
||||
|
||||
async def json(self):
|
||||
return self.body
|
||||
|
||||
|
||||
def ask(body, root):
|
||||
with mock.patch.object(model_folder, "resolve_folder_subdir",
|
||||
side_effect=lambda kind, idx, sub: (root, path_utils.resolve_within(root, sub.strip("/")))), \
|
||||
mock.patch.object(model_folder, "_reveal") as reveal, \
|
||||
mock.patch.object(model_folder, "_open_folder") as open_folder:
|
||||
response = asyncio.run(model_folder.api_open_model_folder(_Request(body)))
|
||||
return response.status, json.loads(response.text), reveal, open_folder
|
||||
|
||||
|
||||
class OpenModelFolderTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.root = os.path.realpath(tempfile.mkdtemp())
|
||||
self.addCleanup(shutil.rmtree, self.root, True)
|
||||
Path(self.root, "Anime").mkdir()
|
||||
Path(self.root, "Anime", "a.safetensors").write_bytes(b"x")
|
||||
|
||||
def test_a_file_is_shown_selected(self):
|
||||
status, _, reveal, open_folder = ask({"type": "loras", "subfolder": "/Anime", "filename": "a.safetensors"}, self.root)
|
||||
self.assertEqual(status, 200)
|
||||
reveal.assert_called_once_with(str(Path(self.root, "Anime", "a.safetensors")))
|
||||
open_folder.assert_not_called()
|
||||
|
||||
def test_without_a_file_name_the_folder_opens(self):
|
||||
status, _, reveal, open_folder = ask({"type": "loras", "subfolder": "/Anime"}, self.root)
|
||||
self.assertEqual(status, 200)
|
||||
open_folder.assert_called_once_with(str(Path(self.root, "Anime")))
|
||||
reveal.assert_not_called()
|
||||
|
||||
def test_missing_and_outside_are_refused(self):
|
||||
self.assertEqual(ask({"type": "loras", "subfolder": "/Nope"}, self.root)[0], 404)
|
||||
self.assertEqual(ask({"type": "loras", "subfolder": "/Anime", "filename": "b.safetensors"}, self.root)[0], 404)
|
||||
self.assertEqual(ask({"type": "loras", "subfolder": "/../.."}, self.root)[0], 400)
|
||||
self.assertEqual(ask({"type": "loras", "subfolder": "/", "filename": "../x"}, self.root)[0], 400)
|
||||
self.assertEqual(ask({"type": "loras", "subfolder": "/Anime", "filename": ".."}, self.root)[0], 400)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,275 @@
|
||||
"""Model import: telling what a model file is from its header (model_kind.py), finding the
|
||||
dropped file on this computer, and putting it into the models folder without replacing
|
||||
anything (api/model_import.py). No network; files go to temporary folders."""
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
|
||||
PLUGIN = Path(__file__).resolve().parents[1]
|
||||
sys.path[:0] = [str(PLUGIN), str(PLUGIN.parents[1])]
|
||||
|
||||
import model_identity # noqa: E402
|
||||
import model_kind # noqa: E402
|
||||
from api import model_import # noqa: E402
|
||||
|
||||
|
||||
def tensors(*names, shapes=None, meta=None):
|
||||
header = {name: {"dtype": "F16", "shape": (shapes or {}).get(name, [4, 4]), "data_offsets": [0, 0]} for name in names}
|
||||
if meta:
|
||||
header["__metadata__"] = meta
|
||||
return header
|
||||
|
||||
|
||||
def safetensors_bytes(header):
|
||||
raw = json.dumps(header).encode("utf-8")
|
||||
return struct.pack("<Q", len(raw)) + raw + b"\0" * 64
|
||||
|
||||
|
||||
def gguf_bytes(arch, names=()):
|
||||
def text(value):
|
||||
raw = value.encode("utf-8")
|
||||
return struct.pack("<Q", len(raw)) + raw
|
||||
data = b"GGUF" + struct.pack("<I", 3) + struct.pack("<Q", len(names)) + struct.pack("<Q", 2)
|
||||
data += text("general.architecture") + struct.pack("<I", 8) + text(arch)
|
||||
data += text("tokenizer.tokens") + struct.pack("<I", 9) + struct.pack("<I", 8) + struct.pack("<Q", 2) + text("a") + text("b")
|
||||
for name in names:
|
||||
data += text(name) + struct.pack("<I", 2) + struct.pack("<QQ", 4, 4) + struct.pack("<I", 1) + struct.pack("<Q", 0)
|
||||
return data
|
||||
|
||||
|
||||
def kind_of(header):
|
||||
return model_kind.classify("safetensors", header)
|
||||
|
||||
|
||||
class KindTests(unittest.TestCase):
|
||||
def test_loras_and_their_base_models(self):
|
||||
sdxl = kind_of(tensors("lora_unet_input_blocks_4_1_proj_in.lora_down.weight", "lora_te1_text_model_encoder_layers_0_mlp_fc1.alpha"))
|
||||
self.assertEqual((sdxl["kind"], sdxl["base"], sdxl["sure"]), ("loras", "SDXL", True))
|
||||
# Diffusers names say nothing: the cross-attention width does (768 = SD 1.5).
|
||||
sd15 = kind_of(tensors("unet.down_blocks.1.attentions.0.transformer_blocks.0.attn2.to_k.lora_A.weight",
|
||||
shapes={"unet.down_blocks.1.attentions.0.transformer_blocks.0.attn2.to_k.lora_A.weight": [16, 768]}))
|
||||
self.assertEqual((sd15["kind"], sd15["base"]), ("loras", "SD 1.5"))
|
||||
flux = kind_of(tensors("lora_unet_double_blocks_0_img_attn_proj.lora_up.weight"))
|
||||
self.assertEqual((flux["kind"], flux["base"]), ("loras", "Flux.1 D"))
|
||||
# Anima (Cosmos-Predict2 with a Qwen3 adapter): Civitai calls it a Checkpoint, it goes in diffusion models.
|
||||
anima = kind_of(tensors("net.blocks.0.self_attn.q_proj.weight", "net.blocks.0.cross_attn.k_proj.weight",
|
||||
"net.x_embedder.proj.1.weight", "net.llm_adapter.blocks.0.mlp.0.weight"))
|
||||
self.assertEqual((anima["kind"], anima["base"], anima["sure"]), ("diffusion_models", "Anima", True))
|
||||
bare = kind_of(tensors("blocks.0.self_attn.q_proj.weight", "blocks.0.cross_attn.k_proj.weight",
|
||||
"blocks.0.adaln_modulation_self_attn.1.weight", "llm_adapter.blocks.0.mlp.0.weight"))
|
||||
self.assertEqual((bare["kind"], bare["base"]), ("diffusion_models", "Anima"))
|
||||
wan = kind_of(tensors("diffusion_model.blocks.0.self_attn.q.lora_A.weight", "diffusion_model.blocks.0.cross_attn.k.lora_B.weight"))
|
||||
self.assertEqual(wan["base"], "Wan Video")
|
||||
# SDXL's families only show in what it was trained on.
|
||||
illustrious = kind_of(tensors("lora_unet_output_blocks_0_1_proj_in.lora_down.weight",
|
||||
meta={"ss_base_model_version": "sdxl_base_v1-0", "ss_sd_model_name": "illustriousXL_v01.safetensors"}))
|
||||
self.assertEqual(illustrious["base"], "Illustrious")
|
||||
lycoris = kind_of(tensors("lora_unet_down_blocks_0_attentions_0_proj_in.hada_w1_a"))
|
||||
self.assertEqual(lycoris["kind"], "loras")
|
||||
|
||||
def test_whole_models_and_their_parts(self):
|
||||
checkpoint = kind_of(tensors("model.diffusion_model.input_blocks.0.0.weight", "model.diffusion_model.label_emb.0.0.weight",
|
||||
"first_stage_model.encoder.down.0.block.0.conv1.weight", "conditioner.embedders.1.model.ln_final.weight"))
|
||||
self.assertEqual((checkpoint["kind"], checkpoint["base"]), ("checkpoints", "SDXL"))
|
||||
merged = kind_of(tensors("model.diffusion_model.input_blocks.0.0.weight", "first_stage_model.decoder.up.0.block.0.conv1.weight",
|
||||
"lora_te_text_model_encoder_layers_0_mlp_fc1.lora_down.weight"))
|
||||
self.assertEqual(merged["kind"], "checkpoints") # a merge that kept a LoRA's keys
|
||||
flux = kind_of(tensors("double_blocks.0.img_attn.qkv.weight", "img_in.weight", "single_blocks.0.linear1.weight"))
|
||||
self.assertEqual((flux["kind"], flux["base"]), ("diffusion_models", "Flux.1 D"))
|
||||
# Qwen-Image also has img_in: not taken for Flux.
|
||||
qwen = kind_of(tensors("transformer_blocks.0.img_mod.1.weight", "img_in.weight", "txt_norm.weight"))
|
||||
self.assertEqual((qwen["kind"], qwen["base"]), ("diffusion_models", "Qwen"))
|
||||
vae = kind_of(tensors("encoder.down.0.block.0.conv1.weight", "decoder.up.0.block.0.conv1.weight"))
|
||||
self.assertEqual((vae["kind"], vae["base"], vae["sure"]), ("vae", "", True))
|
||||
t5 = kind_of(tensors("encoder.block.0.layer.0.SelfAttention.q.weight", "shared.weight"))
|
||||
self.assertEqual(t5["kind"], "text_encoders")
|
||||
clip = kind_of(tensors("text_model.encoder.layers.0.mlp.fc1.weight"))
|
||||
self.assertEqual(clip["kind"], "text_encoders")
|
||||
vision = kind_of(tensors("vision_model.encoder.layers.0.mlp.fc1.weight"))
|
||||
self.assertEqual(vision["kind"], "clip_vision")
|
||||
control = kind_of(tensors("control_model.input_hint_block.0.weight", "control_model.zero_convs.0.0.weight"))
|
||||
self.assertEqual(control["kind"], "controlnet")
|
||||
|
||||
def test_embeddings(self):
|
||||
self.assertEqual(kind_of(tensors("clip_l", "clip_g")), {"kind": "embeddings", "base": "SDXL", "sure": True})
|
||||
sd15 = kind_of(tensors("emb_params", shapes={"emb_params": [8, 768]}))
|
||||
self.assertEqual((sd15["kind"], sd15["base"]), ("embeddings", "SD 1.5"))
|
||||
|
||||
def test_unknown_files_are_not_guessed(self):
|
||||
self.assertEqual(kind_of(tensors("something.weight")), {"kind": "", "base": "", "sure": False})
|
||||
self.assertEqual(model_kind.classify(None, None), {"kind": "", "base": "", "sure": False})
|
||||
|
||||
def test_gguf(self):
|
||||
flux = model_kind.classify("gguf", model_kind.parse_gguf(gguf_bytes("flux", ["double_blocks.0.img_attn.qkv.weight"])))
|
||||
self.assertEqual((flux["kind"], flux["base"]), ("diffusion_models", "Flux.1 D"))
|
||||
self.assertEqual(model_kind.parse_gguf(gguf_bytes("flux", ["a.weight"]))["keys"], ["a.weight"])
|
||||
self.assertEqual(model_kind.classify("gguf", model_kind.parse_gguf(gguf_bytes("t5encoder")))["kind"], "text_encoders")
|
||||
# Cut short: what fits is still read.
|
||||
self.assertEqual(model_kind.parse_gguf(gguf_bytes("wan")[:70])["meta"].get("general.architecture"), "wan")
|
||||
self.assertIsNone(model_kind.parse_gguf(b"not a gguf"))
|
||||
|
||||
def test_files_on_disk_and_the_scanner(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
lora = os.path.join(temp, "x.safetensors")
|
||||
Path(lora).write_bytes(safetensors_bytes(tensors("lora_unet_double_blocks_0_img_attn_proj.lora_up.weight")))
|
||||
self.assertEqual(model_kind.inspect_file(lora)["kind"], "loras")
|
||||
self.assertEqual(model_identity.infer_base_model_from_header(lora), "Flux.1 D")
|
||||
pickle = os.path.join(temp, "x.ckpt")
|
||||
Path(pickle).write_bytes(b"\x80\x02whatever")
|
||||
self.assertEqual(model_kind.inspect_file(pickle), {"kind": "", "base": "", "sure": False, "format": None})
|
||||
self.assertEqual(model_identity.infer_base_model_from_header(pickle), "Unknown")
|
||||
broken = os.path.join(temp, "y.safetensors")
|
||||
Path(broken).write_bytes(struct.pack("<Q", 10 ** 12))
|
||||
self.assertEqual(model_kind.read_head(broken), (None, None))
|
||||
|
||||
|
||||
class ImportTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
base = os.path.realpath(self.temp.name)
|
||||
self.loras = os.path.join(base, "models", "loras")
|
||||
self.downloads = os.path.join(base, "Downloads")
|
||||
os.makedirs(self.loras)
|
||||
os.makedirs(os.path.join(self.downloads, "a", "b"))
|
||||
roots = {"loras": [self.loras], "vae": [os.path.join(base, "models", "vae")]}
|
||||
self.patches = [
|
||||
mock.patch.object(model_import.folder_paths, "folder_names_and_paths", {name: (paths, set()) for name, paths in roots.items()}),
|
||||
mock.patch.object(model_import.folder_paths, "get_folder_paths", side_effect=lambda t: roots.get(t, [])),
|
||||
mock.patch.object(model_import, "add_entry"),
|
||||
mock.patch.object(model_import, "forget_file_lists"),
|
||||
mock.patch.object(model_import, "search_folders", return_value=[("downloads", self.downloads)]),
|
||||
]
|
||||
for patch in self.patches:
|
||||
patch.start()
|
||||
self.data = safetensors_bytes(tensors("lora_unet_double_blocks_0_img_attn_proj.lora_up.weight"))
|
||||
|
||||
def tearDown(self):
|
||||
for patch in reversed(self.patches):
|
||||
patch.stop()
|
||||
self.temp.cleanup()
|
||||
|
||||
def dropped(self, *parts):
|
||||
path = os.path.join(self.downloads, *parts)
|
||||
Path(path).write_bytes(self.data)
|
||||
return path, os.stat(path).st_mtime * 1000
|
||||
|
||||
def test_the_dropped_file_is_found_by_name_size_and_time(self):
|
||||
path, mtime = self.dropped("a", "Cool.safetensors")
|
||||
size = len(self.data)
|
||||
self.assertEqual(model_import.find_original("cool.safetensors", size, mtime), ("downloads", path))
|
||||
self.assertIsNone(model_import.find_original("cool.safetensors", size + 1, mtime))
|
||||
self.assertIsNone(model_import.find_original("cool.safetensors", size, mtime - 60000))
|
||||
# In subfolders two deep, not deeper.
|
||||
self.dropped("a", "b", "two.safetensors")
|
||||
self.assertIsNotNone(model_import.find_original("two.safetensors", size, 0))
|
||||
os.makedirs(os.path.join(self.downloads, "a", "b", "c"))
|
||||
self.dropped("a", "b", "c", "three.safetensors")
|
||||
self.assertIsNone(model_import.find_original("three.safetensors", size, 0))
|
||||
|
||||
def test_inspect_found_and_not_found(self):
|
||||
path, mtime = self.dropped("cool.safetensors")
|
||||
found = model_import.inspect("cool.safetensors", len(self.data), mtime, b"", local=True)
|
||||
self.assertEqual((found["kind"], found["base"], found["where"]), ("loras", "Flux.1 D", "downloads"))
|
||||
self.assertTrue(found["token"])
|
||||
# Asked from another computer: never looked for, judged from the bytes sent.
|
||||
remote = model_import.inspect("cool.safetensors", len(self.data), mtime, self.data, local=False)
|
||||
self.assertNotIn("token", remote)
|
||||
self.assertEqual(remote["kind"], "loras")
|
||||
# A type this ComfyUI has no folder for is not offered.
|
||||
control = safetensors_bytes(tensors("control_model.input_hint_block.0.weight"))
|
||||
self.assertEqual(model_import.inspect("c.safetensors", len(control), 0, control, local=False)["kind"], "")
|
||||
|
||||
def test_place_moves_and_never_replaces(self):
|
||||
path, mtime = self.dropped("cool.safetensors")
|
||||
token = model_import.inspect("cool.safetensors", len(self.data), mtime, b"", local=True)["token"]
|
||||
Path(self.loras, "Flux").mkdir()
|
||||
Path(self.loras, "Flux", "cool.safetensors").write_bytes(b"older")
|
||||
placed = model_import.place({"token": token, "type": "loras", "root": 0, "rel": "Flux/cool.safetensors"})
|
||||
self.assertEqual(placed["rel"], "Flux/cool (2).safetensors")
|
||||
self.assertFalse(os.path.exists(path))
|
||||
self.assertEqual(Path(self.loras, "Flux", "cool.safetensors").read_bytes(), b"older")
|
||||
self.assertEqual(Path(self.loras, "Flux", "cool (2).safetensors").read_bytes(), self.data)
|
||||
with self.assertRaises(model_import.Refused) as refused:
|
||||
model_import.place({"token": token, "type": "loras", "root": 0, "rel": "x.safetensors"})
|
||||
self.assertEqual(refused.exception.code, "gone")
|
||||
|
||||
def test_place_keeps_the_original_when_asked(self):
|
||||
path, mtime = self.dropped("cool.safetensors")
|
||||
token = model_import.inspect("cool.safetensors", len(self.data), mtime, b"", local=True)["token"]
|
||||
model_import.place({"token": token, "type": "loras", "root": 0, "rel": "cool.safetensors", "keep": True})
|
||||
self.assertTrue(os.path.exists(path))
|
||||
self.assertEqual(Path(self.loras, "cool.safetensors").read_bytes(), self.data)
|
||||
self.assertFalse(Path(self.loras, "cool.safetensors.part").exists())
|
||||
|
||||
def test_a_failed_copy_leaves_nothing_behind(self):
|
||||
path, _ = self.dropped("cool.safetensors")
|
||||
dest = os.path.join(self.loras, "cool.safetensors")
|
||||
with mock.patch.object(model_import.shutil, "copyfile", side_effect=OSError("disk full")):
|
||||
with self.assertRaises(OSError):
|
||||
model_import.move_into(path, dest, keep=True)
|
||||
self.assertTrue(os.path.exists(path))
|
||||
self.assertFalse(os.path.exists(dest))
|
||||
|
||||
def test_destinations_stay_inside_the_models_folder(self):
|
||||
for data, code in (({"type": "checkpoints", "rel": "a.safetensors"}, "bad_type"),
|
||||
({"type": "loras", "root": 3, "rel": "a.safetensors"}, "bad_root"),
|
||||
({"type": "loras", "rel": "../a.safetensors"}, "bad_path"),
|
||||
({"type": "loras", "rel": "a.txt"}, "bad_name")):
|
||||
with self.assertRaises(model_import.Refused) as refused:
|
||||
model_import.destination(data)
|
||||
self.assertEqual(refused.exception.code, code)
|
||||
|
||||
def test_upload(self):
|
||||
async def run():
|
||||
app = web.Application()
|
||||
model_import.register_routes(app)
|
||||
async with TestClient(TestServer(app)) as client:
|
||||
ok = await client.put("/anomalous/import/upload", params={"type": "loras", "root": "0", "rel": "up/new.safetensors",
|
||||
"size": str(len(self.data))}, data=self.data)
|
||||
short = await client.put("/anomalous/import/upload", params={"type": "loras", "root": "0", "rel": "up/short.safetensors",
|
||||
"size": str(len(self.data) + 5)}, data=self.data)
|
||||
return ok.status, await ok.json(), short.status, await short.json()
|
||||
status, body, short_status, short_body = asyncio.run(run())
|
||||
self.assertEqual((status, body["rel"]), (200, "up/new.safetensors"))
|
||||
self.assertEqual(Path(self.loras, "up", "new.safetensors").read_bytes(), self.data)
|
||||
self.assertEqual((short_status, short_body["error"]), (400, "incomplete"))
|
||||
self.assertEqual(sorted(os.listdir(os.path.join(self.loras, "up"))), ["new.safetensors"])
|
||||
|
||||
def test_upload_only_from_this_computer(self):
|
||||
async def run():
|
||||
app = web.Application()
|
||||
model_import.register_routes(app)
|
||||
async with TestClient(TestServer(app)) as client: # through a proxy: not this computer
|
||||
resp = await client.put("/anomalous/import/upload", headers={"X-Forwarded-For": "192.168.1.20"},
|
||||
params={"type": "loras", "root": "0", "rel": "far.safetensors", "size": str(len(self.data))},
|
||||
data=self.data)
|
||||
return resp.status, await resp.json()
|
||||
self.assertEqual(asyncio.run(run()), (403, {"error": "not_local"}))
|
||||
self.assertFalse(Path(self.loras, "far.safetensors").exists())
|
||||
|
||||
def test_identify_reports_civitai_and_a_copy_already_there(self):
|
||||
path, mtime = self.dropped("cool.safetensors")
|
||||
token = model_import.inspect("cool.safetensors", len(self.data), mtime, b"", local=True)["token"]
|
||||
record = {"found": True, "base_model": "Illustrious", "model_name": "Cool", "version_name": "v2", "page": "p"}
|
||||
with mock.patch.object(model_import, "civitai_by_hash", return_value=record), \
|
||||
mock.patch.object(model_import, "_same_hash", return_value="Flux/old.safetensors"):
|
||||
found = model_import.identify(token)
|
||||
self.assertEqual(found["civitai"]["version_name"], "v2")
|
||||
self.assertEqual(found["duplicate"], "Flux/old.safetensors")
|
||||
with mock.patch.object(model_import, "civitai_by_hash", side_effect=model_import.SourceUnreachable("down")), \
|
||||
mock.patch.object(model_import, "_same_hash", return_value=None):
|
||||
self.assertEqual(model_import.identify(token)["reason"], "network")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -12,7 +12,9 @@ PLUGIN = Path(__file__).resolve().parents[1]
|
||||
sys.path[:0] = [str(PLUGIN), str(PLUGIN.parents[1])]
|
||||
|
||||
import scraper
|
||||
from api import metadata as metadata_module, model_metadata, scan_summary
|
||||
from aiohttp.test_utils import make_mocked_request
|
||||
|
||||
from api import metadata as metadata_module, model_media, model_metadata, scan_summary
|
||||
from api.metadata import get_metadata
|
||||
|
||||
CIVITAI = {"id": 7, "modelId": 3, "name": "v2", "model": {"name": "Found"},
|
||||
@@ -100,6 +102,38 @@ class LayerTests(unittest.TestCase):
|
||||
user = json.loads(Path(self.base + ".anomalous.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual((user["custom_name"], user["custom_notes"]), ("Mine", "note"))
|
||||
|
||||
def edit(self, **fields):
|
||||
with mock.patch.object(model_metadata, "resolve_folder_subdir", return_value=(str(self.dir), str(self.dir))):
|
||||
return asyncio.run(model_metadata.api_update_metadata(Request({"type": "loras", "filename": "m.safetensors", **fields})))
|
||||
|
||||
def test_the_editor_keeps_a_source_link_and_refuses_what_is_no_web_address(self):
|
||||
self.assertEqual(self.edit(custom_source_url="https://www.liblib.art/modelinfo/abc").status, 200)
|
||||
user = json.loads(Path(self.base + ".anomalous.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(user["source_url"], "https://www.liblib.art/modelinfo/abc")
|
||||
for bad in ("javascript:alert(1)", "file:///C:/x.png", "https://"):
|
||||
self.assertEqual(self.edit(custom_source_url=bad).status, 400)
|
||||
self.assertEqual(json.loads(Path(self.base + ".anomalous.json").read_text(encoding="utf-8")), user)
|
||||
self.edit(custom_source_url="") # emptied on purpose
|
||||
metadata_module.clear_metadata_cache()
|
||||
meta = get_metadata(str(self.model))
|
||||
self.assertEqual((meta["source_url"], "source_url" in meta["user_fields"]), ("", True))
|
||||
|
||||
def test_the_editor_learns_whether_the_cover_shown_is_civitais(self):
|
||||
def covers():
|
||||
request = make_mocked_request("GET", "/anomalous/model_covers?type=loras&path_idx=0&subfolder=/&filename=m.safetensors")
|
||||
with mock.patch.object(model_media, "resolve_folder_subdir", return_value=(str(self.dir), str(self.dir))):
|
||||
return json.loads(asyncio.run(model_media.api_model_covers(request)).text)
|
||||
|
||||
self.assertEqual(covers(), {"civitai": "", "custom": False}) # no cover at all
|
||||
Path(self.base + ".preview.png").write_bytes(b"my own picture")
|
||||
self.assertEqual(covers(), {"civitai": "", "custom": True})
|
||||
Path(self.base + ".civitai_bak.png").write_bytes(b"civitai image")
|
||||
found = covers()
|
||||
self.assertTrue(found["custom"])
|
||||
self.assertIn("filename=m.civitai_bak.png", found["civitai"])
|
||||
Path(self.base + ".preview.png").write_bytes(b"civitai image") # a copy of Civitai's
|
||||
self.assertFalse(covers()["custom"])
|
||||
|
||||
def test_a_scan_does_not_refetch_what_is_on_disk(self):
|
||||
write(self.base + ".info", {**CIVITAI, "anomalous_custom_name": "Found_v2"})
|
||||
Path(self.base + ".civitai_bak.png").write_bytes(b"old civitai image")
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""A model that is a link (on a cloud server the models folder links into a shared, read-only
|
||||
model disk) is the link: its covers and info are the ones beside it, and its name is the link's."""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
PLUGIN = Path(__file__).resolve().parents[1]
|
||||
sys.path[:0] = [str(PLUGIN), str(PLUGIN.parents[1])]
|
||||
|
||||
from api import metadata, model_import, path_utils
|
||||
|
||||
|
||||
class ModelLinkTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.root = Path(os.path.realpath(self.temp.name), "loras")
|
||||
(self.root / "sub").mkdir(parents=True)
|
||||
self.model = self.root / "sub" / "style.safetensors"
|
||||
self.model.write_bytes(b"model")
|
||||
Path(str(self.model)[:-12] + ".info").write_text(json.dumps({"id": 7, "modelId": 3, "name": "v1"}), encoding="utf-8")
|
||||
metadata.clear_metadata_cache()
|
||||
# The model is a link into the platform's model disk, outside the models folder: what
|
||||
# resolving it gives (this computer cannot make links in tests).
|
||||
real = os.path.realpath
|
||||
outside = os.path.join(self.temp.name, "disk", "6b4679369a8f39728585bf250dd86e8")
|
||||
self.resolve = mock.patch("os.path.realpath", side_effect=lambda p: outside if os.path.basename(p) == "style.safetensors" else real(p))
|
||||
|
||||
def tearDown(self):
|
||||
self.temp.cleanup()
|
||||
|
||||
def test_its_info_and_name_are_the_ones_beside_the_link(self):
|
||||
with self.resolve:
|
||||
found = metadata.get_metadata(str(self.model))
|
||||
self.assertEqual((found["info_source"], found["name"]), ("civitai", "v1"))
|
||||
Path(str(self.model)[:-12] + ".info").unlink()
|
||||
metadata.clear_metadata_cache()
|
||||
with self.resolve:
|
||||
self.assertEqual(metadata.get_metadata(str(self.model))["name"], "style") # not the disk's file name
|
||||
|
||||
def test_info_left_beside_the_linked_file_is_read_when_none_is_beside_the_link(self):
|
||||
# Another tool (A1111's Civitai Helper) kept its info beside the real file.
|
||||
disk = Path(self.temp.name, "a1111")
|
||||
disk.mkdir()
|
||||
(disk / "style.civitai.info").write_text(json.dumps({"id": 9, "modelId": 4, "name": "from a1111"}), encoding="utf-8")
|
||||
Path(str(self.model)[:-12] + ".info").unlink()
|
||||
real = os.path.realpath
|
||||
to_a1111 = mock.patch("os.path.realpath", side_effect=lambda p: str(disk / "style.safetensors") if os.path.basename(p) == "style.safetensors" else real(p))
|
||||
with to_a1111, mock.patch("os.path.islink", side_effect=lambda p: os.path.basename(p) == "style.safetensors"):
|
||||
self.assertEqual(metadata.get_metadata(str(self.model))["name"], "from a1111")
|
||||
metadata.clear_metadata_cache()
|
||||
Path(str(self.model)[:-12] + ".info").write_text(json.dumps({"id": 7, "modelId": 3, "name": "v1"}), encoding="utf-8")
|
||||
self.assertEqual(metadata.get_metadata(str(self.model))["name"], "v1") # beside the link wins
|
||||
|
||||
def test_a_model_named_in_its_folder_stays_the_link_but_cannot_leave_the_folder(self):
|
||||
folder = str(self.root / "sub")
|
||||
with self.resolve:
|
||||
self.assertEqual(path_utils.model_file_within(folder, "style.safetensors"), str(self.model))
|
||||
self.assertEqual(path_utils.model_reference_within(str(self.root), os.path.join("sub", "style.safetensors")), str(self.model))
|
||||
for name in ("..", ".", "", "../x.safetensors", "sub/style.safetensors"):
|
||||
with self.assertRaises(ValueError, msg=name):
|
||||
path_utils.model_file_within(folder, name)
|
||||
with self.assertRaises(ValueError):
|
||||
path_utils.model_reference_within(str(self.root), os.path.join("..", "elsewhere", "x.safetensors"))
|
||||
|
||||
def test_moving_a_linked_model_renames_the_link_instead_of_copying_the_disk(self):
|
||||
# The link is on the server's disk; the file behind it is on the platform's model disk.
|
||||
real_stat = os.stat
|
||||
def stat(path, *args, **kwargs):
|
||||
found = real_stat(path, *args, **kwargs)
|
||||
if os.path.basename(path) != "style.safetensors":
|
||||
return found
|
||||
return os.stat_result((found.st_mode, found.st_ino, found.st_dev + 1, *tuple(found)[3:]))
|
||||
dest = str(self.root / "style_moved.safetensors")
|
||||
with mock.patch("os.stat", side_effect=stat):
|
||||
self.assertTrue(model_import._same_drive(str(self.model), dest))
|
||||
model_import.move_into(str(self.model), dest, keep=False)
|
||||
self.assertTrue(os.path.isfile(dest))
|
||||
self.assertFalse(self.model.exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,156 @@
|
||||
"""The models page's Tidy check (api/model_placement.py): models in the wrong folder, identical
|
||||
copies, and moving a model with its covers and info. Files go to temporary folders."""
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
|
||||
PLUGIN = Path(__file__).resolve().parents[1]
|
||||
sys.path[:0] = [str(PLUGIN), str(PLUGIN.parents[1])]
|
||||
|
||||
from api import model_placement # noqa: E402
|
||||
|
||||
|
||||
def safetensors(*names):
|
||||
raw = json.dumps({name: {"dtype": "F16", "shape": [4, 4], "data_offsets": [0, 0]} for name in names}).encode()
|
||||
return struct.pack("<Q", len(raw)) + raw
|
||||
|
||||
|
||||
ANIMA = safetensors("net.blocks.0.self_attn.q_proj.weight", "net.llm_adapter.blocks.0.mlp.0.weight")
|
||||
FLUX_WHOLE = safetensors("model.diffusion_model.double_blocks.0.img_attn.qkv.weight", "first_stage_model.encoder.down.0.block.0.conv1.weight")
|
||||
LORA = safetensors("lora_unet_double_blocks_0_img_attn_proj.lora_up.weight")
|
||||
|
||||
|
||||
class PlacementTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
base = os.path.realpath(self.temp.name)
|
||||
self.roots = {kind: [os.path.join(base, kind)] for kind in ("checkpoints", "loras", "vae")}
|
||||
self.roots["diffusion_models"] = [os.path.join(base, "unet"), os.path.join(base, "diffusion_models")]
|
||||
for paths in self.roots.values():
|
||||
for path in paths:
|
||||
os.makedirs(path, exist_ok=True)
|
||||
self.hashes = {}
|
||||
self.patches = [
|
||||
mock.patch.object(model_placement.folder_paths, "folder_names_and_paths", {k: (v, set()) for k, v in self.roots.items()}),
|
||||
mock.patch.object(model_placement.folder_paths, "get_folder_paths", side_effect=lambda t: self.roots.get(t, [])),
|
||||
mock.patch("api.model_import.folder_paths.get_folder_paths", side_effect=lambda t: self.roots.get(t, [])),
|
||||
mock.patch.object(model_placement, "add_entry"),
|
||||
mock.patch.object(model_placement, "forget_file_lists"),
|
||||
mock.patch.object(model_placement, "get_metadata", side_effect=lambda path: {"hash": self.hashes.get(path, "")}),
|
||||
]
|
||||
for patch in self.patches:
|
||||
patch.start()
|
||||
model_placement._heads.clear()
|
||||
model_placement._hashes.clear()
|
||||
|
||||
def tearDown(self):
|
||||
for patch in reversed(self.patches):
|
||||
patch.stop()
|
||||
self.temp.cleanup()
|
||||
|
||||
def put(self, kind, rel, data, index=0):
|
||||
path = os.path.join(self.roots[kind][index], *rel.split("/"))
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
Path(path).write_bytes(data)
|
||||
return path
|
||||
|
||||
def test_misplaced_models_and_where_they_go(self):
|
||||
self.put("checkpoints", "Anima/realskin.safetensors", ANIMA)
|
||||
self.put("diffusion_models", "Flux/flux-fp8.safetensors", FLUX_WHOLE)
|
||||
self.put("loras", "ok.safetensors", LORA)
|
||||
self.put("checkpoints", "unknown.safetensors", safetensors("something.weight"))
|
||||
data = model_placement.check()
|
||||
self.assertEqual(data["checked"], 4)
|
||||
found = {item["rel"]: item for item in data["misplaced"]}
|
||||
self.assertEqual(set(found), {"Anima/realskin.safetensors", "Flux/flux-fp8.safetensors"})
|
||||
anima = found["Anima/realskin.safetensors"]
|
||||
self.assertEqual((anima["kind"], anima["base"], anima["works"]), ("diffusion_models", "Anima", False))
|
||||
self.assertEqual(anima["to"], {"type": "diffusion_models", "root": 0, "rel": "Anima/realskin.safetensors"})
|
||||
self.assertEqual((anima["name"], anima["version"], anima["preview_url"]), ("realskin", "", ""))
|
||||
# A whole checkpoint in the diffusion models folder still loads there.
|
||||
self.assertTrue(found["Flux/flux-fp8.safetensors"]["works"])
|
||||
|
||||
def test_identical_copies(self):
|
||||
first = self.put("loras", "a.safetensors", LORA)
|
||||
self.put("vae", "copy.safetensors", LORA)
|
||||
self.put("loras", "other.safetensors", LORA + b"x")
|
||||
# Large files never scanned are only counted until a deep check reads them.
|
||||
with mock.patch.object(model_placement, "AUTO_HASH", 10):
|
||||
data = model_placement.check()
|
||||
self.assertEqual(data["duplicates"], [])
|
||||
self.assertEqual(data["unchecked"], {"groups": 1, "bytes": 2 * len(LORA)})
|
||||
model_placement._hashes.clear()
|
||||
self.assertEqual(len(model_placement.check()["duplicates"]), 1) # small ones are read at once
|
||||
deep = model_placement.check(deep=True)["duplicates"]
|
||||
self.assertEqual(len(deep), 1)
|
||||
self.assertEqual({item["rel"] for item in deep[0]["files"]}, {"a.safetensors", "copy.safetensors"})
|
||||
self.assertEqual(deep[0]["sha256"], hashlib.sha256(LORA).hexdigest())
|
||||
# The copy in the folder that fits what it is is the one to keep.
|
||||
self.assertEqual({item["rel"]: item["fits"] for item in deep[0]["files"]}, {"a.safetensors": True, "copy.safetensors": False})
|
||||
# Hashes from scans count without reading.
|
||||
model_placement._hashes.clear()
|
||||
self.hashes[first] = hashlib.sha256(LORA).hexdigest()
|
||||
with mock.patch.object(model_placement, "AUTO_HASH", 10):
|
||||
self.assertEqual(model_placement.check()["unchecked"]["groups"], 1) # the copy is still unknown
|
||||
|
||||
def test_move_takes_covers_and_info_along(self):
|
||||
source = self.put("checkpoints", "Anima/realskin.safetensors", ANIMA)
|
||||
for suffix in (".info", ".preview.png", ".civitai_bak.png", ".anomalous.json"):
|
||||
Path(source[:-len(".safetensors")] + suffix).write_text("x")
|
||||
self.put("diffusion_models", "Anima/realskin.safetensors", b"another model")
|
||||
moved = model_placement.move({"type": "checkpoints", "root": 0, "rel": "Anima/realskin.safetensors",
|
||||
"to_type": "diffusion_models", "to_root": 0, "to_rel": "Anima/realskin.safetensors"})
|
||||
self.assertEqual(moved["rel"], "Anima/realskin (2).safetensors")
|
||||
self.assertEqual(moved["left"], [])
|
||||
target = Path(self.roots["diffusion_models"][0], "Anima")
|
||||
self.assertEqual(sorted(os.listdir(target)), sorted([
|
||||
"realskin.safetensors", "realskin (2).safetensors", "realskin (2).info", "realskin (2).preview.png",
|
||||
"realskin (2).civitai_bak.png", "realskin (2).anomalous.json"]))
|
||||
self.assertEqual(os.listdir(os.path.dirname(source)), [])
|
||||
self.assertEqual(Path(target, "realskin.safetensors").read_bytes(), b"another model")
|
||||
|
||||
def test_shared_covers_stay(self):
|
||||
source = self.put("checkpoints", "m.safetensors", ANIMA)
|
||||
self.put("checkpoints", "m.ckpt", b"older format, same name")
|
||||
Path(source[:-len(".safetensors")] + ".info").write_text("x")
|
||||
model_placement.move({"type": "checkpoints", "root": 0, "rel": "m.safetensors",
|
||||
"to_type": "diffusion_models", "to_root": 1, "to_rel": "m.safetensors"})
|
||||
self.assertEqual(sorted(os.listdir(self.roots["checkpoints"][0])), ["m.ckpt", "m.info"])
|
||||
|
||||
def test_refused_moves(self):
|
||||
self.put("checkpoints", "m.safetensors", ANIMA)
|
||||
for data, code in (({"type": "checkpoints", "rel": "gone.safetensors", "to_type": "loras", "to_rel": "x.safetensors"}, "gone"),
|
||||
({"type": "checkpoints", "rel": "../m.safetensors", "to_type": "loras", "to_rel": "x.safetensors"}, "bad_path"),
|
||||
({"type": "checkpoints", "rel": "m.safetensors", "to_type": "loras", "to_rel": "../x.safetensors"}, "bad_path"),
|
||||
({"type": "nope", "rel": "m.safetensors", "to_type": "loras", "to_rel": "x.safetensors"}, "bad_type")):
|
||||
with self.assertRaises(model_placement.Refused) as refused:
|
||||
model_placement.move(data)
|
||||
self.assertEqual(refused.exception.code, code)
|
||||
self.assertTrue(Path(self.roots["checkpoints"][0], "m.safetensors").exists())
|
||||
|
||||
def test_move_only_from_this_computer(self):
|
||||
self.put("checkpoints", "m.safetensors", ANIMA)
|
||||
move = {"type": "checkpoints", "root": 0, "rel": "m.safetensors", "to_type": "loras", "to_root": 0, "to_rel": "m.safetensors"}
|
||||
|
||||
async def run():
|
||||
app = web.Application()
|
||||
model_placement.register_routes(app)
|
||||
async with TestClient(TestServer(app)) as client: # through a proxy: not this computer
|
||||
resp = await client.post("/anomalous/placement/move", json=move, headers={"X-Forwarded-For": "192.168.1.20"})
|
||||
return resp.status, await resp.json()
|
||||
self.assertEqual(asyncio.run(run()), (403, {"error": "not_local"}))
|
||||
self.assertTrue(Path(self.roots["checkpoints"][0], "m.safetensors").exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,252 @@
|
||||
"""The Tidy tab's "Not used lately" (api/model_usage.py, api/usage_ledger.py): when each model and
|
||||
node pack was last used, from the run record, output images and sounds and saved workflows in
|
||||
temporary folders."""
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
PLUGIN = Path(__file__).resolve().parents[1]
|
||||
sys.path[:0] = [str(PLUGIN), str(PLUGIN.parents[1])]
|
||||
|
||||
from api import image_search, model_usage, usage_ledger # noqa: E402
|
||||
|
||||
DAY = 24 * 3600
|
||||
NOW = 1_800_000_000.0
|
||||
|
||||
|
||||
def output(path, when, nodes):
|
||||
"""A PNG whose prompt holds `nodes` ({id: (class_type, inputs)}), dated `when`."""
|
||||
info = PngInfo()
|
||||
info.add_text("prompt", json.dumps({key: {"class_type": kind, "inputs": inputs} for key, (kind, inputs) in nodes.items()}))
|
||||
Image.new("RGB", (2, 2)).save(path, pnginfo=info)
|
||||
os.utime(path, (when, when))
|
||||
|
||||
|
||||
def node(module, takes=(), makes=()):
|
||||
"""A node class of the pack `module`, taking and making those kinds of thing."""
|
||||
return type("Node", (), {
|
||||
"RELATIVE_PYTHON_MODULE": module, "RETURN_TYPES": tuple(makes),
|
||||
"INPUT_TYPES": classmethod(lambda cls: {"required": {f"in{index}": (kind, {}) for index, kind in enumerate(takes)}}),
|
||||
})
|
||||
|
||||
|
||||
def stat(born, changed):
|
||||
return types.SimpleNamespace(st_birthtime=born, st_ctime=born, st_mtime=changed)
|
||||
|
||||
|
||||
class UsageTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
base = Path(self.temp.name)
|
||||
self.out, self.flows, self.recipes = base / "output", base / "workflows", base / "recipes"
|
||||
custom = base / "custom_nodes"
|
||||
for folder in (self.out / "sub", self.flows, self.recipes):
|
||||
folder.mkdir(parents=True)
|
||||
packs = ["rgthree-comfy", "ComfyUI-Easy-Use", "forgotten-pack", "ComfyUI-Impact-Pack", "comfyui-impact-subpack",
|
||||
"Genie-TTS", "page-only", "ComfyUI-Manager", os.path.basename(model_usage.PLUGIN_DIR)]
|
||||
for name in packs:
|
||||
(custom / name).mkdir(parents=True)
|
||||
(custom / "rgthree-comfy" / "js").mkdir()
|
||||
(custom / "page-only" / "web").mkdir()
|
||||
self.ledger = {"started": None, "types": {}, "models": {}, "opened": {"types": {}, "models": {}}}
|
||||
self.models = [
|
||||
{"type": "checkpoints", "root": 0, "rel": "sdxl/fresh.safetensors", "size": 6},
|
||||
{"type": "loras", "root": 0, "rel": "old.safetensors", "size": 3},
|
||||
{"type": "loras", "root": 0, "rel": "style/kept.safetensors", "size": 2},
|
||||
{"type": "loras", "root": 0, "rel": "never.safetensors", "size": 9},
|
||||
{"type": "vae", "root": 0, "rel": "hashed.safetensors", "size": 1},
|
||||
]
|
||||
nodes = types.SimpleNamespace(
|
||||
NODE_CLASS_MAPPINGS={
|
||||
"KSampler": node("nodes", ["MODEL"], ["LATENT"]),
|
||||
"Power Lora Loader (rgthree)": node("custom_nodes.rgthree-comfy", ["MODEL"], ["MODEL"]),
|
||||
"easy loraStack": node("custom_nodes.ComfyUI-Easy-Use"),
|
||||
"easy seed": node("custom_nodes.ComfyUI-Easy-Use.py.nodes"),
|
||||
"Unused Thing": node("custom_nodes.forgotten-pack"),
|
||||
"DetailerForEach": node("custom_nodes.ComfyUI-Impact-Pack", ["IMAGE", "BBOX_DETECTOR"], ["IMAGE"]),
|
||||
"UltralyticsDetectorProvider": node("custom_nodes.comfyui-impact-subpack", [], ["BBOX_DETECTOR"]),
|
||||
"Genie Speak": node("custom_nodes.Genie-TTS", ["STRING"], ["AUDIO"]),
|
||||
"Manager Thing": node("custom_nodes.ComfyUI-Manager"),
|
||||
},
|
||||
LOADED_MODULE_DIRS={name: str(custom / name) for name in packs},
|
||||
EXTENSION_WEB_DIRS={"rgthree-comfy": str(custom / "rgthree-comfy" / "js"), "page-only": str(custom / "page-only" / "web")},
|
||||
)
|
||||
patches = [
|
||||
mock.patch.object(model_usage, "library", return_value=self.models),
|
||||
mock.patch.object(model_usage, "_card", side_effect=lambda item: {"name": Path(item["rel"]).stem, "version": "", "preview_url": ""}),
|
||||
mock.patch.object(model_usage, "_known_hash", side_effect=lambda item: "AB" * 32 if item["rel"] == "hashed.safetensors" else ""),
|
||||
mock.patch.object(model_usage, "part_dirs", return_value={"comfy_workflows": str(self.flows), "recipes": str(self.recipes), "materials": str(base / "nope")}),
|
||||
mock.patch.object(model_usage.folder_paths, "get_output_directory", return_value=str(self.out)),
|
||||
mock.patch.object(model_usage.folder_paths, "get_folder_paths", side_effect=lambda kind: [str(custom)] if kind == "custom_nodes" else []),
|
||||
mock.patch.object(model_usage.usage_ledger, "read", side_effect=lambda: self.ledger),
|
||||
mock.patch.object(model_usage, "kept_path", return_value=str(base / "usage_kept.json")),
|
||||
mock.patch.dict(sys.modules, {"nodes": nodes}),
|
||||
]
|
||||
for patch in patches:
|
||||
patch.start()
|
||||
self.addCleanup(patch.stop)
|
||||
image_search._record_cache.clear()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
|
||||
def test_models_and_packs_by_when_they_were_last_used(self):
|
||||
output(self.out / "a.png", NOW - 2 * DAY, {"1": ("CheckpointLoaderSimple", {"ckpt_name": "sdxl\\fresh.safetensors"}),
|
||||
"2": ("KSampler", {"seed": 1})})
|
||||
output(self.out / "sub" / "b.png", NOW - 100 * DAY, {"1": ("LoraLoader", {"lora_name": "old.safetensors"}),
|
||||
"2": ("Power Lora Loader (rgthree)", {"lora_1": {"on": True, "lora": "old.safetensors"}})})
|
||||
output(self.out / "c.png", NOW - 40 * DAY, {"1": ("easy seed", {"seed": 2})})
|
||||
(self.flows / "portrait.json").write_text(json.dumps({"nodes": [
|
||||
{"type": "LoraLoader", "widgets_values": ["style/kept.safetensors", 1, 1]},
|
||||
{"type": "easy loraStack", "widgets_values": [True, "simple", 1]}]}), encoding="utf-8")
|
||||
(self.recipes / "r.json").write_text(json.dumps({"models": [{"sha256": "ab" * 32}]}), encoding="utf-8")
|
||||
|
||||
result = model_usage.check(now=NOW)
|
||||
models = {Path(item["rel"]).stem: item for item in result["models"]}
|
||||
self.assertNotIn("fresh", models) # used two days ago
|
||||
self.assertEqual((models["old"]["last_used"], models["old"]["outputs"], models["old"]["saved"]), (NOW - 100 * DAY, 1, 0)) # one image naming it twice
|
||||
self.assertEqual((models["kept"]["last_used"], models["kept"]["saved"]), (None, 1)) # only in a saved workflow
|
||||
self.assertEqual((models["never"]["last_used"], models["never"]["outputs"], models["never"]["saved"]), (None, 0, 0))
|
||||
self.assertEqual(models["hashed"]["saved"], 1) # its SHA-256 is in a recipe
|
||||
self.assertEqual(result["seen"], {"outputs": 3, "saved": 2, "capped": False})
|
||||
self.assertIsNone(result["ledger_since"])
|
||||
|
||||
packs = {item["name"]: item for item in result["packs"]}
|
||||
self.assertEqual(sorted(packs, key=str.lower), ["ComfyUI-Easy-Use", "ComfyUI-Impact-Pack", "comfyui-impact-subpack", "forgotten-pack",
|
||||
"Genie-TTS", "page-only", "rgthree-comfy"]) # ComfyUI's own nodes, the Manager and this plugin are left out
|
||||
self.assertEqual((packs["rgthree-comfy"]["last_used"], packs["rgthree-comfy"]["outputs"], packs["rgthree-comfy"]["web"]), (NOW - 100 * DAY, 1, True))
|
||||
self.assertEqual((packs["ComfyUI-Easy-Use"]["nodes"], packs["ComfyUI-Easy-Use"]["last_used"], packs["ComfyUI-Easy-Use"]["saved"]),
|
||||
(2, NOW - 40 * DAY, 1))
|
||||
self.assertEqual((packs["forgotten-pack"]["last_used"], packs["forgotten-pack"]["saved"], packs["forgotten-pack"]["web"]), (None, 0, False))
|
||||
self.assertEqual((packs["page-only"]["nodes"], packs["page-only"]["web"]), (0, True)) # works on the page only
|
||||
self.assertEqual(packs["comfyui-impact-subpack"]["feeds"], ["ComfyUI-Impact-Pack"]) # makes what only Impact's nodes take
|
||||
self.assertEqual(packs["ComfyUI-Impact-Pack"]["feeds"], []) # IMAGE is ComfyUI's own
|
||||
self.assertEqual(packs["comfyui-impact-subpack"]["words"], "impact subpack")
|
||||
self.assertEqual(packs["ComfyUI-Easy-Use"]["words"], "easy use")
|
||||
|
||||
def test_the_run_record_and_sounds_count_as_use(self):
|
||||
self.ledger = {"started": NOW - 10 * DAY, "types": {"Genie Speak": [NOW - 3 * DAY, 4]},
|
||||
"models": {"never.safetensors": [NOW - 31 * DAY, 2]},
|
||||
"opened": {"types": {}, "models": {"style/kept.safetensors": [NOW - 45 * DAY, 3]}}}
|
||||
sound = self.out / "speech.flac"
|
||||
sound.write_bytes(b"")
|
||||
os.utime(sound, (NOW - 50 * DAY, NOW - 50 * DAY))
|
||||
prompt = {"1": {"class_type": "Unused Thing", "inputs": {"model": "old.safetensors"}}}
|
||||
with mock.patch.object(model_usage, "read_audio_tags", return_value={"prompt": json.dumps(prompt)}):
|
||||
result = model_usage.check(now=NOW)
|
||||
models = {Path(item["rel"]).stem: item for item in result["models"]}
|
||||
packs = {item["name"]: item for item in result["packs"]}
|
||||
self.assertEqual(result["ledger_since"], NOW - 10 * DAY)
|
||||
self.assertEqual((packs["Genie-TTS"]["last_used"], packs["Genie-TTS"]["runs"]), (NOW - 3 * DAY, 4)) # a sound, never an image
|
||||
self.assertEqual((models["never"]["last_used"], models["never"]["runs"]), (NOW - 31 * DAY, 2))
|
||||
self.assertEqual((models["kept"]["last_used"], models["kept"]["opened"], models["kept"]["runs"]), (NOW - 45 * DAY, 3, 0)) # opened, never run
|
||||
self.assertEqual((packs["forgotten-pack"]["last_used"], models["old"]["last_used"]), (NOW - 50 * DAY, NOW - 50 * DAY))
|
||||
self.assertEqual(result["seen"]["outputs"], 1)
|
||||
|
||||
def test_what_came_lately_is_not_judged(self):
|
||||
self.models[1]["stat"] = stat(NOW - 5 * DAY, NOW - 400 * DAY) # copied here five days ago: keeps its old date
|
||||
self.models[3]["stat"] = stat(NOW - 400 * DAY, NOW - 400 * DAY)
|
||||
result = model_usage.check(now=NOW)
|
||||
models = {Path(item["rel"]).stem: item for item in result["models"]}
|
||||
self.assertNotIn("old", models)
|
||||
self.assertEqual(models["never"]["added"], NOW - 400 * DAY)
|
||||
|
||||
def test_a_pack_can_be_marked_as_still_used(self):
|
||||
self.assertEqual(model_usage.keep("ComfyUI-Easy-Use", True), ["ComfyUI-Easy-Use"])
|
||||
for name in ("not-installed", "ComfyUI-Manager"): # only a listed pack ComfyUI loaded
|
||||
with self.assertRaises(KeyError):
|
||||
model_usage.keep(name, True)
|
||||
packs = {item["name"]: item for item in model_usage.check(now=NOW)["packs"]}
|
||||
self.assertEqual((packs["ComfyUI-Easy-Use"]["kept"], packs["forgotten-pack"]["kept"]), (True, False))
|
||||
self.assertEqual(model_usage.keep("ComfyUI-Easy-Use", False), [])
|
||||
self.assertEqual(model_usage.keep("gone-since", False), []) # unmarking any name is fine
|
||||
|
||||
class Request:
|
||||
def __init__(self, body):
|
||||
self.body = body
|
||||
|
||||
async def json(self):
|
||||
return self.body
|
||||
|
||||
for body in ({"pack": "ComfyUI-Easy-Use"}, {"pack": "", "kept": True}, ["x"]):
|
||||
self.assertEqual(asyncio.run(model_usage.api_keep(Request(body))).status, 400)
|
||||
self.assertEqual(asyncio.run(model_usage.api_keep(Request({"pack": "nope", "kept": True}))).status, 404)
|
||||
|
||||
def test_reading_stops_at_the_limits_and_says_so(self):
|
||||
for index in range(3):
|
||||
output(self.out / f"{index}.png", NOW - DAY, {"1": ("KSampler", {})})
|
||||
with mock.patch.object(model_usage, "MAX_IMAGES", 2):
|
||||
result = model_usage.check(now=NOW)
|
||||
self.assertEqual((result["seen"]["outputs"], result["seen"]["capped"]), (2, True))
|
||||
|
||||
def test_a_broken_or_huge_saved_file_is_skipped(self):
|
||||
(self.flows / "broken.json").write_text("{not json", encoding="utf-8")
|
||||
(self.flows / "ok.json").write_text(json.dumps({"x": "never.safetensors"}), encoding="utf-8")
|
||||
with mock.patch.object(model_usage, "MAX_JSON_BYTES", 40):
|
||||
(self.flows / "big.json").write_text(json.dumps({"x": "old.safetensors", "pad": "x" * 100}), encoding="utf-8")
|
||||
result = model_usage.check(now=NOW)
|
||||
models = {Path(item["rel"]).stem: item for item in result["models"]}
|
||||
self.assertEqual((models["never"]["saved"], models["old"]["saved"]), (1, 0))
|
||||
|
||||
|
||||
class LedgerTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.path = os.path.join(self.temp.name, "usage_ledger.json")
|
||||
patch = mock.patch.object(usage_ledger, "ledger_path", return_value=self.path)
|
||||
patch.start()
|
||||
self.addCleanup(patch.stop)
|
||||
usage_ledger._ledger = None
|
||||
self.addCleanup(setattr, usage_ledger, "_ledger", None)
|
||||
|
||||
def test_each_run_is_noted_and_saved(self):
|
||||
prompt = {"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "SDXL\\Base.safetensors"}},
|
||||
"2": {"class_type": "Power Lora Loader (rgthree)", "inputs": {"lora_1": {"on": True, "lora": "a.safetensors"}}},
|
||||
"3": {"class_type": "KSampler", "inputs": {"seed": 5, "model": ["1", 0]}}}
|
||||
usage_ledger.note(prompt, when=100.0)
|
||||
usage_ledger.note(prompt, when=200.0)
|
||||
usage_ledger._timer.cancel()
|
||||
usage_ledger._save()
|
||||
with open(self.path, encoding="utf-8") as f:
|
||||
saved = json.load(f)
|
||||
self.assertEqual(saved["started"], 100.0)
|
||||
self.assertEqual(saved["types"]["KSampler"], [200.0, 2])
|
||||
self.assertEqual(sorted(saved["models"]), ["a.safetensors", "sdxl/base.safetensors"])
|
||||
usage_ledger._ledger = None # read back from the file
|
||||
self.assertEqual(usage_ledger.read()["models"]["a.safetensors"], [200.0, 2])
|
||||
|
||||
def test_an_opened_workflow_is_noted_apart_from_runs(self):
|
||||
class Request:
|
||||
def __init__(self, body):
|
||||
self.body = body
|
||||
|
||||
async def json(self):
|
||||
return self.body
|
||||
|
||||
noted = asyncio.run(usage_ledger.api_opened(Request({"types": ["KSampler", 5, ""], "models": ["Loras\\A.safetensors", "notes.txt"]})))
|
||||
usage_ledger._timer.cancel()
|
||||
self.assertEqual(json.loads(noted.body)["noted"], 2)
|
||||
ledger = usage_ledger.read()
|
||||
self.assertEqual((sorted(ledger["opened"]["types"]), sorted(ledger["opened"]["models"])), (["KSampler"], ["loras/a.safetensors"]))
|
||||
self.assertEqual((ledger["types"], ledger["models"]), ({}, {})) # not counted as a run
|
||||
for body in ({"types": "KSampler", "models": []}, ["KSampler"], {"types": []}):
|
||||
self.assertEqual(asyncio.run(usage_ledger.api_opened(Request(body))).status, 400)
|
||||
|
||||
def test_a_note_never_stops_a_run(self):
|
||||
request = {"prompt": "not a prompt", "client_id": "x"}
|
||||
self.assertIs(usage_ledger.note_prompt(request), request)
|
||||
with mock.patch.object(usage_ledger, "note", side_effect=RuntimeError("boom")):
|
||||
self.assertIs(usage_ledger.note_prompt(request), request)
|
||||
self.assertIsNone(usage_ledger.read()["started"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,61 @@
|
||||
"""The first-run setup's question: does this computer reach Civitai, and is a set key refused?"""
|
||||
import io
|
||||
import sys
|
||||
import unittest
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
PLUGIN = Path(__file__).resolve().parents[1]
|
||||
sys.path[:0] = [str(PLUGIN), str(PLUGIN.parents[1])]
|
||||
|
||||
from api import local_guard, network_check
|
||||
|
||||
|
||||
class _Answer(io.BytesIO):
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
|
||||
def _http_error(code):
|
||||
return urllib.error.HTTPError("https://civitai.com" + network_check.PROBE_PATH, code, "x", {}, io.BytesIO(b""))
|
||||
|
||||
|
||||
class CivitaiCheckTests(unittest.TestCase):
|
||||
def check(self, *answers, key=None):
|
||||
"""The check, with civitai.com answering answers[0] and civitai.red answers[1] (if asked)."""
|
||||
headers = {"User-Agent": "t", **({"Authorization": f"Bearer {key}"} if key else {})}
|
||||
with mock.patch.object(network_check, "civitai_headers", return_value=headers), \
|
||||
mock.patch("civitai_client._answered", ["civitai.com"]), \
|
||||
mock.patch.object(network_check.urllib.request, "urlopen", side_effect=list(answers)) as opened:
|
||||
result = network_check.check_civitai()
|
||||
self.assertEqual(opened.call_args.kwargs["timeout"], network_check.TIMEOUT)
|
||||
return result
|
||||
|
||||
def test_reachable_with_and_without_a_key(self):
|
||||
self.assertEqual((lambda r: (r["reachable"], r["key"]))(self.check(_Answer(b"{}"))), (True, "none"))
|
||||
self.assertEqual((lambda r: (r["reachable"], r["key"]))(self.check(_Answer(b"{}"), key="k")), (True, "set"))
|
||||
|
||||
def test_a_refused_key_is_told_apart_from_no_network(self):
|
||||
refused = self.check(_http_error(401), key="bad")
|
||||
self.assertEqual((refused["reachable"], refused["key"]), (True, "refused"))
|
||||
down = self.check(urllib.error.URLError("timed out"), urllib.error.URLError("refused"))
|
||||
self.assertEqual((down["reachable"], down["key"], down["host"]), (False, "none", ""))
|
||||
self.assertIn("timed out", down["error"]) # why civitai.com, the usual address, failed
|
||||
self.assertFalse(self.check(_http_error(503))["reachable"])
|
||||
|
||||
def test_civitai_red_answering_is_reachable_and_named(self):
|
||||
# Some networks (a cloud server in mainland China) reach only Civitai's other address.
|
||||
result = self.check(urllib.error.URLError("timed out"), _Answer(b"{}"))
|
||||
self.assertEqual((result["reachable"], result["host"]), (True, "civitai.red"))
|
||||
self.assertEqual(self.check(_Answer(b"{}"))["host"], "civitai.com")
|
||||
|
||||
def test_it_answers_this_computer_only(self):
|
||||
self.assertIn("/anomalous/civitai_check", local_guard.LOCAL_GETS)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -35,6 +35,27 @@ class RecipeModelReferenceTests(unittest.TestCase):
|
||||
])
|
||||
self.assertEqual([(r["node_id"], r["category"], r["widget_name"]) for r in refs], [(3, "checkpoint", "checkpoint")])
|
||||
|
||||
def test_stack_nodes_by_their_rows(self):
|
||||
# rgthree's Power Lora Loader: rows are dicts, numbered as rgthree numbers them; off rows load nothing.
|
||||
# Easy-Use's LoRA stack: lora_N_name at 3 + 4*(N-1), up to num_loras; "None" is no model.
|
||||
refs = self._references([
|
||||
{"id": 5, "type": "Power Lora Loader (rgthree)", "widgets_values": [
|
||||
{}, {"type": "PowerLoraLoaderHeaderWidget"},
|
||||
{"on": True, "lora": "a.safetensors", "strength": 1},
|
||||
{"on": False, "lora": "off.safetensors", "strength": 1},
|
||||
{"on": True, "lora": "b.safetensors", "strength": 0.5}, {}, ""]},
|
||||
{"id": 6, "type": "easy loraStack", "widgets_values": [
|
||||
True, "simple", 2, "c.safetensors", 1, 1, 1, "None", 1, 1, 1, "unused.safetensors"]},
|
||||
])
|
||||
self.assertEqual(
|
||||
[(r["node_id"], r["widget_index"], r["category"], r["widget_name"], r["saved_value"]) for r in refs],
|
||||
[
|
||||
(5, 2, "lora", "lora_1", "a.safetensors"),
|
||||
(5, 4, "lora", "lora_3", "b.safetensors"),
|
||||
(6, 3, "lora", "lora_1_name", "c.safetensors"),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
@@ -19,6 +19,7 @@ from api import models
|
||||
from api import parameters
|
||||
from api import recipe_packages
|
||||
from api import materials
|
||||
from api import recipe_images
|
||||
|
||||
|
||||
def recipe_payload(workflow):
|
||||
@@ -109,6 +110,35 @@ class RecipeWorkflowValidationTests(unittest.TestCase):
|
||||
payload.pop("workflow_scope")
|
||||
self.assertEqual(recipes._normalise_recipe(payload)["workflow_scope"], "complete")
|
||||
|
||||
def test_focus_values_are_checked_and_kept(self):
|
||||
payload = recipe_payload(valid_workflow())
|
||||
mark = {"node": "2", "type": "KSampler", "widget": "cfg", "note": " 6-8 "}
|
||||
payload["params"]["focusParams"] = [mark, dict(mark)]
|
||||
self.assertEqual(
|
||||
recipes._normalise_recipe(payload)["params"]["focusParams"],
|
||||
[{"node": "2", "type": "KSampler", "widget": "cfg", "note": "6-8"}],
|
||||
)
|
||||
for bad in ("cfg", [{"node": 2, "widget": "cfg"}], [{"node": "2", "widget": ""}],
|
||||
[{"node": "2", "widget": "cfg", "note": "x" * 121}], [dict(mark, widget=f"w{i}") for i in range(13)]):
|
||||
payload["params"]["focusParams"] = bad
|
||||
with self.assertRaises(ValueError):
|
||||
recipes._normalise_recipe(payload)
|
||||
|
||||
def test_focus_values_save_alone_without_a_version(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
recipe = recipes._normalise_recipe(recipe_payload(valid_workflow()))
|
||||
Path(temp_dir, "r.json").write_text(json.dumps(recipe), encoding="utf-8")
|
||||
mark = {"node": "2", "type": "KSampler", "widget": "cfg", "note": "7-9"}
|
||||
self.assertEqual(recipes._set_recipe_focus(temp_dir, "r.json", [mark]), [mark])
|
||||
saved = json.loads(Path(temp_dir, "r.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(saved["params"]["focusParams"], [mark])
|
||||
self.assertEqual(saved["workflow"], recipe["workflow"])
|
||||
self.assertFalse(Path(temp_dir, ".history").exists())
|
||||
with self.assertRaises(ValueError):
|
||||
recipes._set_recipe_focus(temp_dir, "r.json", [{"node": "2"}])
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
recipes._set_recipe_focus(temp_dir, "missing.json", [])
|
||||
|
||||
def test_invalid_workflow_scope_is_rejected(self):
|
||||
payload = recipe_payload(valid_workflow())
|
||||
payload["workflow_scope"] = "selection"
|
||||
@@ -179,6 +209,26 @@ class RecipeWorkflowValidationTests(unittest.TestCase):
|
||||
self.assertEqual(scanned, 1)
|
||||
self.assertEqual([item["filename"] for item in matches], ["match.png"])
|
||||
|
||||
def test_pictures_of_several_sets_are_counted_in_one_pass(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
first = valid_workflow()
|
||||
second = json.loads(json.dumps(first))
|
||||
second["nodes"][1]["widgets_values"][2] = 42
|
||||
for name, workflow in (("a1.png", first), ("a2.png", first), ("b.png", second)):
|
||||
info = PngImagePlugin.PngInfo()
|
||||
info.add_text("workflow", json.dumps(workflow))
|
||||
Image.new("RGB", (8, 8), "white").save(Path(directory, name), pnginfo=info)
|
||||
os.utime(Path(directory, "a2.png"), (2_000_000_000, 2_000_000_000)) # the newest
|
||||
signature = parameters._parameter_signature(first)["value"]
|
||||
previous_output_dir = recipe_images.folder_paths.get_output_directory
|
||||
recipe_images.folder_paths.get_output_directory = lambda: directory
|
||||
try:
|
||||
found, scanned = recipe_images._pictures_by_signature({signature, "0" * 64})
|
||||
finally:
|
||||
recipe_images.folder_paths.get_output_directory = previous_output_dir
|
||||
self.assertEqual(scanned, 3)
|
||||
self.assertEqual(found, {signature: {"count": 2, "newest": {"filename": "a2.png", "subfolder": ""}}})
|
||||
|
||||
def test_gallery_comparison_reports_parameter_difference_after_node_match(self):
|
||||
recipe = valid_workflow()
|
||||
image_workflow = json.loads(json.dumps(recipe))
|
||||
@@ -525,5 +575,22 @@ class ParameterNotebookRenameTests(unittest.IsolatedAsyncioTestCase):
|
||||
parameters.get_parameters_dir = orig_get_dir
|
||||
|
||||
|
||||
class ParameterSetSignatureTests(unittest.TestCase):
|
||||
def test_lists_set_signatures_per_recipe_skipping_bad_and_oversized_files(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
def write(name, payload):
|
||||
with open(os.path.join(temp_dir, name), "w", encoding="utf-8") as f:
|
||||
f.write(payload if isinstance(payload, str) else json.dumps(payload))
|
||||
signed = lambda value: {"algorithm": "sha256-params-v1", "value": value}
|
||||
write("params_a_1.json", {"recipe_filename": "a.json", "parameter_signature": signed("s1")})
|
||||
write("params_a_2.json", {"recipe_filename": "a.json", "parameter_signature": signed("s2")})
|
||||
write("params_b_1.json", {"recipe_filename": "b.json"})
|
||||
write("params_unbound.json", {"name": "no recipe"})
|
||||
write("params_broken.json", "{not json")
|
||||
write("notes.txt", "ignored")
|
||||
write("params_huge.json", json.dumps({"recipe_filename": "a.json", "pad": "x" * parameters.MAX_RECIPE_BYTES}))
|
||||
self.assertEqual(parameters._parameter_set_signatures(temp_dir), {"a.json": ["s1", "s2"], "b.json": [""]})
|
||||
self.assertEqual(parameters._parameter_set_signatures(os.path.join(temp_dir, "missing")), {})
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Many models, or models on a network drive: headers are read once per file version (kept on
|
||||
disk), the scan page gets the last summary at once, and requests meanwhile share one count."""
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from aiohttp.test_utils import make_mocked_request
|
||||
|
||||
PLUGIN = Path(__file__).resolve().parents[1]
|
||||
sys.path[:0] = [str(PLUGIN), str(PLUGIN.parents[1])]
|
||||
|
||||
from api import metadata, model_heads, scan_summary
|
||||
|
||||
|
||||
def write_model(path, info=None):
|
||||
header = json.dumps({"lora_unet_a.lora_up.weight": {"dtype": "F32", "shape": [1], "data_offsets": [0, 4]}}).encode()
|
||||
Path(path).write_bytes(len(header).to_bytes(8, "little") + header + b"\0" * 4)
|
||||
if info is not None:
|
||||
Path(str(path)[:-12] + ".info").write_text(json.dumps(info), encoding="utf-8")
|
||||
|
||||
|
||||
class ModelHeadsTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.dir = Path(self.temp.name)
|
||||
self.cache = str(self.dir / "model_heads.json")
|
||||
for name, value in (("_heads", None), ("_on_disk", True), ("_timer", None)):
|
||||
patcher = mock.patch.object(model_heads, name, value)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
patcher = mock.patch.object(model_heads, "cache_path", return_value=self.cache)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
self.reads = []
|
||||
real = model_heads.inspect_file
|
||||
patcher = mock.patch.object(model_heads, "inspect_file", side_effect=lambda path: self.reads.append(path) or real(path))
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
def tearDown(self):
|
||||
if model_heads._timer:
|
||||
model_heads._timer.cancel()
|
||||
self.temp.cleanup()
|
||||
|
||||
def test_a_header_is_read_once_per_file_version_also_after_a_restart(self):
|
||||
model = self.dir / "style.safetensors"
|
||||
write_model(model)
|
||||
first = model_heads.inspect(str(model))
|
||||
self.assertEqual(model_heads.inspect(str(model)), first)
|
||||
self.assertEqual(len(self.reads), 1)
|
||||
model_heads.save()
|
||||
model_heads._heads = None # ComfyUI restarted
|
||||
self.assertEqual(model_heads.inspect(str(model)), first)
|
||||
self.assertEqual(len(self.reads), 1)
|
||||
write_model(model, None)
|
||||
os.utime(model, ns=(1, 1)) # the file changed
|
||||
model_heads.inspect(str(model))
|
||||
self.assertEqual(len(self.reads), 2)
|
||||
|
||||
def test_the_longest_unused_files_go_first_when_there_are_too_many(self):
|
||||
model_heads._heads = {"old": [1, 1, 10, "", "", False, None], "new": [1, 1, 20, "", "", False, None]}
|
||||
with mock.patch.object(model_heads, "MAX_ENTRIES", 1):
|
||||
model_heads.save()
|
||||
self.assertEqual(list(json.loads(Path(self.cache).read_text(encoding="utf-8"))), ["new"])
|
||||
|
||||
def test_nothing_is_written_before_the_plugin_starts(self):
|
||||
model_heads._on_disk = False
|
||||
model_heads._heads = None
|
||||
model = self.dir / "style.safetensors"
|
||||
write_model(model)
|
||||
model_heads.inspect(str(model))
|
||||
self.assertIsNone(model_heads._timer)
|
||||
self.assertFalse(os.path.exists(self.cache))
|
||||
|
||||
|
||||
class KeptSummaryTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.dir = Path(self.temp.name)
|
||||
write_model(self.dir / "found.safetensors", {"id": 7, "modelId": 3, "name": "v2", "baseModel": "SDXL 1.0", "model": {"name": "Found"}})
|
||||
write_model(self.dir / "fresh.safetensors")
|
||||
metadata.clear_metadata_cache()
|
||||
for patcher in (mock.patch.object(scan_summary, "_last", {}), mock.patch.object(scan_summary, "_working", None),
|
||||
mock.patch.object(scan_summary, "_summary_path", return_value=str(self.dir / "scan_summary.json")),
|
||||
mock.patch.object(scan_summary, "get_active_scan_paths", return_value=[str(self.dir)]),
|
||||
mock.patch.object(scan_summary, "locate_root", return_value=("loras", 0))):
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
def tearDown(self):
|
||||
self.temp.cleanup()
|
||||
|
||||
def ask(self, route, query=""):
|
||||
async def run():
|
||||
response = await route(make_mocked_request("GET", f"/x{query}"))
|
||||
return json.loads(response.text)
|
||||
return asyncio.run(run())
|
||||
|
||||
def test_the_last_summary_comes_at_once_and_after_a_restart(self):
|
||||
self.assertEqual(self.ask(scan_summary.api_scan_summary, "?quick=1"), {})
|
||||
fresh = self.ask(scan_summary.api_scan_summary)
|
||||
self.assertEqual((fresh["total"], fresh["matched"], fresh["new"]), (2, 1, 1))
|
||||
self.assertNotIn("matched_models", fresh) # listed on request, the page's summary stays small
|
||||
self.assertEqual(self.ask(scan_summary.api_scan_summary, "?quick=1")["summary"], fresh)
|
||||
scan_summary._last.clear() # ComfyUI restarted: from disk
|
||||
self.assertEqual(self.ask(scan_summary.api_scan_summary, "?quick=1")["summary"], fresh)
|
||||
|
||||
def test_the_models_found_on_civitai_are_listed_with_their_names(self):
|
||||
found = self.ask(scan_summary.api_scan_matched)
|
||||
self.assertEqual(found["total"], 1)
|
||||
self.assertEqual({key: found["models"][0][key] for key in ("filename", "name", "base")},
|
||||
{"filename": "found.safetensors", "name": "Found · v2", "base": "SDXL 1.0"})
|
||||
|
||||
def test_requests_meanwhile_share_one_count(self):
|
||||
counted = []
|
||||
real = scan_summary._summarize
|
||||
async def both():
|
||||
return await asyncio.gather(scan_summary._fresh(), scan_summary._fresh())
|
||||
with mock.patch.object(scan_summary, "_summarize", side_effect=lambda **kw: counted.append(1) or real(**kw)):
|
||||
first, second = asyncio.run(both())
|
||||
self.assertEqual(len(counted), 1)
|
||||
self.assertEqual(first, second)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -2,8 +2,10 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
@@ -35,8 +37,8 @@ class ScanReportTests(unittest.TestCase):
|
||||
|
||||
def model(self, name, info=None):
|
||||
path = self.dir / f"{name}.safetensors"
|
||||
header = json.dumps({"__metadata__": {"name": name}, "w": {"dtype": "F32", "shape": [1], "data_offsets": [0, 4]}}).encode()
|
||||
path.write_bytes(len(header).to_bytes(8, "little") + header + b"\0" * 4) # a whole file (not damaged)
|
||||
header = json.dumps({"__metadata__": {"name": name}, "lora_unet_a.lora_up.weight": {"dtype": "F32", "shape": [1], "data_offsets": [0, 4]}}).encode()
|
||||
path.write_bytes(len(header).to_bytes(8, "little") + header + b"\0" * 4) # a whole LoRA file (not damaged)
|
||||
if info is not None:
|
||||
Path(str(path)[:-12] + ".info").write_text(json.dumps(info), encoding="utf-8")
|
||||
return path
|
||||
@@ -74,6 +76,17 @@ class ScanReportTests(unittest.TestCase):
|
||||
self.assertEqual(self.info("bbb")["anomalous_unmatched_reason"], "not_found")
|
||||
self.assertEqual(lines[-1], {"event": "done", "unchanged": 0, "civitai_down": False})
|
||||
|
||||
def test_when_covers_will_not_download_the_rest_fetch_none(self):
|
||||
# Civitai answered on civitai.red, but its pictures (image.civitai.com) cannot be reached.
|
||||
for name in ("c1", "c2", "c3", "c4"):
|
||||
self.model(name)
|
||||
tried = []
|
||||
pictured = {**FOUND, "images": [{"url": "https://image.civitai.com/a/b/orig"}]}
|
||||
with mock.patch.object(scraper, "download_media", side_effect=lambda url, base: tried.append(base) or None):
|
||||
asked, _lines = self.scan(lambda h: dict(pictured))
|
||||
self.assertEqual(len(asked), 4) # names and trigger words still come
|
||||
self.assertEqual(len(tried), scraper.COVERS_DOWN_AFTER)
|
||||
|
||||
def test_when_civitai_stops_answering_the_rest_is_inferred_without_asking(self):
|
||||
for name in ("m1", "m2", "m3", "m4"):
|
||||
self.model(name)
|
||||
@@ -156,10 +169,89 @@ class ScanReportTests(unittest.TestCase):
|
||||
mock.patch.object(scan_summary, "locate_root", return_value=("loras", 1)):
|
||||
summary = scan_summary._summarize()
|
||||
self.assertEqual((summary["total"], summary["matched"], summary["unmatched"], summary["new"], summary["pending"]), (4, 1, 2, 1, 1))
|
||||
self.assertEqual(summary["new_models"], [{"type": "loras", "path_idx": 1, "filename": "new.safetensors", "rel": "new.safetensors"}])
|
||||
self.assertEqual(summary["new_models"], [{"type": "loras", "path_idx": 1, "filename": "new.safetensors", "rel": "new.safetensors", "state": "new"}])
|
||||
reasons = {item["filename"]: (item["reason"], item["base"]) for item in summary["unmatched_models"]}
|
||||
self.assertEqual(reasons, {"gone.safetensors": ("not_found", "SD 1.5"), "off.safetensors": ("offline", "")})
|
||||
|
||||
def test_components_are_counted_and_listed_on_their_own(self):
|
||||
loras, vae = self.dir / "loras", self.dir / "vae"
|
||||
for folder in (loras, vae):
|
||||
folder.mkdir()
|
||||
top = self.dir
|
||||
self.dir = loras
|
||||
self.model("style", {"id": 3})
|
||||
self.dir = vae
|
||||
self.model("found_vae", {"id": 4})
|
||||
self.model("odd_vae", {"id": -1, "anomalous_unmatched_reason": "not_found"})
|
||||
self.model("new_vae")
|
||||
self.dir = top
|
||||
roots = {str(loras): ("loras", 0), str(vae): ("vae", 0)}
|
||||
with mock.patch.object(scan_summary, "get_active_scan_paths", return_value=[str(loras), str(vae)]), \
|
||||
mock.patch.object(scan_summary, "locate_root", side_effect=lambda path: roots[path]):
|
||||
summary = scan_summary._summarize()
|
||||
self.assertEqual((summary["total"], summary["matched"], summary["unmatched"], summary["new"]), (4, 2, 1, 1))
|
||||
self.assertEqual(summary["components"], {"total": 3, "matched": 1, "unmatched": 1, "new": 1, "pending": 0})
|
||||
states = {item["filename"]: (item["state"], item.get("reason", "")) for item in summary["component_models"]}
|
||||
self.assertEqual(states, {"found_vae.safetensors": ("matched", ""), "odd_vae.safetensors": ("unmatched", "not_found"),
|
||||
"new_vae.safetensors": ("new", "")})
|
||||
self.assertTrue(all(item.get("component") for item in summary["unmatched_models"] + summary["new_models"]))
|
||||
|
||||
def test_summary_has_the_scanned_models_covers_newest_first(self):
|
||||
loras, vae = self.dir / "loras", self.dir / "vae"
|
||||
top, now = self.dir, time.time()
|
||||
for folder, name, info, age, cover in (
|
||||
(loras, "old", {"id": 3}, 300, True), (loras, "fresh", {"id": 4}, 100, True),
|
||||
(loras, "gone", {"id": -1, "anomalous_unmatched_reason": "not_found"}, 200, True),
|
||||
(loras, "bare", {"id": 5}, 50, False), (loras, "new", None, 10, True), (vae, "part", {"id": 6}, 5, True)):
|
||||
folder.mkdir(exist_ok=True)
|
||||
self.dir = folder
|
||||
path = self.model(name, info)
|
||||
if cover:
|
||||
(folder / f"{name}.preview.png").write_bytes(b"png")
|
||||
os.utime(path, (now - age, now - age))
|
||||
self.dir = top
|
||||
roots = {str(loras): ("loras", 0), str(vae): ("vae", 0)}
|
||||
with mock.patch.object(scan_summary, "get_active_scan_paths", return_value=[str(loras), str(vae)]), \
|
||||
mock.patch.object(scan_summary, "locate_root", side_effect=lambda path: roots[path]):
|
||||
summary = scan_summary._summarize()
|
||||
with mock.patch.object(scan_summary, "COVER_SAMPLE", 1):
|
||||
capped = scan_summary._summarize()
|
||||
# Scanned ones with a cover, found or not; not the new one, the one without a cover or the component.
|
||||
self.assertEqual([cover["name"] for cover in summary["covers"]], ["fresh", "gone", "old"])
|
||||
self.assertIn("filename=fresh.preview.png", summary["covers"][0]["preview_url"])
|
||||
self.assertEqual([cover["name"] for cover in capped["covers"]], ["fresh"])
|
||||
|
||||
def test_picker_lists_every_model_a_scan_reads_with_its_badge_fields(self):
|
||||
self.model("new")
|
||||
self.model("found", {**FOUND, "description": "<p>long text</p>"})
|
||||
(self.dir / "found.preview.png").write_bytes(b"png")
|
||||
self.model("skipped")
|
||||
(self.dir / "skipped.anomalous.json").write_text(json.dumps({"format": 1, "scan": "skip"}), encoding="utf-8")
|
||||
(self.dir / "sub").mkdir()
|
||||
self.dir, top = self.dir / "sub", self.dir
|
||||
self.model("deep", {"id": -1, "anomalous_unmatched_reason": "damaged"})
|
||||
self.dir = top
|
||||
(self.dir / "old.ckpt").write_bytes(b"never read")
|
||||
with mock.patch.object(scan_summary, "get_active_scan_paths", return_value=[str(self.dir)]), \
|
||||
mock.patch.object(scan_summary, "locate_root", return_value=("loras", 1)):
|
||||
listed = scan_summary._pickable()
|
||||
with mock.patch.object(scan_summary, "MAX_PICKABLE", 2):
|
||||
capped = scan_summary._pickable()
|
||||
models = {item["rel"]: item for item in listed["models"]}
|
||||
self.assertEqual(sorted(models), ["found.safetensors", "new.safetensors", "skipped.safetensors", "sub/deep.safetensors"])
|
||||
self.assertEqual(listed["total"], 4)
|
||||
self.assertEqual(listed["roots"], [{"type": "loras", "path_idx": 1, "name": self.dir.name, "parent": self.dir.parent.name}])
|
||||
found = models["found.safetensors"]
|
||||
self.assertEqual((found["type"], found["path_idx"], found["filename"]), ("loras", 1, "found.safetensors"))
|
||||
self.assertIn("filename=found.preview.png", found["preview_url"])
|
||||
self.assertEqual(found["size"], (self.dir / "found.safetensors").stat().st_size)
|
||||
self.assertEqual(set(found["metadata"]), set(scan_summary.PICKER_FIELDS)) # no description
|
||||
self.assertEqual((found["metadata"]["info_source"], found["metadata"]["baseModel"]), ("civitai", "SDXL 1.0"))
|
||||
self.assertEqual(models["skipped.safetensors"]["metadata"]["scan_choice"], "skip")
|
||||
self.assertEqual(models["sub/deep.safetensors"]["metadata"]["unmatched_reason"], "damaged")
|
||||
self.assertEqual(models["new.safetensors"]["metadata"]["info_source"], "")
|
||||
self.assertEqual((len(capped["models"]), capped["total"]), (2, 4))
|
||||
|
||||
|
||||
class EditDetailTests(unittest.TestCase):
|
||||
def test_editing_a_model_logs_what_changed_from_what(self):
|
||||
|
||||
@@ -11,7 +11,7 @@ sys.path.insert(0, str(PLUGIN_DIR.parents[1]))
|
||||
sys.path.insert(0, str(PLUGIN_DIR))
|
||||
|
||||
import scraper
|
||||
from api import metadata, scan_summary, scanner
|
||||
from api import metadata, scan_choice, scan_summary, scanner
|
||||
from model_identity import computed_file_identity
|
||||
|
||||
DIGEST = "a" * 64
|
||||
@@ -88,6 +88,54 @@ class ScanRetryTests(unittest.TestCase):
|
||||
with mock.patch.object(scan_summary, "get_active_scan_paths", return_value=[self.temp.name]):
|
||||
self.assertEqual(scan_summary._summarize()["damaged"], 1)
|
||||
|
||||
def test_new_models_headers_are_read_once_until_the_file_changes(self):
|
||||
# On a network drive each read waits: the page opened again reads none of them.
|
||||
header = json.dumps({"ema_model.transformer.w": {"dtype": "F32", "shape": [1], "data_offsets": [0, 4]}}).encode()
|
||||
voice = Path(self.temp.name, "voice.safetensors")
|
||||
voice.write_bytes(len(header).to_bytes(8, "little") + header + b"\0" * 4)
|
||||
reads = []
|
||||
real_inspect = metadata.inspect_file
|
||||
metadata.clear_metadata_cache()
|
||||
with mock.patch.object(scan_summary, "get_active_scan_paths", return_value=[self.temp.name]), \
|
||||
mock.patch.object(metadata, "inspect_file", side_effect=lambda path: reads.append(path) or real_inspect(path)):
|
||||
first = scan_summary._summarize()
|
||||
again = scan_summary._summarize()
|
||||
self.assertEqual(len(reads), 2) # new.safetensors and the voice model, each once
|
||||
self.assertEqual(again["not_drawing"], first["not_drawing"])
|
||||
voice.write_bytes(len(header).to_bytes(8, "little") + header + b"\0" * 8) # changed: read again
|
||||
scan_summary._summarize()
|
||||
self.assertEqual([Path(path).name for path in reads].count("voice.safetensors"), 2)
|
||||
|
||||
def test_models_set_not_to_be_scanned_are_left_out(self):
|
||||
# A voice model's header names no drawing model kind: the page asks; "skip" leaves it out.
|
||||
header = json.dumps({"ema_model.transformer.w": {"dtype": "F32", "shape": [1], "data_offsets": [0, 4]}}).encode()
|
||||
voice = Path(self.temp.name, "voice.safetensors")
|
||||
voice.write_bytes(len(header).to_bytes(8, "little") + header + b"\0" * 4)
|
||||
summarize = lambda: scan_summary._summarize()
|
||||
with mock.patch.object(scan_summary, "get_active_scan_paths", return_value=[self.temp.name]):
|
||||
first = summarize()
|
||||
self.assertEqual([item["filename"] for item in first["new_models"] if item.get("not_drawing")], ["voice.safetensors"])
|
||||
self.assertTrue(metadata._read_metadata(str(voice))["not_drawing"])
|
||||
target = [{"type": "checkpoints", "path_idx": 0, "subfolder": "/", "files": ["voice.safetensors", "gone.safetensors"]}]
|
||||
with mock.patch.object(scan_choice, "resolve_folder_subdir", return_value=(self.temp.name, self.temp.name)):
|
||||
self.assertEqual(scan_choice._set_scan_choice(target, "skip"), (1, ["gone.safetensors"]))
|
||||
with self.assertRaises(ValueError):
|
||||
scan_choice._set_scan_choice(target, "never")
|
||||
after = summarize()
|
||||
self.assertEqual((after["excluded"], after["total"], after["not_drawing"]), (1, first["total"] - 1, 0))
|
||||
self.assertEqual(metadata._read_metadata(str(voice))["scan_choice"], "skip")
|
||||
with mock.patch.object(sys, "argv", ["scraper.py", self.temp.name]), \
|
||||
mock.patch.object(scraper, "fetch_civitai_info", return_value=None), \
|
||||
mock.patch.object(scraper, "calculate_sha256", return_value=DIGEST):
|
||||
scraper.main()
|
||||
self.assertFalse(Path(self.temp.name, "voice.info").exists())
|
||||
# Picked by hand, it is scanned all the same.
|
||||
with mock.patch.object(sys, "argv", ["scraper.py", self.temp.name, "--target-files", "voice.safetensors"]), \
|
||||
mock.patch.object(scraper, "fetch_civitai_info", return_value=None), \
|
||||
mock.patch.object(scraper, "calculate_sha256", return_value=DIGEST):
|
||||
scraper.main()
|
||||
self.assertTrue(Path(self.temp.name, "voice.info").exists())
|
||||
|
||||
def test_changed_file_is_hashed_again(self):
|
||||
self.unmatched.write_bytes(b"other bytes, other size")
|
||||
with mock.patch.object(scraper, "write_report") as report:
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Anomalous_TTS updates: only a git clone of the official repository is found, and switching it
|
||||
uses its own folder and state file, never the plugin's."""
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
PLUGIN = Path(__file__).resolve().parents[1]
|
||||
sys.path[:0] = [str(PLUGIN), str(PLUGIN.parents[1])]
|
||||
from api import tts_version, version_manager as vm
|
||||
|
||||
NODES = 'NODE_CLASS_MAPPINGS = {"AnomalousTTS_CharacterSpeech": object}\n'
|
||||
|
||||
|
||||
def git(cwd, *args):
|
||||
return subprocess.run(['git', '-C', str(cwd), *args], check=True, capture_output=True, text=True).stdout.strip()
|
||||
|
||||
|
||||
def commit(repo, name, content, message):
|
||||
Path(repo, name).write_text(content, encoding='utf-8')
|
||||
git(repo, 'add', name)
|
||||
git(repo, '-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '-m', message)
|
||||
|
||||
|
||||
class TtsVersionTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self._tmp.name)
|
||||
self.nodes = self.root / 'custom_nodes'
|
||||
self.nodes.mkdir()
|
||||
paths = mock.patch.object(tts_version.folder_paths, 'get_folder_paths', lambda name: [str(self.nodes)])
|
||||
paths.start()
|
||||
self.addCleanup(paths.stop)
|
||||
state = mock.patch.object(tts_version, 'STATE_FILE', str(self.root / 'tts_state.json'))
|
||||
state.start()
|
||||
self.addCleanup(state.stop)
|
||||
|
||||
def tearDown(self):
|
||||
self._tmp.cleanup()
|
||||
|
||||
def clone(self, name, remote, nodes=NODES):
|
||||
folder = self.nodes / name
|
||||
folder.mkdir()
|
||||
git(folder, 'init', '-q', '-b', 'main')
|
||||
commit(folder, 'nodes.py', nodes, 'one')
|
||||
git(folder, 'remote', 'add', 'origin', remote)
|
||||
return folder
|
||||
|
||||
def test_only_the_official_clone_is_found(self):
|
||||
self.assertIsNone(tts_version.find_checkout())
|
||||
self.clone('A_fork', 'https://github.com/someone/Anomalous_TTS.git')
|
||||
self.clone('Other_pack', 'https://github.com/DemonGatanjieu/Anomalous_TTS', nodes='NODE_CLASS_MAPPINGS = {}\n')
|
||||
(self.nodes / 'Plain_copy').mkdir()
|
||||
Path(self.nodes / 'Plain_copy' / 'nodes.py').write_text(NODES, encoding='utf-8') # no git: Manager's zip, a copy
|
||||
self.assertIsNone(tts_version.find_checkout())
|
||||
self.assertEqual(tts_version.state(), {'found': False})
|
||||
|
||||
folder = self.clone('Anomalous_TTS', 'https://github.com/DemonGatanjieu/Anomalous_TTS.git')
|
||||
repo = tts_version.find_checkout()
|
||||
self.assertEqual(Path(repo.root), folder)
|
||||
found = tts_version.state()
|
||||
self.assertTrue(found['found'])
|
||||
self.assertEqual(found['state']['branch'], 'main')
|
||||
|
||||
def test_switching_acts_on_the_node_pack_only(self):
|
||||
upstream = self.root / 'upstream'
|
||||
upstream.mkdir()
|
||||
git(upstream, 'init', '-q', '-b', 'main')
|
||||
commit(upstream, 'nodes.py', NODES, 'one')
|
||||
git(upstream, 'tag', 'v1.0.0')
|
||||
commit(upstream, 'nodes.py', NODES + '# two\n', 'two')
|
||||
git(upstream, 'tag', 'v1.1.0')
|
||||
folder = self.nodes / 'Anomalous_TTS'
|
||||
git(self.root, 'clone', '-q', str(upstream), str(folder))
|
||||
repo = vm.Checkout(str(folder), tts_version.STATE_FILE)
|
||||
plugin_head = git(PLUGIN, 'rev-parse', 'HEAD')
|
||||
git(folder, 'tag', '-d', 'v1.1.0') # cloned before the release, updated by a pull: no local tag
|
||||
with mock.patch.object(vm, '_fetch_github_releases', side_effect=OSError('offline')):
|
||||
listing = vm.list_releases(True, repo)
|
||||
self.assertEqual((listing['latest'], listing['state']['tag']), ('v1.1.0', 'v1.1.0'))
|
||||
vm.switch_to('v1.0.0', repo)
|
||||
self.assertEqual(vm.current_state(repo)['tag'], 'v1.0.0')
|
||||
self.assertEqual(vm.current_state(repo)['previous']['branch'], 'main')
|
||||
self.assertTrue(Path(tts_version.STATE_FILE).is_file())
|
||||
vm.undo_last_switch(repo)
|
||||
self.assertEqual(vm.current_state(repo)['branch'], 'main')
|
||||
self.assertEqual(git(PLUGIN, 'rev-parse', 'HEAD'), plugin_head)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,67 @@
|
||||
// LoRAs in popular third-party nodes, wherever AMB reads a workflow's models: a workflow's summary
|
||||
// (recipe_parser.js → recipe_loras.js), saved model references (recipe_identity.js) and Model
|
||||
// Check's boxes (lora_nodes.js powerLoraRowBoxes).
|
||||
import assert from 'node:assert/strict';
|
||||
import { extractRecipeMetadata } from '../web/modules/recipe_parser.js';
|
||||
import { deriveRecipeModelReferences, nodeModelValues } from '../web/modules/recipe_identity.js';
|
||||
import { POWER_LORA, powerLoraRowBoxes } from '../web/modules/lora_nodes.js';
|
||||
|
||||
globalThis.LiteGraph = { registered_node_types: {
|
||||
LoraLoader: { nodeData: { input: { required: { lora_name: [['Flux\\a.safetensors', 'b.safetensors']] } } } },
|
||||
} };
|
||||
|
||||
const row = (name, value) => ({ name, type: 'custom', value });
|
||||
const powerLora = {
|
||||
id: 7,
|
||||
type: POWER_LORA,
|
||||
widgets: [
|
||||
{ name: '', type: 'custom', value: {} }, // divider
|
||||
row('PowerLoraLoaderHeaderWidget', { type: 'PowerLoraLoaderHeaderWidget' }),
|
||||
row('lora_1', { on: true, lora: 'Flux\\a.safetensors', strength: 0.8, strengthTwo: null }),
|
||||
row('lora_2', { on: false, lora: 'off.safetensors', strength: 1, strengthTwo: null }),
|
||||
row('lora_3', { on: true, lora: 'b.safetensors', strength: 0.5, strengthTwo: 0.25 }),
|
||||
row('lora_4', { on: true, lora: null, strength: 1 }),
|
||||
{ name: '➕ Add Lora', type: 'custom', value: '' },
|
||||
],
|
||||
};
|
||||
const easyStack = {
|
||||
id: 8,
|
||||
type: 'easy loraStack',
|
||||
widgets: [
|
||||
{ name: 'toggle', value: true }, { name: 'mode', value: 'simple' }, { name: 'num_loras', value: 2 },
|
||||
{ name: 'lora_1_name', value: 'c.safetensors' }, { name: 'lora_1_strength', value: 0.6 },
|
||||
{ name: 'lora_2_name', value: 'None' }, { name: 'lora_2_strength', value: 1 },
|
||||
],
|
||||
};
|
||||
|
||||
// The summary: switched-on rows with a LoRA, the clip strength as the model's unless set apart.
|
||||
const metadata = extractRecipeMetadata({ _nodes: [powerLora, easyStack], links: [] });
|
||||
assert.deepEqual(metadata.loras, [
|
||||
{ name: 'Flux\\a.safetensors', strength_model: 0.8, strength_clip: 0.8 },
|
||||
{ name: 'b.safetensors', strength_model: 0.5, strength_clip: 0.25 },
|
||||
{ name: 'c.safetensors', strength_model: 0.6, strength_clip: null },
|
||||
]);
|
||||
// Saved model references: rows by their place in widgets_values (rows numbered as rgthree numbers them).
|
||||
const savedPower = { id: 7, type: POWER_LORA, widgets_values: [{}, { type: 'PowerLoraLoaderHeaderWidget' },
|
||||
{ on: true, lora: 'a.safetensors', strength: 1 }, { on: false, lora: 'off.safetensors', strength: 1 },
|
||||
{ on: true, lora: 'b.safetensors', strength: 1 }, {}, ''] };
|
||||
assert.deepEqual(nodeModelValues(savedPower), [[2, 'lora', 'lora_1', 'a.safetensors'], [4, 'lora', 'lora_3', 'b.safetensors']]);
|
||||
const savedStack = { id: 8, type: 'easy loraStack', widgets_values: [true, 'simple', 2, 'c.safetensors', 1, 1, 1, 'None', 1, 1, 1, 'unused.safetensors'] };
|
||||
assert.deepEqual(nodeModelValues(savedStack), [[3, 'lora', 'lora_1_name', 'c.safetensors'], [7, 'lora', 'lora_2_name', 'None']]);
|
||||
const refs = deriveRecipeModelReferences({ workflow: { nodes: [savedPower, savedStack] }, params: {} });
|
||||
assert.deepEqual(refs.map(ref => [ref.node_id, ref.widget_index, ref.widget_name, ref.saved_value]), [
|
||||
[7, 2, 'lora_1', 'a.safetensors'], [7, 4, 'lora_3', 'b.safetensors'], [8, 3, 'lora_1_name', 'c.safetensors'],
|
||||
]); // "None" is no model
|
||||
assert.deepEqual(nodeModelValues({ type: 'CheckpointLoaderSimple', widgets_values: ['x.safetensors'] }), [[0, 'checkpoint', 'checkpoint', 'x.safetensors']]);
|
||||
|
||||
// Model Check's boxes: switched-on rows naming a LoRA, read and written like a drop-down.
|
||||
const boxes = powerLoraRowBoxes(powerLora);
|
||||
assert.deepEqual(boxes.map(box => [box.name, box.type, box.value]), [['lora_1', 'combo', 'Flux\\a.safetensors'], ['lora_3', 'combo', 'b.safetensors']]);
|
||||
assert.deepEqual(boxes[0].options.values, ['Flux\\a.safetensors', 'b.safetensors']);
|
||||
boxes[1].value = 'Flux\\a.safetensors';
|
||||
assert.deepEqual(powerLora.widgets[4].value, { on: true, lora: 'Flux\\a.safetensors', strength: 0.5, strengthTwo: 0.25 }); // strength kept
|
||||
boxes[1].anomalous_replaced = { from: 'b.safetensors', to: 'Flux\\a.safetensors' };
|
||||
assert.deepEqual(powerLoraRowBoxes(powerLora)[1].anomalous_replaced, { from: 'b.safetensors', to: 'Flux\\a.safetensors' }); // lasts
|
||||
assert.deepEqual(powerLoraRowBoxes(easyStack), []);
|
||||
|
||||
console.log('third_party_loras: ok');
|
||||
@@ -0,0 +1,89 @@
|
||||
// Run: node tests/tts_presets_api.mjs (pure helpers of the generation presets; no ComfyUI needed)
|
||||
import assert from 'node:assert/strict';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import path from 'node:path';
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const api = await import(pathToFileURL(path.join(root, 'web/modules/tts_presets_api.js')).href);
|
||||
|
||||
const builtin = {
|
||||
stable: { top_k: 10, top_p: 1, temperature: 0.8, repetition_penalty: 1.35 },
|
||||
standard: { top_k: 15, top_p: 1, temperature: 1, repetition_penalty: 1.35 },
|
||||
lively: { top_k: 30, top_p: 1, temperature: 1.1, repetition_penalty: 1.35 },
|
||||
};
|
||||
const view = { default: 'standard', presets: structuredClone(builtin), builtin, languages: { 'ja>en': 'standard' }, cross: 'stable' };
|
||||
|
||||
// Language table: every pair the characters can meet, plus rules already in the file.
|
||||
const groups = [{ language: 'ja', raw: { reference: { language: 'ja' } } }, { language: 'zh', raw: {} }, { language: '', raw: {} }];
|
||||
assert.deepEqual(api.languagePairs(groups), ['ja>zh', 'ja>en', 'zh>ja', 'zh>en']);
|
||||
assert.deepEqual(api.languagePairs([], { 'en>ja': 'lively', cross: 'stable' }), ['en>ja']);
|
||||
|
||||
// The file keeps only what differs from the built-in values.
|
||||
assert.deepEqual(api.presetsFile(view, { defaultName: 'standard', numbers: structuredClone(builtin), rules: {}, cross: 'stable' }),
|
||||
{ format: 1, default: 'standard' });
|
||||
const changed = structuredClone(builtin);
|
||||
changed.lively.temperature = 1.2;
|
||||
assert.deepEqual(api.presetsFile(view, { defaultName: 'lively', numbers: changed, rules: { 'ja>zh': 'stable', 'ja>en': 'standard' }, cross: 'standard' }),
|
||||
{ format: 1, default: 'lively', presets: { lively: { temperature: 1.2 } }, languages: { 'ja>zh': 'stable', cross: 'standard' } });
|
||||
|
||||
// What "general" means for a line: the default preset, or the rule for its pair of languages.
|
||||
assert.equal(api.generalPreset(view, 'ja', 'ja'), 'standard');
|
||||
assert.equal(api.generalPreset(view, 'ja', 'en'), 'standard');
|
||||
assert.equal(api.generalPreset(view, 'ja', 'zh'), 'stable');
|
||||
|
||||
// A character's settings as rows and back, keeping every other field.
|
||||
const settings = { format: 1, aliases: ['阿罗娜'], keep: 1, defaults: { language: 'ja', top_k: 20, by_language: { zh: { preset: 'lively', speed: 1.1, extra: 'x' } } } };
|
||||
const rows = api.tuningRows(settings);
|
||||
assert.deepEqual(rows.all, { preset: '', numbers: { top_k: 20 }, speed: null });
|
||||
assert.deepEqual(rows.zh, { preset: 'lively', numbers: {}, speed: 1.1 });
|
||||
assert.deepEqual(rows.en, { preset: '', numbers: {}, speed: null });
|
||||
rows.zh = { preset: '', numbers: {}, speed: null };
|
||||
rows.en = { preset: 'stable', numbers: { temperature: 0.7 }, speed: 0.9 };
|
||||
const next = api.mergeTuning(settings, rows);
|
||||
assert.deepEqual(next.defaults, { language: 'ja', top_k: 20, by_language: { zh: { extra: 'x' }, en: { preset: 'stable', temperature: 0.7, speed: 0.9 } } });
|
||||
assert.equal(next.keep, 1);
|
||||
const cleared = api.mergeTuning({ defaults: { preset: 'lively' } }, api.tuningRows({}));
|
||||
assert.equal(cleared.defaults, undefined);
|
||||
|
||||
// Presets made here: written whole with their name, the built-in ones only where changed.
|
||||
const mine = { ...structuredClone(builtin), custom_a1: { top_k: 12, top_p: 1, temperature: 0.9, repetition_penalty: 1.35 } };
|
||||
assert.deepEqual(api.presetsFile(view, { defaultName: 'custom_a1', numbers: mine, labels: { custom_a1: '念旁白' }, rules: { 'ja>zh': 'custom_a1' }, cross: 'stable' }),
|
||||
{ format: 1, default: 'custom_a1', presets: { custom_a1: { label: '念旁白', top_k: 12, top_p: 1, temperature: 0.9, repetition_penalty: 1.35 } }, languages: { 'ja>zh': 'custom_a1' } });
|
||||
assert.equal(api.presetLabel({ custom_a1: '念旁白' }, 'custom_a1', key => key), '念旁白');
|
||||
assert.equal(api.presetLabel({}, 'lively', key => key), 'ttsPreset_lively');
|
||||
const id = api.newPresetId(['custom_a1'], 1000);
|
||||
assert.match(id, /^custom_[a-z0-9]{1,12}$/);
|
||||
assert.notEqual(api.newPresetId([id], 1000), id);
|
||||
|
||||
// The state the settings and the Script Director change, and back to the same file.
|
||||
const loaded = { ...view, presets: mine, labels: { custom_a1: '念旁白' }, default: 'custom_a1' };
|
||||
const editable = api.stateFromView(loaded);
|
||||
editable.numbers.custom_a1.top_k = 14;
|
||||
assert.equal(loaded.presets.custom_a1.top_k, 12);
|
||||
assert.deepEqual(api.presetsFile(loaded, editable).presets.custom_a1, { label: '念旁白', top_k: 14, top_p: 1, temperature: 0.9, repetition_penalty: 1.35 });
|
||||
|
||||
// Deleting a preset: where it was used, and those places back on the general setting.
|
||||
const state = { defaultName: 'custom_a1', numbers: structuredClone(mine), labels: { custom_a1: '念旁白' }, rules: { 'ja>zh': 'custom_a1', 'ja>en': 'lively' }, cross: 'custom_a1' };
|
||||
const users = [{ raw: { name: '阿罗娜', settings: { defaults: { by_language: { zh: { preset: 'custom_a1' } } } } } }, { raw: { name: '爱丽丝', settings: {} } }];
|
||||
assert.deepEqual(api.presetUses(state, 'custom_a1', users), { general: ['default', 'ja>zh', 'cross'], characters: ['阿罗娜'] });
|
||||
api.removePreset(state, 'custom_a1');
|
||||
assert.deepEqual([state.defaultName, state.rules, state.cross, Object.keys(state.numbers), state.labels],
|
||||
['standard', { 'ja>en': 'lively' }, 'stable', ['stable', 'standard', 'lively'], {}]);
|
||||
|
||||
// A dialog row's numbers in effect next to the general ones (layers 1 to 4).
|
||||
const general = { ...view, default: 'standard', languages: { 'ja>en': 'standard' }, cross: 'stable' };
|
||||
const tuning = api.tuningRows({ defaults: { preset: 'lively', speed: 1.2, by_language: { zh: { top_k: 5 } } } });
|
||||
const own = api.lineTuning(general, tuning, 'ja', 'ja');
|
||||
assert.deepEqual([own.values.top_k, own.values.speed, own.general.top_k, own.general.speed], [30, 1.2, 15, 1]);
|
||||
const chinese = api.lineTuning(general, tuning, 'ja', 'zh'); // the rule for other languages over the character's preset, then its own number
|
||||
assert.deepEqual([chinese.values.top_k, chinese.values.temperature, chinese.values.speed, chinese.general.top_k], [5, 0.8, 1.2, 10]);
|
||||
const gone = api.lineTuning(general, api.tuningRows({ defaults: { preset: 'custom_zz' } }), 'ja', 'all'); // deleted since: skipped
|
||||
assert.deepEqual(gone.values, gone.general);
|
||||
|
||||
// Steady to lively: the built-in presets in order, and words against a base.
|
||||
assert.deepEqual(['stable', 'standard', 'lively'].map(name => api.liveliness(builtin[name])), [28, 49, 70]);
|
||||
assert.equal(api.compareFeel(builtin.standard, builtin.stable), 'steadier');
|
||||
assert.equal(api.compareFeel(builtin.stable, builtin.lively), 'muchLivelier');
|
||||
assert.equal(api.compareFeel(builtin.standard, { ...builtin.standard, temperature: 1.1 }), 'livelier');
|
||||
assert.equal(api.compareFeel(builtin.standard, { ...builtin.standard, repetition_penalty: 1.5 }), 'about');
|
||||
|
||||
console.log('tts_presets_api: all checks passed');
|
||||
@@ -125,7 +125,9 @@ assert.deepEqual(api.importProblem({ character: '普拉娜', rows: weights, conf
|
||||
['ttsImportNameTaken', { name: '普拉娜' }]);
|
||||
assert.equal(api.importProblem({ character: 'X', rows: weights }), null);
|
||||
assert.deepEqual(api.importProblem({ character: 'X', rows: [weights[0], { kind: 'sovits', name: 'v4.pth', supported: false, version: 'v4' }] }),
|
||||
['ttsImportUnsupportedBlock', { file: 'v4.pth', version: 'v4' }]);
|
||||
['ttsImportUnsupportedBlock', { file: 'v4.pth', version: 'v4' }]); // an Anomalous_TTS before v4 support
|
||||
assert.deepEqual(api.importProblem({ character: 'X', rows: [weights[0], { kind: 'sovits', name: 'odd.pth', supported: false }] }),
|
||||
['ttsImportUnknownBlock', { file: 'odd.pth' }]);
|
||||
assert.deepEqual([undefined, 2.9, 3, 10, 10.1].map(api.usableAsReference), [true, false, true, true, false]);
|
||||
|
||||
// A text file dropped on a clip's text box: a plain file is the line; an annotation file is searched.
|
||||
@@ -163,6 +165,19 @@ assert.deepEqual(api.pretrainedReminder(pre, ['']).items.map(i => i.id), ['huber
|
||||
assert.deepEqual(api.missingForLanguage(pre, 'zh').map(i => i.id), ['hubert', 'roberta']);
|
||||
assert.deepEqual(api.missingForLanguage(pre, 'ja').map(i => i.id), ['hubert']);
|
||||
|
||||
// v3 / v4 / v5 files: listed, counted and reminded of only for characters (or dropped files) whose model needs them.
|
||||
const versions = { pretrained: [...pre.pretrained,
|
||||
{ id: 'v4', needed_for: 'v4', state: 'missing', size: 800, required: true },
|
||||
{ id: 'v5dev', needed_for: 'v5dev', state: 'downloading', size: 800, required: true },
|
||||
{ id: 'v5turbo', needed_for: 'v5turbo', state: 'missing', size: 800, required: true },
|
||||
] };
|
||||
assert.deepEqual(api.shownPretrained(versions).map(i => i.id), ['hubert', 'roberta', 'ja_userdic', 'english', 'v5dev']);
|
||||
assert.deepEqual(api.shownPretrained(versions, ['v4']).map(i => i.id).slice(-2), ['v4', 'v5dev']);
|
||||
assert.equal(api.setupSummary(versions).missing, 4); // v4 and v5turbo not counted while nobody uses them
|
||||
assert.deepEqual(api.pretrainedReminder(versions, ['']).items.map(i => i.id), ['hubert', 'roberta', 'ja_userdic']);
|
||||
assert.deepEqual(api.pretrainedReminder(versions, ['ja'], [], ['v4']).items.map(i => i.id), ['hubert', 'ja_userdic', 'v4']);
|
||||
assert.deepEqual(api.missingForLanguage(versions, '', ['v4']).map(i => i.id), ['hubert', 'v4']);
|
||||
|
||||
// Row tones and section marks in the import form.
|
||||
assert.equal(api.rowState({ progress: 0.42 }).tone, 'busy');
|
||||
assert.deepEqual(api.rowState({ progress: 0.42 }).params, { percent: 42 });
|
||||
|
||||
@@ -103,7 +103,9 @@ app.registerExtension({
|
||||
const liveNode = liveNodes.find(n => n.id === node.id);
|
||||
|
||||
if (node.widgets_values && node.widgets_values.length > 0) {
|
||||
for (const val of node.widgets_values) {
|
||||
for (const saved of node.widgets_values) {
|
||||
// A Power Lora Loader row is an object naming its LoRA.
|
||||
const val = typeof saved?.lora === 'string' ? saved.lora : saved;
|
||||
if (typeof val === 'string') {
|
||||
usedNames.add(val).add(val.replace(/\\/g, '/')).add(val.split(/[/\\]/).pop());
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { createBrowserEntry } from "./modules/browser_entry.js";
|
||||
import { createInterfaceSettings, getCurrentLanguage, setAbyssalScarletTheme, t } from "./modules/interface_settings.js";
|
||||
import { keyParamsSetting } from "./modules/key_params.js";
|
||||
|
||||
export { setAbyssalScarletTheme };
|
||||
|
||||
@@ -8,7 +9,7 @@ const browserEntry = createBrowserEntry({ translate: t, getCurrentLanguage });
|
||||
|
||||
app.registerExtension({
|
||||
name: "Anomalous.ModelBrowser",
|
||||
settings: [...browserEntry.settings, ...createInterfaceSettings()],
|
||||
settings: [...browserEntry.settings, ...createInterfaceSettings(), keyParamsSetting()],
|
||||
actionBarButtons: browserEntry.actionBarButtons,
|
||||
commands: browserEntry.commands,
|
||||
keybindings: browserEntry.keybindings,
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* A small menu of actions, looking like the keep menu: at the pointer (a right-click) or under
|
||||
* a button. A pick, a click elsewhere, the wheel or Esc closes it; its Esc goes no further, so
|
||||
* the page behind stays open. One at a time.
|
||||
*/
|
||||
|
||||
let closeOpenMenu = null;
|
||||
|
||||
function row({ icon, label, run, danger }, close) {
|
||||
const item = document.createElement('button');
|
||||
item.type = 'button';
|
||||
item.className = `anomalous-keep-row${danger ? ' is-danger' : ''}`;
|
||||
item.setAttribute('role', 'menuitem');
|
||||
const mark = document.createElement('span');
|
||||
mark.className = 'anomalous-keep-row-icon';
|
||||
mark.textContent = icon;
|
||||
const text = document.createElement('strong');
|
||||
text.textContent = label;
|
||||
item.append(mark, text);
|
||||
item.onclick = (event) => {
|
||||
close();
|
||||
run(event);
|
||||
};
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* `at`: a pointer event (the menu opens there) or a button (the menu opens under it,
|
||||
* right-aligned; a click on that button is left to it, so it can close the menu again).
|
||||
* `onClose()` runs however it closes. Returns the close function.
|
||||
*/
|
||||
export function openActionMenu(at, items, { className = '', onClose } = {}) {
|
||||
closeOpenMenu?.();
|
||||
const menu = document.createElement('div');
|
||||
menu.className = `anomalous-keep-menu anomalous-action-menu ${className}`.trim();
|
||||
menu.setAttribute('role', 'menu');
|
||||
const onPointer = (pointer) => {
|
||||
if (!menu.contains(pointer.target) && !(at instanceof Element && at.contains(pointer.target))) close();
|
||||
};
|
||||
const onKey = (key) => {
|
||||
if (key.key !== 'Escape') return;
|
||||
key.stopPropagation();
|
||||
close();
|
||||
};
|
||||
const close = () => {
|
||||
menu.remove();
|
||||
window.removeEventListener('pointerdown', onPointer, true);
|
||||
window.removeEventListener('keydown', onKey, true);
|
||||
window.removeEventListener('wheel', close, true);
|
||||
if (closeOpenMenu === close) closeOpenMenu = null;
|
||||
onClose?.();
|
||||
};
|
||||
menu.append(...items.map(item => row(item, close)));
|
||||
document.body.append(menu);
|
||||
const { width, height } = menu.getBoundingClientRect();
|
||||
let x = at.clientX;
|
||||
let y = at.clientY;
|
||||
if (at instanceof Element) {
|
||||
const anchor = at.getBoundingClientRect();
|
||||
x = anchor.right - width;
|
||||
y = anchor.bottom + 6;
|
||||
}
|
||||
menu.style.left = `${Math.max(8, Math.min(x, window.innerWidth - width - 8))}px`;
|
||||
menu.style.top = `${Math.max(8, Math.min(y, window.innerHeight - height - 8))}px`;
|
||||
window.addEventListener('pointerdown', onPointer, true);
|
||||
window.addEventListener('keydown', onKey, true);
|
||||
window.addEventListener('wheel', close, true);
|
||||
closeOpenMenu = close;
|
||||
return close;
|
||||
}
|
||||
@@ -35,6 +35,8 @@ const ACTION_KEYS = {
|
||||
voice_import: 'activityVoiceImport',
|
||||
voice_settings: 'activityVoiceSettings',
|
||||
model_download: 'activityModelDownload',
|
||||
model_import: 'activityModelImport',
|
||||
model_move: 'activityModelMove',
|
||||
backup_export: 'activityBackupExport',
|
||||
backup_import: 'activityBackupImport',
|
||||
};
|
||||
|
||||
@@ -59,6 +59,8 @@ export function gptSovitsGroups(payload) {
|
||||
group: voiceGroupKey(name),
|
||||
character: name,
|
||||
language: item.language || '',
|
||||
model_version: item.version || '', // GPT-SoVITS v2 / v4 / v5dev… (interface 15)
|
||||
needs: Array.isArray(item.needs) ? item.needs : [], // pretrained ids its model needs
|
||||
aliases: Array.isArray(item.aliases) ? item.aliases : [],
|
||||
error: item.error || item.settings_error || '',
|
||||
has_main: Boolean(item.reference?.audio) && !item.error,
|
||||
@@ -89,6 +91,15 @@ async function getJson(url, signal) {
|
||||
return { ok: resp.ok, status: resp.status, data };
|
||||
}
|
||||
|
||||
/**
|
||||
* Each clip's line as the node finds it (a `Map` path -> line; clips without one are left
|
||||
* out), or null when the node is older than interface 14 and cannot tell.
|
||||
*/
|
||||
export async function fetchReferenceLines(name, signal) {
|
||||
const { ok, data } = await getJson(`/anomalous_tts/reference_lines?character=${encodeURIComponent(name)}`, signal);
|
||||
return ok && data?.lines && typeof data.lines === 'object' ? new Map(Object.entries(data.lines)) : null;
|
||||
}
|
||||
|
||||
/** Installed = ComfyUI knows the node class (`/object_info/<class>` is `{}` otherwise). */
|
||||
async function isNodeInstalled(nodeClass) {
|
||||
try {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { showDetail } from './ui_detail.js';
|
||||
import { showEditModal } from './ui_model_editor.js';
|
||||
import { _openAdvancedModelSelector, setWidgetValuePath } from './ui_model_selector.js';
|
||||
import { setWidgetValuePath } from './model_widget_path.js';
|
||||
import { loadModels, applyModelToCanvas, stopMediaInContainer } from './ui_grid.js';
|
||||
import { createDOM, renderSidebar, loadFolders } from './ui_sidebar.js';
|
||||
import { closeWorkspace, hideAllPanels } from './ui_browser_navigation.js';
|
||||
@@ -48,7 +48,7 @@ export class AnomalousBrowser {
|
||||
this.currentPathIdx = 0;
|
||||
this.currentSubfolder = '/';
|
||||
this.foldersData = null;
|
||||
this.expandedFolders = new Set(['/', 'checkpoints', 'loras', 'unet', 'diffusion_models']);
|
||||
this.collapsedFolders = new Set(); // folders folded in the folder column (type + path), this session
|
||||
// Video covers play on hover unless you chose "always": fewer videos decoding at once.
|
||||
this.energySaving = localStorage.getItem('anomalous_energy_saving') !== 'false';
|
||||
this.cardThumbnailMode = localStorage.getItem('anomalous_card_thumbnail_mode') === 'original'
|
||||
@@ -233,5 +233,4 @@ AnomalousBrowser.prototype.stopMediaInContainer = stopMediaInContainer;
|
||||
|
||||
AnomalousBrowser.prototype.showDetail = showDetail;
|
||||
AnomalousBrowser.prototype.showEditModal = showEditModal;
|
||||
AnomalousBrowser.prototype._openAdvancedModelSelector = _openAdvancedModelSelector;
|
||||
AnomalousBrowser.prototype.setWidgetValuePath = setWidgetValuePath;
|
||||
|
||||
@@ -209,8 +209,11 @@ export function createBrowserEntry({ translate, getCurrentLanguage }) {
|
||||
|
||||
async function setup() {
|
||||
removeLegacyTriggerPosition();
|
||||
const cssUrl = '/extensions/Anomalous_Model_Browser/styles.css?v=20261007-theme-2';
|
||||
if (!document.querySelector('link[href^="/extensions/Anomalous_Model_Browser/styles.css"]')) {
|
||||
// Beside this file, not under a fixed name: the plugin folder may be called anything
|
||||
// (a ZIP from GitHub unpacks as Anomalous_Model_Browser-main), and ComfyUI serves it by that name.
|
||||
const cssPath = new URL('../styles.css', import.meta.url).pathname;
|
||||
const cssUrl = cssPath + '?v=20261007-theme-2';
|
||||
if (!document.querySelector(`link[href^="${cssPath}"]`)) {
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.type = 'text/css';
|
||||
|
||||
@@ -13,6 +13,10 @@
|
||||
* its rows), then fills the slots by name, then links; whatever cannot be found is reported,
|
||||
* never guessed.
|
||||
*
|
||||
* rgthree's Power Lora Loader keeps its LoRAs in rows, no drop-downs: a node's rows are stored
|
||||
* as `rows` (lora_nodes.js), made again before its slots are filled, and each row's LoRA is a
|
||||
* model slot with `field: 'lora'` (the row's value is an object).
|
||||
*
|
||||
* A combo with a structure: data = { kind: 'nodes', structure: { version, nodes, links, slots,
|
||||
* open: [{ node, name, type }] }, values: { slotId: value }, covers: { slotId: url } }.
|
||||
*/
|
||||
@@ -23,6 +27,7 @@ import { promptBoxes } from './prompt_boxes.js';
|
||||
import {
|
||||
NONE, STRUCTURE_VERSION, chainOrder, findPort, folderFor, fedByLink, isModelWidget, missingTypes, optionValues, packOf, slashes,
|
||||
} from './combo_slots.js';
|
||||
import { addPowerLoraRows, nativeLora, powerLoraRows } from './lora_nodes.js';
|
||||
|
||||
let folderLists = null;
|
||||
|
||||
@@ -73,6 +78,7 @@ export async function captureStructure(picked, { labelFor } = {}) {
|
||||
for (const { node, models, texts } of kept) {
|
||||
const key = keys.get(node.id);
|
||||
const definitionTitle = globalThis.LiteGraph?.registered_node_types?.[node.type]?.title;
|
||||
const rows = powerLoraRows(node);
|
||||
nodes.push({
|
||||
key, type: node.type, pack: packOf(node.type), name: node.title || node.type, // as the canvas shows it
|
||||
title: node.title && node.title !== definitionTitle ? node.title : '',
|
||||
@@ -80,6 +86,7 @@ export async function captureStructure(picked, { labelFor } = {}) {
|
||||
size: Array.isArray(node.size) ? [Math.round(node.size[0]), Math.round(node.size[1])] : null,
|
||||
widgets: (node.widgets || []).filter(widget => widget.name && widget.type !== 'button' && storable(widget.value))
|
||||
.map(widget => [widget.name, widget.value]),
|
||||
...(rows.length ? { rows } : {}),
|
||||
});
|
||||
for (const widget of models) {
|
||||
if (fedByLink(node, widget)) continue;
|
||||
@@ -91,6 +98,15 @@ export async function captureStructure(picked, { labelFor } = {}) {
|
||||
label: labelFor?.({ kind: 'model', folder, index: counts[folder], widget: widget.name }) || widget.name });
|
||||
values[id] = String(widget.value ?? '');
|
||||
}
|
||||
rows.forEach((row, index) => {
|
||||
if (row.lora === null) return;
|
||||
counts.loras = (counts.loras || 0) + 1;
|
||||
const id = `s${slots.length + 1}`;
|
||||
const widget = `lora_${index + 1}`; // the name the row gets when the node is made again
|
||||
slots.push({ id, node: key, widget, field: 'lora', kind: 'model', folder: 'loras', optional: false,
|
||||
label: labelFor?.({ kind: 'model', folder: 'loras', index: counts.loras, widget }) || widget });
|
||||
values[id] = row.lora;
|
||||
});
|
||||
for (const box of texts) {
|
||||
if (fedByLink(node, box.widget)) continue;
|
||||
const id = `s${slots.length + 1}`;
|
||||
@@ -167,6 +183,7 @@ export function buildStructure(structure, values = {}) {
|
||||
const widget = node.widgets?.find(entry => entry.name === name);
|
||||
if (widget) setBox(node, widget, value); // in order: a count box adds the boxes after it
|
||||
}
|
||||
if (item.rows?.length) addPowerLoraRows(node, item.rows);
|
||||
if (Array.isArray(item.size)) node.size = [Math.max(node.size?.[0] || 0, item.size[0]), Math.max(node.size?.[1] || 0, item.size[1])];
|
||||
}
|
||||
for (const slot of structure.slots) {
|
||||
@@ -176,8 +193,9 @@ export function buildStructure(structure, values = {}) {
|
||||
problems.push({ kind: 'slot_missing', label: slot.label, widget: slot.widget, type: node?.title || node?.type || '' });
|
||||
continue;
|
||||
}
|
||||
const value = Object.hasOwn(values, slot.id) ? values[slot.id] : widget.value;
|
||||
setBox(node, widget, slot.kind === 'model' ? nativeChoice(widget, value) : String(value ?? ''));
|
||||
const value = Object.hasOwn(values, slot.id) ? values[slot.id] : slot.field ? widget.value?.[slot.field] : widget.value;
|
||||
if (slot.field) widget.value = { ...widget.value, [slot.field]: nativeLora(value) };
|
||||
else setBox(node, widget, slot.kind === 'model' ? nativeChoice(widget, value) : String(value ?? ''));
|
||||
}
|
||||
for (const link of structure.links) {
|
||||
const from = made.get(link.from);
|
||||
|
||||
@@ -4,13 +4,17 @@
|
||||
* says (its own subfolder, so the node needs no change; a bare name goes into the settings'
|
||||
* folder) or always the settings' folder, `{base}` there standing for the model's base model
|
||||
* (an existing folder of that name, in any spelling, is used). The file keeps the workflow's
|
||||
* name. No imports, so it is tested on its own.
|
||||
* name. Imported models (model_import.js) follow the same folder. No imports, so it is tested
|
||||
* on its own.
|
||||
*/
|
||||
|
||||
const UNSAFE_FORMATS = /\.(ckpt|pt|pth|bin)$/i;
|
||||
/** Ready-made download folders for the settings (anything else is typed there); the first is the default. */
|
||||
export const FOLDER_PRESETS = ['{base}', 'Downloads/{base}', 'Downloads', ''];
|
||||
export const DEFAULT_FOLDER = FOLDER_PRESETS[0];
|
||||
// Civitai's base models -> the folder name most people use for them.
|
||||
const BASE_FAMILIES = [
|
||||
[/^sd ?1\.[45]/i, 'SD1.5'], [/^sdxl/i, 'SDXL'], [/^sd ?3/i, 'SD3'], [/^pony/i, 'Pony'],
|
||||
[/^sd ?1\.[45]/i, 'SD1.5'], [/^sd ?2/i, 'SD2'], [/^sdxl/i, 'SDXL'], [/^sd ?3/i, 'SD3'], [/^pony/i, 'Pony'],
|
||||
[/^illustrious/i, 'Illustrious'], [/^noob/i, 'NoobAI'], [/^flux/i, 'Flux'], [/^hunyuan/i, 'Hunyuan'],
|
||||
[/^wan/i, 'Wan'], [/^qwen/i, 'Qwen'], [/^hidream/i, 'HiDream'], [/^chroma/i, 'Chroma'],
|
||||
];
|
||||
@@ -51,7 +55,7 @@ export function destinationFor(entry, info, settings, subfolders = []) {
|
||||
const name = fileName(entry.value);
|
||||
const own = dirName(entry.value);
|
||||
if (settings?.place !== 'folder' && own) return `${own}/${name}`;
|
||||
const folder = expandFolder(settings?.folder ?? 'Downloads', info?.base_model, subfolders);
|
||||
const folder = expandFolder(settings?.folder ?? DEFAULT_FOLDER, info?.base_model, subfolders);
|
||||
return folder ? `${folder}/${name}` : name;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,26 +10,7 @@ import { translate as t } from './locales.js';
|
||||
import { recordCanvasStep } from './canvas_history.js';
|
||||
import { showWorkbenchToast } from './ui_prompt_toast.js';
|
||||
import { canvasPoint, followPointer } from './notebook_canvas.js';
|
||||
|
||||
let closeOpenMenu = null;
|
||||
|
||||
function row(icon, label, run, close) {
|
||||
const item = document.createElement('button');
|
||||
item.type = 'button';
|
||||
item.className = 'anomalous-keep-row';
|
||||
item.setAttribute('role', 'menuitem');
|
||||
const mark = document.createElement('span');
|
||||
mark.className = 'anomalous-keep-row-icon';
|
||||
mark.textContent = icon;
|
||||
const text = document.createElement('strong');
|
||||
text.textContent = label;
|
||||
item.append(mark, text);
|
||||
item.onclick = (event) => {
|
||||
close();
|
||||
run(event);
|
||||
};
|
||||
return item;
|
||||
}
|
||||
import { openActionMenu } from './action_menu.js';
|
||||
|
||||
/** The output picture uploaded into the input folder; its name there for a Load Image node. */
|
||||
async function copyToInput(image) {
|
||||
@@ -80,33 +61,8 @@ export async function insertLoadImageNode(owner, image, at) {
|
||||
/** Opens the menu at the pointer; `openDetails()` opens the picture's parameters. */
|
||||
export function openGalleryCardMenu(event, image, { owner, openDetails }) {
|
||||
event.preventDefault();
|
||||
closeOpenMenu?.();
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'anomalous-keep-menu anomalous-gallery-card-menu';
|
||||
menu.setAttribute('role', 'menu');
|
||||
const onPointer = (pointer) => { if (!menu.contains(pointer.target)) close(); };
|
||||
const onKey = (key) => {
|
||||
if (key.key !== 'Escape') return;
|
||||
key.stopPropagation();
|
||||
close();
|
||||
};
|
||||
const close = () => {
|
||||
menu.remove();
|
||||
window.removeEventListener('pointerdown', onPointer, true);
|
||||
window.removeEventListener('keydown', onKey, true);
|
||||
window.removeEventListener('wheel', close, true);
|
||||
if (closeOpenMenu === close) closeOpenMenu = null;
|
||||
};
|
||||
menu.append(
|
||||
row('🖼️', t('galleryInsertAsNode'), (click) => insertLoadImageNode(owner, image, click), close),
|
||||
row('🔍', t('materialViewParameters'), openDetails, close),
|
||||
);
|
||||
document.body.append(menu);
|
||||
const { width, height } = menu.getBoundingClientRect();
|
||||
menu.style.left = `${Math.max(8, Math.min(event.clientX, window.innerWidth - width - 8))}px`;
|
||||
menu.style.top = `${Math.max(8, Math.min(event.clientY, window.innerHeight - height - 8))}px`;
|
||||
window.addEventListener('pointerdown', onPointer, true);
|
||||
window.addEventListener('keydown', onKey, true);
|
||||
window.addEventListener('wheel', close, true);
|
||||
closeOpenMenu = close;
|
||||
openActionMenu(event, [
|
||||
{ icon: '🖼️', label: t('galleryInsertAsNode'), run: (click) => insertLoadImageNode(owner, image, click) },
|
||||
{ icon: '🔍', label: t('materialViewParameters'), run: openDetails },
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Key parameters: the node values the user stars to keep in view (a sampler's cfg, a LoRA's
|
||||
* strength), by node type and widget name, so a star holds in every workflow. Kept in a
|
||||
* hidden ComfyUI setting, which follows the user's ComfyUI profile. Starred in the comparison
|
||||
* window (ui_param_compare.js), which shows them first, as the parameter sets do
|
||||
* (ui_param_set_keys.js).
|
||||
*/
|
||||
|
||||
import { app } from '../../../scripts/app.js';
|
||||
|
||||
export const KEY_PARAMS_SETTING_ID = 'Anomalous.ModelBrowser.KeyParameters';
|
||||
const MAX_KEYS = 40;
|
||||
const listeners = new Set();
|
||||
const keyOf = (type, widget) => `${type}\n${widget}`;
|
||||
|
||||
/** The setting's entry for registerExtension: hidden from ComfyUI's settings dialog. */
|
||||
export function keyParamsSetting() {
|
||||
return { id: KEY_PARAMS_SETTING_ID, name: 'Anomalous key parameters', type: 'hidden', defaultValue: [] };
|
||||
}
|
||||
|
||||
function stored() {
|
||||
try {
|
||||
const value = app.extensionManager?.setting?.get(KEY_PARAMS_SETTING_ID);
|
||||
return Array.isArray(value) ? value.filter(item => typeof item === 'string') : [];
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** A predicate (type, widget name) → starred, read once: pass it to compareParams. */
|
||||
export function keyParamTest() {
|
||||
const keys = new Set(stored());
|
||||
return (type, widget) => keys.has(keyOf(type, widget));
|
||||
}
|
||||
|
||||
/** Stars or unstars a widget of a node type; listeners hear of it once it is saved. */
|
||||
export async function toggleKeyParam(type, widget) {
|
||||
const key = keyOf(type, widget);
|
||||
const keys = stored();
|
||||
const next = keys.includes(key) ? keys.filter(item => item !== key) : [...keys, key].slice(-MAX_KEYS);
|
||||
await app.extensionManager?.setting?.set(KEY_PARAMS_SETTING_ID, next);
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
/** Calls `listener` after each star change; returns the function that stops it. */
|
||||
export function onKeyParamsChange(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* The LoRA nodes a combo (搭配) puts its LoRAs in: ComfyUI's own (one LoRA loader per LoRA, in a
|
||||
* chain), rgthree's Power Lora Loader (one node, a row per LoRA) or Easy-Use's LoRA stack (rows
|
||||
* of up to ten per stack node, applied to the model by Easy Apply LoraStack). A third-party one
|
||||
* is offered only where its node pack is installed. Also the Power Lora Loader's rows, which are
|
||||
* no drop-downs: read and made again for combos saved from canvas nodes (combo_structure.js), and
|
||||
* shown to Model Check as drop-down-like boxes (model_check.js). No imports, so it is tested on its own (LiteGraph is read from globalThis).
|
||||
*/
|
||||
|
||||
export const POWER_LORA = 'Power Lora Loader (rgthree)';
|
||||
const EASY_STACK = 'easy loraStack';
|
||||
const EASY_APPLY = 'easy loraStackApply';
|
||||
const EASY_ROWS = 10; // an Easy-Use stack node holds this many LoRAs; more go in a second stack feeding it
|
||||
|
||||
export const LORA_NODES = Object.freeze([
|
||||
{ id: 'core', labelKey: 'comboLoraNodeCore', hintKey: 'comboLoraNodeCoreHint', types: [] },
|
||||
{ id: 'rgthree', labelKey: 'comboLoraNodeRgthree', hintKey: 'comboLoraNodeRgthreeHint', types: [POWER_LORA], pack: 'rgthree-comfy' },
|
||||
{ id: 'easy', labelKey: 'comboLoraNodeEasy', hintKey: 'comboLoraNodeEasyHint', types: [EASY_STACK, EASY_APPLY], pack: 'ComfyUI-Easy-Use' },
|
||||
]);
|
||||
|
||||
const registered = () => globalThis.LiteGraph?.registered_node_types || {};
|
||||
|
||||
/** The combo's LoRA node by its id; ComfyUI's own for none or an unknown one. */
|
||||
export function loraNodeKind(id) {
|
||||
return LORA_NODES.find(kind => kind.id === id) || LORA_NODES[0];
|
||||
}
|
||||
|
||||
export function isInstalled(kind) {
|
||||
return kind.types.every(type => registered()[type]);
|
||||
}
|
||||
|
||||
/** The LoRA nodes this ComfyUI can make, ComfyUI's own first. */
|
||||
export function installedLoraNodes() {
|
||||
return LORA_NODES.filter(isInstalled);
|
||||
}
|
||||
|
||||
// ComfyUI's LoRA files, as its own LoRA loader lists them.
|
||||
const loraList = () => {
|
||||
const list = registered().LoraLoader?.nodeData?.input?.required?.lora_name?.[0];
|
||||
return Array.isArray(list) ? list.filter(value => typeof value === 'string') : [];
|
||||
};
|
||||
|
||||
/** ComfyUI's own spelling of a LoRA path (folders with \ on Windows), from its LoRA loader's list. */
|
||||
export function nativeLora(path) {
|
||||
const wanted = String(path ?? '').replace(/\\/g, '/');
|
||||
return loraList().find(value => value.replace(/\\/g, '/') === wanted) ?? path;
|
||||
}
|
||||
|
||||
const inputIndex = (node, name) => (node.inputs || []).findIndex(input => input?.name === name);
|
||||
|
||||
function setWidget(node, name, value) {
|
||||
const widget = node.widgets?.find(entry => entry.name === name);
|
||||
if (!widget) return;
|
||||
widget.value = value;
|
||||
widget.callback?.(value, globalThis.app?.canvas, node, null, null);
|
||||
}
|
||||
|
||||
function fitHeight(node) {
|
||||
const computed = node.computeSize?.();
|
||||
if (computed && Array.isArray(node.size)) node.size[1] = Math.max(node.size[1], computed[1]);
|
||||
}
|
||||
|
||||
/** Power Lora Loader: one row per LoRA (on, strength 1), the model and CLIP through it. */
|
||||
function powerLora({ add, source, clipSlot, loras, relX }) {
|
||||
const node = add(POWER_LORA, relX, 0);
|
||||
for (const path of loras) node.addNewLoraWidget?.(nativeLora(path));
|
||||
fitHeight(node);
|
||||
source.connect(0, node, inputIndex(node, 'model'));
|
||||
if (clipSlot !== null) source.connect(clipSlot, node, inputIndex(node, 'clip'));
|
||||
return { last: node, clipSlot: clipSlot === null ? null : 1, relX };
|
||||
}
|
||||
|
||||
/** Easy-Use: stacks of up to ten rows (a later stack feeds the one before), then Apply LoraStack on the model. */
|
||||
function easyStack({ add, source, clipSlot, loras, relX, step }) {
|
||||
const chunks = [];
|
||||
for (let at = 0; at < loras.length; at += EASY_ROWS) chunks.push(loras.slice(at, at + EASY_ROWS));
|
||||
let y = 0;
|
||||
const stacks = chunks.map((chunk) => {
|
||||
const stack = add(EASY_STACK, relX, y);
|
||||
setWidget(stack, 'toggle', true);
|
||||
setWidget(stack, 'mode', 'simple');
|
||||
setWidget(stack, 'num_loras', chunk.length);
|
||||
chunk.forEach((path, row) => setWidget(stack, `lora_${row + 1}_name`, nativeLora(path)));
|
||||
fitHeight(stack);
|
||||
y += (stack.size?.[1] || 400) + 60;
|
||||
return stack;
|
||||
});
|
||||
stacks.slice(1).forEach((stack, index) => stack.connect(0, stacks[index], inputIndex(stacks[index], 'optional_lora_stack')));
|
||||
const apply = add(EASY_APPLY, relX + step, 0);
|
||||
stacks[0].connect(0, apply, inputIndex(apply, 'lora_stack'));
|
||||
source.connect(0, apply, inputIndex(apply, 'model'));
|
||||
if (clipSlot !== null) source.connect(clipSlot, apply, inputIndex(apply, 'optional_clip'));
|
||||
return { last: apply, clipSlot: clipSlot === null ? null : 1, relX: relX + step };
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts `loras` (paths in the loras folder) after `source`, a loader giving MODEL first and, unless
|
||||
* `clipSlot` is null (a UNet), CLIP at `clipSlot`, as `kind`'s nodes made by `add(type, relX,
|
||||
* relY)` from `relX` on, `step` apart. `setPath(node, path)` fills a ComfyUI LoRA loader.
|
||||
* Returns { last, clipSlot, relX }: the node (and its CLIP output) the prompts take, and the
|
||||
* place of the last node made.
|
||||
*/
|
||||
export function chainLoras(kind, { add, source, clipSlot, loras, relX, step, setPath }) {
|
||||
if (!loras.length) return { last: source, clipSlot, relX: relX - step };
|
||||
if (kind.id === 'rgthree') return powerLora({ add, source, clipSlot, loras, relX });
|
||||
if (kind.id === 'easy') return easyStack({ add, source, clipSlot, loras, relX, step });
|
||||
let last = source;
|
||||
let x = relX - step;
|
||||
for (const path of loras) {
|
||||
x += step;
|
||||
const node = add(clipSlot === null ? 'LoraLoaderModelOnly' : 'LoraLoader', x, 0);
|
||||
setPath(node, path);
|
||||
last.connect(0, node, 0);
|
||||
if (clipSlot !== null) last.connect(clipSlot, node, 1); // a checkpoint loader and a LoraLoader both give CLIP second
|
||||
last = node;
|
||||
}
|
||||
return { last, clipSlot, relX: x };
|
||||
}
|
||||
|
||||
/** A Power Lora Loader's rows in order: [{ on, lora, strength, strengthTwo }] (lora null when none chosen). */
|
||||
export function powerLoraRows(node) {
|
||||
if (node?.type !== POWER_LORA) return [];
|
||||
return (node.widgets || []).filter(widget => /^lora_\d+$/.test(widget.name || '') && widget.value && typeof widget.value === 'object')
|
||||
.map(({ value }) => ({ on: value.on !== false, lora: typeof value.lora === 'string' ? value.lora : null,
|
||||
strength: Number.isFinite(value.strength) ? value.strength : 1,
|
||||
strengthTwo: Number.isFinite(value.strengthTwo) ? value.strengthTwo : null }));
|
||||
}
|
||||
|
||||
/**
|
||||
* A Power Lora Loader's switched-on rows with a LoRA, as boxes Model Check and the model picker
|
||||
* read like a drop-down: { name, type: 'combo', value (the row's LoRA), options.values (ComfyUI's
|
||||
* LoRA list) }. Setting the value changes only the row's LoRA; what Model Check notes on a box
|
||||
* (anomalous_replaced) is kept on the row, so it lasts from one check to the next.
|
||||
*/
|
||||
export function powerLoraRowBoxes(node) {
|
||||
if (node?.type !== POWER_LORA) return [];
|
||||
const values = loraList();
|
||||
return (node.widgets || []).filter(row => /^lora_\d+$/.test(row.name || '') && typeof row.value?.lora === 'string' && row.value.on !== false)
|
||||
.map(row => ({
|
||||
name: row.name,
|
||||
type: 'combo',
|
||||
options: { values },
|
||||
get value() { return row.value?.lora ?? null; },
|
||||
set value(lora) { row.value = { ...row.value, lora }; },
|
||||
get anomalous_replaced() { return row.anomalous_replaced; },
|
||||
set anomalous_replaced(note) { row.anomalous_replaced = note; },
|
||||
}));
|
||||
}
|
||||
|
||||
/** Makes `rows` on a new Power Lora Loader; they are named lora_1, lora_2… in that order. */
|
||||
export function addPowerLoraRows(node, rows) {
|
||||
for (const row of rows) {
|
||||
const widget = node.addNewLoraWidget?.();
|
||||
if (widget) widget.value = { ...row };
|
||||
}
|
||||
fitHeight(node);
|
||||
}
|
||||
@@ -295,7 +295,7 @@ function comfyNodeTitle(nodeType) {
|
||||
return (ctor && ctor.title) || nodeType;
|
||||
}
|
||||
|
||||
function comfyWidgetLabels(nodeType) {
|
||||
export function comfyWidgetLabels(nodeType) { // widget labels in order (ComfyUI's, else known ones)
|
||||
if (comfyWidgetLabelCache.has(nodeType)) return comfyWidgetLabelCache.get(nodeType);
|
||||
let labels = [];
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* The models page's base model filter: which family a model is listed under (its scanned base
|
||||
* model, else what its header tells, named as download_places.js names base model folders, so
|
||||
* "SDXL 1.0" and "SDXL" are one) and how many of each a list has. No DOM.
|
||||
*/
|
||||
|
||||
import { baseFolder } from './download_places.js';
|
||||
|
||||
/** The base model family a model is listed under; '' when not known. */
|
||||
export function baseFamily(model) {
|
||||
const meta = model?.metadata || {};
|
||||
const base = String(meta.baseModel || meta.base_guess || '').trim();
|
||||
return /^(unknown|other)?$/i.test(base) ? '' : baseFolder(base);
|
||||
}
|
||||
|
||||
/** [[family, count]] of `models`, most first, the unknown ones last. */
|
||||
export function countBases(models) {
|
||||
const counts = new Map();
|
||||
for (const model of models || []) {
|
||||
const family = baseFamily(model);
|
||||
counts.set(family, (counts.get(family) || 0) + 1);
|
||||
}
|
||||
return [...counts].sort((a, b) => Number(a[0] === '') - Number(b[0] === '') || b[1] - a[1] || a[0].localeCompare(b[0]));
|
||||
}
|
||||
|
||||
/** Whether a model is listed under `family` (null: every model). */
|
||||
export const inFamily = (model, family) => family === null || family === undefined || baseFamily(model) === family;
|
||||