- The scan summary marks new models whose safetensors header reads but names no drawing model
kind (a voice model such as F5-TTS) as not_drawing; the scan page asks: don't scan them,
scan them, or see each. The choice lives in <model>.anomalous.json ("scan": "skip" / "scan",
model_identity.scan_choice), saved by POST /anomalous/scan_choice (api/scan_choice.py, this
computer only, at most 2000 models, only readable .safetensors layers).
- "skip": a scan of a whole folder leaves the model out (picked by hand it is still scanned),
the summary counts it apart (excluded) with its own list to change it back, the card and the
details say "不扫描"; the details have the same button.
- The unmatched count warns only for damaged files or lookups still to do; not-found rows say
the fingerprint is kept for repairing workflows.
- model_kind.inspect_file now gives format None when the header cannot be read, as its text said.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
25 KiB
Backend Architecture
Read this document for changes to api/, scraper.py, model files and sidecars,
recipe persistence, or filesystem-facing routes.
Runtime and route boundary
__init__.py exposes the frontend directory and imports the modular route
package. api/__init__.py registers aiohttp routes, all prefixed with
/anomalous/. Changes to Python modules require a full ComfyUI restart; a
frontend reload alone does not replace registered handlers or module state.
Route registration uses explicit module references, so moving a handler between
modules cannot silently drop, duplicate, or rename an endpoint.
The backend owns filesystem authority. A browser-supplied path, filename,
category, output reference, recipe asset, or archive member is untrusted until
validated. Use the shared helpers in api/path_utils.py (also re-exported by the
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.
Never rely only on .. rejection. Absolute Windows paths, UNC paths, alternate
separators, device paths, and symlinks can bypass naïve string checks. Media
routes also enforce an explicit extension allowlist.
Configured model scope belongs to api.folder_types. Category mode uses
get_active_folder_types(); folder-manager-facing library enumeration uses
get_active_model_roots() so physical mode can retain the exact registered
type, path_idx, and real root for every enabled directory. Real roots are
deduplicated without merging same-named files from different roots. Do not
hardcode a walk across checkpoints, loras, or another category. A directory
disabled in config.json must cause no walk or metadata I/O.
/anomalous/all_scan_models follows that current folder-manager mode and keeps
its paginated response plus limit=0 full-list behavior. Its local source-link
inventory recognizes GGUF and PTH case-insensitively in addition to the existing
list formats. This route-level inventory does not expand global model-format,
scanner, metadata-parser, recovery, or cloud-identification support.
Storage ownership
Runtime UI settings and newly stored API keys live in api/config.json.
scraper.py reads it first and may fall back to the legacy root config.json.
Secrets are never persisted to browser localStorage.
Workflow Recipes live below the active ComfyUI user directory in
workflows/anomalous_recipes; Parameter Notebooks live in
workflows/anomalous_parameters. They are user data, not repository assets.
Writes validate their bounded schema and use atomic replacement.
Backend modules follow route/domain/storage ownership. Model discovery,
identity recovery, metadata mutation, and cover media live in
model_catalog.py, model_resolution.py, model_metadata.py, and
model_media.py. Recipe graph rules, recipe shaping, image processing, and
persistence/history live in workflow_schema.py, recipe_schema.py,
recipe_images.py, and recipe_store.py; recipes.py is the HTTP facade.
Material shaping, private assets, and the single persistence lock/summary cache
live in material_schema.py, material_assets.py, and material_store.py;
materials.py owns request/response mapping and compatibility entry points;
node_material.py saves one canvas node's values as a node_parameter_selection
material (deduplicated by node type and values like other parameter materials).
The former mixed utility routes are separated: media_routes.py owns card
thumbnails and model/output media lookups, gallery_routes.py owns the bounded
output snapshot and deletion (searched through image_search.py, which reads
PNG text chunks only and caches per-image records by mtime), translation_routes.py
owns provider fallback, folder_types.py owns configured visibility and scan scope, and audio_catalog.py
owns generated audio: the output-audio history, temp Preview Audio results and
their copy into output/audio/, deletion, and streaming from the output and temp
folders only. Character voices are the Anomalous_TTS node's data and never pass
through this plugin's backend.
version_manager.py runs git against the plugin checkout only (no shell, no
prompts, CREATE_NO_WINDOW on Windows) and accepts only tags that appear in the
published release list. It refuses switches while tracked files are modified,
never rewrites a local branch that has diverged from the remote, and reports
stable error codes (dirty, diverged, offline, unknown_tag, ...) that the UI
localizes. Switching changes code only; a ComfyUI restart applies it.
Offline inference sidecars use non-positive Civitai IDs as sentinels. Metadata normalization must not expose those values as release-page URLs or resolved model/version identities; only positive IDs may form a Civitai source link.
Combos (搭配, formerly Prompt Notes) use workflows/anomalous_notebooks. First access copies legacy
api/notebooks records without deleting originals or overwriting current notes.
Conflicts receive a deterministic recovered filename. A completion marker makes
the copy retryable after write failure and prevents deleted notes reappearing.
Invalid legacy records remain untouched and are listed in the migration marker.
Notebook I/O runs in a worker under its persistence lock; the UI queues snapshots
and reports failed saves instead of showing success. api.path_utils.atomic_write_json
owns bounded, flushed temporary writes followed by atomic file replacement.
The recipe card endpoint returns lightweight metadata. Full graphs and history are fetched only for detail, edit, compare, restore, export, or another operation that needs them. Successful save, update, and restore responses include a compact integrity receipt containing graph counts and the persisted workflow fingerprint.
Package import is inspect-then-commit. recipe_packages.py accepts only bounded
ZIP packages with a manifest, recipe JSON, declared contained WebP assets, and
optional bounded history. It rejects traversal, symlinks, undeclared members,
archive bombs, and checksum failures; stages all content before the final rename;
and restores the previous recipe set if replacement commit fails. Packages never
install code or dependencies.
Event-loop and scan-state rules
Potentially large disk work does not run on the aiohttp event loop. Recursive
walks, bulk metadata parsing, full-file hashing, and similar operations use
asyncio.to_thread() or the established worker process/thread path. Reading a
small bounded safetensors header is acceptable; reading a whole multi-gigabyte
model to discover metadata is not.
Background work claims state before launching. Folder scans use
.scan_in_progress; global scans use .global_scan_in_progress.
Marker files are versioned JSON records containing backend session, owner PID,
worker PID, and job ID. Status checks validate ownership and process liveness;
file existence alone is not proof that a scan is active.
A marker owned by a dead process, or a legacy plain-text marker, represents an interrupted job. Its marker, progress file, and pending selection may be removed so a restart does not permanently lock scanning. A still-running orphan worker remains locked until that recorded process exits.
scraper.py --progress-file atomically publishes enumeration state, selected
file count, current index, and filename. Status responses merge worker progress
with parent-owned folder progress. The frontend can reconstruct this state by
polling after its UI has been reopened.
scraper.py --report-file appends one JSON line per model it changed (matched,
inferred with its reason, failed with the error, renames and covers) and events
(civitai_down, done with the unchanged count). 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
read its result. The scan page's picked or listed models go to /anomalous/scan_all as
targets (--targets-file per folder), so one scan has one result.
An unmatched model's .info (id -1) keeps why: anomalous_unmatched_reason is
not_found (Civitai answered 404), network (no answer after the retries),
offline, or damaged: Civitai had no record and the safetensors header does not read
whole (model_identity.safetensors_damage: too short, a header longer than the file, not
JSON, or a tensor ending past the file's end; only the header is read). A damaged header
changes the file's hash, so the file is to be downloaded again; the scan summary counts these
(damaged). Online scans look up network and offline models again; not_found and
damaged ones wait for "look up again". After CIVITAI_DOWN_AFTER models in a row without an answer
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. 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.
MCP endpoint
POST /anomalous/mcp (api/mcp_server.py) answers one JSON-RPC message per POST with
one JSON object; GET and DELETE get 405, and nothing is sent unasked. A request whose
params._meta carries io.modelcontextprotocol/protocolVersion is served as 2026-07-28:
the MCP-Protocol-Version, Mcp-Method and (for tools/call) Mcp-Name headers must
match the body, or 400 with -32020; an unknown version gets 400 with -32022 and the
supported list. initialize selects the legacy shape (2025-03-26 to 2025-11-25); no
session id is minted, so both eras are stateless. Unknown methods are -32601 (404 for
modern requests).
The peer must be loopback with no proxy header (Forwarded, X-Forwarded-*,
X-Real-IP), and the Host and any Origin must name a loopback host; anything else is
403. ComfyUI's own origin middleware also applies.
Tools (api/mcp_tools.py) only read and run in worker threads, four at a time. Model ids
are type:path_idx:relative/path and images are paths under the output folder; both are
resolved with resolve_folder_subdir / resolve_within, so an id cannot leave its root.
A tool's own failure (bad id, missing file, unexpected error) is a result with
isError: true, not a protocol error. Pictures attached on request are the gallery's
512 px thumbnails (at most 1.5 MB).
Acting tools (api/mcp_actions.py) never delete or rename files. Canvas actions go
through mcp_bridge.ask_page: it sends {id, action, args} as the anomalous.mcp
websocket event to every open page and waits (30 s, 90 s for model checks; at most 20
waiting). The first page to POST {id, claim: true} to /anomalous/mcp/bridge gets it
(hidden tabs wait 1.5 s so a visible one wins) and posts {id, outcome} back. No page,
no answer in time, or the page's refusal becomes a tool error. Scans and speech call this
server's own /anomalous/scan_all, /anomalous/download/status, /anomalous_tts/characters, /prompt and
/history over loopback, so they run exactly as from the UI (speech waits up to two
minutes for the file). Downloads go through the page (model_download.js downloadMissing /
downloadFrom), so the placement rules and the put-in after a download are the UI's own. Canvas changes are posted to the activity log with via: "mcp".
Backups
api/backup.py packs the user's data into one .zip (POST /anomalous/backup/export, then
GET /anomalous/backup/file) in the temp folder (the newest three are kept): the stores
under stores/<part>/ (recipes, combos, materials, parameters, and comfy_workflows =
user/default/workflows when chosen; the notebooks' legacy-migration marker stays out),
settings/config.json without CIVITAI_API_KEY plus download_settings.json, and for each
model with a user layer or a user cover models/<id>/user.json and cover<suffix> (and,
unless left out, its scan file scan<.info|.civitai.info>; Civitai's cover only when asked,
stored once as civitai<.civitai_bak suffix>), listed
in manifest.json with type, relative path, size and SHA-256 (from its sidecars; none is
computed). The manifest's library lists every model the same way plus its download page;
inspecting a backup returns the ones this computer lacks, which the frontend looks up and
downloads with the model download routes (into the same folder and path). A cover is the user's when it is not byte-identical to <model>.civitai_bak.*,
as in the scanner.
POST /anomalous/backup/inspect receives the .zip into the temp folder, refuses anything
that is not this plugin's backup, too large, or has an unsafe member path, and reports per
store how many files are new, the same or different, and how many models it finds here.
POST /anomalous/backup/apply then writes only the chosen parts: new files are added, the
same skipped, different ones replaced only with replace (the file here goes to the
Recycle Bin first). Models are found by SHA-256, else by models folder, path and equal
size. A scan file is written where the model has none (replaced only with replace);
Civitai's cover where it has no cover at all, with its civitai_bak copy so it stays
Civitai's. Their user layer is merged field by field (empty fields filled; replace takes the
backup's, the old file to the Recycle Bin); the backup's cover replaces a Civitai cover
(whose civitai_bak copy stays) but not another user cover unless replace. Settings
are written only when chosen, keeping this computer's Civitai key. Export and import are
activity entries (backup_export, backup_import).
Model downloads
api/model_download.py serves Model Check's downloads. POST /anomalous/download/lookup
asks api/download_sources.py where each missing model comes from: Civitai by the
workflow's hash (the version's file with that SHA-256 or AutoV2), else a link the
workflow carries — a Civitai link naming a model version (the file of that name, else
its primary file), a Hugging Face or hf-mirror file link (size and SHA-256 from the
site's headers), a GitHub release asset or raw file — else ComfyUI-Manager's model list
(api/manager_catalog.py, the installed Manager's model-list.json, entries of the same
file name fitting the node's folder type). With the workflow's hash only a list entry
whose file has that SHA-256 counts (several of one name are each asked); without one,
a single entry of that name is offered marked by_name, never in "Download all" or
the MCP's download_missing_models. A link whose file has another SHA-256 than the
workflow's hash is refused (different_file). A site that stops answering is skipped
for the rest of the lookup (down), the others are still asked, so Civitai failing in
mainland China does not stop Hugging Face through its mirror. It also returns each type's model folders with free
space and subfolders (two levels).
POST /anomalous/download/start takes the source, type, root index and a relative path
and refuses: a non-https link or one off Civitai, Hugging Face, hf-mirror or GitHub; a
path leaving the root (resolve_within) or not ending in a model extension; a file that
exists; not enough space. Jobs run one at a time in a worker thread into
<file>.part; a new start for the same file continues it with a Range request. The
Civitai key is sent to Civitai only, as an unredirected header, so it never reaches the
file storage the download redirects to. An HTML answer (a login page) fails as
not_a_file, 401/403 as needs_key or forbidden. The finished file must match the
source's SHA-256 (else the workflow's hash) or it is removed as hash_mismatch; then it
is renamed into place, ComfyUI's file-list caches are cleared and the activity log gets
a model_download entry. A file from Hugging Face first gets its repository's model card
(api/hf_card.py, /api/models/<repo> on the same host): the first example image (else
the repository's first picture) as <model>.preview.<ext> when the model has no cover, and
its base model, trigger words (instance_prompt) and example prompts as notes plus the
repository as link in the user layer, only where those are empty. GET /anomalous/download/status lists the jobs of this run,
POST /anomalous/download/cancel stops one (its .part goes). The default place is
user/anomalous/download_settings.json ({place: workflow | folder, folder, hf_mirror}).
With hf_mirror (the lookup's flag; the page sends the setting, or for a Chinese interface
true while it is unset) Hugging Face files are looked up and fetched on hf-mirror.com, and
without it on huggingface.co, whichever host the link names. The mirror sends networks
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 (mcp_server._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 and 50,000 images. Page requests reuse it; explicit refresh and image deletion invalidate it immediately. Larger inventories are returned normally but not cached.
Metadata and embedded safetensors-header hashes may be cached only in a bounded
cache. The key includes the model's real path and the physical size, mtime_ns,
and ctime_ns signatures of the model and relevant sidecars. Return independent
copies; never expose cached mutable dictionaries or lists.
Folder listing inventories a directory once with os.scandir() and preserves
preview priority: .preview.* before bare same-stem media. Avoid an exists()
sequence for every candidate when the directory inventory already has the
answer.
Preview URLs use the selected preview file's stable nanosecond modification time
as the cache version. Do not append Date.now() or random request tokens during
ordinary listing. A changed file must change its URL; an unchanged file should
remain browser-cacheable.
Preview lookup tries a contained exact relative path first and recursively walks the library only for unresolved basename fallbacks. This locates presentation for a model value already supplied by ComfyUI; it is not Model Check discovery.
Balanced grid thumbnails are derived, longest-edge 512 px WebP files in
<user folder>/anomalous/cache/card_thumbnails (not ComfyUI's temp folder, which
ComfyUI empties on every start). The cache is keyed by source real path and
physical signature and capped at 256 MiB with oldest-accessed eviction; the
settings page reads its size (GET /anomalous/card_cache) and empties it
(POST /anomalous/card_cache/clear). A video cover's card image is its first frame
(variant=poster, decoded with PyAV, which ComfyUI ships); the card shows it until
the video plays. Listing a model type queues the covers without a card image for one
background thread, one decode at a time, at most 512 waiting. The original mode
serves source covers, and detail views always use originals. Derived media never
modifies or sits beside a user's cover; unsupported, animated, or failed inputs
fall back to the original (a video without a readable frame has no poster).
Model sidecar and cover lifecycle
Sidecar handling is non-negotiable because it can destroy user metadata or media.
.civitai_bak.*is a persistent restore source created from a real Civitai download. Setting a custom cover changes only.preview.*.- Cover reset first restores
.civitai_bak.*to.preview.*. Otherwise it may remove.preview.*only when a bare original cover can take over. If the preview is the only image, preserve it and return a visible warning. Reset never silently downloads from the network. - Physical rename moves every recognized sidecar to the new model stem. Model deletion may clean sidecars only after the main model is successfully deleted.
- Foundation components—
vae,vae_approx,clip,text_encoders, andclip_vision—are never physical-rename targets. UI and all backend entry points enforce the same denial. - Model files are
MODEL_EXTENSIONSinapi/model_constants.py(.safetensors,.ckpt,.pt,.pth,.bin,.sft,.gguf); listing, counting, search and lookups test names withis_model_file(), without case. They are never sidecar suffixes. Cleanup must not delete a same-stem model with another extension. If such a sibling remains, ambiguous stem-keyed sidecars remain. - Rename/delete/reset uses centralized immutable suffix tuples and a constant number of exact-path checks. Do not walk, glob, index, hash, or call the network for these operations.
Keep product wording precise: deletion cleans sidecars; rename migrates them.
Backend implementation rules
- Prefer standard-library and existing ComfyUI facilities; missing optional dependencies must fail as a localized capability, not break plugin import.
- Use
os.path,pathlib,os.sep, or normalization helpers for paths. Avoid hand-written backslash replacement. - Update an existing
.civitai.infodictionary rather than overwriting it with a one-field object. - Large input collections use a JSON
POSTbody. Do not place them in a query string or pass them as command-line arguments on Windows. tests/holds the plugin's tests (how to run them:docs/guides/testing.md,tools/run_tests.mjs). Runtime code never imports it.