Compare commits
186
Commits
v1.4.2
..
v1.56-beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c6be650c0 | ||
|
|
a8db942e43 | ||
|
|
838904900e | ||
|
|
5a5552286e | ||
|
|
ca730fb08d | ||
|
|
c4e9c2eda2 | ||
|
|
0ada2aab7d | ||
|
|
f56d337ea0 | ||
|
|
b38770cfcc | ||
|
|
ec65e6f0c8 | ||
|
|
e4025cadf4 | ||
|
|
6e188d34e9 | ||
|
|
cfdf892b7b | ||
|
|
d29103e70c | ||
|
|
d2575cd0a0 | ||
|
|
d3348ca40f | ||
|
|
51346120bd | ||
|
|
f7c9af7064 | ||
|
|
4821bbcc52 | ||
|
|
9dd63e2ffa | ||
|
|
ede9788cd1 | ||
|
|
520a8b7a89 | ||
|
|
0cd9a51336 | ||
|
|
6cb8cddbe0 | ||
|
|
eb2f6b7c0e | ||
|
|
f30f4e7181 | ||
|
|
6f671d402f | ||
|
|
f816a8050d | ||
|
|
1929da70d9 | ||
|
|
5067ac52d8 | ||
|
|
a156701ce4 | ||
|
|
ad165a8188 | ||
|
|
a849d12bb6 | ||
|
|
7302532057 | ||
|
|
97dd6efb27 | ||
|
|
9d2406f263 | ||
|
|
2ee14ecc96 | ||
|
|
913a58b2aa | ||
|
|
8698905b76 | ||
|
|
97bc9c6bc3 | ||
|
|
9b01dfb00f | ||
|
|
c753a6c30a | ||
|
|
0f77e0b766 | ||
|
|
a404a5064a | ||
|
|
df41733c02 | ||
|
|
2a8009004b | ||
|
|
f29374a658 | ||
|
|
aa53007df2 | ||
|
|
3d03601f9d | ||
|
|
e81b4dc6d1 | ||
|
|
e3025565da | ||
|
|
c294494ebb | ||
|
|
6b26ddf729 | ||
|
|
ea6f3c43ab | ||
|
|
23f4dbb05b | ||
|
|
469e510c5b | ||
|
|
ae47164fb8 | ||
|
|
c89456b456 | ||
|
|
559fd2457b | ||
|
|
7f50cbb898 | ||
|
|
54397e5411 | ||
|
|
cbd7c81e26 | ||
|
|
cbe9932211 | ||
|
|
cc48b0216c | ||
|
|
09d3eccf40 | ||
|
|
bc66782d90 | ||
|
|
00aefb9eed | ||
|
|
68a979630c | ||
|
|
f9f52a6f65 | ||
|
|
9ba29f9bed | ||
|
|
06bfdaa5ae | ||
|
|
ecb192dcde | ||
|
|
e266542305 | ||
|
|
a81f95bce0 | ||
|
|
2081589ee5 | ||
|
|
cd7b77fb24 | ||
|
|
36eb970893 | ||
|
|
e13bf70296 | ||
|
|
16466b32c6 | ||
|
|
61f8ca0c82 | ||
|
|
ce3f4dba1f | ||
|
|
f485a46600 | ||
|
|
9ad487b371 | ||
|
|
acfb24d0ce | ||
|
|
d6633212e0 | ||
|
|
f1d394b543 | ||
|
|
d74390b6c5 | ||
|
|
014c1fe4cf | ||
|
|
9daa71ba2a | ||
|
|
b56cd844e0 | ||
|
|
ad15565a50 | ||
|
|
b5b9134d21 | ||
|
|
fe79bb2109 | ||
|
|
3d4af1cc97 | ||
|
|
1309889ee8 | ||
|
|
afd7d59b75 | ||
|
|
a05d81448a | ||
|
|
cc514f8c22 | ||
|
|
dd80c89068 | ||
|
|
cc5d9c11db | ||
|
|
6b4ca9e963 | ||
|
|
f64871bcd2 | ||
|
|
37f2bac512 | ||
|
|
30c96c08d9 | ||
|
|
c75395f5c3 | ||
|
|
2e7241ecca | ||
|
|
ef172d890e | ||
|
|
ccaa4c1d0a | ||
|
|
50409182f5 | ||
|
|
69e43b88d9 | ||
|
|
8be2407635 | ||
|
|
11dfa43914 | ||
|
|
c217595f4e | ||
|
|
6a91a18fb4 | ||
|
|
b0eda4ef1f | ||
|
|
efaee80cf1 | ||
|
|
8658e7afde | ||
|
|
5208132473 | ||
|
|
00f7010064 | ||
|
|
e6f060a608 | ||
|
|
36ea570556 | ||
|
|
978e9f7750 | ||
|
|
425778924d | ||
|
|
8b6128a097 | ||
|
|
9d273c4897 | ||
|
|
0cc84783a7 | ||
|
|
c7d9c2955b | ||
|
|
6874116776 | ||
|
|
4f6d26f59c | ||
|
|
e9bd2c31f2 | ||
|
|
fa5a2f7f95 | ||
|
|
cc735f29b3 | ||
|
|
4946864f8b | ||
|
|
a7f6333d00 | ||
|
|
f26098780d | ||
|
|
8a65bb9ea5 | ||
|
|
9ecb733e64 | ||
|
|
d61df08233 | ||
|
|
a9d767e72f | ||
|
|
2d0eebcf77 | ||
|
|
bb530c47bb | ||
|
|
8241584784 | ||
|
|
db2e3f9f0b | ||
|
|
5fb01d1879 | ||
|
|
b775058280 | ||
|
|
a35e1fa463 | ||
|
|
b2784adfd4 | ||
|
|
14a1946fe2 | ||
|
|
4d4d0f4753 | ||
|
|
06fc379357 | ||
|
|
52c327c920 | ||
|
|
6ecb7f6c06 | ||
|
|
8340530e67 | ||
|
|
e90e4e4f99 | ||
|
|
2af795f492 | ||
|
|
c3132c2aaf | ||
|
|
05a3b5f4aa | ||
|
|
0cd5f71061 | ||
|
|
f62df5a363 | ||
|
|
c1069ad766 | ||
|
|
74f37ff0ec | ||
|
|
91d4f0c901 | ||
|
|
52a56acb88 | ||
|
|
f07459ce80 | ||
|
|
05ff5b0677 | ||
|
|
ed01b182b8 | ||
|
|
c3a2e6fa1e | ||
|
|
48526d211c | ||
|
|
4de6691137 | ||
|
|
17a82a593d | ||
|
|
bac0b8f757 | ||
|
|
dee6e6ec79 | ||
|
|
ab86fceb35 | ||
|
|
f604ecd063 | ||
|
|
e91ce32cd5 | ||
|
|
50b3c3250e | ||
|
|
c955903b5a | ||
|
|
17d47bb50b | ||
|
|
2d10ee81be | ||
|
|
14541bdfc9 | ||
|
|
fa61ae327d | ||
|
|
e701072068 | ||
|
|
57bb9dac8e | ||
|
|
fa5e71a5e4 | ||
|
|
cf2866dc10 | ||
|
|
3ce52bd9d6 |
+66
@@ -1,3 +1,69 @@
|
||||
# Local configuration and credentials
|
||||
config.json
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
credentials*.json
|
||||
secrets*.json
|
||||
*.local.json
|
||||
*.pem
|
||||
*.key
|
||||
*.p12
|
||||
*.pfx
|
||||
|
||||
# Runtime caches, logs, databases, and temporary files
|
||||
*.pyc
|
||||
__pycache__/
|
||||
.cache/
|
||||
cache/
|
||||
logs/
|
||||
tmp/
|
||||
temp/
|
||||
*.cache
|
||||
*.log
|
||||
*.pid
|
||||
*.tmp
|
||||
*.temp
|
||||
*.bak
|
||||
*.backup
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
*.db
|
||||
|
||||
# OS and editor-local state
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
*.swp
|
||||
*~
|
||||
|
||||
# Node/JS environment junk
|
||||
node_modules/
|
||||
package.json
|
||||
package-lock.json
|
||||
web/main.js.rebuilt
|
||||
|
||||
# Local notebooks drafts
|
||||
notebooks/
|
||||
api/notebooks/
|
||||
|
||||
# AI Dev Scripts & Temporary text dumps
|
||||
scripts/
|
||||
doctor_code.txt
|
||||
docs/
|
||||
AI_SYSTEM_TEMPLATE.md
|
||||
|
||||
# AI Agents Directory
|
||||
.agents/
|
||||
|
||||
# Test files
|
||||
test_*.js
|
||||
test*.mjs
|
||||
tests/
|
||||
|
||||
# Local diagnostic/test artifacts
|
||||
grep_doctor.txt
|
||||
test_folders.json
|
||||
*.bat
|
||||
|
||||
+361
@@ -0,0 +1,361 @@
|
||||
# Anomalous Model Browser - Architecture Summary (架构指南)
|
||||
|
||||
This document provides a high-level overview of the Anomalous Model Browser plugin for ComfyUI. It is designed to quickly onboard new AI agents or developers to the project's structure, design philosophy, and critical subsystems.
|
||||
|
||||
Workspace lifecycle note: reopening an already initialized Workspace must restore the visible notebook body and reset the hidden recipe body before refreshing data. Hiding the overlay alone is not enough because the next open reuses the existing DOM tree.
|
||||
|
||||
The floating trigger follows the browser modal lifecycle: opening the main browser hides the trigger, while every normal close path restores it. The hidden state uses a scoped class with pointer-events disabled; initialization errors keep the trigger available as the recovery entry point.
|
||||
|
||||
Hidden plugin dialogs must not advertise an active modal state. The model-card settings dialog remains mounted for reuse, but its `aria-modal` value follows the overlay lifecycle: `false` while hidden and `true` only while visibly open. This preserves accessibility semantics without tripping ComfyUI Frontend's global modal guard, which otherwise suppresses command-backed shortcuts such as Queue Prompt, Interrupt, and Escape-to-exit-subgraph. The plugin does not install replacement global keyboard handlers or mutate ComfyUI command bindings.
|
||||
|
||||
Origin refresh is an explicit enrichment mode passed into `_enrich_recipe`; it must not mutate the Python list of references with ad-hoc state. Origin refresh writes schema v5, while normal updates preserve previously imported identity/origin records when the current machine cannot re-resolve them.
|
||||
|
||||
Recipe identity badges use short, bounded labels in the card layout; detailed explanations belong in the help tooltip. Identity badges and model-name rows must remain shrinkable flex children so localized or imported text cannot expand the recipe card beyond its container.
|
||||
|
||||
Recipe common-parameter summaries use a readable minimum-width responsive grid and an explicit label/value row; labels stay horizontal, while long summary values use a single-line ellipsis with copy preserving the full value. Full expansion belongs to detailed parameter rows, not to narrow summary cards.
|
||||
|
||||
Availability refresh actions expose immediate busy feedback, an accessible `aria-busy` state, and a recoverable error state; a disabled button without visible progress is not considered sufficient interaction feedback.
|
||||
|
||||
Identity help uses a click-to-expand inline explanation attached to the badge. The explanation stays in normal card flow with bounded width and wrapping, so it cannot be duplicated by a native `title` tooltip or cover adjacent flex content.
|
||||
|
||||
Recipe origin editing has two supported update paths: complete recipe updates for metadata/workflow edits, and a refresh-only request containing `filename` plus `refreshIdentities`. The latter must clone the stored recipe before enrichment so the refresh action cannot be rejected by complete-recipe validation or accidentally discard the saved workflow.
|
||||
|
||||
Origin edit dialogs locate a saved model reference by stable provenance fields (`node_id`, `widget_index`, category, and saved value), not by display-only fields such as `type`. Localized action labels must use keys that exist in the shared locale catalog; a missing key must never make a visible dialog action appear inert.
|
||||
|
||||
Recipe import matching is a separate, explicit local-recovery action. The recipe detail entry point sends all unresolved references to the hash-resolution batch endpoint, constrained by the reference's model category; the saved author path and filename are never identity evidence. A found local candidate is presentation-only until the user clicks `Apply match`, which updates the corresponding workflow widget through the full-recipe update contract and archives the prior recipe. Exact saved-path availability checks remain a separate lightweight preview operation.
|
||||
|
||||
## Current recipe interaction correction (2026-08-03)
|
||||
|
||||
The recipe detail view keeps Overview, Parameters, and Versions tabs. The Overview contains the model composition blocks; each resolved model name or preview is an explicit entry into the browser model detail view. Model previews are demand-loaded when Overview opens, and returning from model detail restores the recipe detail view. The model browser stores the active grid folder and scroll coordinates before opening detail and restores them when returning to the grid. List-card Append actions await the shared canvas transaction before reporting success or restoring the button state. Export confirmation uses three states (cancel, exclude, include); cancellation must stop the export flow rather than silently becoming an exclusion.
|
||||
|
||||
The Workspace close action is a state restoration boundary. Opening the Workspace records which main-browser panel was visible; closing it hides recipe/notebook content and restores that panel, falling back to the model grid when the previous state is unavailable. All Workspace close affordances use the same cleanup function.
|
||||
|
||||
## Recipe result gallery (2026-08-04)
|
||||
|
||||
Recipe detail has an on-demand Gallery tab for reusing the user's own generated results. Gallery discovery uses a separate `sha256-node-types-v1` signature: it compares the sorted node class composition and count, deliberately ignoring seeds, prompts, model values, and other parameters so ordinary variations still appear. The original `sha256-structural-v1` fingerprint remains the recipe/history integrity fingerprint. Opening a detail view scans at most the newest 200 PNG files below ComfyUI's output directory and reads only bounded embedded `workflow` or API `prompt` metadata; there is no background polling or persistent output index. Clicking a result loads bounded node parameters and shows recipe-versus-image differences; the match itself remains node-only. The main output Gallery refreshes on each open and has no persistent toolbar refresh strip. A chosen result becomes a recipe-owned, compressed WebP cover in `.assets/<recipe-stem>/`, so it survives output cleanup and follows the recipe during package export/import. The original output image remains a local convenience link, never package data or identity evidence.
|
||||
|
||||
## Recipe Parameters & Parameter Notebooks (2026-08-04)
|
||||
|
||||
The "Parameter Notebook" system has been fully integrated into the Workflow Recipe detail view, replacing the static parameters presentation. Rather than existing as a disjoint global list in the sidebar, Parameter Notebooks are now intrinsically linked to their parent Workflow Recipe.
|
||||
|
||||
When a user saves or updates a recipe, the system automatically creates a new, immutable Parameter Notebook capturing the exact generation values of that moment, and associates it with the recipe (`recipe_filename`). In the Recipe Detail view, the "Parameters" tab is now a two-pane interface: a sidebar listing the history of Parameter Notebooks for that recipe, and a main viewer showing the parameters and matching `sha256-params-v1` gallery for the selected notebook. The global sidebar entry for Parameter Notebooks has been removed, solidifying recipes as the primary structural template and parameter notebooks as their concrete instantiation history.
|
||||
|
||||
The Parameters tab keeps runtime-volatile values such as sampler seeds visible by name but redacts their values from summaries, copies, editing, and parameter matching; the raw serialized workflow retains the slot only for ComfyUI compatibility. Common values use readable horizontal label/value cards and preserve full text instead of silently truncating it, and complex arrays like LoRAs are formatted into clean multi-line bulleted lists. The right-hand viewer identifies the active notebook by name and timestamp, and a notebook switch uses a staggered waterfall transition animation to provide clear visual feedback; stale parameter-gallery responses cannot overwrite the newly selected notebook. “New parameter note” clones the selected recipe/snapshot into an editable draft and saves a new immutable snapshot; it never edits or overwrites historical notes. Parameter notebooks support renaming via `POST /anomalous/rename_parameter` (`filename`, `name`), safely updating the notebook name with atomic writes and invalidating in-memory caches. The UI exposes renaming both in the sidebar list rows and on the active notebook detail heading.
|
||||
|
||||
Parameter Notebook actions include “Read current and create” and “Apply to current workflow”. Both use the same skeleton preflight: every saved node must have a matching local node by type/shape (with stable-ID/title preference), while extra local nodes are allowed. Read-current refuses to create a draft when the recipe skeleton is incomplete. Apply uses serialized nodes only for matching, then resolves each match back to the live ComfyUI node before reading `widgets` or invoking callbacks; serialized workflow records must never be treated as runtime widget objects. It validates all widget slots before mutation, skips volatile values, writes widget values inside `beforeChange`/`afterChange`, and calls `onWidgetChanged(index, value, previousValue, liveWidget)` so current ComfyUI error-clearing hooks receive the widget object they require. It marks the canvas dirty and restores prior widget values if a callback fails.
|
||||
|
||||
## Node Assistant & Parameter Notebook Integration (2026-08-06)
|
||||
|
||||
The Parameter Notebook system has been extended to act as a contextual preset library within the Node Assistant (`ui_doctor.js`). When a user selects a specific node (e.g., `KSampler`), the Node Assistant automatically queries the backend for all historical Parameter Notebooks across all recipes, filtering specifically for saved nodes that match the selected `type`.
|
||||
|
||||
The backend (`api/parameters.py`) implements a lightweight in-memory cache for notebook parsing to ensure instantaneous UI updates without heavy disk I/O, which can be bypassed via `?refresh=1` for immediate UI synchronization. The filtered presets are rendered hierarchically (Recipe -> Notebook -> Parameter Summary). Applying a preset invokes `applyLocalNodeParameters`, which performs a targeted, single-node widget replacement while preserving the rest of the canvas and safely triggering all required ComfyUI widget callbacks.
|
||||
|
||||
### Topological Prompt Tracing and Dynamic Role Injection
|
||||
Prompt tagging (Positive vs Negative) leverages a robust Topological Backward-Tracing engine (`recipe_parser.js`) rather than fragile regex matching on node titles. During recipe save, the engine starts from known conditioning ports on samplers (including `positive`, `negative`, `conditioning`, `cond`, `uncond`, `model_cond`, etc.) and traces backward through the graph to explicitly tag upstream `CLIPTextEncode` nodes.
|
||||
These topological `role` properties are baked into the recipe's `metadata.nodes`. To seamlessly bridge this structural knowledge to the Node Assistant, `api/parameters.py` performs Dynamic Role Injection—reading the bounded recipe's metadata by `node_id` and injecting the `role` back into the Parameter Notebook response. This ensures the UI accurately displays `[🟢 正面]` or `[🔴 负面]` prefixes without guessing.
|
||||
|
||||
## 1. Project Philosophy (设计理念)
|
||||
* **Zero Frameworks**: No React, Vue, or build tools. Everything is Vanilla JS and CSS for maximum compatibility, minimum overhead, and zero compilation steps.
|
||||
* **Non-Intrusive Integration**: Operates as a floating Gemini-style popover (`#anomalous-container`) mounted via ComfyUI's standard UI extension system. It avoids altering the native ComfyUI canvas except when explicit interaction is required. The floating trigger is mounted before browser panels are initialized and retries initialization on click, so a panel regression or missing optional browser API cannot hide the plugin entry point.
|
||||
* **Strict DOM Obliteration**: Rather than caching complex DOM structures (like `display: none` for large grids), the UI strictly employs `innerHTML = ''` when navigating between folders. This enforces immediate Garbage Collection, crucial for performance when users have thousands of models.
|
||||
* **Offline-First Resilience**: Model metadata is parsed from local `.info` and `.civitai.info` files or extracted directly from `.safetensors` headers via Python `struct`. API calls to Civitai are explicit, user-initiated actions.
|
||||
|
||||
## 1.5 Internationalization and Licensing Boundaries (2026-08-06)
|
||||
|
||||
The extension is a UI-only ComfyUI integration: `NODE_CLASS_MAPPINGS` is intentionally empty, and the project does not provide or install custom nodes. Its runtime is split between Vanilla JS/CSS frontend modules and Python `aiohttp` routes hosted by ComfyUI. The repository currently has no bundled font, image, video, vendor library, dependency manifest, or license file; Civitai, optional DeepL/Google translation endpoints, ComfyUI host APIs, and Pillow/aiohttp are runtime/environment integrations rather than bundled project assets.
|
||||
|
||||
`web/modules/locales.js` is the canonical home for user-facing translation data. The current codebase still contains substantial inline `zh`/`en` branches across `main.js`, Sidebar, Detail, Gallery, Doctor, Notebook, Dialog, Grid, and the optional hash resolver. The first i18n migration snapshot now provides `supportedLocales`, `normalizeLocale`, `resolveLocale`, `formatTranslation`, `translate`, and `createTranslator`. `main.js` and `ui_dialog.js` use this contract; the remaining modules are intentionally unmigrated until their own reviewable snapshots. The refactor must preserve current Chinese and English behavior, keep model names/paths/node types/user data out of locale dictionaries, and keep translated HTML inside the existing `safe_dom.js` boundary. Missing translations must never prevent a panel or action from rendering. Language selection remains the existing zh/en toggle until a later phase introduces a registered locale selector.
|
||||
|
||||
Every i18n or licensing change is a local, reviewable snapshot. The implementation must update this document in the same change, run proportional syntax/runtime checks, and create a local Git commit. Remote pushes require explicit user authorization. The MIT license applies to project-owned code and documentation only; external service content and host-project dependencies retain their own terms.
|
||||
|
||||
The second migration snapshot extends the shared translation contract to `ui_grid.js` and `ui_gallery.js`. Gallery confirmation and cover-selection banners are assembled with DOM nodes and `textContent` rather than translated raw HTML; user-controlled model names remain text content. These modules resolve strings at render time through `translate`, so existing global language changes continue to be reflected without introducing a new locale selector.
|
||||
|
||||
The third migration snapshot extends the same contract to `ui_detail.js`, `ui_doctor.js`, and `ui_notebooks.js`. Detail editing, model selection, Doctor diagnostics, the Node Assistant, the model picker, and Notebook validation now resolve user-facing strings through `translate` with parameter interpolation for counts, paths, model names, and scan status. Dynamic model/path/node data remains outside the locale dictionaries; rich UI state is composed with DOM nodes where translated text replaced previously formatted status HTML.
|
||||
|
||||
The fourth migration snapshot applies the contract to `web/main.js` for preflight workflow import and workflow share-code export/import. These modal surfaces use shared keys for labels, status messages, and count interpolation; the remaining locale conditional in `translateText` only maps the active locale to the backend service's `zh-CN` or `en` code and is not a UI copy branch. `ui_sidebar.js` remains a separate pending snapshot because its scan wizard, settings hub, language toggle, and folder manager require their own key extraction review.
|
||||
|
||||
The fifth migration snapshot applies the contract to `web/modules/ui_sidebar.js`. Sidebar navigation, scan wizard cards, metadata/hash scan controls, model settings, language-toggle refreshes, layout controls, and folder management now resolve user-facing copy through `translate`. Wizard descriptions that intentionally contain trusted formatting remain dictionary-owned rich text; dynamic file names, counts, API errors, and folder data remain runtime values. Locale checks used only for the language CSS class and toggle direction remain functional state logic rather than inline UI copy.
|
||||
|
||||
Licensing and community-delivery work are intentionally deferred. The current local MIT `LICENSE` change is owned by the user's separate Gemini workflow and is not part of the i18n snapshots. Russian localization begins only after the contributor submits a PR. Remote pushes and Issue #9 synchronization remain disabled until the user explicitly authorizes them after other feature work is complete.
|
||||
|
||||
The sixth migration snapshot completes the Stage 2 UI i18n pass for `ui_recipes.js`, `ui_recipe_detail.js`, the supporting Recipe modules, and `web/hash_resolver.js`. Recipe modules now use the shared `translate` contract rather than importing the dictionary directly; the hash resolver uses the same contract for its manual repair summary with a dynamic repaired-count parameter. Recipe workflow/model data remains runtime data and is not added to locale dictionaries.
|
||||
|
||||
## 2. Directory Structure (核心文件结构)
|
||||
|
||||
```text
|
||||
Anomalous_Model_Browser/
|
||||
├── .agents/ # Local, ignored AI handoff material
|
||||
│ ├── logs/
|
||||
│ │ └── ai_lessons.md # Curated experience summary and recurring pitfalls
|
||||
│ └── plans/ # Product/design proposals that are not runtime behavior
|
||||
├── __init__.py # ComfyUI Extension Entry Point. Registers nodes and sets WEB_DIRECTORY = "./web"
|
||||
├── model_policies.py # Shared backend rename and hash-only recovery policy for protected categories
|
||||
├── api/ # Backend Python API (Modularized)
|
||||
│ ├── __init__.py # Appends all modular routes to server
|
||||
│ ├── config.py # Configuration and paths setup
|
||||
│ ├── metadata.py # Model metadata extraction and parsing
|
||||
│ ├── models.py # Core model listing and routing (API endpoints)
|
||||
│ ├── notebooks.py # Notebooks management logic (Prompt Notes)
|
||||
│ ├── parameters.py # Parameter Notebook CRUD and Gallery logic
|
||||
│ ├── recipes.py # Local Workflow Recipe CRUD and validation
|
||||
│ ├── recipe_packages.py # Bounded ZIP inspection, export, staging, and import commit
|
||||
│ ├── scanner.py # Scanning engine for hash resolution and caching
|
||||
│ └── utils.py # Shared backend utilities and safe path boundary helpers
|
||||
├── scraper.py # Async HTTP client logic & web scraping (Root Level)
|
||||
├── CHANGELOG.md # Version history
|
||||
├── web/
|
||||
│ ├── main.js # Frontend Vanilla JS Entry (Under 1000 lines, mounts modules to prototype)
|
||||
│ ├── hash_resolver.js # Frontend auto-fix engine: scans workflows for missing models
|
||||
│ ├── styles.css # Vanilla CSS with scoped classes (.anomalous-*)
|
||||
│ └── modules/ # Extracted UI logic modules
|
||||
│ ├── model_policies.js # Frontend category inference plus protected rename/recovery policy
|
||||
│ ├── ui_sidebar.js # Sidebar and folder tree logic
|
||||
│ ├── ui_detail.js # Model detail panel
|
||||
│ ├── ui_doctor.js # Model Doctor and Assistant panel
|
||||
│ ├── ui_notebooks.js # Prompt Notes editor
|
||||
│ ├── ui_recipes.js # Workflow Recipe cards, search/filter, save dialog, and actions
|
||||
│ ├── recipe_parser.js # Read-only LiteGraph recipe metadata extraction
|
||||
│ ├── recipe_identity.js # Pure recipe model-reference and identity normalization
|
||||
│ ├── recipe_diff.js # Pure bounded semantic version comparison
|
||||
│ ├── recipe_actions.js # Transactional canvas-append actions
|
||||
│ ├── ui_recipe_detail.js # Demand-loaded recipe detail panel and tab rendering
|
||||
│ ├── ui_gallery.js # Fullscreen Gallery Viewer
|
||||
│ ├── ui_grid.js # Main grid and model loading
|
||||
│ ├── graph_splice.js # Transactional MODEL/CLIP chain insertion and rollback
|
||||
│ ├── locales.js # Dedicated multi-language dictionary (i18n)
|
||||
│ └── safe_dom.js # HTML escaping and allowlisted rich-text sanitization
|
||||
├── tests/ # Backend security and path-boundary regression tests
|
||||
├── docs/ # Supplemental documentation
|
||||
```
|
||||
|
||||
## 3. Backend Architecture (Python API)
|
||||
Modularized into the `api/` package. Registered with ComfyUI's internal `aiohttp` server.
|
||||
All endpoints are prefixed with `/anomalous/`.
|
||||
|
||||
### Python Rules of Engagement:
|
||||
1. **Never block the event loop**: File I/O should ideally be offloaded or fast. Large files are handled via `struct` (reading only the first 64KB for metadata), avoiding full reads.
|
||||
2. **Handle missing dependencies gracefully**: Avoid 3rd-party pip requirements if possible.
|
||||
3. **Always Restart ComfyUI**: Any changes to the `api/` package **REQUIRE** a full ComfyUI server restart to take effect.
|
||||
4. **Never use hardcoded backslash strings**: When writing path manipulation code, use `os.sep` or `os.path.normpath()` instead of `replace('\\', '/')`. Editing tools may corrupt escaped backslashes silently.
|
||||
5. **Dynamic Folder Resolution (Config-Driven)**: Never hardcode folder types (like `checkpoints`, `loras`). Always use `api.utils.get_active_folder_types()` to enforce whitelist logic. Folders disabled by the user in `config.json` are completely skipped during `os.walk`, ensuring zero I/O overhead for hidden directories.
|
||||
6. **Strict Path Containment**: Every request path must go through `resolve_folder_subdir()`, `resolve_within()`, and `require_filename()` as appropriate. Checking only for `..` is forbidden: absolute Windows paths, UNC paths, alternate separators, and symlinks can otherwise escape the configured model/output/notebook directory. File-serving endpoints must also enforce an explicit media-extension allowlist.
|
||||
7. **Canonical Configuration**: Runtime UI settings and newly saved API keys live in `api/config.json`. `scraper.py` reads that file first and falls back to the legacy root `config.json` only for backward compatibility. API keys must never be persisted in browser `localStorage`.
|
||||
8. **Atomic Background State**: Set scan state or create the exclusive marker file before launching a worker thread/process. Folder scans use `.scan_in_progress`; global quick scans use `.global_scan_in_progress`; deep missing-model scans use `GLOBAL_SCAN_STATE`, exposed by `/anomalous/scan_missing_models_status`.
|
||||
9. **Private Development Tests**: The local `tests/` directory contains backend and frontend regression tests for maintainers only. It is intentionally ignored by `.gitignore` and must not be included in the installable/plugin Git distribution. Keep these files locally for validation; do not import them from runtime code or move them into the shipped extension bundle.
|
||||
10. **Workflow Recipes Are User Data**: Recipes live only in `ComfyUI/user/default/workflows/anomalous_recipes`, never in the extension repository. `GET /anomalous/recipes` returns card metadata only; the graph is fetched separately through `GET /anomalous/recipe_full`. Recipe writes are bounded, validate local-only data-image thumbnails and contained output-image references, validate graph node IDs/widget bounds/link endpoints before writing, and use atomic replacement. Successful save, update, and restore responses include a compact integrity receipt (node/link/group counts and the persisted workflow fingerprint). A bound output stores a compressed cover plus a local source reference; it never copies an arbitrary path supplied by the browser. Export packages strip machine-local `source_image` paths, include the union of current and selected-history snapshot assets, and preserve imported model identity records independently from transient local availability. Replacement import stages recipe/assets/history and restores the prior set if the commit fails. `recipe_packages.py` accepts only bounded ZIP packages with `manifest.json`, `recipe.json`, declared contained WebP assets, and optional bounded history. Import is inspect-then-commit, rejects traversal/symlinks/archive bombs/checksum failures, never installs code or dependencies, and stages before atomic rename.
|
||||
|
||||
### NON-NEGOTIABLE: Model Sidecar & Cover Lifecycle / 模型伴生文件与封面生命周期死规矩
|
||||
* `.civitai_bak.*` is a persistent restore source created from a real Civitai download. Setting a custom cover MUST modify only `.preview.*`; it MUST NOT delete, overwrite, or repurpose `.civitai_bak.*`.
|
||||
* Reset cover priority is fixed: restore `.civitai_bak.*` to `.preview.*`; otherwise remove `.preview.*` only when a bare original cover (`model.png`, etc.) can take over naturally. If the active `.preview.*` is the only image, preserve it and return a visible warning instead of causing irreversible cover loss. Reset does not silently redownload from the network.
|
||||
* Physical rename means **move/rename** every recognized sidecar, including `.civitai_bak.*`, to the new model stem. It never means deleting the Civitai backup. Model deletion may clean sidecars only after the selected main model was successfully deleted.
|
||||
* Foundation components (`vae`, `vae_approx`, `clip`, `text_encoders`, and `clip_vision`) are never physical-rename targets. The scan wizard may still fetch their metadata and apply virtual display names, but its physical-rename mode must visibly disclose the protection. Detail UI, scan APIs, metadata updates, and the standalone scraper all enforce the same denial so stale clients or direct requests cannot bypass it.
|
||||
* Main model extensions (`.safetensors`, `.ckpt`, `.pt`, `.bin`) are never sidecar suffixes. A cleanup operation MUST NOT delete a same-stem model with another extension. If such a sibling survives, stem-keyed sidecars are ambiguous and must be preserved for it.
|
||||
* Performance boundary: sidecar operations use centralized immutable suffix tuples and a constant number of exact-path checks. Do not use `os.walk`, directory-wide globbing, full-directory indexing, hashes, or network calls for rename/delete/reset. This keeps the operation independent of model-folder size.
|
||||
* Preserve the product-language distinction in documentation and UI: **delete cleans sidecars; rename migrates sidecars**. Never describe both operations as deleting or "taking away" the files.
|
||||
|
||||
### NON-NEGOTIABLE: Lossless Performance Boundaries / 无损性能优化死规矩
|
||||
* Metadata and embedded safetensors-header hashes may be cached only with a bounded cache whose key includes the real path and physical file signatures (`size`, `mtime_ns`, and `ctime_ns`) for the model and both metadata sidecars. Callers receive independent copies; cached mutable dictionaries/lists must never be exposed directly.
|
||||
* Preview URLs use the preview file's stable nanosecond modification time as their cache version. Never append `Date.now()` or another per-request random token during ordinary listing: doing so disables the browser cache and redownloads unchanged media. A changed cover must still produce a changed URL.
|
||||
* Folder listing must inventory a directory once with `os.scandir()` and preserve the established preview priority (`.preview.*` before bare media). Do not perform one `exists()` sequence per model when the directory inventory already contains the answer.
|
||||
* Recursive directory walks, metadata parsing, and other potentially large disk operations must run through `asyncio.to_thread()` rather than blocking ComfyUI's aiohttp event loop. This improves responsiveness without skipping files.
|
||||
* Large grids keep the complete ordered result set but create cards in bounded animation-frame chunks. Images use native lazy loading/async decoding; videos delay their source until near the viewport and retain the configured autoplay/hover behavior once activated. A new folder request must cancel the previous request and stale render generation.
|
||||
* Card-quality choice is deliberately user-facing but bounded to two understandable modes: `balanced` requests a derived longest-edge-512px WebP for static grid covers, while `original` serves the source cover. Detail pages continue to use the original in both modes. Derived thumbnails live only in ComfyUI's temporary area, are keyed by the source real path plus physical signatures, and are capped at 256 MiB with oldest-accessed eviction. They MUST NOT modify, replace, rename, or sit beside the user's source cover; unsupported/animated images and any generation failure fall back to the original.
|
||||
* Closing the browser immediately aborts listing work, disconnects observers, pauses all media, and releases grid video/audio sources. Lightweight card DOM may remain warm for 90 seconds for a smooth reopen, after which cards and retained model payloads are released automatically. Folder replacement must stop old media before removing DOM nodes. Non-grid panels retain their established UI state, so their paused media is rebuilt only by the panel's existing lifecycle rather than being left visibly broken on reopen.
|
||||
* Model Doctor batch resolution is an I/O optimization only. Provenance-rich workflows skip the redundant full filename-to-hash cache refresh; legacy workflows missing injected provenance still refresh it for compatibility. Requests are grouped by the exact required model-type tuple so each group is scanned once, while every item still uses the same cryptographic hash, exact byte-size, category constraint, conflict rejection, and ambiguity rules as `/anomalous/resolve_hash`. Batch failure must fall back to the single-item endpoint; batching must never introduce filename/path evidence.
|
||||
* Preview resolution should try contained exact relative paths first and walk the library only for unresolved basename fallbacks. Exact-path lookup here locates a preview for a model value already supplied by ComfyUI; it is not Model Doctor identity discovery.
|
||||
|
||||
## 4. Frontend Architecture (Vanilla JS)
|
||||
Located in `web/main.js` (Under 1000 lines, fully refactored into ES Modules). Wraps its logic inside `app.registerExtension({ name: "Anomalous.ModelBrowser", ... })`.
|
||||
|
||||
### The Modular Extraction Strategy (模块化拆分架构)
|
||||
We have successfully transitioned from a monolithic `main.js` to a modular ES architecture.
|
||||
Instead of fragmenting the class scope and losing context, we extracted all UI panels into `web/modules/` and bound them back to the `AnomalousBrowser.prototype`.
|
||||
1. **Modules**: All major UI components (`ui_sidebar.js`, `ui_detail.js`, `ui_doctor.js`, `ui_notebooks.js`, `ui_gallery.js`, `ui_grid.js`) are decoupled ES modules.
|
||||
2. **Shared State**: Everything continues to live on the `AnomalousBrowser` class instance (`this.xxx`). No complex context passing required.
|
||||
3. **TOC (Table of Contents)**: The top of `main.js` contains a TOC mapping out the extracted module bindings.
|
||||
|
||||
### UI Components (Dynamically Created):
|
||||
* **Sidebar (`anomalous-sidebar`)**: Renders the nested folder structure.
|
||||
* **Folder Manager (📁 Manage Folders)**: An interactive modal in `ui_sidebar.js` allowing drag-and-drop reordering and visibility toggling of folders. Drives backend I/O optimization.
|
||||
* **Grid (`anomalous-grid`)**: The main model display area. Emptied (`innerHTML = ''`) and repopulated on every folder click. A virtual/custom display name is the card title when present, while the physical filename remains a smaller secondary line. Global search tries physical filename/path first and only then scans cached sidecar metadata for the virtual display name; disabling virtual naming therefore still leaves the physical filename as the title.
|
||||
* **Detail Panel (`anomalous-detail-panel`)**: Slides out when a specific model is clicked. It identifies the physical filename and absolute local path with a copy action. Relative paths are intentionally omitted because the folder tree already owns navigation context; `[Root]` folder behavior is unchanged.
|
||||
* **Gallery Viewer (`ui_gallery.js`)**: A fullscreen modal for viewing images.
|
||||
* **Prompt Notes (`ui_notebooks.js`, legacy internal name)**: A specialized editor for composing prompts and drag-dropping LoRA/model nodes. Its shared user-facing surface is **Workspace / 创作工作台**, with **Prompt Notes / 提示词笔记** and **Workflow Recipes / 工作流配方** as its two sections. This presentation rename intentionally leaves notebook routes, storage, and internal property names stable for compatibility.
|
||||
* **Workflow Recipes (`ui_recipes.js`, `ui_recipe_detail.js`)**: A second section inside the shared Workspace modal, not a separate top-level navigation surface. Recipes save the complete serialized graph and add two summary layers: adapters for common model/LoRA/prompt/sampling semantics, plus bounded generic widget summaries for every node (including third-party nodes). Prompt extraction utilizes topological backward-tracing (from KSampler input links backwards to CLIPTextEncode nodes) to rigorously identify Positive and Negative tags without relying on fragile node-title regex matching. Save captures the serialized graph exactly once before the dialog; that snapshot, not a later live-canvas serialization, is the workflow sent to the API. Schema v3 introduced a canonical SHA-256 workflow fingerprint and explicit model-reference identity records; schema v4 adds optional recipe-owned model-preview snapshot descriptors. Schema v5 separates model identity from C-site Official origin metadata (model_name/model_url, which can be manually edited or auto-fetched via Civitai API by hash directly from the detail view), ensuring UI rendering prioritizes official names over obfuscated physical filenames. The card API stays light; the full graph and history are fetched only after edit, restore, or “View details.” The detail panel owns Overview, Parameters, and Versions tabs. Parameters are sorted topologically (using Kahn's algorithm on graph links) to match workflow execution order, and their display names dynamically resolve through `LiteGraph.registered_node_types` (and dummy node instantiation for widgets) to automatically support translation plugins. The save flow uses the product default for bounded model-preview snapshots without exposing a per-recipe checkbox; a future Workflow Recipe global setting may own that preference. The edit dialog resolves safe primitive values from the full workflow by node ID/widget index before updating the matching serialized slot; bounded summaries are never the edit source. Structural edits are explicitly loaded onto the canvas and saved back through the same update path. Every update archives the previous full recipe locally (bounded to 20 versions). Missing-node preflight also reads authoritative workflow node types. `recipe_parser.js` never mutates the live canvas.
|
||||
* **Recipe interaction extensions (`recipe_diff.js`, `recipe_actions.js`)**: Recipe cards filter only lightweight name/notes/tags metadata. Version comparison fetches one validated historical JSON and returns bounded semantic changes without instantiating or mutating either graph. Append clones serialized nodes into the live graph with collision-free IDs, transactional link remapping, placement, selection, and rollback. Card and detail actions use a shared busy/restore discipline so a network or canvas failure cannot leave a button permanently disabled or trigger duplicate mutations. Open/Append UI actions share the same exported handlers from `ui_recipe_detail.js`. All Quick Queue functionality was removed to enforce a single execution path through the canvas.
|
||||
* **Recipe detail presentation**: Copy actions use visible temporary success/failure feedback. Name, notes, and tags can be edited inline from the loaded detail view and are persisted through the existing full-recipe update contract, preserving the serialized workflow and history behavior. Workflow fingerprints and model SHA-256 values are advanced information shown only inside collapsed disclosure panels; card and detail surfaces use bounded glass-style cards, responsive grids, and restrained hover transitions. The Overview separates base model and each LoRA into one full-width composition block; sampling values remain in a compact key-value grid.
|
||||
* **Doctor Panel (`ui_doctor.js`)**: Diagnoses model health for individual nodes or the entire workflow. Includes "View Profile" functionality.
|
||||
|
||||
### NON-NEGOTIABLE: Recipe Presentation & Model Preview Boundaries / 配方展示与模型预览边界
|
||||
* Recipe cover, current local model preview, and optional frozen model-preview snapshot are three different concepts. None is model identity evidence.
|
||||
* The authoritative `workflow` field contains only the serialized ComfyUI graph. Recipe covers, preview descriptors, and future snapshot asset IDs belong to additive recipe presentation data; never inject preview bytes or local preview URLs into the graph.
|
||||
* Recipe cards perform no per-model preview lookup. Current previews are demand-loaded only after the Models & reproducibility tab opens and must use a bounded exact-reference/category lookup. Detail opening must not recursively walk model folders.
|
||||
* Do not persist preview URLs as truth: they are current-machine cache locators and may change after rename or cover replacement. Display precedence is future frozen snapshot, then current local preview, then category placeholder.
|
||||
* Frozen preview snapshots are schema-v4 presentation data. The save flow uses them by default and does not expose a per-recipe checkbox; recipes with an explicitly stored `false` value remain compatible until a future Workflow Recipe global setting owns this preference. This default matters because an export can include only snapshots that were saved first. Assets live in a contained recipe-owned `.assets/<recipe-stem>/` directory, are immutable/content-addressed, and are served only through `GET /anomalous/recipe_asset`. Snapshot generation accepts static local preview files only, writes WebP thumbnails no larger than 320 px or 96 KiB each, is capped at 12 images and 1.25 MiB per save/update, and never copies videos or original full-resolution media. Deleting a recipe removes only its contained assets after the recipe file is deleted; retained history may share an asset ID. Export still asks whether to include these bounded presentation assets in the package.
|
||||
* Preview hashes, filenames, paths, images, and visual similarity MUST NEVER create a Verified identity badge or participate in Model Doctor matching. Workflow fingerprints also exclude covers and preview presentation.
|
||||
* Recipe model cards may display the basename and current local preview by default, with full saved paths and hashes behind Advanced information. Overview and compact recipe summaries must use the basename only; a filesystem path is never a model display name. Exact local references are used only for current-machine preview lookup and temporary navigation into the browser detail panel. Imported or unresolved references remain visually inactive until the user explicitly requests matching through the existing hash/size/category resolver; filename or path alone can never activate a match. A successful match activates only the current detail view and does not rewrite the authoritative workflow path.
|
||||
* Navigating from a resolved recipe model reference to the model browser detail panel must preserve the main browser modal. The recipe workspace child overlay is hidden after the detail panel is shown; it must not call the browser-wide `close()` lifecycle, because that lifecycle also hides and releases the target detail panel. The navigation stores a lightweight recipe return token (recipe payload, active tab, and scroll position); the model detail Back action consumes that token, hides/cleans the model panel, and restores the recipe detail view rather than falling back to the model grid.
|
||||
* Local model-preview media may include image, MP4, or WebM files and uses hover playback for video. Recipe save/export never embeds original video media: a bound output video may contribute only a bounded static first-frame thumbnail, while package assets remain contained WebP snapshots.
|
||||
* Detail readability is not card-summary readability: a compact card may use bounded summaries, but the detail panel may resolve the corresponding safe widget value from the already-loaded contained `workflow` by node ID and widget index. Sensitive/button widgets remain excluded. Long values and positive/negative prompts must use a visible collapse/expand control plus a copy action; never silently truncate a detail value. Overview may show normalized positive/negative prompt summaries, while Parameters preserves native node names and renders each saved `CLIPTextEncode` text widget separately. If the bounded summary omits an old native CLIP node, Parameters must recover it from the authoritative serialized workflow's `widgets_values` without changing the saved graph, then keep native CLIP rows together in the presentation order.
|
||||
|
||||
### NON-NEGOTIABLE: Recipe Action Boundaries / 配方动作边界
|
||||
* `Open in Canvas` replaces the live graph only after an explicit dirty-canvas confirmation and never queues automatically. `Append to Canvas` clones saved nodes, remaps IDs/links, validates the complete insertion, and rolls back all inserted nodes on failure. Detail actions use one busy/settle path so a successful canvas action resolves the detail controller and a failed action restores the button state.
|
||||
* Append must not call `loadGraphData` on the live graph. It must not mutate the saved recipe, existing nodes, existing links, or external model files. Unsupported/missing node types produce a visible error instead of a destructive fallback.
|
||||
* Append accepts both LiteGraph tuple links and object-shaped link records, treats groups as first-class inserted items, rejects subgraph definitions until a dedicated ID-remapping path exists, and removes every item created by the action when any insertion step fails.
|
||||
* `recipe_diff.js` compares bounded semantic records only. It must not expose newly-unfiltered sensitive widget values, render a full graph diff, instantiate saved nodes for display, or mutate either recipe.
|
||||
|
||||
### Graph Splicing and Picker Context (`graph_splice.js`, `model_picker.js`)
|
||||
Manual LoRA insertion is an explicit canvas mutation and is isolated from Model Doctor identity recovery. The graph helper analyzes ports by their declared `MODEL` and `CLIP` types, never by fixed slot indexes or display names. It supports inserting a compatible loader before a node with connected MODEL/CLIP inputs, or after a node with MODEL/CLIP outputs. Downstream fan-out is rejected as ambiguous until the user can choose a concrete branch.
|
||||
|
||||
Graph edits are transactional: validate the complete topology before mutation, wrap the operation in `graph.beforeChange()` / `graph.afterChange()`, and restore every original connection if node creation or any link operation fails. A successful insertion must be one undoable graph change and must dirty the canvas. The helper must not move, delete, or rewrite existing nodes or widgets, and model choices supplied by the UI must remain constrained to ComfyUI's native combo values for the inserted node.
|
||||
|
||||
The Node Assistant owns the corresponding picker UI. Replacement actions live in the selected-node toolbar, while insert-before/insert-after actions are enabled only when graph analysis reports a safe topology. The picker derives its complete candidate set from the target widget's native combo values, then provides client-side folder browsing, full-path search, and sorting. It must not broaden the list with models from another category or require metadata scanning merely to select a filename.
|
||||
|
||||
`model_picker.js` owns pure picker-type inference, base-model family normalization, and bounded upstream MODEL/CLIP traversal used to discover the nearest non-LoRA main model. Picker presentation metadata is resolved in one batch through `/anomalous/resolve_paths_to_previews`. The request supplies the native folder-type boundary inferred from the target widget, and the response may include preview URLs, model category, and cached sidecar metadata for each requested path. Picker cards expose category and `baseModel` badges using text-only DOM assignment. LoRA compatibility filtering uses the connected main model's `baseModel` metadata, falling back to the current LoRA's metadata only when the main model cannot be identified. This is a user-visible browsing filter, not Model Doctor identity evidence; missing metadata must remain accessible through an explicit unfiltered option.
|
||||
|
||||
### JavaScript Rules of Engagement:
|
||||
1. **Strict Localization (双语)**:
|
||||
- Never use hardcoded UI strings.
|
||||
- Always use the ternary operator bounded to the global state: `window.anomalous_browser_lang === 'zh' ? '中文' : 'English'`.
|
||||
- Never use variables like `currentLang === 'zh'` for DOM rendering, because they fail when closure scopes diverge from the global state.
|
||||
2. **Avoid Global Scope Pollution**: Scope all IDs and classes with `anomalous-`.
|
||||
3. **Z-Index Tiers & Overlap Prevention (层叠规范)**:
|
||||
- Base UI: `10000`
|
||||
- Overlay Modals: `10001` to `999999`
|
||||
- *Critical Rule*: Never arbitrarily assign z-indexes. A child modal MUST have a strictly higher z-index to prevent the parent from consuming clicks.
|
||||
4. **DOM ID Conflicts with CSS**: When assigning an `id` to an element dynamically, always `grep_search` `styles.css` first.
|
||||
5. **Untrusted Text and Rich HTML**: Filenames, folder names, notebook names, workflow values, and metadata are untrusted. Use `textContent` or `escapeHtml()` for normal text. Only Civitai description/notes fields may retain formatting, and they must be inserted with `setSafeRichHtml()` from `safe_dom.js`; direct metadata assignment to `innerHTML` is forbidden.
|
||||
|
||||
## 5. Hash Resolver Subsystem & Scanning Engine (`hash_resolver.js` & `scraper.py`)
|
||||
A highly complex subsystem responsible for automatically resolving missing/broken model references in workflows, and aggressively scanning models to build local caches.
|
||||
|
||||
### NON-NEGOTIABLE: Model Doctor Identity Boundary / 模型医生身份判定死规矩
|
||||
* Model Doctor exists to recover the same physical model referenced by provenance data embedded by this plugin in an exported workflow or image. Local path differences and local renames are the problem being solved; therefore a path, filename, display name, custom name, source filename, or fuzzy name similarity MUST NEVER be used as evidence that two models are identical.
|
||||
* Automatic identity evidence is limited to plugin-carried cryptographic hashes, exact physical byte size as a controlled fallback/disambiguator for ordinary model categories, and the model category required by the target widget. Foundation components (`vae`, `vae_approx`, `clip`, `text_encoders`, and `clip_vision`) are hash-only recovery categories: byte size alone never repairs them. A supplied hash mismatch never falls back to a filename or size-only guess.
|
||||
* An existing native combo value remains usable even when a foundation component's current local hash differs from the workflow provenance hash. Model Doctor shows a non-blocking identity-change warning because ComfyUI itself can load the same-named file; it does not mark the node missing or replace the value. If the filename is absent, foundation components may be redirected only by one exact in-category hash match. A workflow without that hash is left unresolved for manual handling.
|
||||
* Paths and filenames may be used only after model identity has already been established: to return the resolved local dropdown value and to verify that value against ComfyUI's native combo choices. They must not participate in candidate selection, ranking, tie-breaking, or ambiguity resolution.
|
||||
* Normalizing slash direction for a value that already has an exact equivalent in the widget's native ComfyUI options is representation normalization, not model discovery, and does not weaken this identity boundary.
|
||||
* If a legacy/corrupt hash cannot be recovered uniquely from allowed evidence (for example several in-category files have the same byte size), Model Doctor MUST report ambiguity or remain unresolved. It must never guess from the old filename/path. Manual replacement is the explicit fallback.
|
||||
* This boundary is part of the plugin's native product contract. Do not weaken, reinterpret, or bypass it without explicit user authorization.
|
||||
|
||||
### The Global Hash Cache (`window.anomalous_hash_cache`)
|
||||
* This is the absolute core of the resolution engine. It maps physical filenames to their exact SHA256 hashes.
|
||||
* It is fetched on startup by the optional resolver via `fetch('/anomalous/all_hashes')`. If the current ComfyUI frontend does not expose a compatible graph serialization API, the resolver disables itself with a warning; this must never prevent the main browser extension and its visible entry point from loading.
|
||||
* Whenever a scan finishes (Scan Wizard or Deep Hash Scan), `window.anomalous_reload_hashes()` MUST be called to synchronize the frontend dictionary with the newly generated `.info` files on the disk.
|
||||
* The cache exposes relative-path and basename aliases only when they are unambiguous. If two local models share a key but have different hash/size values, that alias is omitted instead of silently choosing one. Frontend lookups must prefer the full widget value before falling back to a basename.
|
||||
|
||||
### Model Provenance Binding (模型溯源绑定 - `anomalous_inject_hash`)
|
||||
* Controls whether hashes are invisibly injected into the `extra_pnginfo` and workflow JSON when a user saves a workflow or generates an image.
|
||||
* **Logic Flow**: The active graph constructor's `prototype.serialize` is hooked when available (falling back to the legacy global `LGraph` only when present). If enabled, it intercepts the serialization, looks up every model widget's filename in `window.anomalous_hash_cache`, and explicitly writes `extraObj.anomalous_hashes[node_id_filename] = {hash, size}`. The cache includes VAE/VAE Approx, CLIP/Text Encoder, and CLIP Vision directories as well as ordinary model categories. A foundation component with no recorded hash is not given size-only provenance. A missing graph API disables only this optional hook.
|
||||
* Exact-path checks are limited to pre-flight verification that an already-resolved local reference exists; they are outside Model Doctor provenance recovery and must never be used as its fallback. For provenance recovery, the backend constrains the search to the model category inferred from the node/widget and intersects the saved hash with the saved byte size. Ordinary model categories may use one unique in-category byte-size match when no hash is available; equal-sized candidates remain unresolved. Foundation components remain unresolved without a hash, and a real hash/size conflict is rejected instead of choosing arbitrarily.
|
||||
* Civitai `.info` files may describe several physical files (for example a diffusion model, text encoder, and VAE). `get_metadata()` must select the matching `files[]` entry by exact physical byte size and may use an unmatched entry only when it is the sole hash candidate. It must never take the first SHA256 or use a filename to choose between entries.
|
||||
|
||||
### The Triple-Fallback Scanning Engine (`api/scanner.py` & `scraper.py`)
|
||||
When "Deep Hash Scan" is triggered, it runs in a background thread to prevent UI lockup. It uses a triple fallback to identify models:
|
||||
1. **Fallback 1 (Header Hash Match)**: Extracts `modelspec.hash.sha256` or Blake3/AutoV2 directly from the `.safetensors` header (O(1) speed). Hits Civitai API to fetch official metadata.
|
||||
2. **Fallback 2 (Full File SHA256)**: If the header has no hash, it brutally calculates the full file SHA256 (Slow) and hits Civitai.
|
||||
3. **Fallback 3 (Offline Inference)**: If Civitai returns 404, it reads the Tensor Fingerprints (keys like `cond_stage_model`) from the header to guess the Base Model (SDXL, SD 1.5, Flux, SD3) and builds a local `.info` file.
|
||||
* **Offline hash injection**: After a fallback succeeds, the engine injects the discovered hash into the generated JSON. Consumers must still associate that entry with the current physical file using size/name matching; array position is not an identity guarantee.
|
||||
* Physical rename conflicts are non-destructive. When the target filename already exists, both files must be hashed; deletion is permitted only when their complete SHA256 values match. Different files with the same generated display name must both be preserved.
|
||||
|
||||
### Resolution Execution (`window.anomalous_resolve_all_missing_nodes`)
|
||||
Scans all nodes in `app.graph._nodes` that are colored red.
|
||||
* Extracts the saved hash from the graph's `anomalous_hashes` using normalized path separators (`/` and `\`).
|
||||
* Sends hash, byte size, and the inferred model category to `/anomalous/resolve_hash` (or `/anomalous/resolve_hash_batch`).
|
||||
* **On-Demand Dynamic Hash Resolution**: If size matches a candidate that lacks cached hash metadata, the backend calculates its SHA256 on demand. If the hash matches, it establishes identity and writes an offline inferred `.info` file to cache the provenance.
|
||||
* **Civitai Direct Lookup & Domain Routing**: Clicking the Civitai button queries the official `/api/v1/model-versions/by-hash/<hash>` endpoint to resolve `modelId` and version, dynamically routing NSFW models to `civitai.red` and safe models to `civitai.com`.
|
||||
* **View Hash Details Inspection (`openHashDetailDialog`)**: Every model card provides a dedicated `🔑 查看哈希 (View Hash)` button. Clicking opens an intuitive modal comparing the workflow-embedded provenance hash and size against the local disk model's hash and size, with one-click clipboard copying.
|
||||
* If a match is found, the frontend refreshes native ComfyUI combo definitions and requires the returned path to exist in that widget's native choices. Cross-category or otherwise invalid paths are rejected; the resolver must never append a foreign path to `widget.options.values` merely to report success.
|
||||
* After native validation succeeds, it mutates the dropdown `value`, removes the red color, and flags it as `anomalous_auto_resolved`.
|
||||
|
||||
## 6. Known Gotchas & Critical Context (新接手必读)
|
||||
* **Metadata Override Danger**: If you write to a `.civitai.info` file, ALWAYS read it first and update the dictionary. Never just dump `{custom_name: 'foo'}`.
|
||||
* **Search Context Trap**: If the user searches using the sidebar while a detail panel is open, you MUST explicitly hide the detail panel and show the grid.
|
||||
* **Media Leakage**: Destroying a DOM element that contains a playing `<video>` or `<audio>` does **not** stop the audio. You must `.pause()` it before doing `innerHTML = ''`.
|
||||
* **Video Cover Compatibility & Interaction**: Any frontend component displaying model preview covers MUST check if the file is `.mp4`/`.webm`. The model-card setting owns the grid behavior: `always` may autoplay only while the card is near/in the viewport; `hover` loads/plays on pointer hover and pauses on leave. Both modes use muted/loop/playsinline and release their source when the browser closes. Single main covers may keep their established autoplay behavior. **CRITICAL GOTCHA**: Do NOT use `new URL(url).pathname` to check extensions! Our backend serves images via query parameters (e.g. `?filename=cover.mp4`), so `pathname` strips the filename. Always test the full URL using regex like `/\.(mp4|webm)(?:$|\?|&|#)/i`.
|
||||
* **Variable Naming Collisions**: When moving UI elements, search for ALL references to the old `const` declaration. A single duplicate `const` in the same scope kills the entire module at parse time.
|
||||
* **Data Scaling & Payload Limits**: When passing arrays/lists (e.g. hundreds of selected files), NEVER append them to URL query parameters (`GET` requests), or you will trigger `414 URI Too Long`. Use `POST` with a `JSON Body`. Similarly, NEVER pass massive arrays directly to `subprocess.run` via CLI arguments, as it will crash silently due to OS character limits (8191 on Windows). Always dump massive data to an intermediate `.json` file for the subprocess to read.
|
||||
|
||||
> For a complete list of past mistakes and detailed post-mortems, refer to `.agents/logs/ai_lessons.md`. Engineering history and change tracking are managed via Git commits and ARCHITECTURE.md.
|
||||
|
||||
## 7. Mandatory Change Snapshot Protocol (强制变更快照流程)
|
||||
|
||||
Every product-code change in this plugin must finish as one coherent local Git snapshot:
|
||||
|
||||
1. Run checks proportional to the touched behavior before committing.
|
||||
2. Update this `ARCHITECTURE.md` in the same change so directory ownership, data flow, API contracts, naming boundaries, and non-negotiable rules remain accurate. If the code does not alter an architectural boundary, record that the existing boundary is intentionally unchanged rather than inventing a new abstraction.
|
||||
3. If critical bugs or architectural pitfalls were resolved, record lessons learned into `.agents/logs/ai_lessons.md`.
|
||||
4. Create a local Git commit after verification. Keep unrelated work out of the snapshot and do not push unless the user explicitly requests it.
|
||||
5. Leave a clean worktree for the next agent, or document every intentional uncommitted file in the handoff.
|
||||
|
||||
`CHANGELOG.md` is public user-facing release communication. Update it only for intentional user-visible release notes; never use it for internal design decisions, implementation details, test output, or agent handoff. Planning-only documentation may be committed separately, but proposals must be labeled clearly and must not be described as implemented runtime behavior.
|
||||
|
||||
## 66. Recipe Canvas Actions Use Append as the Safe Composition Path
|
||||
|
||||
Recipe cards and detail views intentionally expose only “Append to Canvas”. The removed “Open to canvas” action was ambiguous across ComfyUI versions: the legacy API replaced the current graph, while the current workflow-tab API opens a new temporary tab unless an internal active-workflow object is supplied. Keeping only append avoids both meanings and preserves the user's current canvas.
|
||||
|
||||
The separate structural-edit action may still load a recipe into a new canvas for editing; it is not a recipe composition action and remains explicitly confirmed. Successful append actions continue to hide the notebook body, recipe view, recipe modal panel, and outer modal before invoking the existing close/media cleanup.
|
||||
|
||||
## 67. Recipe Save Does Not Ask for Presentation Pins or Snapshot Policy
|
||||
|
||||
The save dialog records recipe identity, notes, tags, and cover/source image. It does not render the expandable “Choose key parameters” section or a model-preview snapshot policy checkbox: the complete serialized workflow remains the source of truth, and these implementation choices should not burden users who only want to save/share a recipe. The current bounded snapshot default is internal and can later move behind a Workflow Recipe global setting. Existing `params.pinned` data is preserved when editing an older recipe so removing the control is not a destructive metadata migration; new saves use an empty pin list.
|
||||
|
||||
## 68. Parameter Notebook Switches Must Identify the Right-Hand State
|
||||
|
||||
The Parameters tab is a two-pane history browser, so highlighting only the left notebook list is insufficient feedback. The right pane must show the active notebook name and timestamp in a prominent selection banner and animate the panel on a genuine notebook switch. Parameter-gallery requests carry a selection token and must discard stale responses after the user switches notebooks.
|
||||
|
||||
Saved parameter notes are individually removable from the notebook sidebar. The row uses sibling selection and delete buttons rather than nested interactive controls, asks for explicit confirmation, and posts only the validated notebook filename to `/anomalous/delete_parameter`. Deleting the active note clears its selection, invalidates in-flight parameter-gallery requests, reloads the notebook list, and selects the newest remaining note. The current recipe parameter baseline is not a stored notebook row and therefore cannot be deleted through this control.
|
||||
|
||||
## 69. Docked Notebook Controls Must Shrink as a Unit
|
||||
|
||||
The notebook create row contains a text input and a confirmation button. In the 660px docked browser, the notebook sidebar is reduced to 80px, so the input must have `min-width: 0` and the confirmation button must remain a non-shrinking fixed-size item. Otherwise the input's placeholder intrinsic width pushes the check button outside the clickable sidebar.
|
||||
|
||||
The output gallery is a user-refreshed view of ComfyUI's output directory. Opening the gallery performs one page-one scan, and the toolbar's Refresh button explicitly repeats that scan on demand. There is no background polling; the grid preserves scroll position when manually refreshed.
|
||||
|
||||
## 70. Keep the main browser entry module parseable
|
||||
|
||||
The floating trigger is created by `web/main.js`, but ComfyUI loads every JavaScript file in the custom node's `WEB_DIRECTORY` as an ES module. A syntax or duplicate top-level declaration in any imported module prevents `main.js` from registering and leaves no visible plugin icon, even when the backend routes and the optional hash resolver are healthy.
|
||||
|
||||
When a trigger disappears, verify the browser extension registry and load the main entry in isolation before changing CSS or z-index values. In particular, do not duplicate helper declarations while merging recipe UI changes; shared helpers in `ui_recipes.js` must have one definition per module. `node --check` is required for the affected module, and a real ComfyUI runtime check must confirm that `#anomalous-trigger-btn` is created.
|
||||
|
||||
## 71. Recipe and Node Assistant Integration Must Fail Loudly and Preserve Host State
|
||||
|
||||
The Node Assistant consumes parameter notebooks by node type, while the Recipe detail remains the authoritative source for full-workflow skeleton matching. The assistant's preset endpoint may cache notebook discovery briefly, but save/delete operations invalidate that cache and an explicit assistant refresh requests a fresh read.
|
||||
|
||||
Applying a node preset is a transactional live-widget operation: volatile seed slots are ignored, values are cloned safely, widget callbacks and ComfyUI's four-argument `onWidgetChanged(index, value, previousValue, liveWidget)` hook are invoked only when callable, and callback failures roll back the node before the UI reports failure. A successful write marks both graph and canvas dirty and emits `graphChanged` so recipe and assistant surfaces can refresh.
|
||||
|
||||
Prompt roles are derived only from an explicit allowlist of ComfyUI prompt nodes, consumers, and conditioning pass-through nodes. Link storage may be an array, object, or `Map`, and every linked role input is inspected. Unknown third-party nodes are opaque boundaries: neither their title nor their type name may turn an unresolved text into a positive or negative prompt. Saved role metadata is joined to assistant records using string-safe node IDs; legacy recipes use exact, unambiguous full-value recovery and otherwise remain unknown.
|
||||
|
||||
The assistant must not call removed doctor-panel methods from canvas selection hooks. Existing host selection callbacks are preserved, and the assistant hook only updates its own panel.
|
||||
|
||||
## 72. Legacy Prompt Roles Are Recovered from Full Prompt Values
|
||||
|
||||
Older recipes may contain correct `params.promptPositive` and `params.promptNegative` arrays but no per-node `role` field. When the Node Assistant prepares a node-type preset response, the backend compares the node's complete saved text widget value against those arrays and injects a role only when the match is unambiguous. An identical value present in both arrays remains unclassified rather than being guessed; explicit metadata roles always take precedence.
|
||||
|
||||
## 73. Prompt Roles Are Conservative and User-Correctable
|
||||
|
||||
The first supported automatic contract is intentionally narrow: ComfyUI's native `CLIPTextEncode` can receive a role only through allowlisted official sampler/Guider inputs and allowlisted conditioning pass-through nodes. An unresolved native prompt is `unknown`; a third-party text candidate is also `unknown` unless the user labels it. The detail panel states this limitation directly and never uses node titles, prompt content, or a default-positive rule as semantic evidence.
|
||||
|
||||
Manual labels are recipe-skeleton metadata stored in `params.promptRoleOverrides`, keyed by string-safe node ID and guarded by the saved node type. Supported values are `positive`, `negative`, `both`, `ignored`, and `unknown`; removing an override restores the saved automatic result. Recipe updates retain an override only while node ID and type still match. The Parameters view and Node Assistant must consume the recipe override before automatic or legacy metadata, so all surfaces show the same role. Future third-party adapters may expand the allowlist, but unknown nodes must remain conservative until explicitly supported.
|
||||
|
||||
## 74. MIT Copyright Permission and Project Branding Are Separate
|
||||
|
||||
`LICENSE` remains the unmodified standard MIT license and is the copyright license for repository code, documentation, stylesheets, and ordinary UI resources unless a file expressly says otherwise. Do not append project-specific restrictions to that license or describe ordinary UI implementation as proprietary while simultaneously licensing the repository under MIT.
|
||||
|
||||
`TRADEMARKS.md` owns the separate source-identification boundary for the name **Anomalous Model Browser**, stylized presentations of that name, and any graphic explicitly designated as the official logo. It permits truthful references, links, compatibility statements, unmodified redistribution, and clear “based on” attribution. Public modified distributions use a distinct primary name/logo and must not imply official status or endorsement. README summarizes and links to both documents; legal-policy changes must be intentional and must not be hidden in engineering changelogs.
|
||||
|
||||
## 75. Beta Workflow Features Carry Localized Data-Safety Guidance
|
||||
|
||||
Workflow Recipes and the Node Assistant's recipe-powered parameter presets are explicitly presented as beta features until a stable release decision removes that status. The Node Assistant's Actions tab (visual model replacement and LoRA insertion) is mature and outside that beta boundary. The Recipe list renders its compact notice at library level, while the assistant notice belongs inside the hidden-by-default Parameter Presets container and appears only when that tab is active; no warning sentence is hardcoded in a UI module. The Settings Help content and bilingual README repeat the operational boundary and identify `workflows/anomalous_recipes` plus `workflows/anomalous_parameters` under the active ComfyUI user directory as the data to back up.
|
||||
|
||||
Documentation distinguishes the two application scopes: Node Assistant presets update the currently selected same-type node and ignore volatile values, while a Parameter Notebook's “Apply to Current Workflow” verifies the complete recipe skeleton before updating safe widgets. Model-preview snapshots are presentation assets, never model-file backups. README and Help must remain bilingual and describe the same behavior whenever these workflows change.
|
||||
|
||||
`locales.js` is the authoritative source for every user-visible runtime string in this feature. Chinese and English dictionaries must have identical key sets, no duplicate keys, and all statically referenced `t('key')` calls must resolve in both dictionaries. Language parity and static-key coverage are release checks for changes to Recipe, Assistant, Settings, or Help surfaces.
|
||||
|
||||
Persistent beta notices carry `data-anomalous-i18n-key` metadata. The sidebar language switch refreshes those live nodes in place and rebuilds the Node Assistant around the current selection, so changing language does not require closing either surface and cannot leave its warning, placeholder, or selected-node content in the previous language. Beta notices must be scoped to the exact experimental interaction rather than a stable parent surface. Any future persistent localized notice should use the same contract.
|
||||
|
||||
## 76. Recipe-to-Model Navigation Is an Exclusive, Recoverable Transition
|
||||
|
||||
Opening a local model from Recipe Overview temporarily replaces every main content surface: grid, Gallery, Model Doctor, Node Assistant, parameter panel, and Workspace are hidden before the model detail becomes visible. The recipe payload, active tab, scroll position, and original `workspaceReturnState` remain available across the transition, while `recipeModelReturn` exclusively owns reconstruction of the recipe detail; model detail and Node Assistant must never be visible together.
|
||||
|
||||
There are two valid ways to end this transition. The model detail Back action (or reopening Workspace and choosing Workflow Recipes) consumes `recipeModelReturn` and reconstructs the exact recipe detail. Navigating directly to another main panel or closing Workspace abandons it: stale payload/scroll state is cleared, detail media and DOM are released, and the recipe list plus action bar are restored. `showRecipes()` also normalizes those list surfaces defensively whenever no detail is active, so a lost callback cannot produce a blank Recipe body. Opening Workspace while a recipe-model return is pending must not overwrite the earlier `workspaceReturnState`, otherwise closing the reconstructed recipe could restore an empty model-detail panel.
|
||||
+206
-94
@@ -1,32 +1,131 @@
|
||||
# 📈 Anomalous Model Browser Changelog
|
||||
|
||||
## v1.4.2 (Language Sync & Gallery Patch)
|
||||
### 🐛 Bug Fixes
|
||||
- **Native Language Sync**: Fixed a bug where the plugin would not properly synchronize its UI language with ComfyUI's modern native `Comfy.Locale` settings. The language check has been migrated from local storage caching to a dynamic `app.ui.settings` API call during the extension setup phase.
|
||||
- **Gallery Zoom Review**: Verified and confirmed that the mouse wheel zoom feature inside the gallery image viewer is functioning natively as intended.
|
||||
## v1.4.1 (The Drag & Drop Resilience Update)
|
||||
### 🚀 Enhancements
|
||||
- **Silent Quick Scan Button**: Added a new dedicated `🔍 Check & Auto-Scan Missing Info` button to the Settings Hub. It silently finds unscanned models and extracts their hashes in the background without popping up the intrusive Civitai black-box prompt, keeping your workflow uninterrupted.
|
||||
- **Hires Fix Drag Resilience**: Fixed a critical Chromium memory allocation crash where dragging extremely large (Hires. fix) images from the Gallery would silently fail to initiate. Implemented a lightweight, translucent SVG "Workflow" ghost using `setDragImage`, allowing massive 4K+ images to be dragged seamlessly onto the ComfyUI canvas without memory limits.
|
||||
- **Operation Guide**: Added a highly requested `docs/核心按钮操作指南_大白话版.md` (Layman's Core Operation Guide) to clearly distinguish between the different scanning and fixing buttons for new users.
|
||||
## v1.56 Beta (Model Doctor Intelligence & Smart Civitai Routing)
|
||||
|
||||
### 🚀 Major Improvements & Features
|
||||
- **On-Demand Dynamic Hash Resolution (按需动态算哈希与智能认亲)**: Upgraded Model Doctor's fallback resolution engine. When a local candidate model matches the exact byte size of a missing reference but lacks local metadata (e.g., after renaming and deleting `.info` files), the backend dynamically calculates its SHA256 on demand. Upon cryptographic verification, the node is seamlessly resolved and an offline inferred `.info` record is saved locally.
|
||||
- **Dedicated Hash Inspection Modal (🔑 查看哈希透视详情)**: Added a dedicated `🔑 查看哈希 (View Hash)` button on every model card in the Doctor Dashboard. Clicking it opens a crystal-clear inspection modal comparing the workflow-embedded provenance hash & size with local disk hash & size, featuring one-click clipboard copying.
|
||||
- **Smart Civitai by-hash Routing & NSFW Domain Dispatch (C 站精准直达与 .red/.com 智能分流)**: Upgraded the "🌐 Civitai" action buttons across Model Doctor and Workflow Import. Instead of crude search bar queries, it now queries the official Civitai `by-hash` API to resolve exact Model IDs and Version IDs, automatically routing NSFW models to `civitai.red` and safe models to `civitai.com`.
|
||||
- **UNET Default Scan Coverage (UNET 目录扫描补齐)**: Added `models/unet` to the default scanned model types, ensuring UNET / Diffusion Models are consistently indexed during background deep scans.
|
||||
- **Parameter Notebook Renaming (参数笔记本重命名)**: Users can now rename saved parameter notebooks directly from both the recipe sidebar list and the detail heading via inline `✏️` action buttons and modal prompts, safely updating names with atomic writes without disrupting parameter provenance or canvas matching.
|
||||
- **Cross-Platform Path Separator Normalization (跨平台斜杠全链路归一化)**: Fully unified Windows `\` and Unix `/` path handling across workflow serialization, hash retrieval, and node diagnostic matching, preventing lookup misses caused by cross-platform workflow exchange.
|
||||
|
||||
### 🐛 Bug Fixes & UX Polish
|
||||
- Fixed a false-negative rejection issue where size-matching renamed models without local `.info` files were incorrectly rejected as `identity_conflict`.
|
||||
- Fixed duplicate key icons on the Model Doctor Hash Inspection modal header.
|
||||
- Fixed a loop duplication issue in the workflow import modal preflight inspection.
|
||||
- Updated unit test suite with comprehensive coverage for dynamic hash resolution and unet folder discovery.
|
||||
|
||||
## v1.55 Beta (Workflow Recipes Preview)
|
||||
|
||||
> **Beta scope:** Workflow Recipes, Parameter Notebooks, and the recipe-powered Parameter Presets tab in Node Assistant are preview features. The Node Assistant's Actions tab—visual model replacement and LoRA insertion—is not part of this beta. Save the current canvas and back up `workflows/anomalous_recipes` plus `workflows/anomalous_parameters` before updating, importing, restoring, deleting, or applying recipe data. The stable target for these preview features is v1.6.
|
||||
|
||||
### 🚀 Major Features
|
||||
- **Workflow Recipes (工作流配方)**: Save a complete workflow with a cover, name, tags, notes, model references, prompts, sampler settings, safe node parameters, and a reproducibility fingerprint. Recipes append to the current canvas without replacing it.
|
||||
- **Recipe Detail Workspace (配方详情)**: Added Overview, Parameter Notebook, Gallery, and Versions tabs. Long values can be expanded or copied, model requirements are shown as separate cards, and the overview avoids exposing local model paths as display names.
|
||||
- **Parameter Notebooks (参数笔记本)**: Create edited parameter notes, capture a fresh note from a matching live canvas, switch between saved notes with clear feedback, delete individual notes, and apply one note back to a workflow only after the complete recipe skeleton matches. Volatile values such as seeds are intentionally ignored.
|
||||
- **Node Assistant Recipe Presets (节点助手配方预设)**: The Parameter Presets tab reads same-type node values saved by Workflow Recipes. Select a node such as KSampler and apply sampler, scheduler, steps, CFG, denoise, and other safe values in one click without replacing the rest of the workflow.
|
||||
- **Model Identity and Local Matching (模型身份与本地匹配)**: Recipe model cards retain hashes, size, model category, available Civitai origin names/links, and previews. Imported recipes can match another author's model references to local files without trusting their folder paths.
|
||||
- **Recipe Gallery and Parameter Comparison (配方图库与参数对比)**: Find historical outputs with the same node structure, inspect embedded generation data, compare differing parameters, and promote an output image to the recipe cover.
|
||||
- **Version History (版本历史)**: Recipe updates retain bounded local history. Compare meaningful changes, restore an older version safely, and preserve the current version before restoration.
|
||||
- **Portable Recipe Packages (便携配方包)**: Import or export recipes with optional preview snapshots and history. Model identity hashes can be removed for privacy. Preview snapshots contain presentation images only and never include model files.
|
||||
- **Conservative Prompt Roles (保守的正负提示词识别)**: Native supported prompt paths are classified without title guessing. Unknown third-party text nodes remain unclassified until the user labels them as positive, negative, shared, or ignored.
|
||||
|
||||
### ✨ Interface and Interaction
|
||||
- Renamed the combined Notes and Recipes area to **Creative Workspace / 创作工作台**, with separate Prompt Notes and Workflow Recipes sections.
|
||||
- Recipe models, LoRAs, prompt text, common parameters, and generic node controls use dedicated responsive layouts with copy and full-content expansion where needed.
|
||||
- Returning from a model detail page restores the previous recipe position instead of jumping back to the top.
|
||||
- The main output gallery refreshes when opened, avoiding continuous background polling.
|
||||
- Beta and data-protection guidance is available in the Recipe list, the Node Assistant Parameter Presets tab, Settings Help, and README in both Chinese and English.
|
||||
|
||||
### 🐛 Reliability Fixes
|
||||
- Fixed a cross-panel state leak where opening a model from a Recipe Overview could overlap Node Assistant, then leave the next Workflow Recipe visit with an empty list. Returning normally, switching directly to another main panel, reopening Workspace, and closing Workspace mid-return now all restore one coherent surface.
|
||||
- Fixed blank Workspace/Parameter Notebook surfaces and restored the plugin trigger when frontend module errors prevented registration.
|
||||
- Fixed parameter application against current ComfyUI widget callbacks and added transactional rollback when a node update fails.
|
||||
- Fixed positive/negative prompt duplication and legacy role recovery while keeping ambiguous third-party prompt nodes visibly unknown.
|
||||
- Fixed panel closing and canvas handoff behavior, docked notebook confirmation controls, recipe scroll restoration, long model status text, and narrow parameter layouts.
|
||||
- Added backend validation and regression coverage for recipe round-tripping, linked inputs, parameter-note deletion, model matching, and prompt-role metadata.
|
||||
|
||||
## v1.5.1 (The Workflow Exchange & Deep Scan Update)
|
||||
### 🚀 Major Features
|
||||
- **Lossless Workflow Exchange (无损工作流交互)**: Added dedicated Import/Export buttons to the bottom-left of the plugin UI. This feature allows users to directly extract, share, and import pure workflow data without relying on original PNG images, completely bypassing the metadata-stripping compression used by social platforms (like WeChat or Discord) that causes workflow loss.
|
||||
- **Force Overwrite Configs (强制覆盖已有配置)**: Added a new "Force Overwrite" toggle in the Scan Wizard. When enabled, the scanner will deliberately ignore existing local `.info` files and force a fresh SHA256 hash calculation and a complete metadata pull from Civitai. This is a lifesaver for repairing incorrectly matched or corrupted model profiles.
|
||||
- **Model Card Settings (模型卡片设置)**: The Settings Hub now opens one focused panel for video-cover playback (`Always play` or recommended `Play on hover`) and card image quality (`Optimized thumbnail` or `Original cover`). Existing autoplay preferences remain compatible, while image-quality choice is intentionally simple and local to the browser.
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
- **Orange Toast Eradication**: Completely ripped out the overly aggressive `app.queuePrompt` interceptor. Users will no longer be bombarded by the orange "Unscanned Models" warning toast every time they click Generate.
|
||||
- **Base Model Hash Misattribution**: Fixed a critical bug in the fast scanner where it would aggressively fuzzy-match any 64-character hex string in the metadata. This previously caused LoRAs (trained via Kohya) to inherit their Base Model's hash (`ss_sd_model_hash`), resulting in totally incorrect Civitai mappings. The scanner now strictly adheres to the official `modelspec.hash.sha256/blake3` standard.
|
||||
- **Lossless Cover Reset**: Reset now restores the persistent `.civitai_bak.*` copy first, falls back to a bare original cover for non-Civitai models, and preserves the active preview with a visible warning when no recoverable source exists.
|
||||
- **Safe Sidecar Lifecycle**: Deleting a model cleans recognized sidecars, while renaming migrates them—including the Civitai backup—to the new stem. Same-stem model files with different model extensions are never treated as sidecar garbage.
|
||||
- **Bounded Sidecar I/O**: Repeated extension literals in the API and scanner were replaced with fixed tuples and exact-path checks. Rename, delete, and reset remain constant-bounded and never scan an entire model directory.
|
||||
- **Reset/Rename Extension Integrity**: Fixed a reused loop variable that could change a model's extension when cover reset and physical rename were requested together.
|
||||
- **Signature-Aware Metadata Cache**: Unchanged model metadata and embedded safetensors hashes are now served from bounded caches keyed by physical file signatures. Sidecar or model changes invalidate entries automatically, while defensive copies prevent cache pollution.
|
||||
- **Stable Cover Caching**: Removed per-refresh random cover URLs and switched to nanosecond file-version tokens, allowing the browser to reuse unchanged images and videos while still updating changed covers immediately.
|
||||
- **Responsive Large Libraries**: Folder/model disk work now runs outside the aiohttp event loop, current-folder discovery uses one `os.scandir()` inventory, stale requests are aborted, and cards render in animation-frame chunks with lazy media activation.
|
||||
- **Batched Model Doctor Resolution**: Provenance-rich workflows skip a redundant full hash-cache walk, then resolve missing nodes through a type-grouped batch endpoint so each required category is walked once per repair pass. Legacy workflows still refresh the filename cache when needed. The original hash/size/type/ambiguity rules remain unchanged, with automatic fallback to the single-item endpoint.
|
||||
- **Direct Preview Fast Path**: Visual replacement resolves contained relative model paths directly and performs a full library walk only for unresolved basename fallbacks.
|
||||
- **Balanced Cover Memory Use**: Static grid covers default to cached 512px WebP derivatives while detail views retain the original. Derived files are stored in ComfyUI's temporary area, capped at 256 MiB, and never alter source covers. Closing the browser releases active media immediately and releases the warm card DOM after 90 seconds of inactivity.
|
||||
|
||||
## v1.5.0 (The Workflow & UI Evolution Update)
|
||||
### 🚀 Major Features
|
||||
- **Node Assistant (节点助手)**: Introduced a dedicated "Assistant" tab within the Model Doctor. Selecting any node in the canvas instantly focuses the Assistant on that specific node, providing deep analysis and contextual controls without obscuring your view.
|
||||
- **Visual Model Switcher (可视化模型选择)**: Eliminated blind dropdown menus! When replacing a model, a stunning visual grid pops up inside the Assistant panel, displaying full cover images and model names. You can now select replacements purely visually.
|
||||
- **Global Health Dashboard (全图体检报表)**: A top-level dashboard in the Doctor panel provides a bird's-eye view of your entire workflow. It categorizes nodes into "Healthy", "Missing", "Replaced", and "Unrelated", giving you instant situational awareness.
|
||||
- **Inline Node Notes (内联节点备注)**: Added the ability to view and edit model notes directly beneath the preview image in the Node Assistant, ensuring zero layout shifting and maintaining a continuous UX flow.
|
||||
- **Local Metadata Parsing Toggle (非C站模型本地解析)**: Added a new explicit toggle in the Settings Hub. This allows users to enable deep local/offline metadata scanning (e.g. for HuggingFace models) without strictly enforcing a Civitai lookup.
|
||||
|
||||
### 🐛 Bug Fixes & UX Polish
|
||||
- **Regex Replacement Bug**: Fixed a critical bug where regex backslashes were swallowed during update patching, causing a syntax error in `main.js` that made the plugin icon disappear.
|
||||
- **Dynamic Regex Splitting**: Ensured file paths are split properly using both forward slashes and backslashes `/[\/]/` for complete cross-platform Windows/Linux compatibility.
|
||||
- **Temporary Feature Suspension**: Temporarily commented out the WIP "Preflight Import Workflow" button while its parsing engine is being finalized.
|
||||
|
||||
## v1.4.4 (The Cover Decoupling Update)
|
||||
### 🚀 Enhancements
|
||||
- **Custom Cover Decoupling**: Manually selected covers (from the gallery or local uploads) now exclusively use a `.preview.*` suffix. The backend no longer artificially renames standard non-Civitai `.png` files to `.civitai_bak`, preventing semantic entanglement.
|
||||
- **Intuitive Reset Logic**: Clicking 'Reset' now cleanly deletes the custom `.preview.*` file. For non-Civitai models, it organically falls back to your original manual cover. For Civitai models, it accurately restores the `.civitai_bak` file obtained from the initial scan.
|
||||
- **UX Workflow Polish**: After changing a cover via the gallery or local upload, the UI no longer stubbornly bounces you back to the Model Edit modal. It gracefully returns you straight to the model grid to admire the new cover, eliminating the confusing implication that you still needed to click 'Save'.
|
||||
- **Cache Busting for Covers**: Added a timestamp parameter when loading covers into the grid so that uploading a new custom cover instantly updates the display without requiring a manual browser refresh.
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
- **Video Cover Regex Fix**: Fixed a bug where `.mp4` and `.webm` video covers failed to play if cache-busting URL parameters were appended. The regex now gracefully handles trailing `&` or `?` parameters.
|
||||
- **State Null-Reference Crash**: Fixed an `undefined` error where a missing `this.models` assignment during the model reload phase caused the UI to abort mid-transition, resulting in a blank screen.
|
||||
|
||||
|
||||
## v1.4.3 (The Silent Load Update)
|
||||
### 🚀 Enhancements
|
||||
- **Silent Loading by Default**: Reversed the default behavior of the hash resolver. To prevent interrupting workflows for users with massive unindexed local libraries, the aggressive "Missing Models Alert" popups and heavy background hash fetching on startup are now **OFF by default**. You can enable the auto-detect feature manually via the Settings Hub.
|
||||
- **Refined Settings UI**: Updated the terminology in the Settings Hub. The toggle has been re-worded from "Auto-Detect Missing: ON/OFF" to "🪄 Auto-Detect Missing on Load: [ON]" and "🔕 Auto-Detect Missing on Load: [OFF]" to eliminate any semantic confusion regarding the plugin's status.
|
||||
|
||||
|
||||
## v1.4.2 (Language Sync & Gallery Patch)
|
||||
### 🚀 Enhancements
|
||||
- **ComfyUI Manager Ready**: The plugin is officially available in the ComfyUI Manager default node list!
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
- **Native Language Sync**: Fixed a bug where the plugin would not properly synchronize its UI language with ComfyUI's modern native `Comfy.Locale` settings. The language check has been migrated from local storage caching to a dynamic `app.ui.settings` API call during the extension setup phase.
|
||||
- **Gallery Zoom Review**: Verified and confirmed that the mouse wheel zoom feature inside the gallery image viewer is functioning natively as intended.
|
||||
## v1.4.1 (The Drag & Drop Resilience Update)
|
||||
### 🚀 Enhancements
|
||||
- **Silent Quick Scan Button**: Added a new dedicated `🔍 Check & Auto-Scan Missing Info` button to the Settings Hub. It silently finds unscanned models and extracts their hashes in the background without popping up the intrusive Civitai black-box prompt, keeping your workflow uninterrupted.
|
||||
- **Hires Fix Drag Resilience**: Fixed a critical Chromium memory allocation crash where dragging extremely large (Hires. fix) images from the Gallery would silently fail to initiate. Implemented a lightweight, translucent SVG "Workflow" ghost using `setDragImage`, allowing massive 4K+ images to be dragged seamlessly onto the ComfyUI canvas without memory limits.
|
||||
- **Operation Guide**: Added a highly requested `docs/核心按钮操作指南_大白话版.md` (Layman's Core Operation Guide) to clearly distinguish between the different scanning and fixing buttons for new users.
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
- **Orange Toast Eradication**: Completely ripped out the overly aggressive `app.queuePrompt` interceptor. Users will no longer be bombarded by the orange "Unscanned Models" warning toast every time they click Generate.
|
||||
- **CSS Compatibility**: Added the standard `line-clamp` property alongside `-webkit-line-clamp` in the stylesheet for better cross-browser compatibility when truncating model names.
|
||||
|
||||
## v1.4.0 (The UI & Architecture Overhaul)
|
||||
### 💎 Gemini-Style Popovers & UX
|
||||
- **Lightweight Side Popovers**: Completely dismantled the heavy, center-screen settings modal with blurred backgrounds. Rebuilt the Settings Hub as a lightweight, non-intrusive side-popover that snaps to the sidebar—heavily inspired by Gemini's web UI.
|
||||
- **Global Click-Outside**: Implemented a zero-leak Vanilla JS global `mousedown` listener. Clicking anywhere outside an active popover instantly and smoothly dismisses it, vastly improving workflow immersion.
|
||||
- **Full-Width Action Dock**: Replaced the cramped bottom capsule with a 100% width solid Action Dock. This provides a stable visual foundation and properly balances the action buttons (Scan, Fix) against the Settings cog.
|
||||
- **Gallery Letterboxing**: Switched the Image Gallery's thumbnail rendering from `object-fit: cover` to `object-fit: contain` with a deep `#000` background. Tall 9:16 vertical character portraits are now 100% visible, perfectly framed by natural letterboxing instead of being decapitated.
|
||||
|
||||
### ⚙️ Deep Architecture & Bounds Fixing
|
||||
- **Banned CSS `zoom`**: Eradicated the CSS `zoom` property for UI scaling, which was severely distorting the browser's physical bounding boxes and causing the plugin to randomly "fly off-screen" when dragged to the top edge.
|
||||
- **Rem/Calc Sizing Engine**: Adopted a bottom-up relative scaling approach using `font-size: calc(16px * scale)`. The plugin container maintains stable physical dimensions while internals scale smoothly, restoring absolute precision to the drag-and-drop collision physics.
|
||||
- **State Mutex Locks**: Implemented strict interaction locks. Entering an exclusive fullscreen view (like the Gallery) now aggressively disables the main hamburger menu to prevent rendering conflicts and DOM state overlapping.
|
||||
- **Centralized i18n Mutator Engine**: Solved the "Fake Localization" bug where newly added dynamic buttons remained in Chinese. All persistent DOM elements are now strictly bound to the Top-Down language mutation hook, ensuring flawless 1-click bilingual swapping without Vue or React.
|
||||
|
||||
## v1.4.0 (The UI & Architecture Overhaul)
|
||||
### 💎 Gemini-Style Popovers & UX
|
||||
- **Lightweight Side Popovers**: Completely dismantled the heavy, center-screen settings modal with blurred backgrounds. Rebuilt the Settings Hub as a lightweight, non-intrusive side-popover that snaps to the sidebar—heavily inspired by Gemini's web UI.
|
||||
- **Global Click-Outside**: Implemented a zero-leak Vanilla JS global `mousedown` listener. Clicking anywhere outside an active popover instantly and smoothly dismisses it, vastly improving workflow immersion.
|
||||
- **Full-Width Action Dock**: Replaced the cramped bottom capsule with a 100% width solid Action Dock. This provides a stable visual foundation and properly balances the action buttons (Scan, Fix) against the Settings cog.
|
||||
- **Gallery Letterboxing**: Switched the Image Gallery's thumbnail rendering from `object-fit: cover` to `object-fit: contain` with a deep `#000` background. Tall 9:16 vertical character portraits are now 100% visible, perfectly framed by natural letterboxing instead of being decapitated.
|
||||
|
||||
### ⚙️ Deep Architecture & Bounds Fixing
|
||||
- **Banned CSS `zoom`**: Eradicated the CSS `zoom` property for UI scaling, which was severely distorting the browser's physical bounding boxes and causing the plugin to randomly "fly off-screen" when dragged to the top edge.
|
||||
- **Rem/Calc Sizing Engine**: Adopted a bottom-up relative scaling approach using `font-size: calc(16px * scale)`. The plugin container maintains stable physical dimensions while internals scale smoothly, restoring absolute precision to the drag-and-drop collision physics.
|
||||
- **State Mutex Locks**: Implemented strict interaction locks. Entering an exclusive fullscreen view (like the Gallery) now aggressively disables the main hamburger menu to prevent rendering conflicts and DOM state overlapping.
|
||||
- **Centralized i18n Mutator Engine**: Solved the "Fake Localization" bug where newly added dynamic buttons remained in Chinese. All persistent DOM elements are now strictly bound to the Top-Down language mutation hook, ensuring flawless 1-click bilingual swapping without Vue or React.
|
||||
|
||||
## v1.3.0 (Zero-API Tensor Fingerprinting Update)
|
||||
### 🧠 HuggingFace Native Support
|
||||
- **Offline Base Model Inference**: Added a zero-API local inference engine! For models downloaded purely from HuggingFace (or private unreleased models) that return a 404 on Civitai, the scanner no longer gives up. It now forcibly parses the .safetensors structure and uses **Tensor Fingerprinting** (e.g., detecting double_blocks.0.img_attn for Flux) to accurately deduce the underlying base architecture with 100% precision.
|
||||
@@ -34,6 +133,19 @@
|
||||
|
||||
# 📈 Anomalous Model Browser Changelog
|
||||
|
||||
## v2.0.0 (The Workflow & UI Evolution Update)
|
||||
### 🚀 Major Features
|
||||
- **Node Assistant (节点助手)**: Introduced a dedicated "Assistant" tab within the Model Doctor. Selecting any node in the canvas instantly focuses the Assistant on that specific node, providing deep analysis and contextual controls without obscuring your view.
|
||||
- **Visual Model Switcher (可视化模型选择)**: Eliminated blind dropdown menus! When replacing a model, a stunning visual grid pops up inside the Assistant panel, displaying full cover images and model names. You can now select replacements purely visually.
|
||||
- **Global Health Dashboard (全图体检报表)**: A top-level dashboard in the Doctor panel provides a bird's-eye view of your entire workflow. It categorizes nodes into "Healthy", "Missing", "Replaced", and "Unrelated", giving you instant situational awareness.
|
||||
- **Inline Node Notes (内联节点备注)**: Added the ability to view and edit model notes directly beneath the preview image in the Node Assistant, ensuring zero layout shifting and maintaining a continuous UX flow.
|
||||
- **Local Metadata Parsing Toggle (非C站模型本地解析)**: Added a new explicit toggle in the Settings Hub. This allows users to enable deep local/offline metadata scanning (e.g. for HuggingFace models) without strictly enforcing a Civitai lookup.
|
||||
|
||||
### 🐛 Bug Fixes & UX Polish
|
||||
- **Regex Replacement Bug**: Fixed a critical bug where regex backslashes were swallowed during update patching, causing a syntax error in `main.js` that made the plugin icon disappear.
|
||||
- **Dynamic Regex Splitting**: Ensured file paths are split properly using both forward slashes and backslashes `/[\/]/` for complete cross-platform Windows/Linux compatibility.
|
||||
- **Temporary Feature Suspension**: Temporarily commented out the WIP "Preflight Import Workflow" button while its parsing engine is being finalized.
|
||||
|
||||
## v1.2.0 (The O(1) Speed & Tiered Resolution Update)
|
||||
### 🚀 Architectural Breakthroughs
|
||||
- **Tiered Fallback Resolution Engine**: The core hashing and scanning engine has been completely rewritten. When scanning new .safetensors files without an .info file, the scraper no longer blindly computes the SHA256 of the entire 7GB+ file. Instead, it extracts the uint64 header size and parses the internal JSON to retrieve the embedded modelspec.hash.sha256. This drops the hash time for new models from minutes down to O(1) milliseconds, achieving true instantaneous lightweight scans.
|
||||
@@ -42,76 +154,76 @@
|
||||
- **Gentle Workflow Error Reminder**: Removed the overly aggressive background ghost-clicker that attempted to clear Vue side-panel errors by simulating clicks. Replaced it with a clean, bilingual user alert gently reminding them to click the [Refresh] button if the ComfyUI Workflow Overview panel still shows red errors.
|
||||
- **Documentation Badges**: Added quick-access badges to the README header for 1-click navigation to the Changelog and Developer Notes on GitHub.
|
||||
|
||||
# 馃搱 Anomalous Model Browser Changelog
|
||||
|
||||
## v1.1.1 (Ultimate Model Resolution & Diagnostic Overhaul)
|
||||
### 馃殌 Enhancements
|
||||
- **Lightweight Scan Speed Demon**: Rewrote the "Lightweight Scan" incremental skip condition. By recognizing that lightweight mode purposefully omits media (`--skip-media`), the scanner now skips models *instantly* if a `.info` file exists, bypassing the heavy SHA256 computation block. Lightweight scans are now truly lightweight, parsing hundreds of gigabytes in mere seconds instead of minutes.
|
||||
- **One-Click Visual Residue Eradication**: Both the post-scan auto-fix and the "One-Click Fix Workflow" settings button have been supercharged. They now aggressively target `node.color`, `node.bgcolor`, and `node.has_errors`. Even if the string matches perfectly but the frontend dropdown cache is just stale, the script violently resets ComfyUI's native red error highlighting. The workflow immediately visually clears without requiring a browser refresh.
|
||||
|
||||
### 馃悰 Bug Fixes
|
||||
- **The "Overlapping Directory" Deadlock**: Fixed a critical bug in `api.py` where fallback `target_size` matching completely aborted if `len(size_matches) > 1`. If users had `extra_model_paths.yaml` aliasing `checkpoints` and `diffusion_models` to the exact same physical folder, `os.walk` naturally duplicated the entries. Implemented an `os.path.realpath` deduplication matrix to fuse these ghosts into a single mathematical truth, restoring size-based missing node resolution to 100% reliability.
|
||||
- **Silent Scanner Crashing**: Fixed a tiny but fatal Python `IndentationError` in `scraper.py` (introduced during the skip-media logic update) that caused the lightweight scanner to silently crash before doing anything. The UI DEVNULL suppression hid the error, creating the illusion of the scan "ignoring" models. The indentation is now strictly PEP-8 compliant.
|
||||
## v1.1.0 (Integrated Gallery & Advanced UX Update)
|
||||
### 鉁?Major Features
|
||||
- **Integrated Image Gallery**: Added a brand new "Gallery" hub that seamlessly syncs with your ComfyUI `output` folder. Browse your generation history natively within the plugin!
|
||||
- **Infinite Lazy Loading**: The gallery utilizes `IntersectionObserver` to automatically load images as you scroll, ensuring zero lag even with thousands of generations.
|
||||
- **Drag-and-Drop Workflow Import**: Every gallery image acts exactly like native ComfyUI assets. You can directly drag any image onto the canvas or a `Load Image` node to effortlessly extract its metadata and workflow.
|
||||
- **Foolproof Image Deletion**: Built an immersive, full-card confirmation overlay for deleting images, replacing the tiny inline buttons. It drastically increases click accuracy and perfectly follows Fitts's Law.
|
||||
|
||||
### 馃拕 UX & UI Polish
|
||||
- **"Intent Delay" Hover Mechanics**: Fixed "Strobe Effects" where rapid mouse sweeping caused flickering UI. Added a customized `0.35s cubic-bezier` curve with a `0.08s` delay to all grid cards and notebook items, ensuring items only pop up when you actually mean to look at them.
|
||||
- **Smart Sidebar Auto-Hide**: When clicking the "Gallery" top menu button, the left folder tree now intelligently auto-hides itself, leaving maximum screen space for viewing your images. It automatically restores when returning to the Models page.
|
||||
- **CSS Grid Refinements**: The gallery uses responsive flex layout (`minmax(250px, 1fr)`), automatically balancing 4 beautiful thumbnails per row in expanded mode.
|
||||
|
||||
|
||||
## v1.0.3 (Responsive UI & Critical Hotfixes)
|
||||
### 馃殌 Enhancements
|
||||
- **Settings Panel Redesign**: Transformed the raw API Key input field into a clean, dedicated `馃攽 API Key Config` modal button. Added detailed, beginner-friendly descriptions for all settings buttons (Scan, Clean, AutoPlay) to clarify their destructive/background behaviors.
|
||||
- **Top Bar Layout Logic**: The header buttons (Models, Settings, etc.) now intelligently hide themselves when the left sidebar folder tree is expanded on narrow screens, automatically reappearing when the tree is collapsed to avoid layout breakage.
|
||||
- **English Typography Polish**: Added specific CSS `.anomalous-lang-en` scaling to increase the English header buttons to `1.05em` with generous padding, perfectly matching the visual weight of the Chinese UI.
|
||||
- **Pure Icon Narrow Sidebar**: The Notebook's internal left sidebar is now extremely responsive. On narrow screens (`< 600px`), it aggressively collapses to `60px` width, hiding all notebook names and displaying only pure document icons for a sleek micro-toolbar feel.
|
||||
- **Logical Settings Order**: Swapped the positions of the language toggle and the Close button in the settings panel. The language toggle now sits proudly at the top with a distinct margin, while the red Close button acts as a final anchor at the bottom.
|
||||
|
||||
### 馃悰 Bug Fixes
|
||||
- **The "Missing Plugin Button" Trap**: Completely eliminated a notorious "old bug" where the floating plugin button would vanish forever if a user resized their window or switched to a smaller monitor (due to `localStorage` caching out-of-bounds `left/top` coordinates like `3000px`). The button now forces a safe bottom-right fallback if the saved coordinates exceed `window.innerWidth`.
|
||||
- **Fatal Vite Preload Crash**: Fixed a critical `SyntaxError` caused by a missing template literal backtick (`` ` ``) in the translation dictionary. This tiny missing character previously triggered a `[vite:preloadError]`, causing the entire `main.js` to abort loading before rendering the UI.
|
||||
- **Double Icon Squashing**: Addressed a bug where the top header's "Notebooks" button would turn into a weird, empty dark gray rectangle on narrow screens. Extracted the `馃搼` emoji out of the translation string wrapper so the icon remains fully visible even when the text is responsively hidden.
|
||||
|
||||
---
|
||||
|
||||
# v1.0.2 (UX & Polish Hotfix)
|
||||
## 馃殌 Enhancements
|
||||
- **Magnetic Matrix Deployment**: When sending a Notebook to the canvas, the entire architecture (Checkpoint + Loras + CLIP Encoders) is now mathematically arranged in a clean, linear assembly line instead of clumping together. The entire node group magnetically sticks to your cursor until you click the canvas to drop it.
|
||||
- **Smart Session Memory**: Re-opening the Notebook panel now instantly resumes your exact previous editing session rather than resetting to a blank state.
|
||||
- **Auto-Focus First Notebook**: If no session is active, opening the Notebook modal will automatically open your first existing notebook to prevent "empty screen" fatigue.
|
||||
- **Instant Save Feedback**: Added a satisfying 1.5-second green `鉁卄 transient animation to the notebook save button for psychological assurance.
|
||||
- **Clearer Documentation**: Updated README codebase size estimation to reflect reality (~150KB - 200KB) due to the massive features added, while still maintaining pure Vanilla JS zero-dependency dominance.
|
||||
|
||||
## 馃悰 Bug Fixes
|
||||
- **Double Icon Glitch**: Removed a hardcoded `鉃昤 emoji on the "Apply to Canvas" button that duplicated the icon injected by the translation engine.
|
||||
- **Localization Override**: Fixed a critical bug where scan success dialogs were defaulting to English despite the UI being set to Chinese. This was caused by the plugin improperly reading the host ComfyUI root DOM `lang` attribute instead of the plugin's internal state.
|
||||
- **Safer Reboot Advice**: Replaced misleading mentions of "Refresh ComfyUI" with strict advice to "Restart ComfyUI backend" after model scans to prevent deep path caching crashes.
|
||||
|
||||
---
|
||||
|
||||
# v1.0.1 (Notebook System Expansion)
|
||||
## 馃専 New Features
|
||||
- **Notebook System (`馃搼 Notebooks`)**: A powerful new drafting space designed for workflow preparation.
|
||||
- **Bilingual Prompt Editor**: A dual-pane translation workspace (English/Chinese) that supports Google and DeepL translation via local Python backend (caching in `translations.json`).
|
||||
- **Interactive Tag Engine**: Automatically splits prompts by commas into styled tags. Includes hover-sync between English and Chinese tags, individual tag editing, and 1-click clipboard copying.
|
||||
- **Dynamic Architecture Filtering**: The Notebook allows selecting a Base Model directly parsed from your local library (e.g., `SDXL`, `SD 1.5`, `Pony`), strictly omitting unowned default models. Selecting an architecture perfectly filters the Main Model and Lora galleries below it.
|
||||
- **One-Click Deployment**: The **"馃殌 Send to Canvas"** button seamlessly injects your configured Checkpoint loader, Lora loaders, and CLIP Text Encode nodes directly onto the ComfyUI canvas, fully wired and ready to go.
|
||||
|
||||
## 馃洜锔?Enhancements & UX Tweaks
|
||||
- **Zero-Wait Delete Actions**: The "Delete Notebook" interaction has been overhauled. Instead of a mandatory 3-second wait, it now transforms into an explicit `[鈿狅笍 Sure?]` and `[鉁昡` cancellation button combo, dramatically improving the user experience for accidental clicks.
|
||||
- **Buttery Smooth Hover Dynamics**: Replaced harsh instant hovers on model cards with a `cubic-bezier(0.2, 0.8, 0.2, 1)` transition and a micro `0.02s` delay. This eliminates strobe-flashing when the mouse sweeps quickly across galleries, providing an extremely premium damping feel.
|
||||
- **Smart Startup Navigation**: The plugin no longer opens to an empty root directory by default. It now intelligently scans all loaded trees on startup and auto-focuses the first valid folder containing models, ensuring you dive straight into your library.
|
||||
- **Deep i18n Integration**: Total internationalization coverage extended to all deep-layer UI elements, including Notebook buttons, translation triggers, and dynamic dialogs. Language toggling triggers an instant virtual DOM re-render without page refreshes.
|
||||
|
||||
## 馃悰 Bug Fixes
|
||||
- **Base Model Metadata Pollution**: Fixed an issue where the `baseModel` dropdown forcefully injected unowned models (like `HunyuanVideo` or `OmniGen`) into the UI. The filter now strictly traverses actual local `.safetensors` headers and `.info` configurations to provide a 100% accurate reflection of your physical library.
|
||||
- **Nested Scrolling Traps**: Eliminated a severe UX "scroll trap" within the Notebook dual-pane editor by removing inner `max-height` constraints, allowing natural flex expansion utilizing the modal's primary scrollbar.
|
||||
- **Model Selection Jumping**: Fixed layout shifting caused by variable-length text tags by implementing a rigid CSS grid/flex architecture (`.anomalous-nb-tag-row`).
|
||||
# 馃搱 Anomalous Model Browser Changelog
|
||||
|
||||
## v1.1.1 (Ultimate Model Resolution & Diagnostic Overhaul)
|
||||
### 馃殌 Enhancements
|
||||
- **Lightweight Scan Speed Demon**: Rewrote the "Lightweight Scan" incremental skip condition. By recognizing that lightweight mode purposefully omits media (`--skip-media`), the scanner now skips models *instantly* if a `.info` file exists, bypassing the heavy SHA256 computation block. Lightweight scans are now truly lightweight, parsing hundreds of gigabytes in mere seconds instead of minutes.
|
||||
- **One-Click Visual Residue Eradication**: Both the post-scan auto-fix and the "One-Click Fix Workflow" settings button have been supercharged. They now aggressively target `node.color`, `node.bgcolor`, and `node.has_errors`. Even if the string matches perfectly but the frontend dropdown cache is just stale, the script violently resets ComfyUI's native red error highlighting. The workflow immediately visually clears without requiring a browser refresh.
|
||||
|
||||
### 馃悰 Bug Fixes
|
||||
- **The "Overlapping Directory" Deadlock**: Fixed a critical bug in `api.py` where fallback `target_size` matching completely aborted if `len(size_matches) > 1`. If users had `extra_model_paths.yaml` aliasing `checkpoints` and `diffusion_models` to the exact same physical folder, `os.walk` naturally duplicated the entries. Implemented an `os.path.realpath` deduplication matrix to fuse these ghosts into a single mathematical truth, restoring size-based missing node resolution to 100% reliability.
|
||||
- **Silent Scanner Crashing**: Fixed a tiny but fatal Python `IndentationError` in `scraper.py` (introduced during the skip-media logic update) that caused the lightweight scanner to silently crash before doing anything. The UI DEVNULL suppression hid the error, creating the illusion of the scan "ignoring" models. The indentation is now strictly PEP-8 compliant.
|
||||
## v1.1.0 (Integrated Gallery & Advanced UX Update)
|
||||
### 鉁?Major Features
|
||||
- **Integrated Image Gallery**: Added a brand new "Gallery" hub that seamlessly syncs with your ComfyUI `output` folder. Browse your generation history natively within the plugin!
|
||||
- **Infinite Lazy Loading**: The gallery utilizes `IntersectionObserver` to automatically load images as you scroll, ensuring zero lag even with thousands of generations.
|
||||
- **Drag-and-Drop Workflow Import**: Every gallery image acts exactly like native ComfyUI assets. You can directly drag any image onto the canvas or a `Load Image` node to effortlessly extract its metadata and workflow.
|
||||
- **Foolproof Image Deletion**: Built an immersive, full-card confirmation overlay for deleting images, replacing the tiny inline buttons. It drastically increases click accuracy and perfectly follows Fitts's Law.
|
||||
|
||||
### 馃拕 UX & UI Polish
|
||||
- **"Intent Delay" Hover Mechanics**: Fixed "Strobe Effects" where rapid mouse sweeping caused flickering UI. Added a customized `0.35s cubic-bezier` curve with a `0.08s` delay to all grid cards and notebook items, ensuring items only pop up when you actually mean to look at them.
|
||||
- **Smart Sidebar Auto-Hide**: When clicking the "Gallery" top menu button, the left folder tree now intelligently auto-hides itself, leaving maximum screen space for viewing your images. It automatically restores when returning to the Models page.
|
||||
- **CSS Grid Refinements**: The gallery uses responsive flex layout (`minmax(250px, 1fr)`), automatically balancing 4 beautiful thumbnails per row in expanded mode.
|
||||
|
||||
|
||||
## v1.0.3 (Responsive UI & Critical Hotfixes)
|
||||
### 馃殌 Enhancements
|
||||
- **Settings Panel Redesign**: Transformed the raw API Key input field into a clean, dedicated `馃攽 API Key Config` modal button. Added detailed, beginner-friendly descriptions for all settings buttons (Scan, Clean, AutoPlay) to clarify their destructive/background behaviors.
|
||||
- **Top Bar Layout Logic**: The header buttons (Models, Settings, etc.) now intelligently hide themselves when the left sidebar folder tree is expanded on narrow screens, automatically reappearing when the tree is collapsed to avoid layout breakage.
|
||||
- **English Typography Polish**: Added specific CSS `.anomalous-lang-en` scaling to increase the English header buttons to `1.05em` with generous padding, perfectly matching the visual weight of the Chinese UI.
|
||||
- **Pure Icon Narrow Sidebar**: The Notebook's internal left sidebar is now extremely responsive. On narrow screens (`< 600px`), it aggressively collapses to `60px` width, hiding all notebook names and displaying only pure document icons for a sleek micro-toolbar feel.
|
||||
- **Logical Settings Order**: Swapped the positions of the language toggle and the Close button in the settings panel. The language toggle now sits proudly at the top with a distinct margin, while the red Close button acts as a final anchor at the bottom.
|
||||
|
||||
### 馃悰 Bug Fixes
|
||||
- **The "Missing Plugin Button" Trap**: Completely eliminated a notorious "old bug" where the floating plugin button would vanish forever if a user resized their window or switched to a smaller monitor (due to `localStorage` caching out-of-bounds `left/top` coordinates like `3000px`). The button now forces a safe bottom-right fallback if the saved coordinates exceed `window.innerWidth`.
|
||||
- **Fatal Vite Preload Crash**: Fixed a critical `SyntaxError` caused by a missing template literal backtick (`` ` ``) in the translation dictionary. This tiny missing character previously triggered a `[vite:preloadError]`, causing the entire `main.js` to abort loading before rendering the UI.
|
||||
- **Double Icon Squashing**: Addressed a bug where the top header's "Notebooks" button would turn into a weird, empty dark gray rectangle on narrow screens. Extracted the `馃搼` emoji out of the translation string wrapper so the icon remains fully visible even when the text is responsively hidden.
|
||||
|
||||
---
|
||||
|
||||
# v1.0.2 (UX & Polish Hotfix)
|
||||
## 馃殌 Enhancements
|
||||
- **Magnetic Matrix Deployment**: When sending a Notebook to the canvas, the entire architecture (Checkpoint + Loras + CLIP Encoders) is now mathematically arranged in a clean, linear assembly line instead of clumping together. The entire node group magnetically sticks to your cursor until you click the canvas to drop it.
|
||||
- **Smart Session Memory**: Re-opening the Notebook panel now instantly resumes your exact previous editing session rather than resetting to a blank state.
|
||||
- **Auto-Focus First Notebook**: If no session is active, opening the Notebook modal will automatically open your first existing notebook to prevent "empty screen" fatigue.
|
||||
- **Instant Save Feedback**: Added a satisfying 1.5-second green `鉁卄 transient animation to the notebook save button for psychological assurance.
|
||||
- **Clearer Documentation**: Updated README codebase size estimation to reflect reality (~150KB - 200KB) due to the massive features added, while still maintaining pure Vanilla JS zero-dependency dominance.
|
||||
|
||||
## 馃悰 Bug Fixes
|
||||
- **Double Icon Glitch**: Removed a hardcoded `鉃昤 emoji on the "Apply to Canvas" button that duplicated the icon injected by the translation engine.
|
||||
- **Localization Override**: Fixed a critical bug where scan success dialogs were defaulting to English despite the UI being set to Chinese. This was caused by the plugin improperly reading the host ComfyUI root DOM `lang` attribute instead of the plugin's internal state.
|
||||
- **Safer Reboot Advice**: Replaced misleading mentions of "Refresh ComfyUI" with strict advice to "Restart ComfyUI backend" after model scans to prevent deep path caching crashes.
|
||||
|
||||
---
|
||||
|
||||
# v1.0.1 (Notebook System Expansion)
|
||||
## 馃専 New Features
|
||||
- **Notebook System (`馃搼 Notebooks`)**: A powerful new drafting space designed for workflow preparation.
|
||||
- **Bilingual Prompt Editor**: A dual-pane translation workspace (English/Chinese) that supports Google and DeepL translation via local Python backend (caching in `translations.json`).
|
||||
- **Interactive Tag Engine**: Automatically splits prompts by commas into styled tags. Includes hover-sync between English and Chinese tags, individual tag editing, and 1-click clipboard copying.
|
||||
- **Dynamic Architecture Filtering**: The Notebook allows selecting a Base Model directly parsed from your local library (e.g., `SDXL`, `SD 1.5`, `Pony`), strictly omitting unowned default models. Selecting an architecture perfectly filters the Main Model and Lora galleries below it.
|
||||
- **One-Click Deployment**: The **"馃殌 Send to Canvas"** button seamlessly injects your configured Checkpoint loader, Lora loaders, and CLIP Text Encode nodes directly onto the ComfyUI canvas, fully wired and ready to go.
|
||||
|
||||
## 馃洜锔?Enhancements & UX Tweaks
|
||||
- **Zero-Wait Delete Actions**: The "Delete Notebook" interaction has been overhauled. Instead of a mandatory 3-second wait, it now transforms into an explicit `[鈿狅笍 Sure?]` and `[鉁昡` cancellation button combo, dramatically improving the user experience for accidental clicks.
|
||||
- **Buttery Smooth Hover Dynamics**: Replaced harsh instant hovers on model cards with a `cubic-bezier(0.2, 0.8, 0.2, 1)` transition and a micro `0.02s` delay. This eliminates strobe-flashing when the mouse sweeps quickly across galleries, providing an extremely premium damping feel.
|
||||
- **Smart Startup Navigation**: The plugin no longer opens to an empty root directory by default. It now intelligently scans all loaded trees on startup and auto-focuses the first valid folder containing models, ensuring you dive straight into your library.
|
||||
- **Deep i18n Integration**: Total internationalization coverage extended to all deep-layer UI elements, including Notebook buttons, translation triggers, and dynamic dialogs. Language toggling triggers an instant virtual DOM re-render without page refreshes.
|
||||
|
||||
## 馃悰 Bug Fixes
|
||||
- **Base Model Metadata Pollution**: Fixed an issue where the `baseModel` dropdown forcefully injected unowned models (like `HunyuanVideo` or `OmniGen`) into the UI. The filter now strictly traverses actual local `.safetensors` headers and `.info` configurations to provide a 100% accurate reflection of your physical library.
|
||||
- **Nested Scrolling Traps**: Eliminated a severe UX "scroll trap" within the Notebook dual-pane editor by removing inner `max-height` constraints, allowing natural flex expansion utilizing the modal's primary scrollbar.
|
||||
- **Model Selection Jumping**: Fixed layout shifting caused by variable-length text tags by implementing a rigid CSS grid/flex architecture (`.anomalous-nb-tag-row`).
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 DemonGatanjieu
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,133 +1,161 @@
|
||||
# Anomalous Model Browser
|
||||
<div align="center">
|
||||
|
||||
[](CHANGELOG.md)
|
||||
# 🚀 Anomalous Model Browser
|
||||
|
||||
[English](#english) | [中文](#中文)
|
||||
**A Comprehensive Creative Workspace & Model Manager for ComfyUI**
|
||||
*零依赖 C 站元数据抓取 · 智能模型医生 · 可视化节点助手 · 工作流配方与参数笔记*
|
||||
|
||||
<br/>
|
||||
|
||||
[](https://github.com/ltdrdata/ComfyUI-Manager)
|
||||
[](LICENSE)
|
||||
[](CHANGELOG.md)
|
||||
[](https://www.bilibili.com/video/BV1a1bv68EuA/)
|
||||
[](https://youtu.be/hAvsj7uiaCw)
|
||||
|
||||
<br/>
|
||||
|
||||
[**English**](#-english) | [**中文说明**](#-中文)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
<h2 id="english">English</h2>
|
||||
<h2 id="english">🇬🇧 English</h2>
|
||||
|
||||
A lightweight (< 250KB), zero-dependency ComfyUI model browser plugin featuring hash-level workflow repair, a powerful smart notebook, deep local scanning, and gallery management.
|
||||
> **Anomalous Model Browser** is an all-in-one management suite for ComfyUI. It empowers your generative workflow with hash-based model scraping, one-click broken-path resolution (Model Doctor), visual LoRA insertion/replacement (Node Assistant), and immutable Workflow Recipes with Parameter Notebooks.
|
||||
|
||||
### Core Feature 1: Hash-Level Workflow Repair (Gene-Level Self-Healing)
|
||||
### 🎬 Video Walkthrough & Demos
|
||||
* 📺 **YouTube**: [Watch Quick Walkthrough on YouTube](https://youtu.be/hAvsj7uiaCw)
|
||||
* 📺 **Bilibili**: [Watch Video Demo on Bilibili (在哔哩哔哩观看)](https://www.bilibili.com/video/BV1a1bv68EuA/)
|
||||
|
||||
**Workflows and images generated or saved while this plugin is active will have their models' Civitai Hashes automatically embedded.**
|
||||
### 🌟 Core Capabilities
|
||||
|
||||
> ⚠️ **Crucial Prerequisite**: After installing the plugin, you MUST run a **[Scan]** from the UI to build your local hash database. Once scanned, any workflows or images you generate thereafter will automatically carry the hash metadata required for self-healing.
|
||||
| 🚀 Feature | 💡 Description |
|
||||
| :--- | :--- |
|
||||
| **Comprehensive Model Management** | Automatically extract Base Model architectures, cover images, trigger words, and author info via hash scanning. Customize names, notes, and covers with safe deletion & visual replacement. |
|
||||
| **Workflow Repair (Model Doctor 🩺)** | Auto-detect missing model nodes when loading external workflows and swap them with matching local paths in one click. |
|
||||
| **Node Assistant & Presets (🤖)** | Visually replace models or safely insert LoRAs into compatible chains directly from the canvas. Apply node parameters from Workflow Recipes transactionally. |
|
||||
| **Workflow Recipes & Presets (🧰)** | Save complete workflows with covers, notes, tags, model identities, and parameter snapshots. Append to canvas, compare versions, and export portable packages. |
|
||||
| **Smart Prompt Notebook (📑)** | Architecture-aware compatibility matching for Checkpoints and LoRAs, built-in translation, auto-tagging, and 1-click canvas deployment. |
|
||||
| **History Gallery (🖼️)** | Native viewer for your `output` folder with mouse-wheel zoom, deletion, and direct drag-and-drop workflow reconstruction onto the canvas. |
|
||||
|
||||
When loading a workflow or image that was saved with this plugin, if you encounter red "Missing Node" errors due to mismatched model names:
|
||||
1. Click the **Repair Workflow** button.
|
||||
2. The plugin will scan your local directory using the embedded Hash data.
|
||||
3. Once a match is found, it automatically reconnects and replaces the missing nodes.
|
||||
### 📦 Quick Installation
|
||||
|
||||
*(Note: The plugin relies on these injected hashes for precise matching. Historical workflows or images generated without this plugin will lack the necessary hash data and cannot be automatically repaired.)*
|
||||
1. Open your terminal in the ComfyUI `custom_nodes` directory:
|
||||
```bash
|
||||
cd custom_nodes
|
||||
git clone https://github.com/DemonGatanjieu/Anomalous_Model_Browser.git
|
||||
```
|
||||
2. Restart ComfyUI. *(Alternatively, install via **ComfyUI Manager** by searching for `Anomalous Model Browser`)*
|
||||
|
||||
### Core Feature 2: Smart Notebook (Workflow Drafting)
|
||||
<br/>
|
||||
|
||||
Since the local scanner natively identifies the architecture (Base Model) of your files, the Notebook acts as a powerful drafting area:
|
||||
* **Architecture Filtering**: Select a Base Model, and the notebook will strictly filter and display only the compatible Main Models and Loras.
|
||||
* **Visual Selection**: Displays preview images for your models directly within the notebook interface.
|
||||
* **Prompt Management**: Paste your prompts to instantly receive a bilingual translation for easy side-by-side editing.
|
||||
* **One-Click Deploy**: Press "Send to Canvas" and your entire selected stack (Main Model, Loras, Prompts) will drop onto the canvas fully wired up.
|
||||
<details>
|
||||
<summary><b>📖 Click to Expand: Step-by-Step Operating Guide</b></summary>
|
||||
|
||||
### Core Feature 3: Deep Scanning & Metadata Management
|
||||
<br/>
|
||||
|
||||
* **O(1) Instant Hash Extraction**: Instead of computing hashes for gigabytes of data, the plugin reads the header of `.safetensors` files to extract the hash instantly. It then automatically downloads the corresponding `.info` and previews from Civitai.
|
||||
* **Offline Tensor Gene Inference**: For private models or models not on Civitai, the plugin analyzes the internal tensor structure (e.g., detecting `double_blocks` for Flux) to accurately deduce the Base Model architecture, ensuring even orphaned models work seamlessly with the Notebook filtering.
|
||||
* **Model Deduplication**: Physically identifies and cleans up redundant `.safetensors` files that share the exact same hash under different names.
|
||||
* **Model Details**: Read usage instructions and 1-click copy trigger words directly from the model page UI.
|
||||
#### 1. Initialization (Scan Wizard) 🔄
|
||||
* **Location**: The Scan icon (**🔄**) at the bottom-left of the sidebar.
|
||||
* **Operation**: Open the Scan Wizard, choose your configuration, and execute the scan with an active network connection to build your local model database.
|
||||
|
||||
### Other Practical Features
|
||||
#### 2. Workflow Repair (Model Doctor) 🩺
|
||||
* **Location**: The second Stethoscope icon (**🩺**) from the bottom-left.
|
||||
* **Operation**: When importing foreign workflows with red broken model nodes, click Model Doctor to automatically identify and batch-replace them with local equivalents.
|
||||
|
||||
* **Sidebar Docking & Node Dropping**: Dock the browser to the sidebar. Click the `+` icon in the top right of any model card to instantly drop its loader node onto your canvas.
|
||||
* **O(1) Fast Scanning**: No Node.js required. Exceptional scroll performance with Eco Mode (hover to play) and Auto-play toggles.
|
||||
* **Native Output Gallery**: Browse ComfyUI output history with infinite lazy loading, and drag-and-drop images directly to the canvas to restore workflows.
|
||||
* **Model Management**: Securely delete models from the UI, automatically cleaning up associated metadata and preview images.
|
||||
#### 3. Visual Swapping & Presets (Node Assistant) 🤖
|
||||
* **Location**: The third Robot icon (**🤖**) from the bottom-left.
|
||||
* **Actions**: Select a node on the canvas to visually replace models or insert LoRAs before/after a compatible MODEL + CLIP chain.
|
||||
* **Parameter Presets (Beta)**: Switch to **Parameter Presets** to apply recipe-saved parameters to matching canvas nodes in one click (seeds and volatile values are safely ignored).
|
||||
|
||||
### Installation Guide
|
||||
#### 4. Workflow Recipes & Parameter Notebooks 🧰
|
||||
* **Location**: Open **Creative Workspace (📑)** and switch to **Workflow Recipes**.
|
||||
* **Features**: Save graphs with covers, notes, and model hashes. Append recipes to current canvas, inspect parameter differences against outputs, compare versions, and import/export portable packages.
|
||||
|
||||
Open your terminal, navigate to the ComfyUI `custom_nodes` folder, and run the following commands:
|
||||
#### 5. Settings Panel ⚙️
|
||||
* **Location**: The Gear icon (**⚙️**) at the bottom-left.
|
||||
* **Options**: Language toggle, UI font scale, hover/autoplay video covers, card thumbnails, and custom folder management.
|
||||
|
||||
```bash
|
||||
cd custom_nodes
|
||||
git clone https://github.com/your-username/Anomalous_Model_Browser.git
|
||||
```
|
||||
*(Note: Restart ComfyUI after cloning to load the plugin)*
|
||||
#### 6. Creative Workspace & Prompt Notes 📑
|
||||
* **Location**: Top navigation tabs (**Models 📦**, **Gallery 🖼️**, **Workspace 📑**, **Dock Side ◧**).
|
||||
* **Usage**: Select a Base Model architecture, attach compatible LoRAs, paste & auto-tag bilingual prompts, and click **Send to Canvas** to deploy directly.
|
||||
|
||||
### Quick Start
|
||||
> [!WARNING]
|
||||
> **Beta Data Protection:** Workflow Recipes and Parameter Presets are currently in testing. Please back up `workflows/anomalous_recipes` and `workflows/anomalous_parameters` inside your ComfyUI user directory before updating.
|
||||
|
||||
**Initial Setup (Required):**
|
||||
1. **Step 1**: Click the **Model Browser** button to open the panel.
|
||||
2. **Step 2**: Navigate to the Settings tab and run a **Full Scan** (or scan individual folders) to build your local hash database.
|
||||
|
||||
**Standard Usage (Adding Nodes):**
|
||||
- Browse your local models. Click the **`+`** icon on any model card to instantly drop its loader node onto the canvas.
|
||||
|
||||
**Repairing Broken Workflows:**
|
||||
1. **Step 1**: Drag and drop a reference image containing Hash info (generated with this plugin) into the ComfyUI canvas.
|
||||
2. **Step 2**: If nodes turn red, click the prompted **Repair Workflow** button and wait for the plugin to restore node connections.
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<h2 id="中文">中文</h2>
|
||||
<h2 id="中文">🇨🇳 中文说明</h2>
|
||||
|
||||
一个不到 250KB、零依赖、集成了哈希级工作流修复、智能笔记本、深度本地扫描与图库管理的 ComfyUI 模型浏览器插件。
|
||||
> **Anomalous Model Browser** 是为 ComfyUI 量身打造的全能创作工作台与模型管家。告别复杂的模型长路径,集成基于哈希的零依赖 C 站元数据抓取、一键拯救爆红节点的“模型医生”、可视化模型替换与 LoRA 插入的“节点助手”,以及支持参数复用的“工作流配方与笔记本”体系。
|
||||
|
||||
### 核心特性一:哈希级工作流修复(基因级自愈)
|
||||
### 🎬 视频演示与教程
|
||||
* 📺 **哔哩哔哩 (Bilibili)**:[在 B 站观看快速上手与使用演示](https://www.bilibili.com/video/BV1a1bv68EuA/)
|
||||
* 📺 **YouTube**:[在 YouTube 观看视频演示](https://youtu.be/hAvsj7uiaCw)
|
||||
|
||||
**在启用本插件的状态下,生成或保存的工作流与图片,都会被自动注入模型对应的 Civitai Hash 和文件信息。**
|
||||
### 🌟 核心特性速览
|
||||
|
||||
> ⚠️ **重要前置条件**:安装插件后,请务必先在界面内运行一次**【扫描】**,建立本地哈希数据库。此后,您导出的所有工作流与图片才会自带模型特征,享有自愈能力。
|
||||
| 🚀 功能板块 | 💡 详细说明 |
|
||||
| :--- | :--- |
|
||||
| **全方位模型管理** | 通过文件哈希自动提取基础架构、封面图、触发词与作者信息;支持看图一键替换模型、自定义备注与安全删除。 |
|
||||
| **节点智能修复 (模型医生 🩺)** | 导入他人工作流发生节点爆红时,模型医生可自动识别缺失模型并一键替换为本地有效路径。 |
|
||||
| **节点助手与参数预设 (🤖)** | 画布选中节点即可可视化选图换模型、向兼容链路插入 LoRA,或一键应用工作流配方中沉淀的节点参数(自动跳过易变种子)。 |
|
||||
| **工作流配方 (🧰)** | 保存完整工作流及封面、标签、模型身份与参数快照;支持追加到画布、历史版本比对、参数差异分析及便携分享包导入导出。 |
|
||||
| **智能提示词笔记 (📑)** | 架构级兼容性匹配(主模型+兼容 LoRA),内置双语分块翻译与标签编辑,支持一键打包发送至画布。 |
|
||||
| **原生出图图库 (🖼️)** | 原生读取本地 `output` 文件夹,支持滚轮缩放与安全删除,**直接将图片拖拽至画布即可原地还原工作流**。 |
|
||||
|
||||
当你拖入一张由此插件保存的图片或工作流时,如果因为环境不同导致模型找不到、节点全红报错:
|
||||
1. 点一下界面上的**修复工作流**按钮。
|
||||
2. 插件会自动根据内置的 Hash 数据,在你的本地目录中精准寻找对应的模型。
|
||||
3. 找到后自动重新连线替换,哪怕你把模型改成了任意名字。
|
||||
### 📦 快速安装
|
||||
|
||||
*(注意:系统依赖模型哈希进行精准匹配。如果在没有安装本插件前生成的旧工作流,或他人未使用本插件生成的图片,由于缺失哈希信息,将无法触发全自动修复,仍需手动重选。)*
|
||||
1. 在 ComfyUI 的 `custom_nodes` 目录下打开终端执行:
|
||||
```bash
|
||||
cd custom_nodes
|
||||
git clone https://github.com/DemonGatanjieu/Anomalous_Model_Browser.git
|
||||
```
|
||||
2. 重启 ComfyUI 即可使用。(*也可以直接在 **ComfyUI Manager** 搜索 `Anomalous Model Browser` 点击安装*)
|
||||
|
||||
### 核心特性二:智能笔记本(工作流草稿本)
|
||||
<br/>
|
||||
|
||||
得益于本地扫描功能可以精准识别并提取模型的基础架构 (Base Model),笔记本模块提供了极为强大的草稿与组装功能:
|
||||
* **架构隔离筛选**:在笔记本中选定 Base Model 后,系统会自动为你筛选出与之完全兼容的主模型和 Lora,杜绝搭配错误。
|
||||
* **可视化挑选**:直接在笔记本页面中显示模型的关联预览图(利用抓取的 C站 数据)。
|
||||
* **提示词翻译对照**:粘贴提示词后,支持一键翻译,形成双语对照,方便打草稿和修改。
|
||||
* **免连线一键发布**:配置好模型和提示词后,点击“发送到画布”,整套节点配置会被直接打包投放到画布并自动完成连线,彻底告别一个个拉节点和连线的折磨。
|
||||
<details>
|
||||
<summary><b>📖 点击展开:标准操作指南(图文步骤)</b></summary>
|
||||
|
||||
### 核心特性三:深度扫描与元数据管理
|
||||
<br/>
|
||||
|
||||
* **O(1) 极速哈希提取**:无需计算几十 GB 模型的完整哈希。插件直接解析 `.safetensors` 文件头,瞬间截获 Hash 值,并自动去 C站 下载对应的 `.info` 说明与预览图。
|
||||
* **脱机张量推断(万能 Base 提取)**:即便是不在 C站 上的私模或其它平台模型,插件会强行解析网络结构(如发现 `double_blocks` 则识别为 Flux),在本地生成虚拟配置。让孤儿模型也能完美参与笔记本的 UI 筛选匹配。
|
||||
* **物理版本去重**:自动识别并清理不同名字但 Hash 完全相同的冗余 `.safetensors` 文件。
|
||||
* **快捷详情查阅**:在模型页面可以直接查看模型使用说明,并支持**一键复制触发词 (Trigger Words)**。
|
||||
#### 1. 前置准备 (扫描向导) 🔄
|
||||
* **入口位置**:侧边栏左下角 **扫描图标 (🔄)**。
|
||||
* **操作步骤**:首次使用请先打开扫描向导,根据需要选择扫描模式并执行(请保持网络畅通)。扫描完成后即建立本地模型库,全面激活插件特性。
|
||||
|
||||
### 其他实用功能
|
||||
#### 2. 拯救爆红 (模型医生) 🩺
|
||||
* **入口位置**:侧边栏左下角第二个 **听诊器图标 (🩺)**。
|
||||
* **操作步骤**:载入他人工作流或图片出现红框缺失报错时,点击模型医生即可智能识别缺失项并一键批量映射为本地正确路径。
|
||||
|
||||
* **侧边栏停靠与快捷投放**:支持将插件停靠在侧边栏。点击模型卡片右上角的 **`+`** 号图标,即可瞬间将该模型的加载器 (Loader) 节点拍到画布上。
|
||||
* **前端极致性能**:不安装 Node.js,列表滚动性能极佳。提供节能模式(悬浮播放)与自动播放开关。
|
||||
* **原生生成图库 (Gallery)**:无缝浏览 output 文件夹的历史记录,无限懒加载,支持将图片直接拖拽至画布瞬间还原工作流。
|
||||
* **内置空间管理**:支持在界面安全删除模型,并自动清理配套的垃圾信息文件及预览图。
|
||||
#### 3. 选中交互与预设 (节点助手) 🤖
|
||||
* **入口位置**:侧边栏左下角第三个 **机器人图标 (🤖)**。
|
||||
* **动作功能**:在画布选中模型节点后,可在“动作”页可视化换模,或在兼容的 MODEL + CLIP 链前后插入 LoRA。
|
||||
* **参数预设(测试)**:切换至“参数笔记本”页,可读取配方中同类型节点的保存参数并一键应用(自动忽略易变种子)。
|
||||
|
||||
### 安装指南
|
||||
#### 4. 工作流配方与参数笔记本 🧰
|
||||
* **入口位置**:顶部 **创作工作台 (📑)** ➔ 切换至 **工作流配方**。
|
||||
* **主要功能**:完整保存当前节点图、封面、标签与参数快照;支持无损追加到当前画布、出图参数差异对比、版本回滚与便携包分享。
|
||||
|
||||
请打开命令行终端,进入 ComfyUI 的 `custom_nodes` 文件夹,执行以下命令即可完成安装:
|
||||
#### 5. 个性化配置 (设置面板) ⚙️
|
||||
* **入口位置**:侧边栏左下角 **齿轮图标 (⚙️)**。
|
||||
* **个性调节**:支持中英文界面切换、字体大小缩放、视频封面悬停/常开播放、缩略图优化与目录黑名单管理。
|
||||
|
||||
```bash
|
||||
cd custom_nodes
|
||||
git clone https://github.com/your-username/Anomalous_Model_Browser.git
|
||||
```
|
||||
*(提示:克隆完成后,重启 ComfyUI 即可加载插件)*
|
||||
#### 6. 创作工作台与提示词笔记 📑
|
||||
* **入口位置**:顶部导航栏 (**模型 📦**、**图库 🖼️**、**工作台 📑**、**侧栏停靠 ◧**)。
|
||||
* **提示词组装**:选择基础模型架构过滤兼容 LoRA,粘贴提示词并一键双语翻译,点击 **发送到画布** 即可自动连线布署。
|
||||
|
||||
### 快速上手 (Quick Start)
|
||||
> [!WARNING]
|
||||
> **测试功能数据安全提醒:** 工作流配方与参数预设目前属于测试阶段,更新插件前建议备份 ComfyUI 用户目录下的 `workflows/anomalous_recipes` 与 `workflows/anomalous_parameters` 文件夹。
|
||||
|
||||
**初始配置 (必做):**
|
||||
1. **第一步**:点击界面主菜单或侧边栏的 **Model Browser** 按钮打开面板。
|
||||
2. **第二步**:务必运行一次**【扫描】**(可全局或分文件夹扫描),建立本地模型哈希数据库。此后保存的工作流才会具备自愈基因。
|
||||
</details>
|
||||
|
||||
**常规使用 (浏览与添加模型):**
|
||||
- 浏览模型库,点击任意模型卡片右上角的 **`+` (加号)** 图标,即可将该模型的加载节点直接丢到画布上。
|
||||
---
|
||||
|
||||
**修复报错工作流:**
|
||||
1. **第一步**:将带有 Hash 信息的图片(须在使用本插件且扫描建库后生成)拖入 ComfyUI 画布。
|
||||
2. **第二步**:若发生模型路径不匹配导致的飘红报错,点击提示的 **一键修复工作流** 按钮,等待插件在本地检索 Hash 并自动恢复节点连接。
|
||||
### 📝 License & Branding (开源与品牌声明)
|
||||
|
||||
* **Code License (代码授权)**: The source code is released under the [MIT License](LICENSE). 本项目源代码基于 MIT 许可证开源,可自由使用、修改与分发。
|
||||
* **Branding & Trademarks (商标与品牌保护)**: The name **Anomalous Model Browser** and the official logo identify official releases. Forks should use distinct names/branding. 详见 [Trademark and Brand Policy](TRADEMARKS.md)。
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# Trademark and Brand Policy / 商标与品牌政策
|
||||
|
||||
This policy separates the project's open-source copyright license from use of its brand identifiers. It does not change or add restrictions to the MIT License.
|
||||
|
||||
本政策用于区分项目的开源版权许可与品牌标识的使用方式,不修改 MIT 许可证,也不向 MIT 许可证附加限制。
|
||||
|
||||
## Open-source materials / 开源内容
|
||||
|
||||
Unless a file states otherwise, the source code, documentation, stylesheets, ordinary interface resources, and other copyrightable materials distributed in this repository are licensed under the [MIT License](LICENSE). The MIT License permits use, copying, modification, publication, distribution, sublicensing, and sale subject to its notice requirement.
|
||||
|
||||
除非文件中另有明确说明,本仓库发布的源代码、文档、样式表、普通界面资源以及其他受版权保护的内容均采用 [MIT License](LICENSE) 授权。遵守许可证中的版权及许可声明保留要求后,MIT 许可证允许使用、复制、修改、发布、分发、再许可和销售。
|
||||
|
||||
## Project marks / 项目标识
|
||||
|
||||
The following are project brand identifiers (the "Project Marks"):
|
||||
|
||||
- the name **Anomalous Model Browser**;
|
||||
- stylized presentations of that name used to identify an official release;
|
||||
- any graphic expressly identified by the repository owner as the official project logo.
|
||||
|
||||
以下内容属于项目品牌标识(“项目标识”):
|
||||
|
||||
- 名称 **Anomalous Model Browser**;
|
||||
- 用于识别官方版本的该名称之视觉化表现;
|
||||
- 由仓库所有者明确指定为官方项目 Logo 的图形。
|
||||
|
||||
The Project Marks identify the source of official releases. Permission to use copyrightable material under the MIT License does not by itself imply that a modified distribution is an official release or is endorsed by the project owner.
|
||||
|
||||
项目标识用于辨认官方发布来源。依据 MIT 许可证使用受版权保护的内容,并不意味着修改后的发行版属于官方版本或获得项目所有者认可。
|
||||
|
||||
## Uses that do not require permission / 无需许可的使用
|
||||
|
||||
You may, without separate permission:
|
||||
|
||||
- truthfully refer to the official project by name, including in articles, reviews, compatibility statements, and dependency notices;
|
||||
- link to the official repository;
|
||||
- state that your software is based on, derived from, or compatible with Anomalous Model Browser, provided the statement is accurate and does not imply sponsorship or endorsement;
|
||||
- redistribute an unmodified official release with its original notices and branding intact;
|
||||
- use screenshots of the official software for documentation, discussion, reviews, or tutorials, provided the context does not imply that a modified product is official.
|
||||
|
||||
您无需另行取得许可即可:
|
||||
|
||||
- 在文章、评测、兼容性说明和依赖声明中如实提及官方项目名称;
|
||||
- 链接到官方仓库;
|
||||
- 在内容准确且不暗示赞助或认可的前提下,说明您的软件基于、衍生自或兼容 Anomalous Model Browser;
|
||||
- 在保留原始声明和品牌标识的情况下,重新分发未经修改的官方版本;
|
||||
- 将官方软件截图用于文档、讨论、评测或教程,但不得借此暗示修改后的产品属于官方版本。
|
||||
|
||||
## Publicly distributed modified versions / 公开分发的修改版本
|
||||
|
||||
If you publicly distribute a modified version or fork:
|
||||
|
||||
- use a distinct primary product name and a distinct logo so users can readily distinguish it from the official project;
|
||||
- identify it clearly as an unofficial fork or modified version;
|
||||
- you may use wording such as "Based on Anomalous Model Browser" to describe its origin;
|
||||
- preserve the copyright and permission notices required by the MIT License.
|
||||
|
||||
如果您公开分发修改版本或分支:
|
||||
|
||||
- 应使用不同的主要产品名称和不同的 Logo,使用户能够清楚地区分其与官方项目;
|
||||
- 应明确说明它属于非官方分支或修改版本;
|
||||
- 可以使用“基于 Anomalous Model Browser”等文字如实说明来源;
|
||||
- 必须保留 MIT 许可证要求的版权及许可声明。
|
||||
|
||||
These branding guidelines do not require renaming private modifications that are not distributed to others.
|
||||
|
||||
本品牌指引不要求仅供个人使用、未向他人分发的私有修改版本改名。
|
||||
|
||||
## Uses requiring permission / 需要许可的使用
|
||||
|
||||
Unless applicable law permits the use independently, written permission from the repository owner is required to:
|
||||
|
||||
- present a modified fork as an official release;
|
||||
- use the Project Marks as the primary name or logo of a publicly distributed modified product;
|
||||
- use a confusingly similar name, logo, repository listing, domain, or package identity that is likely to make users believe a third-party distribution is official;
|
||||
- state or imply sponsorship, certification, partnership, or endorsement by the project owner when none exists.
|
||||
|
||||
除非适用法律独立允许该使用,否则下列情况需要获得仓库所有者的书面许可:
|
||||
|
||||
- 将修改后的分支作为官方版本发布;
|
||||
- 将项目标识用作公开分发之修改产品的主要名称或 Logo;
|
||||
- 使用可能使用户误认为第三方发行版属于官方版本的近似名称、Logo、仓库条目、域名或软件包标识;
|
||||
- 在不存在相关关系时,宣称或暗示项目所有者提供了赞助、认证、合作或认可。
|
||||
|
||||
## Questions and permissions / 咨询与授权
|
||||
|
||||
For questions or permission requests, contact the repository owner through the official repository: <https://github.com/DemonGatanjieu/Anomalous_Model_Browser>.
|
||||
|
||||
如需咨询或申请许可,请通过官方仓库联系仓库所有者:<https://github.com/DemonGatanjieu/Anomalous_Model_Browser>。
|
||||
|
||||
This policy is a project branding notice, not legal advice. Rights that apply independently under local law are not restricted by this document.
|
||||
|
||||
本政策是项目品牌说明,不构成法律意见,也不限制依据当地法律独立享有的权利。
|
||||
@@ -1,988 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import urllib.parse
|
||||
import subprocess
|
||||
import threading
|
||||
from aiohttp import web
|
||||
import folder_paths
|
||||
|
||||
def get_metadata(file_path):
|
||||
base_path = os.path.splitext(file_path)[0]
|
||||
metadata = {
|
||||
"name": os.path.basename(base_path),
|
||||
"description": "",
|
||||
"notes": "",
|
||||
"trainedWords": [],
|
||||
"baseModel": "",
|
||||
"civitai_url": "",
|
||||
"hash": ""
|
||||
}
|
||||
|
||||
info_files = [f"{base_path}.civitai.info", f"{base_path}.info"]
|
||||
for info_file in info_files:
|
||||
if os.path.exists(info_file):
|
||||
try:
|
||||
with open(info_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
name = data.get("name", "")
|
||||
if "model" in data and isinstance(data["model"], dict):
|
||||
model_name = data["model"].get("name", "")
|
||||
name = f"{model_name} - {name}".strip(' -')
|
||||
|
||||
description = data.get("description", "") or ""
|
||||
if not description and "model" in data and isinstance(data["model"], dict):
|
||||
description = data["model"].get("description", "")
|
||||
notes = data.get("notes", "")
|
||||
trained_words = data.get("trainedWords", [])
|
||||
base_model = data.get("baseModel", "")
|
||||
|
||||
model_id = data.get("modelId", "")
|
||||
version_id = data.get("id", "")
|
||||
civitai_url = ""
|
||||
if model_id:
|
||||
# Handle Civitai's new mature content policy
|
||||
nsfw_level = data.get("nsfwLevel", 1)
|
||||
is_nsfw = False
|
||||
if "model" in data and isinstance(data["model"], dict):
|
||||
is_nsfw = data["model"].get("nsfw", False)
|
||||
|
||||
domain = "civitai.red" if (nsfw_level > 1 or is_nsfw) else "civitai.com"
|
||||
civitai_url = f"https://{domain}/models/{model_id}"
|
||||
if version_id:
|
||||
civitai_url += f"?modelVersionId={version_id}"
|
||||
|
||||
|
||||
hash_val = ""
|
||||
for file_info in data.get("files", []):
|
||||
if isinstance(file_info, dict):
|
||||
hashes = file_info.get("hashes", {})
|
||||
if isinstance(hashes, dict) and "SHA256" in hashes:
|
||||
hash_val = hashes["SHA256"]
|
||||
break
|
||||
|
||||
metadata["name"] = name if name else metadata["name"]
|
||||
metadata["description"] = description
|
||||
metadata["notes"] = notes
|
||||
metadata["trainedWords"] = trained_words
|
||||
metadata["baseModel"] = base_model
|
||||
metadata["civitai_url"] = civitai_url
|
||||
metadata["hash"] = hash_val
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return metadata
|
||||
|
||||
|
||||
async def api_get_folders(request):
|
||||
types = ['checkpoints', 'loras', 'unet', 'diffusion_models']
|
||||
result = []
|
||||
seen_dirs = set()
|
||||
|
||||
for t in 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 not os.path.exists(base_dir):
|
||||
continue
|
||||
real_path = os.path.realpath(base_dir)
|
||||
if real_path in seen_dirs:
|
||||
continue
|
||||
seen_dirs.add(real_path)
|
||||
|
||||
tree = {}
|
||||
for root, dirs, files in os.walk(base_dir):
|
||||
has_models = any(f.endswith(('.safetensors', '.ckpt', '.pt')) for f in files)
|
||||
rel = os.path.relpath(root, base_dir)
|
||||
if rel == '.':
|
||||
rel = '/'
|
||||
else:
|
||||
rel = '/' + rel.replace('\\', '/')
|
||||
tree[rel] = {
|
||||
"path": rel,
|
||||
"name": os.path.basename(root) if rel != '/' else '[Root]',
|
||||
"has_models": has_models,
|
||||
"model_count": sum(1 for f in files if f.endswith(('.safetensors', '.ckpt', '.pt')))
|
||||
}
|
||||
|
||||
label = t.capitalize()
|
||||
if path_idx > 0:
|
||||
label += f" ({os.path.basename(base_dir)})"
|
||||
|
||||
result.append({
|
||||
"type": t,
|
||||
"path_idx": path_idx,
|
||||
"label": label,
|
||||
"folders": tree
|
||||
})
|
||||
|
||||
return web.json_response({"folders": result})
|
||||
|
||||
|
||||
async def api_get_models(request):
|
||||
folder_type = request.query.get('type', 'checkpoints')
|
||||
subfolder = request.query.get('subfolder', '/')
|
||||
try:
|
||||
path_idx = int(request.query.get('path_idx', 0))
|
||||
except:
|
||||
path_idx = 0
|
||||
|
||||
try:
|
||||
paths = folder_paths.get_folder_paths(folder_type)
|
||||
except Exception:
|
||||
return web.json_response({"models": []})
|
||||
if not paths or path_idx >= len(paths):
|
||||
return web.json_response({"models": []})
|
||||
|
||||
if '..' in subfolder:
|
||||
return web.Response(status=400, text='Invalid subfolder')
|
||||
|
||||
base_dir = paths[path_idx]
|
||||
if subfolder == '/':
|
||||
target_dir = base_dir
|
||||
rel_subfolder = ""
|
||||
else:
|
||||
rel_subfolder = subfolder.strip('/')
|
||||
target_dir = os.path.join(base_dir, rel_subfolder)
|
||||
|
||||
if not os.path.exists(target_dir):
|
||||
return web.json_response({"models": []})
|
||||
|
||||
models = []
|
||||
try:
|
||||
entries = os.listdir(target_dir)
|
||||
except Exception:
|
||||
return web.json_response({"models": []})
|
||||
|
||||
for f in sorted(entries):
|
||||
if f.endswith('.safetensors') or f.endswith('.ckpt') or f.endswith('.pt'):
|
||||
file_path = os.path.join(target_dir, f)
|
||||
if not os.path.isfile(file_path):
|
||||
continue
|
||||
|
||||
meta = get_metadata(file_path)
|
||||
|
||||
base_name = os.path.splitext(f)[0]
|
||||
preview_file = None
|
||||
for ext in ['.preview.png', '.png', '.jpg', '.jpeg', '.webp', '.mp4', '.webm']:
|
||||
if os.path.exists(os.path.join(target_dir, base_name + ext)):
|
||||
preview_file = base_name + ext
|
||||
break
|
||||
|
||||
preview_url = ""
|
||||
if preview_file:
|
||||
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)
|
||||
preview_url = f"/anomalous/image?type={q_type}&path_idx={q_idx}&subfolder={q_sub}&filename={q_file}"
|
||||
|
||||
try:
|
||||
size_bytes = os.path.getsize(file_path)
|
||||
size_mb = round(size_bytes / (1024 * 1024), 1)
|
||||
except Exception:
|
||||
size_mb = 0
|
||||
size_bytes = 0
|
||||
|
||||
models.append({
|
||||
"filename": f,
|
||||
"size_mb": size_mb,
|
||||
"size_bytes": size_bytes,
|
||||
"metadata": meta,
|
||||
"preview_url": preview_url
|
||||
})
|
||||
|
||||
return web.json_response({"models": models})
|
||||
|
||||
|
||||
async def api_serve_image(request):
|
||||
"""Dedicated image serving endpoint for model preview images."""
|
||||
folder_type = request.query.get('type', 'checkpoints')
|
||||
try:
|
||||
path_idx = int(request.query.get('path_idx', 0))
|
||||
except:
|
||||
path_idx = 0
|
||||
subfolder = request.query.get('subfolder', '')
|
||||
filename = request.query.get('filename', '')
|
||||
|
||||
if not filename or '..' in filename or '..' in subfolder:
|
||||
return web.Response(status=400, text='Invalid request')
|
||||
|
||||
try:
|
||||
paths = folder_paths.get_folder_paths(folder_type)
|
||||
except Exception:
|
||||
return web.Response(status=404, text='Folder type not found')
|
||||
if not paths or path_idx >= len(paths):
|
||||
return web.Response(status=404, text='Folder type not found')
|
||||
|
||||
base_dir = paths[path_idx]
|
||||
if subfolder:
|
||||
file_path = os.path.join(base_dir, subfolder, filename)
|
||||
else:
|
||||
file_path = os.path.join(base_dir, filename)
|
||||
|
||||
if not os.path.exists(file_path) or not os.path.isfile(file_path):
|
||||
return web.Response(status=404, text='Image not found')
|
||||
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
content_types = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.webp': 'image/webp',
|
||||
'.gif': 'image/gif'
|
||||
}
|
||||
content_type = content_types.get(ext, 'application/octet-stream')
|
||||
|
||||
return web.FileResponse(file_path, headers={'Content-Type': content_type})
|
||||
|
||||
|
||||
|
||||
async def api_scan_status(request):
|
||||
folder_type = request.query.get('type', 'checkpoints')
|
||||
subfolder = request.query.get('subfolder', '/')
|
||||
try:
|
||||
path_idx = int(request.query.get('path_idx', 0))
|
||||
except:
|
||||
path_idx = 0
|
||||
try:
|
||||
paths = folder_paths.get_folder_paths(folder_type)
|
||||
except Exception:
|
||||
return web.json_response({"scanning": False})
|
||||
if not paths or path_idx >= len(paths) or '..' in subfolder:
|
||||
return web.json_response({"scanning": False})
|
||||
|
||||
base_dir = paths[path_idx]
|
||||
if subfolder == '/':
|
||||
target_dir = base_dir
|
||||
else:
|
||||
target_dir = os.path.join(base_dir, subfolder.strip('/'))
|
||||
|
||||
marker_file = os.path.join(target_dir, '.scan_in_progress')
|
||||
result_file = os.path.join(target_dir, '.scan_result.json')
|
||||
scanning = os.path.exists(marker_file)
|
||||
|
||||
data = {"scanning": scanning}
|
||||
if not scanning and os.path.exists(result_file):
|
||||
try:
|
||||
with open(result_file, 'r', encoding='utf-8') as f:
|
||||
data["result"] = __import__('json').load(f)
|
||||
os.remove(result_file)
|
||||
except:
|
||||
pass
|
||||
|
||||
return web.json_response(data)
|
||||
|
||||
async def api_scan_folder(request):
|
||||
"""Launches the scraper in the background for a specific directory."""
|
||||
folder_type = request.query.get('type', 'checkpoints')
|
||||
subfolder = request.query.get('subfolder', '/')
|
||||
try:
|
||||
path_idx = int(request.query.get('path_idx', 0))
|
||||
except:
|
||||
path_idx = 0
|
||||
|
||||
if '..' in subfolder:
|
||||
return web.json_response({"status": "error", "message": "Invalid subfolder"})
|
||||
|
||||
try:
|
||||
paths = folder_paths.get_folder_paths(folder_type)
|
||||
except Exception:
|
||||
return web.json_response({"status": "error", "message": "Invalid folder type"})
|
||||
|
||||
if not paths or path_idx >= len(paths):
|
||||
return web.json_response({"status": "error", "message": "Invalid path index"})
|
||||
|
||||
base_dir = paths[path_idx]
|
||||
if subfolder == '/':
|
||||
target_dir = base_dir
|
||||
else:
|
||||
target_dir = os.path.join(base_dir, subfolder.strip('/'))
|
||||
|
||||
if not os.path.exists(target_dir):
|
||||
return web.json_response({"status": "error", "message": "Directory does not exist"})
|
||||
|
||||
# Get the scraper path relative to this script
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
scraper_path = os.path.join(script_dir, "scraper.py")
|
||||
|
||||
if not os.path.exists(scraper_path):
|
||||
return web.json_response({"status": "error", "message": "scraper.py not found in extension directory"})
|
||||
|
||||
print(f"[Anomalous Browser] Starting background scan for: {target_dir}")
|
||||
|
||||
try:
|
||||
marker_file = os.path.join(target_dir, '.scan_in_progress')
|
||||
with open(marker_file, 'w') as f: f.write('1')
|
||||
def run_bg():
|
||||
try:
|
||||
subprocess.run(
|
||||
[sys.executable, scraper_path, target_dir],
|
||||
cwd=script_dir,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL
|
||||
)
|
||||
finally:
|
||||
if hasattr(folder_paths, "filename_list_cache"):
|
||||
folder_paths.filename_list_cache.clear()
|
||||
if hasattr(folder_paths, "cache_helper") and hasattr(folder_paths.cache_helper, "clear"):
|
||||
folder_paths.cache_helper.clear()
|
||||
|
||||
if os.path.exists(marker_file):
|
||||
try: os.remove(marker_file)
|
||||
except: pass
|
||||
|
||||
threading.Thread(target=run_bg, daemon=True).start()
|
||||
|
||||
return web.json_response({"status": "ok", "message": "Scan started in background. Check console for details."})
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)})
|
||||
|
||||
async def api_scan_all(request):
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
scraper_path = os.path.join(script_dir, "scraper.py")
|
||||
marker_file = os.path.join(script_dir, '.global_scan_in_progress')
|
||||
|
||||
if os.path.exists(marker_file):
|
||||
return web.json_response({"status": "error", "message": "Global scan already in progress"})
|
||||
|
||||
try:
|
||||
with open(marker_file, 'w') as f: f.write('1')
|
||||
|
||||
def run_global_bg():
|
||||
try:
|
||||
types = ['checkpoints', 'loras', 'unet', 'diffusion_models']
|
||||
for t in types:
|
||||
try:
|
||||
paths = folder_paths.get_folder_paths(t)
|
||||
if not paths: continue
|
||||
for base_dir in paths:
|
||||
if not os.path.exists(base_dir): continue
|
||||
print(f"[Anomalous Browser] Global scan processing: {base_dir}")
|
||||
subprocess.run(
|
||||
[sys.executable, scraper_path, base_dir, "--skip-rename", "--skip-media"],
|
||||
cwd=script_dir,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[Anomalous Browser] Global scan error on {t}: {e}")
|
||||
finally:
|
||||
if hasattr(folder_paths, "filename_list_cache"):
|
||||
folder_paths.filename_list_cache.clear()
|
||||
if hasattr(folder_paths, "cache_helper") and hasattr(folder_paths.cache_helper, "clear"):
|
||||
folder_paths.cache_helper.clear()
|
||||
|
||||
if os.path.exists(marker_file):
|
||||
try: os.remove(marker_file)
|
||||
except: pass
|
||||
|
||||
threading.Thread(target=run_global_bg, daemon=True).start()
|
||||
return web.json_response({"status": "ok", "message": "Global scan started"})
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)})
|
||||
|
||||
async def api_global_scan_status(request):
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
marker_file = os.path.join(script_dir, '.global_scan_in_progress')
|
||||
return web.json_response({"scanning": os.path.exists(marker_file)})
|
||||
async def api_clear_cache(request):
|
||||
try:
|
||||
if hasattr(folder_paths, "filename_list_cache"):
|
||||
folder_paths.filename_list_cache.clear()
|
||||
if hasattr(folder_paths, "cache_helper") and hasattr(folder_paths.cache_helper, "clear"):
|
||||
folder_paths.cache_helper.clear()
|
||||
return web.json_response({"status": "success"})
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)})
|
||||
|
||||
async def api_get_config(request):
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
config_path = os.path.join(script_dir, "config.json")
|
||||
has_key = False
|
||||
if os.path.exists(config_path):
|
||||
try:
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
cfg = json.load(f)
|
||||
has_key = bool(cfg.get("CIVITAI_API_KEY", "").strip())
|
||||
except:
|
||||
pass
|
||||
return web.json_response({"has_api_key": has_key})
|
||||
|
||||
async def api_save_config(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
api_key = data.get("api_key", "").strip()
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
config_path = os.path.join(script_dir, "config.json")
|
||||
|
||||
cfg = {}
|
||||
if os.path.exists(config_path):
|
||||
try:
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
cfg = json.load(f)
|
||||
except:
|
||||
pass
|
||||
|
||||
cfg["CIVITAI_API_KEY"] = api_key
|
||||
with open(config_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(cfg, f, indent=4)
|
||||
|
||||
return web.json_response({"status": "ok"})
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)})
|
||||
|
||||
|
||||
async def api_delete_model(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
folder_type = data.get('type', 'checkpoints')
|
||||
subfolder = data.get('subfolder', '/')
|
||||
filename = data.get('filename', '')
|
||||
try:
|
||||
path_idx = int(data.get('path_idx', 0))
|
||||
except:
|
||||
path_idx = 0
|
||||
|
||||
if not filename or '..' in filename or '..' in subfolder:
|
||||
return web.json_response({"status": "error", "message": "Invalid request parameters"})
|
||||
|
||||
try:
|
||||
paths = folder_paths.get_folder_paths(folder_type)
|
||||
except Exception:
|
||||
return web.json_response({"status": "error", "message": "Invalid folder type"})
|
||||
|
||||
if not paths or path_idx >= len(paths):
|
||||
return web.json_response({"status": "error", "message": "Invalid path index"})
|
||||
|
||||
base_dir = paths[path_idx]
|
||||
if subfolder == '/':
|
||||
target_dir = base_dir
|
||||
else:
|
||||
target_dir = os.path.join(base_dir, subfolder.strip('/'))
|
||||
|
||||
model_path = os.path.join(target_dir, filename)
|
||||
if not os.path.exists(model_path):
|
||||
return web.json_response({"status": "error", "message": "Model file not found"})
|
||||
|
||||
# 1. 优先尝试删除你点击的主模型文件
|
||||
try:
|
||||
os.remove(model_path)
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
if "being used" in error_msg or "WinError 32" in error_msg or "Permission" in error_msg:
|
||||
error_msg = "文件被占用 (正在被 ComfyUI 使用)。请先重启 ComfyUI 或在工作流中卸载该模型后再删除!"
|
||||
return web.json_response({"status": "error", "message": f"主模型删除失败: {error_msg}"})
|
||||
|
||||
base_name = os.path.splitext(filename)[0]
|
||||
|
||||
# 2. 主模型成功删除后,再清理配套的垃圾文件
|
||||
associated_exts = [
|
||||
'.safetensors', '.ckpt', '.pt', '.bin',
|
||||
'.info', '.civitai.info',
|
||||
'.png', '.preview.png', '.jpg', '.jpeg', '.webp', '.gif',
|
||||
'.json', '.txt', '.yaml'
|
||||
]
|
||||
|
||||
deleted_files = [filename]
|
||||
for ext in associated_exts:
|
||||
file_to_del = os.path.join(target_dir, base_name + ext)
|
||||
if file_to_del == model_path:
|
||||
continue
|
||||
if os.path.exists(file_to_del) and os.path.isfile(file_to_del):
|
||||
try:
|
||||
os.remove(file_to_del)
|
||||
deleted_files.append(base_name + ext)
|
||||
except Exception as e:
|
||||
print(f"[Anomalous Browser] Warning: Failed to delete {file_to_del}: {e}")
|
||||
|
||||
# 3. 修正:前端期待的成功状态是 "success" 而不是 "ok"
|
||||
return web.json_response({"status": "success", "deleted": deleted_files})
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return web.json_response({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
async def api_clean_civitai_info(request):
|
||||
try:
|
||||
types = ['checkpoints', 'loras', 'unet', 'diffusion_models']
|
||||
deleted_count = 0
|
||||
for t in types:
|
||||
try:
|
||||
paths = folder_paths.get_folder_paths(t)
|
||||
except Exception:
|
||||
continue
|
||||
if not paths: continue
|
||||
|
||||
for base_dir in paths:
|
||||
if not os.path.exists(base_dir): continue
|
||||
for root, dirs, files in os.walk(base_dir):
|
||||
for file in files:
|
||||
if file.endswith('.civitai.info'):
|
||||
file_path = os.path.join(root, file)
|
||||
try:
|
||||
os.remove(file_path)
|
||||
deleted_count += 1
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
return web.json_response({"status": "success", "count": deleted_count})
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
async def api_compatible_models(request):
|
||||
base_model = request.query.get('base_model', '')
|
||||
target_type = request.query.get('target_type', 'loras')
|
||||
|
||||
if not base_model:
|
||||
return web.json_response({"models": []})
|
||||
|
||||
target_types = [t.strip() for t in target_type.split(',')]
|
||||
compatible_models = []
|
||||
seen_files = 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 not os.path.exists(base_dir):
|
||||
continue
|
||||
|
||||
for root, _, files in os.walk(base_dir):
|
||||
for f in files:
|
||||
if f.endswith('.safetensors') or f.endswith('.ckpt') or f.endswith('.pt'):
|
||||
file_path = os.path.join(root, f)
|
||||
real_path = os.path.realpath(file_path)
|
||||
if real_path in seen_files:
|
||||
continue
|
||||
seen_files.add(real_path)
|
||||
|
||||
meta = get_metadata(file_path)
|
||||
m_bm = str(meta.get("baseModel", "")).strip().lower().replace(" ", "")
|
||||
req_bm = str(base_model).strip().lower().replace(" ", "")
|
||||
|
||||
if req_bm and m_bm and (req_bm in m_bm or m_bm in req_bm):
|
||||
rel_subfolder = os.path.relpath(root, base_dir)
|
||||
if rel_subfolder == '.':
|
||||
rel_subfolder = '/'
|
||||
else:
|
||||
rel_subfolder = '/' + rel_subfolder.replace('\\', '/')
|
||||
|
||||
base_name = os.path.splitext(f)[0]
|
||||
preview_file = None
|
||||
for ext in ['.preview.png', '.png', '.jpg', '.jpeg', '.webp', '.mp4', '.webm']:
|
||||
if os.path.exists(os.path.join(root, base_name + ext)):
|
||||
preview_file = base_name + ext
|
||||
break
|
||||
|
||||
preview_url = ""
|
||||
if preview_file:
|
||||
q_type = urllib.parse.quote(t)
|
||||
q_idx = str(path_idx)
|
||||
q_sub = urllib.parse.quote(rel_subfolder.strip('/')) if rel_subfolder != '/' else ""
|
||||
q_file = urllib.parse.quote(preview_file)
|
||||
preview_url = f"/anomalous/image?type={q_type}&path_idx={q_idx}&subfolder={q_sub}&filename={q_file}"
|
||||
|
||||
try:
|
||||
size_bytes = os.path.getsize(file_path)
|
||||
size_mb = round(size_bytes / (1024 * 1024), 1)
|
||||
except Exception:
|
||||
size_mb = 0
|
||||
size_bytes = 0
|
||||
|
||||
compatible_models.append({
|
||||
"type": t,
|
||||
"path_idx": path_idx,
|
||||
"subfolder": rel_subfolder,
|
||||
"filename": f,
|
||||
"size_mb": size_mb,
|
||||
"size_bytes": size_bytes,
|
||||
"preview_url": preview_url,
|
||||
"metadata": meta
|
||||
})
|
||||
|
||||
return web.json_response({"models": compatible_models})
|
||||
|
||||
async def api_get_notebooks(request):
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
nb_dir = os.path.join(script_dir, "notebooks")
|
||||
if not os.path.exists(nb_dir):
|
||||
os.makedirs(nb_dir)
|
||||
|
||||
notebooks = []
|
||||
for f in os.listdir(nb_dir):
|
||||
if f.endswith('.json'):
|
||||
try:
|
||||
with open(os.path.join(nb_dir, f), 'r', encoding='utf-8') as file:
|
||||
data = json.load(file)
|
||||
notebooks.append({
|
||||
"filename": f,
|
||||
"name": data.get("name", f.replace('.json', '')),
|
||||
"data": data
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return web.json_response({"notebooks": notebooks})
|
||||
|
||||
async def api_save_notebook(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
filename = data.get("filename", "")
|
||||
if not filename or '..' in filename:
|
||||
return web.json_response({"status": "error", "message": "Invalid filename"})
|
||||
if not filename.endswith('.json'):
|
||||
filename += '.json'
|
||||
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
nb_dir = os.path.join(script_dir, "notebooks")
|
||||
if not os.path.exists(nb_dir):
|
||||
os.makedirs(nb_dir)
|
||||
|
||||
file_path = os.path.join(nb_dir, filename)
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data.get("data", {}), f, indent=4, ensure_ascii=False)
|
||||
|
||||
return web.json_response({"status": "success"})
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)})
|
||||
|
||||
async def api_delete_notebook(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
filename = data.get("filename", "")
|
||||
if not filename or '..' in filename:
|
||||
return web.json_response({"status": "error", "message": "Invalid filename"})
|
||||
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
nb_dir = os.path.join(script_dir, "notebooks")
|
||||
file_path = os.path.join(nb_dir, filename)
|
||||
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
return web.json_response({"status": "success"})
|
||||
return web.json_response({"status": "error", "message": "File not found"})
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)})
|
||||
|
||||
async def api_translate(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
text = data.get("text", "")
|
||||
tl = data.get("target_lang", "zh-CN")
|
||||
if not text:
|
||||
return web.json_response({"translated": ""})
|
||||
|
||||
# Try DeepL if API key exists
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
config_path = os.path.join(script_dir, "config.json")
|
||||
deepl_key = ""
|
||||
if os.path.exists(config_path):
|
||||
try:
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
cfg = json.load(f)
|
||||
deepl_key = cfg.get("DEEPL_API_KEY", "").strip()
|
||||
except:
|
||||
pass
|
||||
|
||||
if deepl_key:
|
||||
deepl_map = { "zh-CN": "ZH", "en": "EN", "ja": "JA", "ko": "KO", "fr": "FR", "de": "DE", "es": "ES", "ru": "RU" }
|
||||
d_tl = deepl_map.get(tl, "EN")
|
||||
url = "https://api-free.deepl.com/v2/translate" if ":fx" in deepl_key else "https://api.deepl.com/v2/translate"
|
||||
payload = urllib.parse.urlencode({
|
||||
"auth_key": deepl_key,
|
||||
"text": text,
|
||||
"target_lang": d_tl
|
||||
}).encode('utf-8')
|
||||
req = urllib.request.Request(url, data=payload)
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
result = json.loads(resp.read().decode('utf-8'))
|
||||
return web.json_response({"translated": result["translations"][0]["text"]})
|
||||
else:
|
||||
# Fallback to Google Translate (free, no key)
|
||||
url = f"https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl={tl}&dt=t&q={urllib.parse.quote(text)}"
|
||||
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
result = json.loads(resp.read().decode('utf-8'))
|
||||
# Google Translate returns a list of fragments
|
||||
translated_text = "".join([part[0] for part in result[0]])
|
||||
return web.json_response({"translated": translated_text})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Translate Error: {e}")
|
||||
return web.json_response({"translated": text, "error": str(e)})
|
||||
|
||||
|
||||
async def api_base_models(request):
|
||||
target_types = ['checkpoints', 'unet', 'diffusion_models', 'loras']
|
||||
base_models = set()
|
||||
seen_files = set()
|
||||
|
||||
for t in target_types:
|
||||
try:
|
||||
paths = folder_paths.get_folder_paths(t)
|
||||
except Exception:
|
||||
continue
|
||||
if not paths: continue
|
||||
|
||||
for base_dir in paths:
|
||||
if not os.path.exists(base_dir): continue
|
||||
for root, _, files in os.walk(base_dir):
|
||||
for f in files:
|
||||
if f.endswith('.safetensors') or f.endswith('.ckpt') or f.endswith('.pt'):
|
||||
file_path = os.path.join(root, f)
|
||||
real_path = os.path.realpath(file_path)
|
||||
if real_path in seen_files: continue
|
||||
seen_files.add(real_path)
|
||||
|
||||
meta = get_metadata(file_path)
|
||||
m_bm = meta.get("baseModel", "")
|
||||
if m_bm and str(m_bm).strip():
|
||||
# Remove typical generic strings that might pollute
|
||||
clean_bm = str(m_bm).strip()
|
||||
base_models.add(clean_bm)
|
||||
|
||||
return web.json_response({"base_models": sorted(list(base_models))})
|
||||
|
||||
async def api_get_gallery_images(request):
|
||||
try:
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
if not os.path.exists(output_dir):
|
||||
return web.json_response({"images": [], "total": 0, "page": 1, "pages": 0})
|
||||
|
||||
page = int(request.query.get('page', 1))
|
||||
limit = int(request.query.get('limit', 50))
|
||||
|
||||
valid_exts = {'.png', '.jpg', '.jpeg', '.webp', '.gif'}
|
||||
images = []
|
||||
|
||||
for root, dirs, files in os.walk(output_dir):
|
||||
for f in files:
|
||||
ext = os.path.splitext(f)[1].lower()
|
||||
if ext in valid_exts:
|
||||
rel_path = os.path.relpath(root, output_dir)
|
||||
subfolder = "" if rel_path == "." else rel_path.replace('\\', '/')
|
||||
full_path = os.path.join(root, f)
|
||||
try:
|
||||
mtime = os.path.getmtime(full_path)
|
||||
except:
|
||||
mtime = 0
|
||||
images.append({
|
||||
"filename": f,
|
||||
"subfolder": subfolder,
|
||||
"type": "output",
|
||||
"mtime": mtime
|
||||
})
|
||||
|
||||
# Sort by mtime descending (newest first)
|
||||
images.sort(key=lambda x: x['mtime'], reverse=True)
|
||||
|
||||
total = len(images)
|
||||
start_idx = (page - 1) * limit
|
||||
end_idx = start_idx + limit
|
||||
paginated_images = images[start_idx:end_idx]
|
||||
|
||||
return web.json_response({
|
||||
"images": paginated_images,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pages": (total + limit - 1) // limit if limit > 0 else 0
|
||||
})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
async def api_delete_gallery_image(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
filename = data.get("filename")
|
||||
subfolder = data.get("subfolder", "")
|
||||
|
||||
if not filename or '..' in filename or '..' in subfolder:
|
||||
return web.json_response({"status": "error", "message": "Invalid parameters"})
|
||||
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
if subfolder:
|
||||
file_path = os.path.join(output_dir, subfolder, filename)
|
||||
else:
|
||||
file_path = os.path.join(output_dir, filename)
|
||||
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
return web.json_response({"status": "success"})
|
||||
else:
|
||||
return web.json_response({"status": "error", "message": "File not found"})
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
async def api_resolve_hash(request):
|
||||
target_hash = request.query.get("hash", "").strip().upper()
|
||||
size_str = request.query.get("size", "").strip()
|
||||
filename_query = request.query.get("filename", "").strip()
|
||||
target_size = int(size_str) if size_str.isdigit() else None
|
||||
|
||||
if not target_hash and not target_size and not filename_query:
|
||||
return web.json_response({"found": False})
|
||||
|
||||
types = ['checkpoints', 'loras', 'unet', 'diffusion_models']
|
||||
|
||||
# First pass: try exact hash match in .info files
|
||||
if target_hash:
|
||||
for t in types:
|
||||
try:
|
||||
paths = folder_paths.get_folder_paths(t)
|
||||
except Exception:
|
||||
continue
|
||||
if not paths:
|
||||
continue
|
||||
|
||||
for base_dir in paths:
|
||||
if not os.path.exists(base_dir):
|
||||
continue
|
||||
|
||||
for root, dirs, files in os.walk(base_dir):
|
||||
for file in files:
|
||||
if file.endswith('.safetensors') or file.endswith('.ckpt') or file.endswith('.pt'):
|
||||
file_path = os.path.join(root, file)
|
||||
meta = get_metadata(file_path)
|
||||
if meta.get("hash", "").upper() == target_hash:
|
||||
# ComfyUI widget expects relative path from folder_paths base_dir
|
||||
rel_path = os.path.relpath(file_path, base_dir)
|
||||
if rel_path.startswith('.\\') or rel_path.startswith('./'):
|
||||
rel_path = rel_path[2:]
|
||||
rel_path = rel_path.replace('\\', '/')
|
||||
return web.json_response({
|
||||
"found": True,
|
||||
"type": t,
|
||||
"filename": rel_path
|
||||
})
|
||||
|
||||
# Second pass: Fallback size match
|
||||
if target_size is not None:
|
||||
size_matches = []
|
||||
seen_realpaths = set()
|
||||
for t in types:
|
||||
try:
|
||||
paths = folder_paths.get_folder_paths(t)
|
||||
except Exception:
|
||||
continue
|
||||
if not paths:
|
||||
continue
|
||||
|
||||
for base_dir in paths:
|
||||
if not os.path.exists(base_dir):
|
||||
continue
|
||||
|
||||
for root, dirs, files in os.walk(base_dir):
|
||||
for file in files:
|
||||
if file.endswith('.safetensors') or file.endswith('.ckpt') or file.endswith('.pt'):
|
||||
file_path = os.path.join(root, file)
|
||||
try:
|
||||
if os.path.getsize(file_path) == target_size:
|
||||
real_p = os.path.realpath(file_path)
|
||||
if real_p in seen_realpaths:
|
||||
continue
|
||||
seen_realpaths.add(real_p)
|
||||
|
||||
rel_path = os.path.relpath(file_path, base_dir)
|
||||
if rel_path.startswith('.\\') or rel_path.startswith('./'):
|
||||
rel_path = rel_path[2:]
|
||||
rel_path = rel_path.replace('\\', '/')
|
||||
size_matches.append({
|
||||
"type": t,
|
||||
"filename": rel_path
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# If exactly ONE file matches the size, it's a definitive match
|
||||
if len(size_matches) == 1:
|
||||
return web.json_response({
|
||||
"found": True,
|
||||
"type": size_matches[0]["type"],
|
||||
"filename": size_matches[0]["filename"],
|
||||
"matched_by_size": True
|
||||
})
|
||||
|
||||
return web.json_response({"found": False})
|
||||
|
||||
async def api_get_all_hashes(request):
|
||||
"""
|
||||
Returns a dictionary of all scanned models with their hash and size.
|
||||
Keyed by both relative path and basename for maximum frontend resilience.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
def fetch_all():
|
||||
hashes = {}
|
||||
types = ['checkpoints', 'loras', 'unet', 'diffusion_models']
|
||||
for t in types:
|
||||
try:
|
||||
paths = folder_paths.get_folder_paths(t)
|
||||
if not paths: continue
|
||||
for base_dir in paths:
|
||||
if not os.path.exists(base_dir): continue
|
||||
for root, dirs, files in os.walk(base_dir):
|
||||
for file in files:
|
||||
if file.endswith('.safetensors') or file.endswith('.ckpt') or file.endswith('.pt'):
|
||||
file_path = os.path.join(root, file)
|
||||
try:
|
||||
size_bytes = os.path.getsize(file_path)
|
||||
except Exception:
|
||||
size_bytes = 0
|
||||
|
||||
meta = get_metadata(file_path)
|
||||
hash_val = ""
|
||||
if meta and meta.get("hash"):
|
||||
hash_val = meta["hash"]
|
||||
|
||||
rel_path = os.path.relpath(file_path, base_dir)
|
||||
if rel_path.startswith('.\\') or rel_path.startswith('./'):
|
||||
rel_path = rel_path[2:]
|
||||
rel_path = rel_path.replace('\\', '/')
|
||||
basename = os.path.basename(file_path)
|
||||
|
||||
val = {"hash": hash_val, "size": size_bytes}
|
||||
hashes[rel_path] = val
|
||||
hashes[basename] = val
|
||||
except Exception:
|
||||
pass
|
||||
return hashes
|
||||
|
||||
hashes = await asyncio.to_thread(fetch_all)
|
||||
return web.json_response(hashes)
|
||||
|
||||
def setup_routes(app):
|
||||
app.router.add_get('/anomalous/folders', api_get_folders)
|
||||
app.router.add_get('/anomalous/models', api_get_models)
|
||||
app.router.add_get('/anomalous/image', api_serve_image)
|
||||
app.router.add_post('/anomalous/scan', api_scan_folder)
|
||||
app.router.add_get('/anomalous/scan_status', api_scan_status)
|
||||
app.router.add_get('/anomalous/config', api_get_config)
|
||||
app.router.add_post('/anomalous/save_config', api_save_config)
|
||||
app.router.add_post('/anomalous/delete_model', api_delete_model)
|
||||
app.router.add_post('/anomalous/clean_civitai_info', api_clean_civitai_info)
|
||||
app.router.add_get('/anomalous/compatible_models', api_compatible_models)
|
||||
app.router.add_get('/anomalous/notebooks', api_get_notebooks)
|
||||
app.router.add_post('/anomalous/save_notebook', api_save_notebook)
|
||||
app.router.add_post('/anomalous/delete_notebook', api_delete_notebook)
|
||||
app.router.add_post('/anomalous/translate', api_translate)
|
||||
app.router.add_get('/anomalous/base_models', api_base_models)
|
||||
app.router.add_get('/anomalous/gallery_images', api_get_gallery_images)
|
||||
app.router.add_post('/anomalous/delete_gallery_image', api_delete_gallery_image)
|
||||
app.router.add_get('/anomalous/resolve_hash', api_resolve_hash)
|
||||
app.router.add_get('/anomalous/all_hashes', api_get_all_hashes)
|
||||
app.router.add_post('/anomalous/scan_all', api_scan_all)
|
||||
app.router.add_get('/anomalous/global_scan_status', api_global_scan_status)
|
||||
@@ -0,0 +1,72 @@
|
||||
from .models import *
|
||||
from .scanner import *
|
||||
from .config import *
|
||||
from .notebooks import *
|
||||
from .parameters import *
|
||||
from .recipes import *
|
||||
from .recipe_packages import *
|
||||
from .utils import *
|
||||
|
||||
def setup_routes(app):
|
||||
app.router.add_get('/anomalous/folders', api_get_folders)
|
||||
app.router.add_get('/anomalous/all_folder_types', api_get_all_folder_types)
|
||||
app.router.add_get('/anomalous/models', api_get_models)
|
||||
app.router.add_get('/anomalous/all_scan_models', api_get_all_scan_models)
|
||||
app.router.add_get('/anomalous/batch_select', api_batch_select)
|
||||
app.router.add_get('/anomalous/image', api_serve_image)
|
||||
app.router.add_post('/anomalous/scan', api_scan_folder)
|
||||
app.router.add_get('/anomalous/scan_status', api_scan_status)
|
||||
app.router.add_get('/anomalous/find_model', api_find_model)
|
||||
app.router.add_get('/anomalous/config', api_get_config)
|
||||
app.router.add_post('/anomalous/save_config', api_save_config)
|
||||
app.router.add_post('/anomalous/delete_model', api_delete_model)
|
||||
app.router.add_post('/anomalous/clean_civitai_info', api_clean_civitai_info)
|
||||
app.router.add_get('/anomalous/compatible_models', api_compatible_models)
|
||||
app.router.add_get('/anomalous/notebooks', api_get_notebooks)
|
||||
app.router.add_post('/anomalous/save_notebook', api_save_notebook)
|
||||
app.router.add_post('/anomalous/delete_notebook', api_delete_notebook)
|
||||
|
||||
# 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_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)
|
||||
|
||||
# Recipe Routes
|
||||
app.router.add_get('/anomalous/recipes', api_get_recipes)
|
||||
app.router.add_get('/anomalous/recipe_full', api_get_recipe_full)
|
||||
app.router.add_get('/anomalous/recipe_asset', api_get_recipe_asset)
|
||||
app.router.add_get('/anomalous/recipe_gallery', api_get_recipe_gallery)
|
||||
app.router.add_get('/anomalous/recipe_parameter_gallery', api_get_recipe_parameter_gallery)
|
||||
app.router.add_get('/anomalous/recipe_gallery_compare', api_get_recipe_gallery_compare)
|
||||
app.router.add_post('/anomalous/save_recipe', api_save_recipe)
|
||||
app.router.add_post('/anomalous/update_recipe', api_update_recipe)
|
||||
app.router.add_post('/anomalous/set_recipe_gallery_cover', api_set_recipe_gallery_cover)
|
||||
app.router.add_post('/anomalous/delete_recipe', api_delete_recipe)
|
||||
app.router.add_get('/anomalous/recipe_history', api_get_recipe_history)
|
||||
app.router.add_get('/anomalous/recipe_version', api_get_recipe_version)
|
||||
app.router.add_post('/anomalous/restore_recipe_version', api_restore_recipe_version)
|
||||
app.router.add_post('/anomalous/refresh_recipe_identity', api_refresh_recipe_identity)
|
||||
app.router.add_post('/anomalous/export_recipe_package', api_export_recipe_package)
|
||||
app.router.add_post('/anomalous/import_recipe_package_inspect', api_import_recipe_package_inspect)
|
||||
app.router.add_post('/anomalous/import_recipe_package_commit', api_import_recipe_package_commit)
|
||||
|
||||
app.router.add_post('/anomalous/translate', api_translate)
|
||||
app.router.add_get('/anomalous/base_models', api_base_models)
|
||||
app.router.add_get('/anomalous/gallery_images', api_get_gallery_images)
|
||||
app.router.add_post('/anomalous/delete_gallery_image', api_delete_gallery_image)
|
||||
app.router.add_get('/anomalous/resolve_hash', api_resolve_hash)
|
||||
app.router.add_post('/anomalous/resolve_hash_batch', api_resolve_hash_batch)
|
||||
app.router.add_get('/anomalous/all_hashes', api_get_all_hashes)
|
||||
app.router.add_post('/anomalous/scan_all', api_scan_all)
|
||||
app.router.add_get('/anomalous/global_scan_status', api_global_scan_status)
|
||||
app.router.add_get('/anomalous/scan_missing_models_status', api_scan_missing_models_status)
|
||||
app.router.add_post('/anomalous/clear_cache', api_clear_cache)
|
||||
app.router.add_post('/anomalous/update_metadata', api_update_metadata)
|
||||
app.router.add_post('/anomalous/set_custom_cover', api_set_custom_cover)
|
||||
app.router.add_post('/anomalous/upload_custom_cover', api_upload_custom_cover)
|
||||
app.router.add_get('/anomalous/model_images', api_get_model_images)
|
||||
app.router.add_post('/anomalous/resolve_paths_to_previews', api_resolve_paths_to_previews)
|
||||
app.router.add_post('/anomalous/scan_missing_models', api_scan_missing_models)
|
||||
@@ -0,0 +1,83 @@
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import urllib.parse
|
||||
import subprocess
|
||||
import threading
|
||||
import asyncio
|
||||
from aiohttp import web
|
||||
import folder_paths
|
||||
import struct
|
||||
|
||||
async def api_get_config(request):
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
config_path = os.path.join(script_dir, "config.json")
|
||||
has_key = False
|
||||
folder_types = []
|
||||
if os.path.exists(config_path):
|
||||
try:
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
cfg = json.load(f)
|
||||
has_key = bool(cfg.get("CIVITAI_API_KEY", "").strip())
|
||||
folder_types = cfg.get("folder_types_config", [])
|
||||
except:
|
||||
pass
|
||||
if not has_key:
|
||||
legacy_config_path = os.path.join(os.path.dirname(script_dir), "config.json")
|
||||
if os.path.exists(legacy_config_path):
|
||||
try:
|
||||
with open(legacy_config_path, 'r', encoding='utf-8') as f:
|
||||
legacy_cfg = json.load(f)
|
||||
has_key = bool(legacy_cfg.get("CIVITAI_API_KEY", "").strip())
|
||||
except Exception:
|
||||
pass
|
||||
return web.json_response({"has_api_key": has_key, "folder_types_config": folder_types})
|
||||
|
||||
async def api_save_config(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
api_key = data.get("api_key")
|
||||
folder_types_config = data.get("folder_types_config")
|
||||
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
config_path = os.path.join(script_dir, "config.json")
|
||||
|
||||
cfg = {}
|
||||
if os.path.exists(config_path):
|
||||
try:
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
cfg = json.load(f)
|
||||
except:
|
||||
pass
|
||||
|
||||
if api_key is not None and not isinstance(api_key, str):
|
||||
return web.json_response({"status": "error", "message": "Invalid API key"}, status=400)
|
||||
if folder_types_config is not None and not isinstance(folder_types_config, list):
|
||||
return web.json_response({"status": "error", "message": "Invalid folder configuration"}, status=400)
|
||||
if "physical_folders_config" in data and not isinstance(data.get("physical_folders_config"), list):
|
||||
return web.json_response({"status": "error", "message": "Invalid physical folder configuration"}, status=400)
|
||||
if "folder_view_mode" in data and data.get("folder_view_mode") not in {"abstract", "physical"}:
|
||||
return web.json_response({"status": "error", "message": "Invalid folder view mode"}, status=400)
|
||||
|
||||
if api_key is not None:
|
||||
cfg["CIVITAI_API_KEY"] = api_key.strip()
|
||||
|
||||
if folder_types_config is not None:
|
||||
cfg["folder_types_config"] = folder_types_config
|
||||
|
||||
if "physical_folders_config" in data:
|
||||
cfg["physical_folders_config"] = data.get("physical_folders_config")
|
||||
|
||||
if "folder_view_mode" in data:
|
||||
cfg["folder_view_mode"] = data.get("folder_view_mode")
|
||||
|
||||
temp_path = config_path + ".tmp"
|
||||
with open(temp_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(cfg, f, indent=4)
|
||||
os.replace(temp_path, config_path)
|
||||
|
||||
return web.json_response({"status": "ok"})
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)})
|
||||
|
||||
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import urllib.parse
|
||||
import subprocess
|
||||
import threading
|
||||
import asyncio
|
||||
import copy
|
||||
from functools import lru_cache
|
||||
from aiohttp import web
|
||||
import folder_paths
|
||||
import struct
|
||||
|
||||
def _read_safetensors_hash(file_path):
|
||||
"""Extract hash from safetensors header metadata (O(1) fast read, no full-file SHA256)."""
|
||||
try:
|
||||
with open(file_path, "rb") as f:
|
||||
header_size_bytes = f.read(8)
|
||||
if len(header_size_bytes) < 8:
|
||||
return None
|
||||
header_size = struct.unpack('<Q', header_size_bytes)[0]
|
||||
if header_size > 100 * 1024 * 1024:
|
||||
return None
|
||||
|
||||
header_json_bytes = f.read(header_size)
|
||||
header_str = header_json_bytes.decode('utf-8')
|
||||
header_json = json.loads(header_str)
|
||||
|
||||
metadata = header_json.get('__metadata__', {})
|
||||
if not metadata:
|
||||
return None
|
||||
|
||||
if 'modelspec.hash.sha256' in metadata:
|
||||
return metadata['modelspec.hash.sha256']
|
||||
if 'modelspec.hash.blake3' in metadata:
|
||||
return metadata['modelspec.hash.blake3']
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
@lru_cache(maxsize=4096)
|
||||
def _extract_safetensors_hash_cached(file_path, signature):
|
||||
return _read_safetensors_hash(file_path)
|
||||
|
||||
|
||||
def _extract_safetensors_hash(file_path):
|
||||
normalized_path = os.path.realpath(file_path)
|
||||
return _extract_safetensors_hash_cached(normalized_path, _file_signature(normalized_path))
|
||||
|
||||
|
||||
def _select_info_file(data, file_path):
|
||||
"""Select the Civitai file entry that actually describes ``file_path``.
|
||||
|
||||
A model version may contain a checkpoint, text encoder, and VAE in the same
|
||||
``files`` array. Taking the first SHA256 silently assigns the dependency's
|
||||
hash to every local sidecar copied from that version. Physical byte size is
|
||||
the strongest cheap discriminator available here and remains valid after a
|
||||
local rename. Filenames are intentionally excluded: local renames are the
|
||||
reason provenance recovery exists and cannot be identity evidence.
|
||||
"""
|
||||
entries = []
|
||||
for file_info in data.get("files", []):
|
||||
if not isinstance(file_info, dict):
|
||||
continue
|
||||
hashes = file_info.get("hashes", {})
|
||||
sha256 = hashes.get("SHA256") if isinstance(hashes, dict) else None
|
||||
if not sha256:
|
||||
continue
|
||||
|
||||
size_bytes = None
|
||||
try:
|
||||
if file_info.get("sizeKB") is not None:
|
||||
size_bytes = int(round(float(file_info["sizeKB"]) * 1024))
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
pass
|
||||
entries.append((file_info, str(sha256), size_bytes))
|
||||
|
||||
if not entries:
|
||||
return None
|
||||
|
||||
try:
|
||||
physical_size = os.path.getsize(file_path)
|
||||
except OSError:
|
||||
physical_size = None
|
||||
|
||||
if physical_size is not None:
|
||||
size_matches = [entry for entry in entries if entry[2] == physical_size]
|
||||
if len(size_matches) == 1:
|
||||
return size_matches[0][0]
|
||||
if len(size_matches) > 1:
|
||||
return None
|
||||
|
||||
# Offline-generated metadata commonly contains one injected hash without
|
||||
# Civitai size/name data. It is safe only when there is a single candidate.
|
||||
if len(entries) == 1:
|
||||
return entries[0][0]
|
||||
return None
|
||||
|
||||
|
||||
def _select_info_hash(data, file_path):
|
||||
selected = _select_info_file(data, file_path)
|
||||
if not selected:
|
||||
return ""
|
||||
hashes = selected.get("hashes", {})
|
||||
return str(hashes.get("SHA256", "")) if isinstance(hashes, dict) else ""
|
||||
|
||||
def _read_metadata(file_path):
|
||||
base_path = os.path.splitext(file_path)[0]
|
||||
metadata = {
|
||||
"name": os.path.basename(base_path),
|
||||
"description": "",
|
||||
"notes": "",
|
||||
"trainedWords": [],
|
||||
"baseModel": "",
|
||||
"civitai_url": "",
|
||||
"hash": "",
|
||||
"custom_name": "",
|
||||
"custom_notes": ""
|
||||
}
|
||||
|
||||
info_files = [f"{base_path}.info", f"{base_path}.civitai.info"]
|
||||
for info_file in info_files:
|
||||
if os.path.exists(info_file):
|
||||
try:
|
||||
with open(info_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
name = data.get("name", "")
|
||||
if "model" in data and isinstance(data["model"], dict):
|
||||
model_name = data["model"].get("name", "")
|
||||
name = f"{model_name} - {name}".strip(' -')
|
||||
|
||||
description = data.get("description", "") or ""
|
||||
if not description and "model" in data and isinstance(data["model"], dict):
|
||||
description = data["model"].get("description", "")
|
||||
notes = data.get("notes", "")
|
||||
trained_words = data.get("trainedWords", [])
|
||||
base_model = data.get("baseModel", "")
|
||||
|
||||
model_id = data.get("modelId", "")
|
||||
version_id = data.get("id", "")
|
||||
civitai_url = ""
|
||||
if model_id:
|
||||
# Handle Civitai's new mature content policy
|
||||
nsfw_level = data.get("nsfwLevel", 1)
|
||||
is_nsfw = False
|
||||
if "model" in data and isinstance(data["model"], dict):
|
||||
is_nsfw = data["model"].get("nsfw", False)
|
||||
|
||||
domain = "civitai.red" if (nsfw_level > 1 or is_nsfw) else "civitai.com"
|
||||
civitai_url = f"https://{domain}/models/{model_id}"
|
||||
if version_id:
|
||||
civitai_url += f"?modelVersionId={version_id}"
|
||||
|
||||
|
||||
selected_file = _select_info_file(data, file_path)
|
||||
hash_val = ""
|
||||
if selected_file:
|
||||
hashes = selected_file.get("hashes", {})
|
||||
if isinstance(hashes, dict):
|
||||
hash_val = str(hashes.get("SHA256", ""))
|
||||
|
||||
if name: metadata["name"] = name
|
||||
if description: metadata["description"] = description
|
||||
if notes: metadata["notes"] = notes
|
||||
if trained_words: metadata["trainedWords"] = trained_words
|
||||
if base_model: metadata["baseModel"] = base_model
|
||||
if civitai_url: metadata["civitai_url"] = civitai_url
|
||||
if model_id: metadata["model_id"] = model_id
|
||||
if version_id: metadata["version_id"] = version_id
|
||||
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"]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback: if no hash found from .info files, try extracting from safetensors header directly
|
||||
if not metadata["hash"] and file_path.endswith('.safetensors'):
|
||||
header_hash = _extract_safetensors_hash(file_path)
|
||||
if header_hash:
|
||||
metadata["hash"] = header_hash
|
||||
|
||||
return metadata
|
||||
|
||||
|
||||
def _file_signature(file_path):
|
||||
try:
|
||||
stat = os.stat(file_path)
|
||||
return (stat.st_size, stat.st_mtime_ns, stat.st_ctime_ns)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _metadata_signature(file_path):
|
||||
base_path = os.path.splitext(file_path)[0]
|
||||
return (
|
||||
_file_signature(file_path),
|
||||
_file_signature(f"{base_path}.info"),
|
||||
_file_signature(f"{base_path}.civitai.info"),
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=4096)
|
||||
def _get_metadata_cached(file_path, signature):
|
||||
# ``signature`` is deliberately unused by the parser itself; it is part of
|
||||
# the cache key and changes whenever the model or either metadata sidecar
|
||||
# changes. The cache is bounded so removed/renamed models cannot grow it
|
||||
# without limit.
|
||||
return _read_metadata(file_path)
|
||||
|
||||
|
||||
def get_metadata(file_path):
|
||||
normalized_path = os.path.realpath(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))
|
||||
|
||||
|
||||
def clear_metadata_cache():
|
||||
_get_metadata_cached.cache_clear()
|
||||
_extract_safetensors_hash_cached.cache_clear()
|
||||
|
||||
|
||||
def get_metadata_cache_info():
|
||||
return _get_metadata_cached.cache_info()
|
||||
|
||||
|
||||
+1365
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,77 @@
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import urllib.parse
|
||||
import subprocess
|
||||
import threading
|
||||
import asyncio
|
||||
from aiohttp import web
|
||||
import folder_paths
|
||||
import struct
|
||||
from .utils import require_filename, resolve_within
|
||||
|
||||
async def api_get_notebooks(request):
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
nb_dir = os.path.join(script_dir, "notebooks")
|
||||
if not os.path.exists(nb_dir):
|
||||
os.makedirs(nb_dir)
|
||||
|
||||
notebooks = []
|
||||
for f in os.listdir(nb_dir):
|
||||
if f.endswith('.json'):
|
||||
try:
|
||||
with open(os.path.join(nb_dir, f), 'r', encoding='utf-8') as file:
|
||||
data = json.load(file)
|
||||
notebooks.append({
|
||||
"filename": f,
|
||||
"name": data.get("name", f.replace('.json', '')),
|
||||
"data": data
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return web.json_response({"notebooks": notebooks})
|
||||
|
||||
async def api_save_notebook(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
filename = data.get("filename", "")
|
||||
if not filename.endswith('.json'):
|
||||
filename += '.json'
|
||||
try:
|
||||
filename = require_filename(filename)
|
||||
except ValueError:
|
||||
return web.json_response({"status": "error", "message": "Invalid filename"}, status=400)
|
||||
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
nb_dir = os.path.join(script_dir, "notebooks")
|
||||
if not os.path.exists(nb_dir):
|
||||
os.makedirs(nb_dir)
|
||||
|
||||
file_path = resolve_within(nb_dir, filename)
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data.get("data", {}), f, indent=4, ensure_ascii=False)
|
||||
|
||||
return web.json_response({"status": "success"})
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)})
|
||||
|
||||
async def api_delete_notebook(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
filename = data.get("filename", "")
|
||||
try:
|
||||
filename = require_filename(filename)
|
||||
except ValueError:
|
||||
return web.json_response({"status": "error", "message": "Invalid filename"}, status=400)
|
||||
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
nb_dir = os.path.join(script_dir, "notebooks")
|
||||
file_path = resolve_within(nb_dir, filename)
|
||||
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
return web.json_response({"status": "success"})
|
||||
return web.json_response({"status": "error", "message": "File not found"})
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)})
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
import asyncio
|
||||
import os
|
||||
import json
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
import re
|
||||
from aiohttp import web
|
||||
import folder_paths
|
||||
from .utils import require_filename, resolve_within
|
||||
from .recipes import MAX_RECIPE_BYTES, _parameter_signature, _parameter_gallery_images, get_recipes_dir, _read_recipe
|
||||
|
||||
def get_parameters_dir():
|
||||
# Store parameter notebooks in the user directory
|
||||
# ComfyUI/user/default/workflows/anomalous_parameters
|
||||
user_dir = (
|
||||
folder_paths.get_user_directory()
|
||||
if hasattr(folder_paths, "get_user_directory")
|
||||
else None
|
||||
)
|
||||
if not user_dir:
|
||||
# Fallback if user_dir is not supported in this ComfyUI version
|
||||
base_dir = folder_paths.base_path
|
||||
user_dir = os.path.join(base_dir, "user", "default")
|
||||
|
||||
workflows_dir = os.path.join(user_dir, "workflows")
|
||||
parameters_dir = os.path.join(workflows_dir, "anomalous_parameters")
|
||||
|
||||
if not os.path.exists(parameters_dir):
|
||||
os.makedirs(parameters_dir, exist_ok=True)
|
||||
return parameters_dir
|
||||
|
||||
|
||||
|
||||
async def api_get_parameters(request):
|
||||
try:
|
||||
recipe_filename = request.query.get("recipe_filename")
|
||||
if recipe_filename:
|
||||
recipe_filename = require_filename(recipe_filename)
|
||||
if not recipe_filename.endswith(".json"):
|
||||
raise ValueError("Invalid recipe filename")
|
||||
except (AttributeError, ValueError):
|
||||
return web.json_response({"status": "error", "message": "Invalid recipe filename"}, status=400)
|
||||
parameters_dir = get_parameters_dir()
|
||||
notebooks = await asyncio.to_thread(_read_parameter_notebooks, parameters_dir, recipe_filename)
|
||||
return web.json_response({"notebooks": notebooks})
|
||||
|
||||
|
||||
_parameter_notebooks_cache = None
|
||||
_parameter_notebooks_cache_time = 0
|
||||
|
||||
def _invalidate_parameter_notebooks_cache():
|
||||
global _parameter_notebooks_cache, _parameter_notebooks_cache_time
|
||||
_parameter_notebooks_cache = None
|
||||
_parameter_notebooks_cache_time = 0
|
||||
|
||||
def _prompt_role_for_node(node, params):
|
||||
"""Return an explicit role, or conservatively recover one from legacy values."""
|
||||
if not isinstance(node, dict) or not isinstance(params, dict):
|
||||
return None
|
||||
allowed_roles = {"positive", "negative", "both", "ignored", "unknown"}
|
||||
node_id = node.get("id")
|
||||
overrides = params.get("promptRoleOverrides")
|
||||
if isinstance(overrides, dict):
|
||||
override = overrides.get(str(node_id))
|
||||
if override is None:
|
||||
override = overrides.get(node_id)
|
||||
if isinstance(override, dict) and override.get("role") in allowed_roles:
|
||||
expected_type = override.get("nodeType")
|
||||
if not expected_type or expected_type == node.get("type"):
|
||||
return override["role"]
|
||||
|
||||
summaries = params.get("nodes")
|
||||
if isinstance(summaries, list):
|
||||
for summary in summaries:
|
||||
if not isinstance(summary, dict) or str(summary.get("id")) != str(node_id):
|
||||
continue
|
||||
if summary.get("role") in allowed_roles:
|
||||
return summary["role"]
|
||||
break
|
||||
|
||||
values = node.get("widgets_values")
|
||||
if not isinstance(values, list) or not values:
|
||||
return None
|
||||
value = values[0]
|
||||
positive_values = params.get("promptPositive", [])
|
||||
negative_values = params.get("promptNegative", [])
|
||||
positive = set(item for item in positive_values if isinstance(item, str)) if isinstance(positive_values, list) else set()
|
||||
negative = set(item for item in negative_values if isinstance(item, str)) if isinstance(negative_values, list) else set()
|
||||
if value in negative and value not in positive:
|
||||
return "negative"
|
||||
if value in positive and value not in negative:
|
||||
return "positive"
|
||||
return None
|
||||
|
||||
def _get_all_notebooks_cached(parameters_dir, force_refresh=False):
|
||||
global _parameter_notebooks_cache, _parameter_notebooks_cache_time
|
||||
# Cache for 2 seconds to prevent rapid disk I/O on UI interactions
|
||||
if not force_refresh and _parameter_notebooks_cache is not None and time.time() - _parameter_notebooks_cache_time < 2.0:
|
||||
return _parameter_notebooks_cache
|
||||
notebooks = _read_parameter_notebooks(parameters_dir)
|
||||
_parameter_notebooks_cache = notebooks
|
||||
_parameter_notebooks_cache_time = time.time()
|
||||
return notebooks
|
||||
|
||||
async def api_get_parameters_by_type(request):
|
||||
try:
|
||||
node_type = request.query.get("type")
|
||||
if not node_type:
|
||||
raise ValueError("Missing node type")
|
||||
except ValueError:
|
||||
return web.json_response({"status": "error", "message": "Missing node type"}, status=400)
|
||||
|
||||
parameters_dir = get_parameters_dir()
|
||||
force_refresh = request.query.get("refresh") == "1"
|
||||
notebooks = await asyncio.to_thread(_get_all_notebooks_cached, parameters_dir, force_refresh)
|
||||
|
||||
# Filter and extract node data
|
||||
# Structure: [ { "recipe_filename": ..., "notebooks": [ { "name": ..., "nodes": [...] } ] } ]
|
||||
|
||||
grouped = {}
|
||||
for nb in notebooks:
|
||||
workflow = nb.get("data", {}).get("workflow")
|
||||
if not isinstance(workflow, dict):
|
||||
continue
|
||||
|
||||
nodes = workflow.get("nodes", [])
|
||||
if not isinstance(nodes, list):
|
||||
continue
|
||||
|
||||
matched_nodes = []
|
||||
for n in nodes:
|
||||
if not isinstance(n, dict):
|
||||
continue
|
||||
if n.get("type") == node_type:
|
||||
matched_nodes.append({
|
||||
"id": n.get("id"),
|
||||
"title": n.get("title") or n.get("type"),
|
||||
"type": n.get("type"),
|
||||
"widgets_values": n.get("widgets_values", []),
|
||||
"role": _prompt_role_for_node(n, nb.get("data", {}).get("params")),
|
||||
})
|
||||
|
||||
if not matched_nodes:
|
||||
continue
|
||||
|
||||
recipe_fn = nb.get("data", {}).get("recipe_filename") or "unbound"
|
||||
if recipe_fn not in grouped:
|
||||
grouped[recipe_fn] = []
|
||||
|
||||
grouped[recipe_fn].append({
|
||||
"filename": nb.get("filename"),
|
||||
"name": nb.get("name"),
|
||||
"timestamp": nb.get("timestamp"),
|
||||
"nodes": matched_nodes
|
||||
})
|
||||
|
||||
recipes_dir = get_recipes_dir()
|
||||
recipe_name_cache = {}
|
||||
recipe_role_cache = {}
|
||||
|
||||
result = []
|
||||
for r_fn, nbs in grouped.items():
|
||||
recipe_roles = {}
|
||||
if r_fn == "unbound":
|
||||
human_name = "Unbound"
|
||||
else:
|
||||
if r_fn not in recipe_name_cache:
|
||||
try:
|
||||
r_data = _read_recipe(resolve_within(recipes_dir, r_fn))
|
||||
recipe_name_cache[r_fn] = r_data.get("name") if r_data else r_fn
|
||||
recipe_params = r_data.get("params") if isinstance(r_data, dict) else None
|
||||
if r_data and isinstance(recipe_params, dict):
|
||||
role_map = {}
|
||||
workflow = r_data.get("workflow")
|
||||
workflow_nodes = workflow.get("nodes", []) if isinstance(workflow, dict) else []
|
||||
for n in workflow_nodes:
|
||||
if not isinstance(n, dict) or "id" not in n:
|
||||
continue
|
||||
role = _prompt_role_for_node(n, recipe_params)
|
||||
if role is not None:
|
||||
role_map[n["id"]] = role
|
||||
recipe_role_cache[r_fn] = role_map
|
||||
except Exception:
|
||||
recipe_name_cache[r_fn] = r_fn
|
||||
human_name = recipe_name_cache[r_fn] or r_fn
|
||||
recipe_roles = recipe_role_cache.get(r_fn, {})
|
||||
|
||||
for nb in nbs:
|
||||
for n in nb["nodes"]:
|
||||
node_id = n.get("id")
|
||||
role = recipe_roles.get(node_id)
|
||||
if role is None:
|
||||
role = recipe_roles.get(str(node_id))
|
||||
if role is None:
|
||||
role = n.get("role")
|
||||
if role is not None:
|
||||
n["role"] = role
|
||||
|
||||
result.append({
|
||||
"recipe_filename": r_fn,
|
||||
"recipe_name": human_name,
|
||||
"notebooks": nbs
|
||||
})
|
||||
|
||||
return web.json_response({"groups": result})
|
||||
|
||||
def _read_parameter_notebooks(parameters_dir, recipe_filename=None):
|
||||
notebooks = []
|
||||
try:
|
||||
filenames = os.listdir(parameters_dir)
|
||||
except OSError:
|
||||
return notebooks
|
||||
for filename in filenames:
|
||||
if not filename.endswith(".json"):
|
||||
continue
|
||||
file_path = resolve_within(parameters_dir, filename)
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
if recipe_filename and data.get("recipe_filename") != recipe_filename:
|
||||
continue
|
||||
notebooks.append({
|
||||
"filename": filename,
|
||||
"name": data.get("name", "Untitled Parameter Notebook"),
|
||||
"data": data,
|
||||
"timestamp": data.get("timestamp", 0),
|
||||
})
|
||||
except (OSError, ValueError, json.JSONDecodeError):
|
||||
continue
|
||||
notebooks.sort(key=lambda item: item.get("timestamp", 0), reverse=True)
|
||||
return notebooks
|
||||
|
||||
async def api_save_parameter(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
return web.json_response({"status": "error", "message": "Invalid parameter data"}, status=400)
|
||||
|
||||
if not isinstance(data, dict) or not isinstance(data.get("workflow"), dict):
|
||||
return web.json_response({"status": "error", "message": "Invalid parameter workflow"}, status=400)
|
||||
|
||||
recipe_filename = data.get("recipe_filename")
|
||||
if recipe_filename is not None:
|
||||
try:
|
||||
recipe_filename = require_filename(recipe_filename)
|
||||
if not recipe_filename.endswith(".json"):
|
||||
raise ValueError("Invalid recipe filename")
|
||||
except (AttributeError, ValueError):
|
||||
return web.json_response({"status": "error", "message": "Invalid recipe filename"}, status=400)
|
||||
data["recipe_filename"] = recipe_filename
|
||||
|
||||
filename_stem = os.path.splitext(recipe_filename)[0] if recipe_filename else "unbound"
|
||||
filename_stem = re.sub(r"[^A-Za-z0-9_-]+", "_", filename_stem)[:64]
|
||||
filename = f"params_{filename_stem}_{int(time.time())}_{uuid.uuid4().hex[:8]}.json"
|
||||
data["parameter_signature"] = _parameter_signature(data["workflow"])
|
||||
data["timestamp"] = int(time.time() * 1000)
|
||||
encoded = json.dumps(data, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||
if len(encoded) > MAX_RECIPE_BYTES:
|
||||
return web.json_response({"status": "error", "message": "Parameter notebook is too large"}, status=413)
|
||||
|
||||
try:
|
||||
parameters_dir = get_parameters_dir()
|
||||
file_path = resolve_within(parameters_dir, filename)
|
||||
fd, temp_path = tempfile.mkstemp(prefix=".parameter-", suffix=".tmp", dir=parameters_dir)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, separators=(",", ":"))
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(temp_path, file_path)
|
||||
finally:
|
||||
if os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
_invalidate_parameter_notebooks_cache()
|
||||
except OSError:
|
||||
return web.json_response({"status": "error", "message": "Could not save parameter notebook"}, status=500)
|
||||
|
||||
return web.json_response({"status": "success", "filename": filename})
|
||||
|
||||
async def api_delete_parameter(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
filename = require_filename(data.get("filename", ""))
|
||||
if not filename.endswith(".json"):
|
||||
raise ValueError("Invalid filename")
|
||||
except (ValueError, AttributeError):
|
||||
return web.json_response({"status": "error", "message": "Invalid filename"}, status=400)
|
||||
|
||||
try:
|
||||
parameters_dir = get_parameters_dir()
|
||||
file_path = resolve_within(parameters_dir, filename)
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
_invalidate_parameter_notebooks_cache()
|
||||
except OSError:
|
||||
return web.json_response({"status": "error", "message": "Could not delete parameter notebook"}, status=500)
|
||||
|
||||
return web.json_response({"status": "success"})
|
||||
|
||||
async def api_rename_parameter(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
filename = require_filename(data.get("filename", ""))
|
||||
if not filename.endswith(".json"):
|
||||
raise ValueError("Invalid filename")
|
||||
new_name = str(data.get("name", "")).strip()
|
||||
if not new_name:
|
||||
raise ValueError("Name cannot be empty")
|
||||
if len(new_name) > 200:
|
||||
new_name = new_name[:200]
|
||||
except (ValueError, AttributeError, json.JSONDecodeError):
|
||||
return web.json_response({"status": "error", "message": "Invalid parameter data"}, status=400)
|
||||
|
||||
try:
|
||||
parameters_dir = get_parameters_dir()
|
||||
file_path = resolve_within(parameters_dir, filename)
|
||||
if not os.path.exists(file_path):
|
||||
return web.json_response({"status": "error", "message": "Parameter notebook not found"}, status=404)
|
||||
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
nb_data = json.load(f)
|
||||
|
||||
if not isinstance(nb_data, dict):
|
||||
return web.json_response({"status": "error", "message": "Invalid parameter file format"}, status=400)
|
||||
|
||||
nb_data["name"] = new_name
|
||||
nb_data["timestamp"] = int(time.time() * 1000)
|
||||
|
||||
fd, temp_path = tempfile.mkstemp(prefix=".parameter-", suffix=".tmp", dir=parameters_dir)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(nb_data, f, ensure_ascii=False, separators=(",", ":"))
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(temp_path, file_path)
|
||||
finally:
|
||||
if os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
|
||||
_invalidate_parameter_notebooks_cache()
|
||||
except OSError:
|
||||
return web.json_response({"status": "error", "message": "Could not update parameter notebook"}, status=500)
|
||||
|
||||
return web.json_response({"status": "success", "filename": filename, "name": new_name})
|
||||
|
||||
async def api_get_parameter_gallery(request):
|
||||
"""Find recent output PNGs whose embedded parameters match one parameter notebook."""
|
||||
try:
|
||||
filename = request.query.get("filename")
|
||||
fingerprint = request.query.get("fingerprint")
|
||||
if filename:
|
||||
filename = require_filename(filename)
|
||||
if not filename.endswith(".json"):
|
||||
raise ValueError("Invalid parameter notebook")
|
||||
parameters_dir = get_parameters_dir()
|
||||
file_path = resolve_within(parameters_dir, filename)
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
fingerprint = data.get("parameter_signature", {}).get("value")
|
||||
if not fingerprint:
|
||||
fingerprint = _parameter_signature(data.get("workflow"))["value"]
|
||||
elif not isinstance(fingerprint, str) or not re.fullmatch(r"[0-9a-f]{64}", fingerprint, re.IGNORECASE):
|
||||
raise ValueError("Invalid fingerprint")
|
||||
|
||||
images, scanned = await asyncio.to_thread(_parameter_gallery_images, fingerprint.lower())
|
||||
except (AttributeError, ValueError, json.JSONDecodeError):
|
||||
return web.json_response({"status": "error", "message": "Invalid parameter gallery request"}, status=400)
|
||||
except FileNotFoundError:
|
||||
return web.json_response({"status": "error", "message": "File not found"}, status=404)
|
||||
except OSError:
|
||||
return web.json_response({"status": "error", "message": "Could not read parameter gallery"}, status=500)
|
||||
|
||||
return web.json_response({
|
||||
"status": "success",
|
||||
"fingerprint": fingerprint.lower(),
|
||||
"match_mode": "params",
|
||||
"images": images,
|
||||
"scanned": scanned,
|
||||
})
|
||||
@@ -0,0 +1,507 @@
|
||||
"""Bounded import/export for self-contained Workflow Recipe packages."""
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
import zipfile
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from . import recipes as recipe_store
|
||||
from .recipes import get_recipes_dir
|
||||
from .utils import require_filename, resolve_within
|
||||
|
||||
|
||||
PACKAGE_VERSION = 1
|
||||
MAX_UPLOAD_BYTES = 32 * 1024 * 1024
|
||||
MAX_ENTRY_COUNT = 256
|
||||
MAX_ENTRY_BYTES = 16 * 1024 * 1024
|
||||
MAX_EXPANDED_BYTES = 64 * 1024 * 1024
|
||||
MAX_COMPRESSION_RATIO = 100
|
||||
MAX_HISTORY_ENTRIES = 100
|
||||
MAX_JSON_DEPTH = 64
|
||||
MAX_INSPECTIONS = 4
|
||||
INSPECTION_TTL_SECONDS = 10 * 60
|
||||
ALLOWED_COMPRESSION = {zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED}
|
||||
_INSPECTIONS = {}
|
||||
|
||||
|
||||
def _json_depth(value, depth=0):
|
||||
if depth > MAX_JSON_DEPTH:
|
||||
return depth
|
||||
if isinstance(value, dict):
|
||||
return max((_json_depth(item, depth + 1) for item in value.values()), default=depth)
|
||||
if isinstance(value, list):
|
||||
return max((_json_depth(item, depth + 1) for item in value), default=depth)
|
||||
return depth
|
||||
|
||||
|
||||
def _sha256(data):
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def _safe_archive_name(name):
|
||||
if not isinstance(name, str) or not name or len(name) > 240:
|
||||
raise ValueError("Invalid package entry name")
|
||||
if chr(92) in name or name.startswith("/") or ":" in name:
|
||||
raise ValueError("Invalid package entry name")
|
||||
parts = name.split("/")
|
||||
if any(not part or part in (".", "..") for part in parts):
|
||||
raise ValueError("Invalid package entry name")
|
||||
return name
|
||||
|
||||
|
||||
def _entry_is_symlink(info):
|
||||
return stat.S_ISLNK((info.external_attr >> 16) & 0xFFFF)
|
||||
|
||||
|
||||
def _read_zip_entry(archive, name, maximum=MAX_ENTRY_BYTES):
|
||||
info = archive.getinfo(name)
|
||||
if info.file_size > maximum:
|
||||
raise ValueError("Package entry is too large")
|
||||
data = archive.read(info)
|
||||
if len(data) != info.file_size:
|
||||
raise ValueError("Package entry size mismatch")
|
||||
return data
|
||||
|
||||
|
||||
def _validate_zip(raw):
|
||||
if not isinstance(raw, bytes) or len(raw) > MAX_UPLOAD_BYTES:
|
||||
raise ValueError("Package upload is too large")
|
||||
try:
|
||||
archive = zipfile.ZipFile(io.BytesIO(raw), "r")
|
||||
except (OSError, zipfile.BadZipFile) as error:
|
||||
raise ValueError("Invalid recipe package") from error
|
||||
|
||||
infos = archive.infolist()
|
||||
if len(infos) > MAX_ENTRY_COUNT:
|
||||
raise ValueError("Too many package entries")
|
||||
names = []
|
||||
expanded = 0
|
||||
for info in infos:
|
||||
name = _safe_archive_name(info.filename)
|
||||
if name in names or info.is_dir() or _entry_is_symlink(info):
|
||||
raise ValueError("Invalid or duplicate package entry")
|
||||
if info.compress_type not in ALLOWED_COMPRESSION or info.flag_bits & 0x1:
|
||||
raise ValueError("Unsupported package compression")
|
||||
if info.file_size > MAX_ENTRY_BYTES:
|
||||
raise ValueError("Package entry is too large")
|
||||
if info.compress_size and info.file_size / info.compress_size > MAX_COMPRESSION_RATIO:
|
||||
raise ValueError("Package compression ratio is unsafe")
|
||||
expanded += info.file_size
|
||||
if expanded > MAX_EXPANDED_BYTES:
|
||||
raise ValueError("Package expands beyond the safety limit")
|
||||
names.append(name)
|
||||
|
||||
if "manifest.json" not in names or "recipe.json" not in names:
|
||||
raise ValueError("Package is missing manifest or recipe")
|
||||
return archive, names
|
||||
|
||||
|
||||
def _parse_json(data, label):
|
||||
try:
|
||||
value = json.loads(data.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
||||
raise ValueError(f"Invalid {label}") from error
|
||||
if _json_depth(value) > MAX_JSON_DEPTH:
|
||||
raise ValueError(f"{label} is nested too deeply")
|
||||
return value
|
||||
|
||||
|
||||
def _asset_id_from_reference(reference):
|
||||
asset_id = reference.get("preview", {}).get("snapshot_asset_id") if isinstance(reference, dict) else None
|
||||
if not isinstance(asset_id, str) or not asset_id.endswith(".webp"):
|
||||
return None
|
||||
return require_filename(asset_id)
|
||||
|
||||
|
||||
def _cover_asset_id(recipe):
|
||||
presentation = recipe.get("presentation", {}) if isinstance(recipe, dict) else {}
|
||||
asset_id = presentation.get("cover_asset_id") if isinstance(presentation, dict) else None
|
||||
if not isinstance(asset_id, str) or not asset_id.startswith("cover-") or not asset_id.endswith(".webp"):
|
||||
return None
|
||||
return require_filename(asset_id)
|
||||
|
||||
|
||||
def _validate_manifest(manifest, archive, names):
|
||||
if not isinstance(manifest, dict) or manifest.get("package_version") != PACKAGE_VERSION:
|
||||
raise ValueError("Unsupported recipe package version")
|
||||
declared = manifest.get("entries", [])
|
||||
if not isinstance(declared, list) or len(declared) > MAX_ENTRY_COUNT:
|
||||
raise ValueError("Invalid package manifest")
|
||||
declared_map = {}
|
||||
for item in declared:
|
||||
if not isinstance(item, dict):
|
||||
raise ValueError("Invalid package manifest entry")
|
||||
name = _safe_archive_name(item.get("name"))
|
||||
if name in declared_map or name not in names:
|
||||
raise ValueError("Package manifest does not match archive")
|
||||
declared_map[name] = item
|
||||
for name in names:
|
||||
if name == "manifest.json":
|
||||
continue
|
||||
item = declared_map.get(name)
|
||||
if not item:
|
||||
raise ValueError("Package contains undeclared data")
|
||||
data = _read_zip_entry(archive, name)
|
||||
if item.get("sha256") != _sha256(data) or item.get("size") != len(data):
|
||||
raise ValueError("Package checksum mismatch")
|
||||
|
||||
|
||||
def _sanitize_recipe_for_export(recipe, include_snapshots=True, include_identity=True):
|
||||
value = json.loads(json.dumps(recipe, ensure_ascii=False))
|
||||
# A source_image points into the exporting machine's output directory and
|
||||
# is not portable package data. The bounded thumbnail remains the cover.
|
||||
value["source_image"] = None
|
||||
for reference in value.get("params", {}).get("model_references", []):
|
||||
if not include_snapshots:
|
||||
reference.pop("preview", None)
|
||||
if not include_identity:
|
||||
identity = reference.get("identity")
|
||||
if isinstance(identity, dict):
|
||||
for key in ("sha256", "size", "provenance"):
|
||||
identity.pop(key, None)
|
||||
identity["status"] = "unverified"
|
||||
return value
|
||||
|
||||
|
||||
def _referenced_asset_ids(recipe, include_model_previews=True):
|
||||
result = set()
|
||||
cover_asset = _cover_asset_id(recipe)
|
||||
if cover_asset:
|
||||
result.add(cover_asset)
|
||||
if include_model_previews:
|
||||
references = recipe.get("params", {}).get("model_references", [])
|
||||
for reference in references if isinstance(references, list) else []:
|
||||
asset_id = _asset_id_from_reference(reference)
|
||||
if asset_id:
|
||||
result.add(asset_id)
|
||||
return result
|
||||
|
||||
|
||||
def _add_zip_entry(archive, entries, name, data, media_type):
|
||||
name = _safe_archive_name(name)
|
||||
archive.writestr(name, data)
|
||||
entries.append({"name": name, "size": len(data), "sha256": _sha256(data), "media_type": media_type})
|
||||
|
||||
|
||||
def _recipe_history_files(recipes_dir, filename):
|
||||
history_dir = recipe_store._history_dir(recipes_dir, filename)
|
||||
files = []
|
||||
if not os.path.isdir(history_dir):
|
||||
return files
|
||||
with os.scandir(history_dir) as entries:
|
||||
for entry in entries:
|
||||
if entry.is_file() and entry.name.endswith(".json"):
|
||||
files.append(require_filename(entry.name))
|
||||
files.sort()
|
||||
return files[:MAX_HISTORY_ENTRIES]
|
||||
|
||||
|
||||
def _build_export(raw_recipe, recipes_dir, filename, options):
|
||||
include_snapshots = options.get("include_snapshots") is True
|
||||
include_history = options.get("include_history") is True
|
||||
include_identity = options.get("include_identity", True) is True
|
||||
recipe = _sanitize_recipe_for_export(raw_recipe, include_snapshots, include_identity)
|
||||
history_recipes = []
|
||||
if include_history:
|
||||
for history_name in _recipe_history_files(recipes_dir, filename):
|
||||
history_path = resolve_within(recipe_store._history_dir(recipes_dir, filename), history_name)
|
||||
with open(history_path, "rb") as history_file:
|
||||
data = history_file.read(MAX_ENTRY_BYTES + 1)
|
||||
if len(data) > MAX_ENTRY_BYTES:
|
||||
raise ValueError("Historical recipe is too large")
|
||||
history_recipe = _parse_json(data, "historical recipe")
|
||||
history_recipes.append((history_name, _sanitize_recipe_for_export(history_recipe, include_snapshots, include_identity)))
|
||||
asset_ids = _referenced_asset_ids(recipe, include_snapshots)
|
||||
for _, history_recipe in history_recipes:
|
||||
asset_ids.update(_referenced_asset_ids(history_recipe, include_snapshots))
|
||||
entries = []
|
||||
output = io.BytesIO()
|
||||
with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as archive:
|
||||
recipe_bytes = json.dumps(recipe, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||
_add_zip_entry(archive, entries, "recipe.json", recipe_bytes, "application/json")
|
||||
|
||||
if asset_ids:
|
||||
assets_dir = recipe_store._recipe_assets_dir(recipes_dir, filename)
|
||||
for asset_id in sorted(asset_ids):
|
||||
asset_path = resolve_within(assets_dir, asset_id)
|
||||
if not os.path.isfile(asset_path):
|
||||
continue
|
||||
with open(asset_path, "rb") as asset_file:
|
||||
data = asset_file.read(MAX_ENTRY_BYTES + 1)
|
||||
if len(data) > MAX_ENTRY_BYTES or not data.startswith(b"RIFF") or data[8:12] != b"WEBP":
|
||||
continue
|
||||
_add_zip_entry(archive, entries, f"assets/{asset_id}", data, "image/webp")
|
||||
|
||||
if include_history:
|
||||
for history_name, history_recipe in history_recipes:
|
||||
data = json.dumps(history_recipe, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||
_add_zip_entry(archive, entries, f"history/{history_name}", data, "application/json")
|
||||
|
||||
manifest = {
|
||||
"package_version": PACKAGE_VERSION,
|
||||
"recipe_schema_version": recipe.get("schema_version", 1),
|
||||
"exported_at": int(time.time() * 1000),
|
||||
"options": {
|
||||
"include_snapshots": include_snapshots,
|
||||
"include_history": include_history,
|
||||
"include_identity": include_identity,
|
||||
},
|
||||
"entries": entries,
|
||||
}
|
||||
manifest_bytes = json.dumps(manifest, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||
archive.writestr("manifest.json", manifest_bytes)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def _validate_recipe_assets(archive, recipes, names):
|
||||
referenced = set()
|
||||
for recipe in recipes:
|
||||
referenced.update(_referenced_asset_ids(recipe))
|
||||
for asset_id in referenced:
|
||||
name = f"assets/{asset_id}"
|
||||
if name not in names:
|
||||
raise ValueError("Recipe references a missing snapshot asset")
|
||||
data = _read_zip_entry(archive, name)
|
||||
if not data.startswith(b"RIFF") or data[8:12] != b"WEBP":
|
||||
raise ValueError("Snapshot asset is not a WebP image")
|
||||
for name in names:
|
||||
if name.startswith("assets/") and (name[7:] not in referenced or not name.endswith(".webp")):
|
||||
raise ValueError("Package contains an unreferenced asset")
|
||||
|
||||
|
||||
def _inspect_package(raw):
|
||||
archive, names = _validate_zip(raw)
|
||||
with archive:
|
||||
manifest = _parse_json(_read_zip_entry(archive, "manifest.json"), "manifest")
|
||||
_validate_manifest(manifest, archive, names)
|
||||
recipe = _parse_json(_read_zip_entry(archive, "recipe.json"), "recipe")
|
||||
recipe_store._normalise_recipe(recipe)
|
||||
history_names = [name for name in names if name.startswith("history/")]
|
||||
if len(history_names) > MAX_HISTORY_ENTRIES:
|
||||
raise ValueError("Too many history entries")
|
||||
history_recipes = []
|
||||
for name in history_names:
|
||||
history = _parse_json(_read_zip_entry(archive, name), "historical recipe")
|
||||
recipe_store._normalise_recipe(history)
|
||||
history_recipes.append(history)
|
||||
_validate_recipe_assets(archive, [recipe, *history_recipes], names)
|
||||
return {
|
||||
"manifest": manifest,
|
||||
"recipe": recipe,
|
||||
"history_names": history_names,
|
||||
"asset_names": [name for name in names if name.startswith("assets/")],
|
||||
}
|
||||
|
||||
|
||||
def _prune_inspections():
|
||||
now = time.time()
|
||||
for token, record in list(_INSPECTIONS.items()):
|
||||
if now - record["created"] > INSPECTION_TTL_SECONDS:
|
||||
_INSPECTIONS.pop(token, None)
|
||||
while len(_INSPECTIONS) >= MAX_INSPECTIONS:
|
||||
oldest = min(_INSPECTIONS, key=lambda token: _INSPECTIONS[token]["created"])
|
||||
_INSPECTIONS.pop(oldest, None)
|
||||
|
||||
|
||||
def _existing_recipe_names(recipes_dir):
|
||||
return [item["data"].get("name", "") for item in recipe_store._list_recipes(recipes_dir)]
|
||||
|
||||
|
||||
def _unique_name(name, existing):
|
||||
base = name.strip() or "Imported Workflow Recipe"
|
||||
if base not in existing:
|
||||
return base
|
||||
for index in range(2, 1000):
|
||||
candidate = f"{base} ({index})"
|
||||
if candidate not in existing:
|
||||
return candidate
|
||||
raise ValueError("Could not create a unique recipe name")
|
||||
|
||||
|
||||
def _copy_imported_assets(archive, inspection, staging, source_recipe, target_recipe):
|
||||
asset_dir = os.path.join(staging, "assets")
|
||||
os.makedirs(asset_dir, exist_ok=True)
|
||||
for name in inspection["asset_names"]:
|
||||
asset_id = require_filename(name.split("/", 1)[1])
|
||||
data = _read_zip_entry(archive, name)
|
||||
with open(resolve_within(asset_dir, asset_id), "wb") as asset_file:
|
||||
asset_file.write(data)
|
||||
|
||||
source_refs = {
|
||||
(
|
||||
item.get("node_id"),
|
||||
item.get("widget_index"),
|
||||
item.get("category"),
|
||||
item.get("saved_value"),
|
||||
): item
|
||||
for item in source_recipe.get("params", {}).get("model_references", [])
|
||||
if isinstance(item, dict)
|
||||
}
|
||||
for target in target_recipe.get("params", {}).get("model_references", []):
|
||||
source = source_refs.get((target.get("node_id"), target.get("widget_index"), target.get("category"), target.get("saved_value")))
|
||||
asset_id = _asset_id_from_reference(source or {})
|
||||
if asset_id and os.path.isfile(resolve_within(asset_dir, asset_id)):
|
||||
target["preview"] = source["preview"]
|
||||
|
||||
|
||||
def _write_staged_json(path, value):
|
||||
with open(path, "w", encoding="utf-8", newline="\n") as output:
|
||||
json.dump(value, output, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def _commit_import(record, payload):
|
||||
recipes_dir = get_recipes_dir()
|
||||
package_recipe = json.loads(json.dumps(record["recipe"], ensure_ascii=False))
|
||||
collision = payload.get("collision", "rename")
|
||||
name_override = payload.get("name")
|
||||
if name_override is not None:
|
||||
if not isinstance(name_override, str) or not name_override.strip():
|
||||
raise ValueError("Invalid imported recipe name")
|
||||
package_recipe["name"] = name_override.strip()
|
||||
|
||||
existing_names = _existing_recipe_names(recipes_dir)
|
||||
target_filename = None
|
||||
if collision == "replace":
|
||||
target_filename = require_filename(payload.get("target_filename", ""))
|
||||
if not target_filename.endswith(".json"):
|
||||
raise ValueError("Invalid replacement target")
|
||||
if not os.path.isfile(resolve_within(recipes_dir, target_filename)):
|
||||
raise FileNotFoundError
|
||||
elif collision != "rename":
|
||||
raise ValueError("Unsupported collision choice")
|
||||
else:
|
||||
package_recipe["name"] = _unique_name(package_recipe.get("name", ""), existing_names)
|
||||
|
||||
normalized = recipe_store._normalise_recipe(package_recipe)
|
||||
normalized["presentation"]["imported"] = True
|
||||
filename = target_filename or f"recipe_{int(time.time())}_{uuid.uuid4().hex[:8]}.json"
|
||||
normalized = recipe_store._enrich_recipe(normalized)
|
||||
|
||||
staging = tempfile.mkdtemp(prefix=".recipe-import-", dir=recipes_dir)
|
||||
final_path = resolve_within(recipes_dir, filename)
|
||||
final_assets = recipe_store._recipe_assets_dir(recipes_dir, filename)
|
||||
final_history = recipe_store._history_dir(recipes_dir, filename)
|
||||
backup_recipe = None
|
||||
backup_assets = None
|
||||
backup_history = None
|
||||
installed_assets = False
|
||||
installed_history = False
|
||||
try:
|
||||
archive, _ = _validate_zip(record["raw"])
|
||||
with archive:
|
||||
_copy_imported_assets(archive, record, staging, record["recipe"], normalized)
|
||||
_write_staged_json(os.path.join(staging, "recipe.json"), normalized)
|
||||
if record["history_names"]:
|
||||
history_stage = os.path.join(staging, "history")
|
||||
os.makedirs(history_stage, exist_ok=True)
|
||||
for name in record["history_names"]:
|
||||
history = _parse_json(_read_zip_entry(archive, name), "historical recipe")
|
||||
_write_staged_json(os.path.join(history_stage, require_filename(name.split("/", 1)[1])), history)
|
||||
|
||||
if collision == "replace":
|
||||
backup_recipe = os.path.join(staging, "old-recipe.json")
|
||||
os.replace(final_path, backup_recipe)
|
||||
if os.path.isdir(final_assets):
|
||||
backup_assets = os.path.join(staging, "old-assets")
|
||||
shutil.move(final_assets, backup_assets)
|
||||
if record["history_names"] and os.path.isdir(final_history):
|
||||
backup_history = os.path.join(staging, "old-history")
|
||||
shutil.move(final_history, backup_history)
|
||||
|
||||
os.replace(os.path.join(staging, "recipe.json"), final_path)
|
||||
staged_assets = os.path.join(staging, "assets")
|
||||
if os.path.isdir(staged_assets):
|
||||
os.replace(staged_assets, final_assets)
|
||||
installed_assets = True
|
||||
if record["history_names"]:
|
||||
history_stage = os.path.join(staging, "history")
|
||||
if os.path.isdir(final_history):
|
||||
shutil.rmtree(final_history)
|
||||
os.replace(history_stage, final_history)
|
||||
installed_history = True
|
||||
except Exception:
|
||||
if collision == "replace" and backup_recipe and os.path.isfile(backup_recipe):
|
||||
if installed_assets and os.path.isdir(final_assets):
|
||||
shutil.rmtree(final_assets, ignore_errors=True)
|
||||
if installed_history and os.path.isdir(final_history):
|
||||
shutil.rmtree(final_history, ignore_errors=True)
|
||||
if os.path.isfile(final_path):
|
||||
os.remove(final_path)
|
||||
os.replace(backup_recipe, final_path)
|
||||
if backup_assets and os.path.isdir(backup_assets):
|
||||
shutil.move(backup_assets, final_assets)
|
||||
if backup_history and os.path.isdir(backup_history):
|
||||
shutil.move(backup_history, final_history)
|
||||
raise
|
||||
finally:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
return filename, normalized.get("name", "")
|
||||
|
||||
|
||||
async def api_export_recipe_package(request):
|
||||
try:
|
||||
payload = await request.json()
|
||||
filename = require_filename(payload.get("filename", ""))
|
||||
if not filename.endswith(".json"):
|
||||
raise ValueError("Invalid recipe filename")
|
||||
recipes_dir = get_recipes_dir()
|
||||
recipe = await asyncio.to_thread(recipe_store._read_recipe, resolve_within(recipes_dir, filename))
|
||||
package = await asyncio.to_thread(_build_export, recipe, recipes_dir, filename, payload)
|
||||
except (AttributeError, TypeError, ValueError, json.JSONDecodeError):
|
||||
return web.json_response({"status": "error", "message": "Invalid export request"}, status=400)
|
||||
except FileNotFoundError:
|
||||
return web.json_response({"status": "error", "message": "Recipe not found"}, status=404)
|
||||
except OSError:
|
||||
return web.json_response({"status": "error", "message": "Could not export recipe"}, status=500)
|
||||
return web.Response(
|
||||
body=package,
|
||||
content_type="application/zip",
|
||||
headers={"Content-Disposition": f'attachment; filename="{os.path.splitext(filename)[0]}.anomalous-recipe.zip"'},
|
||||
)
|
||||
|
||||
|
||||
async def api_import_recipe_package_inspect(request):
|
||||
try:
|
||||
raw = await request.content.read(MAX_UPLOAD_BYTES + 1)
|
||||
report = await asyncio.to_thread(_inspect_package, raw)
|
||||
except (ValueError, OSError, zipfile.BadZipFile):
|
||||
return web.json_response({"status": "error", "message": "Invalid recipe package"}, status=400)
|
||||
_prune_inspections()
|
||||
token = uuid.uuid4().hex
|
||||
_INSPECTIONS[token] = {"created": time.time(), "raw": raw, **report}
|
||||
return web.json_response({
|
||||
"status": "success",
|
||||
"token": token,
|
||||
"recipe": {"name": report["recipe"].get("name", ""), "tags": report["recipe"].get("tags", [])},
|
||||
"asset_count": len(report["asset_names"]),
|
||||
"history_count": len(report["history_names"]),
|
||||
"existing_names": _existing_recipe_names(get_recipes_dir()),
|
||||
})
|
||||
|
||||
|
||||
async def api_import_recipe_package_commit(request):
|
||||
try:
|
||||
payload = await request.json()
|
||||
token = payload.get("token")
|
||||
if not isinstance(token, str) or token not in _INSPECTIONS:
|
||||
raise ValueError("Invalid inspection token")
|
||||
record = _INSPECTIONS.pop(token)
|
||||
if time.time() - record["created"] > INSPECTION_TTL_SECONDS:
|
||||
raise ValueError("Inspection token expired")
|
||||
filename, name = await asyncio.to_thread(_commit_import, record, payload)
|
||||
except (AttributeError, TypeError, ValueError, json.JSONDecodeError):
|
||||
return web.json_response({"status": "error", "message": "Could not commit recipe package"}, status=400)
|
||||
except FileNotFoundError:
|
||||
return web.json_response({"status": "error", "message": "Replacement recipe not found"}, status=404)
|
||||
except OSError:
|
||||
return web.json_response({"status": "error", "message": "Could not commit recipe package"}, status=500)
|
||||
return web.json_response({"status": "success", "filename": filename, "name": name})
|
||||
+1382
File diff suppressed because it is too large
Load Diff
+432
@@ -0,0 +1,432 @@
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import urllib.parse
|
||||
import subprocess
|
||||
import threading
|
||||
import asyncio
|
||||
from aiohttp import web
|
||||
import folder_paths
|
||||
import struct
|
||||
from .utils import get_active_folder_types, get_active_scan_paths, resolve_folder_subdir
|
||||
try:
|
||||
from ..model_policies import is_physical_rename_protected
|
||||
except ImportError:
|
||||
from model_policies import is_physical_rename_protected
|
||||
|
||||
|
||||
def _protected_type_for_path(folder_path):
|
||||
"""Resolve protected registered aliases for a global physical-folder scan."""
|
||||
target = os.path.realpath(folder_path)
|
||||
for folder_type in folder_paths.folder_names_and_paths.keys():
|
||||
if not is_physical_rename_protected(folder_type=folder_type):
|
||||
continue
|
||||
try:
|
||||
if any(os.path.realpath(path) == target for path in folder_paths.get_folder_paths(folder_type)):
|
||||
return folder_type
|
||||
except Exception:
|
||||
continue
|
||||
return ""
|
||||
|
||||
async def api_scan_status(request):
|
||||
folder_type = request.query.get('type', 'checkpoints')
|
||||
subfolder = request.query.get('subfolder', '/')
|
||||
try:
|
||||
path_idx = int(request.query.get('path_idx', 0))
|
||||
except:
|
||||
path_idx = 0
|
||||
try:
|
||||
paths = folder_paths.get_folder_paths(folder_type)
|
||||
except Exception:
|
||||
return web.json_response({"scanning": False})
|
||||
if not paths or path_idx < 0 or path_idx >= len(paths):
|
||||
return web.json_response({"scanning": False})
|
||||
try:
|
||||
base_dir, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder)
|
||||
except ValueError:
|
||||
return web.json_response({"scanning": False})
|
||||
|
||||
marker_file = os.path.join(target_dir, '.scan_in_progress')
|
||||
result_file = os.path.join(target_dir, '.scan_result.json')
|
||||
scanning = os.path.exists(marker_file)
|
||||
|
||||
data = {"scanning": scanning}
|
||||
if not scanning and os.path.exists(result_file):
|
||||
try:
|
||||
with open(result_file, 'r', encoding='utf-8') as f:
|
||||
data["result"] = __import__('json').load(f)
|
||||
os.remove(result_file)
|
||||
except:
|
||||
pass
|
||||
|
||||
return web.json_response(data)
|
||||
|
||||
async def api_scan_folder(request):
|
||||
"""Launches the scraper in the background for a specific directory."""
|
||||
folder_type = request.query.get('type', 'checkpoints')
|
||||
subfolder = request.query.get('subfolder', '/')
|
||||
try:
|
||||
path_idx = int(request.query.get('path_idx', 0))
|
||||
except:
|
||||
path_idx = 0
|
||||
|
||||
try:
|
||||
base_dir, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder)
|
||||
except (ValueError, KeyError):
|
||||
return web.json_response({"status": "error", "message": "Invalid folder type"})
|
||||
|
||||
if not os.path.exists(target_dir):
|
||||
return web.json_response({"status": "error", "message": "Directory does not exist"})
|
||||
|
||||
# Get the scraper path relative to this script
|
||||
plugin_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
scraper_path = os.path.join(plugin_dir, "scraper.py")
|
||||
|
||||
if not os.path.exists(scraper_path):
|
||||
return web.json_response({"status": "error", "message": "scraper.py not found in extension directory"})
|
||||
|
||||
print(f"[Anomalous Browser] Starting background scan for: {target_dir}")
|
||||
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
data = {}
|
||||
|
||||
offline_only = data.get("offline_only", False)
|
||||
skip_rename = data.get("skip_rename", False)
|
||||
virtual_rename = data.get("virtual_rename", False)
|
||||
physical_rename_requested = data.get("physical_rename", False)
|
||||
physical_rename = physical_rename_requested and not is_physical_rename_protected(
|
||||
folder_type=folder_type,
|
||||
folder_path=target_dir,
|
||||
)
|
||||
force_overwrite = data.get("force_overwrite", False)
|
||||
|
||||
target_files_list = data.get("target_files", [])
|
||||
if not target_files_list:
|
||||
target_files_str = request.query.get('target_files', '')
|
||||
target_files_list = [f.strip() for f in target_files_str.split(',')] if target_files_str else []
|
||||
|
||||
try:
|
||||
marker_file = os.path.join(target_dir, '.scan_in_progress')
|
||||
try:
|
||||
with open(marker_file, 'x') as f:
|
||||
f.write('1')
|
||||
except FileExistsError:
|
||||
return web.json_response({"status": "error", "message": "Scan already in progress"}, status=409)
|
||||
|
||||
if target_files_list:
|
||||
targets_file = os.path.join(target_dir, '.scan_targets.json')
|
||||
with open(targets_file, 'w', encoding='utf-8') as f:
|
||||
__import__('json').dump(target_files_list, f)
|
||||
|
||||
def run_bg():
|
||||
try:
|
||||
cmd = [sys.executable, scraper_path, target_dir, "--folder-type", folder_type]
|
||||
if offline_only:
|
||||
cmd.append("--offline-only")
|
||||
if skip_rename:
|
||||
cmd.append("--skip-rename")
|
||||
if virtual_rename:
|
||||
cmd.append("--virtual-rename")
|
||||
if physical_rename:
|
||||
cmd.append("--physical-rename")
|
||||
if force_overwrite:
|
||||
cmd.append("--force-overwrite")
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=plugin_dir
|
||||
)
|
||||
if result.returncode != 0:
|
||||
result_file = os.path.join(target_dir, '.scan_result.json')
|
||||
with open(result_file, 'w', encoding='utf-8') as f:
|
||||
json.dump({"success": 0, "fail": 1, "error": f"Scanner exited with code {result.returncode}"}, f)
|
||||
finally:
|
||||
if hasattr(folder_paths, "filename_list_cache"):
|
||||
try: folder_paths.filename_list_cache.clear()
|
||||
except: pass
|
||||
if hasattr(folder_paths, "cache_helper") and hasattr(folder_paths.cache_helper, "clear"):
|
||||
try: folder_paths.cache_helper.clear()
|
||||
except: pass
|
||||
|
||||
if os.path.exists(marker_file):
|
||||
try: os.remove(marker_file)
|
||||
except: pass
|
||||
|
||||
threading.Thread(target=run_bg, daemon=True).start()
|
||||
|
||||
return web.json_response({"status": "ok", "message": "Scan started in background. Check console for details."})
|
||||
except Exception as e:
|
||||
if 'marker_file' in locals() and os.path.exists(marker_file):
|
||||
try: os.remove(marker_file)
|
||||
except OSError: pass
|
||||
return web.json_response({"status": "error", "message": str(e)})
|
||||
|
||||
async def api_scan_all(request):
|
||||
plugin_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
scraper_path = os.path.join(plugin_dir, "scraper.py")
|
||||
marker_file = os.path.join(plugin_dir, '.global_scan_in_progress')
|
||||
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
data = {}
|
||||
|
||||
offline_only = data.get("offline_only", False)
|
||||
use_local_metadata = data.get("use_local_metadata", True)
|
||||
skip_rename = data.get("skip_rename", True)
|
||||
virtual_rename = data.get("virtual_rename", False)
|
||||
physical_rename = data.get("physical_rename", False)
|
||||
force_overwrite = data.get("force_overwrite", False)
|
||||
skip_media = data.get("skip_media", False)
|
||||
|
||||
try:
|
||||
try:
|
||||
with open(marker_file, 'x') as f:
|
||||
f.write('1')
|
||||
except FileExistsError:
|
||||
return web.json_response({"status": "error", "message": "Global scan already in progress"}, status=409)
|
||||
|
||||
def run_global_bg():
|
||||
try:
|
||||
paths_to_scan = get_active_scan_paths()
|
||||
for base_dir in paths_to_scan:
|
||||
if not os.path.exists(base_dir): continue
|
||||
try:
|
||||
print(f"[Anomalous Browser] Global scan processing: {base_dir}")
|
||||
cmd = [sys.executable, scraper_path, base_dir]
|
||||
protected_type = _protected_type_for_path(base_dir)
|
||||
if protected_type:
|
||||
cmd.extend(["--folder-type", protected_type])
|
||||
if offline_only:
|
||||
cmd.append("--offline-only")
|
||||
if skip_rename:
|
||||
cmd.append("--skip-rename")
|
||||
if virtual_rename:
|
||||
cmd.append("--virtual-rename")
|
||||
if physical_rename and not is_physical_rename_protected(
|
||||
folder_type=protected_type,
|
||||
folder_path=base_dir,
|
||||
):
|
||||
cmd.append("--physical-rename")
|
||||
if force_overwrite:
|
||||
cmd.append("--force-overwrite")
|
||||
if skip_media:
|
||||
cmd.append("--skip-media")
|
||||
if not use_local_metadata:
|
||||
cmd.append("--skip-local-metadata")
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=plugin_dir
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f"[Anomalous Browser] Scanner exited with code {result.returncode}: {base_dir}")
|
||||
except Exception as e:
|
||||
print(f"[Anomalous Browser] Global scan error on {base_dir}: {e}")
|
||||
finally:
|
||||
if hasattr(folder_paths, "filename_list_cache"):
|
||||
try: folder_paths.filename_list_cache.clear()
|
||||
except: pass
|
||||
if hasattr(folder_paths, "cache_helper") and hasattr(folder_paths.cache_helper, "clear"):
|
||||
try: folder_paths.cache_helper.clear()
|
||||
except: pass
|
||||
|
||||
if os.path.exists(marker_file):
|
||||
try: os.remove(marker_file)
|
||||
except: pass
|
||||
|
||||
threading.Thread(target=run_global_bg, daemon=True).start()
|
||||
return web.json_response({"status": "ok", "message": "Global scan started"})
|
||||
except Exception as e:
|
||||
if os.path.exists(marker_file):
|
||||
try: os.remove(marker_file)
|
||||
except OSError: pass
|
||||
return web.json_response({"status": "error", "message": str(e)})
|
||||
|
||||
async def api_global_scan_status(request):
|
||||
plugin_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
marker_file = os.path.join(plugin_dir, '.global_scan_in_progress')
|
||||
return web.json_response({"scanning": os.path.exists(marker_file)})
|
||||
async def api_clean_civitai_info(request):
|
||||
try:
|
||||
paths_to_clean = get_active_scan_paths()
|
||||
deleted_count = 0
|
||||
for base_dir in paths_to_clean:
|
||||
if not os.path.exists(base_dir): continue
|
||||
for root, dirs, files in os.walk(base_dir):
|
||||
for file in files:
|
||||
if file.endswith('.civitai.info'):
|
||||
file_path = os.path.join(root, file)
|
||||
try:
|
||||
os.remove(file_path)
|
||||
deleted_count += 1
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
return web.json_response({"status": "success", "count": deleted_count})
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
GLOBAL_SCAN_STATE = {
|
||||
"scanning": False,
|
||||
"total": 0,
|
||||
"current": 0,
|
||||
"filename": "",
|
||||
"error": ""
|
||||
}
|
||||
GLOBAL_SCAN_LOCK = threading.Lock()
|
||||
|
||||
async def api_scan_missing_models_status(request):
|
||||
return web.json_response(GLOBAL_SCAN_STATE)
|
||||
|
||||
async def api_scan_missing_models(request):
|
||||
import sys
|
||||
import threading
|
||||
import traceback
|
||||
|
||||
plugin_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if plugin_dir not in sys.path:
|
||||
sys.path.insert(0, plugin_dir)
|
||||
|
||||
try:
|
||||
from scraper import extract_safetensors_hash, calculate_sha256, fetch_civitai_info, infer_base_model_from_header
|
||||
except ImportError:
|
||||
return web.json_response({"status": "error", "message": "Failed to load scraper module"})
|
||||
|
||||
with GLOBAL_SCAN_LOCK:
|
||||
if GLOBAL_SCAN_STATE["scanning"]:
|
||||
return web.json_response({"status": "error", "message": "Scan already in progress"}, status=409)
|
||||
GLOBAL_SCAN_STATE["scanning"] = True
|
||||
GLOBAL_SCAN_STATE["total"] = 0
|
||||
GLOBAL_SCAN_STATE["current"] = 0
|
||||
GLOBAL_SCAN_STATE["filename"] = "Initializing..."
|
||||
GLOBAL_SCAN_STATE["error"] = ""
|
||||
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
data = {}
|
||||
|
||||
force_overwrite = data.get("force_overwrite", False)
|
||||
|
||||
def run_deep_scan():
|
||||
try:
|
||||
paths_to_scan = get_active_scan_paths()
|
||||
files_to_scan = []
|
||||
for base_dir in paths_to_scan:
|
||||
if not os.path.exists(base_dir): continue
|
||||
for root, dirs, files in os.walk(base_dir):
|
||||
for file in files:
|
||||
if not file.endswith('.safetensors'):
|
||||
continue
|
||||
file_path = os.path.join(root, file)
|
||||
base_path = os.path.splitext(file_path)[0]
|
||||
info_path = base_path + ".info"
|
||||
civitai_info_path = base_path + ".civitai.info"
|
||||
|
||||
# Check if a valid info file exists
|
||||
has_valid_info = False
|
||||
actual_info = info_path if os.path.exists(info_path) else (civitai_info_path if os.path.exists(civitai_info_path) else None)
|
||||
|
||||
if force_overwrite:
|
||||
actual_info = None
|
||||
|
||||
if actual_info:
|
||||
try:
|
||||
with open(actual_info, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
for fi in data.get("files", []):
|
||||
if isinstance(fi, dict) and "hashes" in fi and "SHA256" in fi["hashes"]:
|
||||
if fi["hashes"]["SHA256"]:
|
||||
has_valid_info = True
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not has_valid_info:
|
||||
files_to_scan.append(file_path)
|
||||
|
||||
GLOBAL_SCAN_STATE["total"] = len(files_to_scan)
|
||||
|
||||
for idx, file_path in enumerate(files_to_scan):
|
||||
filename = os.path.basename(file_path)
|
||||
GLOBAL_SCAN_STATE["current"] = idx + 1
|
||||
GLOBAL_SCAN_STATE["filename"] = filename
|
||||
|
||||
try:
|
||||
civitai_data = None
|
||||
file_hash = None
|
||||
|
||||
# Fallback 1: Try header hash on Civitai
|
||||
header_hash = extract_safetensors_hash(file_path)
|
||||
if header_hash:
|
||||
civitai_data = fetch_civitai_info(header_hash)
|
||||
if civitai_data:
|
||||
file_hash = header_hash
|
||||
|
||||
# Fallback 2: If header hash fails, compute full SHA256
|
||||
if not civitai_data:
|
||||
full_hash = calculate_sha256(file_path)
|
||||
civitai_data = fetch_civitai_info(full_hash)
|
||||
file_hash = full_hash
|
||||
|
||||
# Fallback 3: Local Offline Inference
|
||||
if not civitai_data:
|
||||
inferred_base = infer_base_model_from_header(file_path)
|
||||
if inferred_base == 'Unknown':
|
||||
inferred_base = ""
|
||||
|
||||
if not file_hash:
|
||||
file_hash = header_hash or calculate_sha256(file_path)
|
||||
|
||||
civitai_data = {
|
||||
"id": -1,
|
||||
"modelId": -1,
|
||||
"name": os.path.splitext(filename)[0],
|
||||
"baseModel": inferred_base,
|
||||
"description": "<p>Automatically inferred by Anomalous Local Engine.</p>",
|
||||
"model": {
|
||||
"name": os.path.splitext(filename)[0],
|
||||
"type": "LORA" if "lora" in file_path.lower() else "Checkpoint"
|
||||
},
|
||||
"files": [{"hashes": {"SHA256": file_hash}}]
|
||||
}
|
||||
|
||||
# Ensure SHA256 is explicitly injected into the info data
|
||||
if civitai_data:
|
||||
if not civitai_data.get("files"):
|
||||
civitai_data["files"] = [{"hashes": {"SHA256": file_hash}}]
|
||||
elif isinstance(civitai_data["files"][0], dict):
|
||||
if "hashes" not in civitai_data["files"][0]:
|
||||
civitai_data["files"][0]["hashes"] = {}
|
||||
civitai_data["files"][0]["hashes"]["SHA256"] = file_hash
|
||||
|
||||
# Save info file
|
||||
info_path = os.path.splitext(file_path)[0] + ".info"
|
||||
with open(info_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(civitai_data, f, ensure_ascii=True, indent=4)
|
||||
|
||||
except Exception as e:
|
||||
GLOBAL_SCAN_STATE["error"] = f"{filename}: {e}"
|
||||
|
||||
except Exception as e:
|
||||
GLOBAL_SCAN_STATE["error"] = str(e)
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
GLOBAL_SCAN_STATE["scanning"] = False
|
||||
GLOBAL_SCAN_STATE["filename"] = ""
|
||||
|
||||
# Clear caches
|
||||
if hasattr(folder_paths, "filename_list_cache"):
|
||||
folder_paths.filename_list_cache.clear()
|
||||
if hasattr(folder_paths, "cache_helper") and hasattr(folder_paths.cache_helper, "clear"):
|
||||
folder_paths.cache_helper.clear()
|
||||
|
||||
try:
|
||||
threading.Thread(target=run_deep_scan, daemon=True).start()
|
||||
except Exception as e:
|
||||
GLOBAL_SCAN_STATE["scanning"] = False
|
||||
GLOBAL_SCAN_STATE["error"] = str(e)
|
||||
return web.json_response({"status": "error", "message": str(e)}, status=500)
|
||||
return web.json_response({"status": "success", "message": "Deep scan started in background"})
|
||||
+610
@@ -0,0 +1,610 @@
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import subprocess
|
||||
import threading
|
||||
import asyncio
|
||||
from aiohttp import web
|
||||
import folder_paths
|
||||
import struct
|
||||
import hashlib
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
|
||||
CARD_THUMBNAIL_EDGE = 512
|
||||
CARD_THUMBNAIL_CACHE_LIMIT = 256 * 1024 * 1024
|
||||
CARD_THUMBNAIL_STATIC_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.webp', '.avif'}
|
||||
_thumbnail_cleanup_lock = threading.Lock()
|
||||
_thumbnail_generation_slots = threading.BoundedSemaphore(2)
|
||||
_thumbnail_last_cleanup = 0.0
|
||||
_thumbnail_created_since_cleanup = 0
|
||||
|
||||
|
||||
def resolve_within(base_dir, *parts):
|
||||
"""Resolve a path and require it to stay inside base_dir, including through symlinks."""
|
||||
base_real = os.path.realpath(base_dir)
|
||||
candidate = os.path.realpath(os.path.join(base_real, *parts))
|
||||
try:
|
||||
if os.path.commonpath([base_real, candidate]) != base_real:
|
||||
raise ValueError("Path escapes the configured directory")
|
||||
except ValueError:
|
||||
raise ValueError("Path escapes the configured directory")
|
||||
return candidate
|
||||
|
||||
|
||||
def get_folder_root(folder_type, path_idx=0):
|
||||
paths = folder_paths.get_folder_paths(folder_type)
|
||||
if not paths or not isinstance(path_idx, int) or path_idx < 0 or path_idx >= len(paths):
|
||||
raise ValueError("Invalid folder path")
|
||||
return os.path.realpath(paths[path_idx])
|
||||
|
||||
|
||||
def resolve_folder_subdir(folder_type, path_idx=0, subfolder='/'):
|
||||
base_dir = get_folder_root(folder_type, path_idx)
|
||||
relative = "" if subfolder in (None, "", "/") else str(subfolder).strip("/\\")
|
||||
return base_dir, resolve_within(base_dir, relative)
|
||||
|
||||
|
||||
def require_filename(filename):
|
||||
if not isinstance(filename, str) or not filename or os.path.basename(filename) != filename:
|
||||
raise ValueError("Invalid filename")
|
||||
return filename
|
||||
|
||||
|
||||
def _thumbnail_cache_directory():
|
||||
try:
|
||||
base_dir = folder_paths.get_temp_directory()
|
||||
except Exception:
|
||||
base_dir = None
|
||||
if not isinstance(base_dir, (str, os.PathLike)):
|
||||
base_dir = tempfile.gettempdir()
|
||||
cache_dir = os.path.join(base_dir, "anomalous_model_browser", "card_thumbnails")
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
return cache_dir
|
||||
|
||||
|
||||
def _prune_thumbnail_cache(cache_dir, created=False):
|
||||
"""Occasionally cap the derived thumbnail cache without touching source covers."""
|
||||
global _thumbnail_last_cleanup, _thumbnail_created_since_cleanup
|
||||
now = time.monotonic()
|
||||
with _thumbnail_cleanup_lock:
|
||||
if created:
|
||||
_thumbnail_created_since_cleanup += 1
|
||||
now = time.monotonic()
|
||||
if (
|
||||
now - _thumbnail_last_cleanup < 3600
|
||||
and _thumbnail_created_since_cleanup < 64
|
||||
):
|
||||
return
|
||||
_thumbnail_last_cleanup = now
|
||||
_thumbnail_created_since_cleanup = 0
|
||||
entries = []
|
||||
total_size = 0
|
||||
try:
|
||||
with os.scandir(cache_dir) as iterator:
|
||||
for entry in iterator:
|
||||
if not entry.is_file() or not entry.name.endswith('.webp'):
|
||||
continue
|
||||
try:
|
||||
stat = entry.stat()
|
||||
except OSError:
|
||||
continue
|
||||
total_size += stat.st_size
|
||||
entries.append((stat.st_mtime_ns, stat.st_size, entry.path))
|
||||
except OSError:
|
||||
return
|
||||
if total_size <= CARD_THUMBNAIL_CACHE_LIMIT:
|
||||
return
|
||||
target_size = int(CARD_THUMBNAIL_CACHE_LIMIT * 0.8)
|
||||
for _, size, path in sorted(entries):
|
||||
try:
|
||||
os.remove(path)
|
||||
total_size -= size
|
||||
except OSError:
|
||||
pass
|
||||
if total_size <= target_size:
|
||||
break
|
||||
|
||||
|
||||
def _build_card_thumbnail_impl(source_path):
|
||||
"""Return a cached 512px WebP card image, or the original on any safe fallback."""
|
||||
try:
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
source_stat = os.stat(source_path)
|
||||
cache_key = "\0".join((
|
||||
os.path.realpath(source_path),
|
||||
str(source_stat.st_size),
|
||||
str(source_stat.st_mtime_ns),
|
||||
str(getattr(source_stat, 'st_ctime_ns', 0)),
|
||||
str(CARD_THUMBNAIL_EDGE),
|
||||
))
|
||||
digest = hashlib.sha256(cache_key.encode('utf-8', errors='surrogatepass')).hexdigest()
|
||||
cache_dir = _thumbnail_cache_directory()
|
||||
cached_path = os.path.join(cache_dir, f"{digest}.webp")
|
||||
if os.path.isfile(cached_path):
|
||||
try:
|
||||
os.utime(cached_path, None)
|
||||
except OSError:
|
||||
pass
|
||||
_prune_thumbnail_cache(cache_dir)
|
||||
return cached_path
|
||||
|
||||
with Image.open(source_path) as image:
|
||||
if getattr(image, 'is_animated', False):
|
||||
return source_path
|
||||
image = ImageOps.exif_transpose(image)
|
||||
if max(image.size) <= CARD_THUMBNAIL_EDGE:
|
||||
return source_path
|
||||
resampling = getattr(Image, 'Resampling', Image).LANCZOS
|
||||
image.thumbnail((CARD_THUMBNAIL_EDGE, CARD_THUMBNAIL_EDGE), resampling)
|
||||
if image.mode not in ('RGB', 'RGBA'):
|
||||
image = image.convert('RGBA' if 'transparency' in image.info else 'RGB')
|
||||
temp_path = (
|
||||
f"{cached_path}.{os.getpid()}.{threading.get_ident()}.tmp"
|
||||
)
|
||||
try:
|
||||
image.save(temp_path, format='WEBP', quality=84, method=4)
|
||||
os.replace(temp_path, cached_path)
|
||||
finally:
|
||||
if os.path.exists(temp_path):
|
||||
try:
|
||||
os.remove(temp_path)
|
||||
except OSError:
|
||||
pass
|
||||
_prune_thumbnail_cache(cache_dir, created=True)
|
||||
return cached_path
|
||||
except Exception:
|
||||
return source_path
|
||||
|
||||
|
||||
def _build_card_thumbnail(source_path):
|
||||
# Bound simultaneous decodes so opening a large folder cannot monopolize CPU/RAM.
|
||||
with _thumbnail_generation_slots:
|
||||
return _build_card_thumbnail_impl(source_path)
|
||||
|
||||
async def api_serve_image(request):
|
||||
"""Dedicated image serving endpoint for model preview images."""
|
||||
folder_type = request.query.get('type', 'checkpoints')
|
||||
try:
|
||||
path_idx = int(request.query.get('path_idx', 0))
|
||||
except:
|
||||
path_idx = 0
|
||||
subfolder = request.query.get('subfolder', '')
|
||||
filename = request.query.get('filename', '')
|
||||
|
||||
try:
|
||||
filename = require_filename(filename)
|
||||
_, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder)
|
||||
file_path = resolve_within(target_dir, filename)
|
||||
except (ValueError, KeyError):
|
||||
return web.Response(status=400, text='Invalid request')
|
||||
|
||||
if not os.path.exists(file_path) or not os.path.isfile(file_path):
|
||||
return web.Response(status=404, text='Image not found')
|
||||
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
content_types = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.webp': 'image/webp',
|
||||
'.gif': 'image/gif',
|
||||
'.avif': 'image/avif',
|
||||
'.mp4': 'video/mp4',
|
||||
'.webm': 'video/webm',
|
||||
'.mov': 'video/quicktime',
|
||||
'.avi': 'video/x-msvideo'
|
||||
}
|
||||
content_type = content_types.get(ext)
|
||||
if content_type is None:
|
||||
return web.Response(status=415, text='Unsupported media type')
|
||||
|
||||
served_path = file_path
|
||||
if (
|
||||
request.query.get('variant') == 'card'
|
||||
and ext in CARD_THUMBNAIL_STATIC_EXTENSIONS
|
||||
):
|
||||
served_path = await asyncio.to_thread(_build_card_thumbnail, file_path)
|
||||
if served_path != file_path:
|
||||
content_type = 'image/webp'
|
||||
|
||||
headers = {'Content-Type': content_type}
|
||||
if request.query.get('t'):
|
||||
headers['Cache-Control'] = 'public, max-age=31536000, immutable'
|
||||
return web.FileResponse(served_path, headers=headers)
|
||||
|
||||
|
||||
|
||||
async def api_clear_cache(request):
|
||||
try:
|
||||
from .metadata import clear_metadata_cache
|
||||
|
||||
if hasattr(folder_paths, "filename_list_cache"):
|
||||
folder_paths.filename_list_cache.clear()
|
||||
if hasattr(folder_paths, "cache_helper") and hasattr(folder_paths.cache_helper, "clear"):
|
||||
folder_paths.cache_helper.clear()
|
||||
clear_metadata_cache()
|
||||
return web.json_response({"status": "success"})
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)})
|
||||
|
||||
|
||||
async def api_translate(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
text = data.get("text", "")
|
||||
tl = data.get("target_lang", "zh-CN")
|
||||
if not text:
|
||||
return web.json_response({"translated": ""})
|
||||
|
||||
# Try DeepL if API key exists
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
config_path = os.path.join(script_dir, "config.json")
|
||||
deepl_key = ""
|
||||
if os.path.exists(config_path):
|
||||
try:
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
cfg = json.load(f)
|
||||
deepl_key = cfg.get("DEEPL_API_KEY", "").strip()
|
||||
except:
|
||||
pass
|
||||
|
||||
if deepl_key:
|
||||
deepl_map = { "zh-CN": "ZH", "en": "EN", "ja": "JA", "ko": "KO", "fr": "FR", "de": "DE", "es": "ES", "ru": "RU" }
|
||||
d_tl = deepl_map.get(tl, "EN")
|
||||
url = "https://api-free.deepl.com/v2/translate" if ":fx" in deepl_key else "https://api.deepl.com/v2/translate"
|
||||
payload = urllib.parse.urlencode({
|
||||
"auth_key": deepl_key,
|
||||
"text": text,
|
||||
"target_lang": d_tl
|
||||
}).encode('utf-8')
|
||||
req = urllib.request.Request(url, data=payload)
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
result = json.loads(resp.read().decode('utf-8'))
|
||||
return web.json_response({"translated": result["translations"][0]["text"]})
|
||||
else:
|
||||
# Fallback to Google Translate (free, no key)
|
||||
url = f"https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl={tl}&dt=t&q={urllib.parse.quote(text)}"
|
||||
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
result = json.loads(resp.read().decode('utf-8'))
|
||||
# Google Translate returns a list of fragments
|
||||
translated_text = "".join([part[0] for part in result[0]])
|
||||
return web.json_response({"translated": translated_text})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Translate Error: {e}")
|
||||
return web.json_response({"translated": text, "error": str(e)})
|
||||
|
||||
|
||||
async def api_get_gallery_images(request):
|
||||
try:
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
if not os.path.exists(output_dir):
|
||||
return web.json_response({"images": [], "total": 0, "page": 1, "pages": 0})
|
||||
|
||||
page = max(1, int(request.query.get('page', 1)))
|
||||
limit = min(200, max(1, int(request.query.get('limit', 50))))
|
||||
|
||||
valid_exts = {'.png', '.jpg', '.jpeg', '.webp', '.gif'}
|
||||
def collect_images():
|
||||
images = []
|
||||
for root, dirs, files in os.walk(output_dir):
|
||||
for f in files:
|
||||
ext = os.path.splitext(f)[1].lower()
|
||||
if ext in valid_exts:
|
||||
rel_path = os.path.relpath(root, output_dir)
|
||||
subfolder = "" if rel_path == "." else rel_path.replace('\\', '/')
|
||||
full_path = os.path.join(root, f)
|
||||
try:
|
||||
mtime = os.path.getmtime(full_path)
|
||||
except OSError:
|
||||
mtime = 0
|
||||
images.append({
|
||||
"filename": f,
|
||||
"subfolder": subfolder,
|
||||
"type": "output",
|
||||
"mtime": mtime
|
||||
})
|
||||
images.sort(key=lambda x: x['mtime'], reverse=True)
|
||||
return images
|
||||
|
||||
images = await asyncio.to_thread(collect_images)
|
||||
|
||||
total = len(images)
|
||||
start_idx = (page - 1) * limit
|
||||
end_idx = start_idx + limit
|
||||
paginated_images = images[start_idx:end_idx]
|
||||
|
||||
return web.json_response({
|
||||
"images": paginated_images,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pages": (total + limit - 1) // limit if limit > 0 else 0
|
||||
})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
async def api_delete_gallery_image(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
filename = data.get("filename")
|
||||
subfolder = data.get("subfolder", "")
|
||||
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
try:
|
||||
filename = require_filename(filename)
|
||||
target_dir = resolve_within(output_dir, subfolder)
|
||||
file_path = resolve_within(target_dir, filename)
|
||||
except ValueError:
|
||||
return web.json_response({"status": "error", "message": "Invalid parameters"}, status=400)
|
||||
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
return web.json_response({"status": "success"})
|
||||
else:
|
||||
return web.json_response({"status": "error", "message": "File not found"})
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
def read_png_text_fast(path):
|
||||
try:
|
||||
with open(path, 'rb') as f:
|
||||
signature = f.read(8)
|
||||
if signature != b'\x89PNG\r\n\x1a\n':
|
||||
return None
|
||||
while True:
|
||||
length_bytes = f.read(4)
|
||||
if not length_bytes: break
|
||||
length = struct.unpack('>I', length_bytes)[0]
|
||||
chunk_type = f.read(4)
|
||||
if chunk_type == b'tEXt':
|
||||
data = f.read(length)
|
||||
keyword, text = data.split(b'\0', 1)
|
||||
if keyword == b'prompt':
|
||||
return text.decode('utf-8', errors='ignore')
|
||||
else:
|
||||
f.seek(length, 1)
|
||||
f.seek(4, 1)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
async def api_get_model_images(request):
|
||||
model_name = request.rel_url.query.get('model_name', '')
|
||||
if not model_name:
|
||||
return web.json_response({'images': []})
|
||||
|
||||
base_target = os.path.basename(model_name).lower()
|
||||
|
||||
# We will search the output directory
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
if not os.path.exists(output_dir):
|
||||
return web.json_response({'images': []})
|
||||
|
||||
def find_images():
|
||||
matched_images = []
|
||||
for root, _, files in os.walk(output_dir):
|
||||
for file in files:
|
||||
if not file.lower().endswith('.png'):
|
||||
continue
|
||||
full_path = os.path.join(root, file)
|
||||
prompt_text = read_png_text_fast(full_path)
|
||||
if not prompt_text:
|
||||
continue
|
||||
try:
|
||||
prompt_data = json.loads(prompt_text)
|
||||
matched = any(
|
||||
os.path.basename(v).lower() == base_target
|
||||
for node in prompt_data.values()
|
||||
if isinstance(node, dict) and 'class_type' in node
|
||||
for v in node.get('inputs', {}).values()
|
||||
if isinstance(v, str)
|
||||
)
|
||||
if not matched:
|
||||
continue
|
||||
rel_path = os.path.relpath(root, output_dir).replace('\\', '/')
|
||||
if rel_path == '.':
|
||||
rel_path = ''
|
||||
url = f'/view?filename={urllib.parse.quote(file)}&type=output'
|
||||
if rel_path:
|
||||
url += f'&subfolder={urllib.parse.quote(rel_path)}'
|
||||
matched_images.append({'url': url, 'mtime': os.path.getmtime(full_path)})
|
||||
except (OSError, ValueError, TypeError, json.JSONDecodeError):
|
||||
pass
|
||||
matched_images.sort(key=lambda x: x['mtime'], reverse=True)
|
||||
return matched_images
|
||||
|
||||
matched_images = await asyncio.to_thread(find_images)
|
||||
|
||||
return web.json_response({'images': matched_images})
|
||||
|
||||
def get_folder_view_mode():
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
config_path = os.path.join(script_dir, "config.json")
|
||||
try:
|
||||
if os.path.exists(config_path):
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
cfg = json.load(f)
|
||||
return cfg.get("folder_view_mode", "abstract")
|
||||
except:
|
||||
pass
|
||||
return "abstract"
|
||||
|
||||
def get_all_physical_basenames():
|
||||
basenames = set()
|
||||
for t in folder_paths.folder_names_and_paths.keys():
|
||||
try:
|
||||
paths = folder_paths.get_folder_paths(t)
|
||||
if not paths: continue
|
||||
for p in paths:
|
||||
bn = os.path.basename(os.path.normpath(p))
|
||||
if bn: basenames.add(bn)
|
||||
except:
|
||||
pass
|
||||
return list(basenames)
|
||||
|
||||
def get_active_physical_basenames():
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
config_path = os.path.join(script_dir, "config.json")
|
||||
|
||||
all_bns = get_all_physical_basenames()
|
||||
active = []
|
||||
configured = set()
|
||||
|
||||
try:
|
||||
if os.path.exists(config_path):
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
cfg = json.load(f)
|
||||
pfc = cfg.get("physical_folders_config")
|
||||
if pfc and isinstance(pfc, list):
|
||||
for item in pfc:
|
||||
bn = item.get("type")
|
||||
if bn in all_bns:
|
||||
configured.add(bn)
|
||||
if item.get("visible", True):
|
||||
active.append(bn)
|
||||
|
||||
for bn in all_bns:
|
||||
if bn not in configured:
|
||||
active.append(bn)
|
||||
return active
|
||||
except:
|
||||
pass
|
||||
|
||||
return all_bns
|
||||
|
||||
def get_active_scan_paths():
|
||||
mode = get_folder_view_mode()
|
||||
paths = set()
|
||||
|
||||
if mode == "physical":
|
||||
active_bns = set(get_active_physical_basenames())
|
||||
for t in folder_paths.folder_names_and_paths.keys():
|
||||
try:
|
||||
ps = folder_paths.get_folder_paths(t)
|
||||
if not ps: continue
|
||||
for p in ps:
|
||||
if not os.path.exists(p): continue
|
||||
bn = os.path.basename(os.path.normpath(p))
|
||||
if bn in active_bns:
|
||||
paths.add(os.path.realpath(p))
|
||||
except:
|
||||
pass
|
||||
else:
|
||||
active_types = get_active_folder_types()
|
||||
for t in active_types:
|
||||
try:
|
||||
ps = folder_paths.get_folder_paths(t)
|
||||
if not ps: continue
|
||||
for p in ps:
|
||||
if not os.path.exists(p): continue
|
||||
paths.add(os.path.realpath(p))
|
||||
except:
|
||||
pass
|
||||
|
||||
return list(paths)
|
||||
|
||||
def get_active_folder_types():
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
config_path = os.path.join(script_dir, "config.json")
|
||||
|
||||
all_types = list(folder_paths.folder_names_and_paths.keys())
|
||||
default_types = ['checkpoints', 'loras', 'diffusion_models', 'unet', 'controlnet', 'vae']
|
||||
|
||||
try:
|
||||
if os.path.exists(config_path):
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
cfg = json.load(f)
|
||||
ftc = cfg.get("folder_types_config")
|
||||
if ftc and isinstance(ftc, list):
|
||||
active = []
|
||||
configured_types = set()
|
||||
for item in ftc:
|
||||
t = item.get("type")
|
||||
if t in all_types:
|
||||
configured_types.add(t)
|
||||
if item.get("visible", True):
|
||||
active.append(t)
|
||||
# For any newly registered types not in config, do not show them by default
|
||||
# unless they are in default_types
|
||||
for t in all_types:
|
||||
if t not in configured_types and t in default_types:
|
||||
active.append(t)
|
||||
return active
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback if no config exists
|
||||
active = [t for t in default_types if t in all_types]
|
||||
return active
|
||||
|
||||
async def api_get_all_folder_types(request):
|
||||
mode = get_folder_view_mode()
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
config_path = os.path.join(script_dir, "config.json")
|
||||
|
||||
result = []
|
||||
|
||||
if mode == "physical":
|
||||
all_bns = get_all_physical_basenames()
|
||||
configured = set()
|
||||
try:
|
||||
if os.path.exists(config_path):
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
cfg = json.load(f)
|
||||
pfc = cfg.get("physical_folders_config")
|
||||
if pfc and isinstance(pfc, list):
|
||||
for item in pfc:
|
||||
bn = item.get("type")
|
||||
if bn in all_bns:
|
||||
configured.add(bn)
|
||||
result.append({
|
||||
"type": bn,
|
||||
"visible": item.get("visible", True)
|
||||
})
|
||||
except:
|
||||
pass
|
||||
|
||||
default_physical_types = ['checkpoints', 'loras', 'unet', 'diffusion_models', 'controlnet', 'vae']
|
||||
for bn in all_bns:
|
||||
if bn not in configured:
|
||||
result.append({
|
||||
"type": bn,
|
||||
"visible": bn in default_physical_types
|
||||
})
|
||||
else:
|
||||
# Abstract mode
|
||||
all_types = list(folder_paths.folder_names_and_paths.keys())
|
||||
default_types = ['checkpoints', 'loras', 'diffusion_models', 'controlnet', 'vae']
|
||||
configured_types = set()
|
||||
try:
|
||||
if os.path.exists(config_path):
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
cfg = json.load(f)
|
||||
ftc = cfg.get("folder_types_config")
|
||||
if ftc and isinstance(ftc, list):
|
||||
for item in ftc:
|
||||
t = item.get("type")
|
||||
if t in all_types:
|
||||
configured_types.add(t)
|
||||
result.append({
|
||||
"type": t,
|
||||
"visible": item.get("visible", True)
|
||||
})
|
||||
except:
|
||||
pass
|
||||
|
||||
for t in all_types:
|
||||
if t not in configured_types:
|
||||
result.append({
|
||||
"type": t,
|
||||
"visible": t in default_types
|
||||
})
|
||||
|
||||
return web.json_response({"folder_types": result, "folder_view_mode": mode})
|
||||
@@ -1,175 +0,0 @@
|
||||
# 📖 Error & Experience Summary (开发避坑与经验总结)
|
||||
|
||||
This document serves as an architectural retrospective and UX diagnostic log for the development of the Anomalous Model Browser.
|
||||
这份文档旨在记录 Anomalous 插件开发过程中的架构演进、UX 设计教训以及核心 Bug 排查经验。
|
||||
|
||||
---
|
||||
|
||||
## 1. UX Scroll Traps (交互嵌套滚动陷阱)
|
||||
**The Problem (问题)**: In the Notebook modal, the translation editor had a `max-height` and `overflow-y`. Since the parent modal was also scrollable, it created a "Scroll Trap". Hitting the bottom of the editor unpredictably transferred the scroll event to the outer body.
|
||||
(在笔记本弹窗中,由于翻译编辑器固定了最大高度并允许滚动,导致与父级弹窗的滚动条发生冲突。用户滚动到底部时会突然把外层页面卷走,体验极差。)
|
||||
**The Solution (方案)**: Removed the `max-height` and internal scrollbar. Letting the translation editor expand naturally utilizing the primary window scrollbar guarantees a continuous experience.
|
||||
(彻底移除内部最大高度和滚动条。让编辑器自然撑开,统一使用最外层的主滚动条,保证心流不断档。)
|
||||
|
||||
## 2. Artificial Metadata Pollution (伪造数据的反噬)
|
||||
**The Problem (问题)**: To make the "Base Model" dropdown look complete, the backend artificially injected hardcoded architectures (`SDXL`, `HunyuanVideo`) even if the user didn't own them. This confused users.
|
||||
(为了让“模型架构”下拉菜单看起来更“丰满”,后端硬编码注入了一些用户根本没下载过的新架构。结果让用户极度困惑,以为自己眼花了。)
|
||||
**The Solution (方案)**: Data truth must strictly reflect the local disk state. Dropped artificial padding; the UI dropdown now accurately mirrors only the physical files on disk.
|
||||
(数据必须绝对忠于本地磁盘。删除了所有的硬编码注入,确保下拉菜单严格反映本地真实的生态。)
|
||||
|
||||
## 3. UI Strobe Effects on Grid Hovers (网格悬浮的“光污染”)
|
||||
**The Problem (问题)**: Standard `transition: all 0.2s` meant quickly sweeping the mouse across the model grid caused all cards to rapidly light up like a strobe light.
|
||||
(为卡片设置了标准的 0.2秒 悬浮动画,导致鼠标快速扫过网格时,所有卡片像迪厅频闪一样疯狂亮起,显得非常廉价。)
|
||||
**The Solution (方案)**: Introduced a `transition-delay: 0.08s` low-pass filter. The recommended UX range for hover intent is 50ms - 100ms. This 80ms delay is imperceptible for intentional clicks but completely eliminates flashes from accidental sweeps.
|
||||
(引入了 `0.08s` 动画延迟作为低通滤波器。根据人机交互体验,区分“无意滑过”与“有意悬停”的黄金推荐区间是 50ms - 100ms。我们采用的 80ms 延迟对正常点击毫无察觉,但完美过滤了鼠标无意扫过造成的闪烁。)
|
||||
|
||||
## 4. "Hostage" Inline Confirmations (被“绑架”的内联确认)
|
||||
**The Problem (问题)**: Clicking "Delete" changed the button to "Are you sure?" with a 3-second timeout, but lacked a "Cancel" button. Users misclicking were held hostage for 3 seconds.
|
||||
(点击删除后会变成“确定删除吗?”并锁定 3 秒,但没有“取消”按钮。用户如果不小心点错,只能像被绑架一样干等 3 秒它恢复。)
|
||||
**The Solution (方案)**: Wrapped the logic to instantly reveal a secondary `[✕]` cancel button upon the first click, giving control back immediately.
|
||||
(在点击删除时,立马在旁边弹出 `[✕]` 取消按钮。点击取消直接清空倒计时,立刻把控制权还给用户。)
|
||||
|
||||
## 5. First-load "Dead Interface" Avoidance (首次加载的“白屏假死”)
|
||||
**The Problem (问题)**: Initializing the sidebar to the root directory `/` presented a blank screen if users only had models nested deep in subfolders.
|
||||
(插件默认打开根目录 `/`。如果用户的模型全都在很深的子文件夹里,打开插件就会看到一片空白,以为插件坏了。)
|
||||
**The Solution (方案)**: Implemented `firstLoadDone` to automatically force the UI to open the very first directory possessing `model_count > 0`.
|
||||
(加入了智能寻路逻辑,插件首次加载时自动扫描目录树,直接定位并打开第一个存有模型的文件夹。)
|
||||
|
||||
## 6. Structural Layout Breakages (流式布局的坍塌)
|
||||
**The Problem (问题)**: Placing varying lengths of translation tags sequentially caused extreme layout jitter when hovering.
|
||||
(把中英文标签简单并排放在一起,由于字符串长度不一,鼠标悬浮时极容易发生换行挤压,导致整个界面疯狂跳动。)
|
||||
**The Solution (方案)**: Enforced a strict "Row-based" flex architecture (`align-items: stretch`). Each tag pair is locked into a geometrical row.
|
||||
(采用极其严格的行级 Flex 布局,把中英文双语硬性锁死在同一个拉伸行内,无论字数多少,几何结构绝对稳定。)
|
||||
|
||||
## 7. Modal State Persistence (弹窗状态的幽灵)
|
||||
**The Problem (问题)**: Closing and reopening the Notebook modal showed a blank editor because the JS object cached `this.currentNotebook` but the DOM was obliterated.
|
||||
(关掉笔记本弹窗再打开,右侧编辑器全空。因为 JS 内存里还记着上一次的状态,但 DOM 节点已经被销毁重建了。)
|
||||
**The Solution (方案)**: Explicitly check cached state during modal initialization and force `renderNotebookEditor()` if necessary.
|
||||
(在弹窗初始化时主动校验缓存,强制触发重新渲染,实现了完美的“会话级记忆”。)
|
||||
|
||||
## 8. Missing Visual Feedback for Background Operations (无反馈的幽灵进程)
|
||||
**The Problem (问题)**: The Notebook "Save" button executed a background `fetch` perfectly, but provided absolutely no visual feedback. Users assumed it was broken.
|
||||
(点击“保存”按钮后,后台默默完成了请求,但按钮本身没有任何变化。用户以为没点上,引发数据丢失的焦虑。)
|
||||
**The Solution (方案)**: Clicking Save instantly changes the button to a green `✅` state for 1.5 seconds.
|
||||
(点击保存后强制变绿 `✅` 1.5 秒,给用户极强的心理安全感。)
|
||||
|
||||
## 9. Spatial Geometry for Multi-Node Spawning (多节点生成的空间重叠)
|
||||
**The Problem (问题)**: Spawning an entire Notebook (Main Model + Loras + CLIPs) caused all nodes to clump perfectly at `(0,0)`.
|
||||
(一键生成整个笔记本(主模型+N个Lora+提示词)到画布时,所有节点全叠在同一个坐标上,变成了一坨。)
|
||||
**The Solution (方案)**: Implemented a "Relative Offset Matrix Layout" (`X + 350*N`, `Y + 250`) and bound it to the `mousemove` event (Magnetic Sticking).
|
||||
(加入了偏移矩阵计算,并绑定鼠标移动。用户可以直观地拖着排成流水的节点序列在画布上移动,然后再点击放下。)
|
||||
|
||||
## 10. Localization Host-Environment Constraints (寄生环境的本地化陷阱)
|
||||
**The Problem (问题)**: Reading `document.documentElement.lang` failed because ComfyUI forces the host HTML to `en-US`, breaking our Chinese translations.
|
||||
(插件试图读取网页根节点的 lang 属性来决定语言。但 ComfyUI 强行把底座设成了纯英文,导致中国用户永远看不到中文提示。)
|
||||
**The Solution (方案)**: Never trust the host document properties. Rerouted localization strictly to our internal `currentLang` variable.
|
||||
(永远不要相信宿主环境。彻底切断外部依赖,纯靠插件内部自己的 `currentLang` 变量来统治中英文环境。)
|
||||
|
||||
## 11. Strict Path Separator Validation (路径分隔符的玄学崩溃)
|
||||
**The Problem (问题)**: Windows uses backslashes (`\`), but assigning `anime/model` to a node's combo widget failed ComfyUI's strict string matching, causing red nodes.
|
||||
(Windows 的底层模型路径带反斜杠,但前端组件要求精确匹配字符串,哪怕是一个斜杠方向不对,节点也会标红报错。)
|
||||
**The Solution (方案)**: Built `setWidgetValuePath` to normalize both target paths and native options with `.replace(/\\/g, '/')` before assignment.
|
||||
(做了一个拦截器,强行把系统内的选项和我们要赋的值全部统一转成正斜杠再进行比较,直接绕过操作系统的路径陷阱。)
|
||||
|
||||
## 12. Backend Starvation During Inference (算力挤兑导致的接口饿死)
|
||||
**The Problem (问题)**: Fetching plugin data while ComfyUI is generating an image causes timeouts because of PyTorch GIL-locking.
|
||||
(在 ComfyUI 画图满载的时候,点插件会导致接口超时。因为单线程的 Python 完全被 GPU 推理抢占了。)
|
||||
**The Solution (方案)**: Accept it gracefully. Database management and intense GPU inference are mutually exclusive workflows.
|
||||
(优雅地接受这个现实。不需要过度工程化地去做离线队列,画图和管理模型本来就不该同时进行。)
|
||||
|
||||
## 13. DOM Obliteration vs Virtual DOM Hoarding (物理毁灭 vs 虚拟回收)
|
||||
**The Problem (问题)**: Maintaining performance when navigating folders containing thousands of models.
|
||||
(当一个文件夹里有上千个模型时,频繁切换文件夹会导致浏览器内存爆炸。)
|
||||
**The Solution (方案)**: Instead of `display: none` hoarding, strictly enforce `this.grid.innerHTML = ''` to physically obliterate nodes and trigger immediate Garbage Collection.
|
||||
(放弃“隐藏重用”的传统思路。切文件夹时直接暴力清空内部的 HTML 节点,强迫浏览器立马回收内存,保持零负担。)
|
||||
|
||||
## 14. The "Missing Plugin Icon" Trap (LocalStorage 坐标越界飞天)
|
||||
**The Problem (问题)**: If the floating trigger button was saved at `X=3000px` on a 4K monitor, loading it on a `1920px` laptop rendered it permanently off-screen.
|
||||
(在大显示器上把插件按钮拖到了最右边,换到小笔记本上时,坐标超出了屏幕,按钮彻底消失,再也点不出来了。)
|
||||
**The Solution (方案)**: Implemented a bounds clamp checking coordinates against `window.innerWidth`. If out of bounds, snap it to the bottom-right corner.
|
||||
(在启动时强制对比当前屏幕大小,一旦发现越界,立刻把它抓回到右下角的安全区内。)
|
||||
|
||||
## 15. The `[vite:preloadError]` Template Literal Crash (一个反引号引发的血案)
|
||||
**The Problem (问题)**: A missing backtick in a dynamic ES Module dictionary crashed the entire ComfyUI extension loader before execution.
|
||||
(在汉化字典里不小心删掉了一个反引号,导致 Vite 打包工具报语法错误,整个插件直接在加载前暴毙,UI 按钮全都没了。)
|
||||
**The Solution (方案)**: JS zero-tolerance for syntax errors requires extreme validation. Fixed the missing backtick.
|
||||
(原生 JS 没有任何编译容错,任何语法拼写都必须如履薄冰,及时修复了那个反引号。)
|
||||
|
||||
## 16. Target Size Deduplication Bug (重叠目录的体积匹配悖论)
|
||||
**The Problem (问题)**: Fixing models by file size required finding *exactly one* matching file. But users mapping `checkpoints` and `diffusion_models` to the same folder caused `os.walk` to find the same physical file twice, aborting the fix.
|
||||
(物理寻址兜底算法要求“体积必须唯一”。但用户配置了多个虚拟路径指向同一个物理文件夹,导致同一个文件被扫出两次,系统误判为“体积不唯一”而放弃修复。)
|
||||
**The Solution (方案)**: Added `os.path.realpath` deduplication matrix to condense multiple virtual paths back to a single hard drive sector.
|
||||
(引入了绝对物理地址 `realpath` 去重机制,无论虚拟路径怎么重叠,物理层面上都将其归一化。)
|
||||
|
||||
## 17. The "Lightweight Scan" Re-Hash Catastrophe (轻量扫描的重算灾难)
|
||||
**The Problem (问题)**: The lightweight scan (`--skip-media`) demanded *both* an `.info` file and a preview image to skip hashing. Since it didn't download images, it forcefully re-hashed hundreds of GBs of models every time.
|
||||
(轻量化极速扫描因为不会下载图片,导致“是否跳过计算”的判断条件(既有 info 又有图)永远不成立。引发了灾难性的全盘重新计算 SHA256,慢如蜗牛。)
|
||||
**The Solution (方案)**: Modified skip logic conditionally: if explicitly skipping media, the existence of `.info` is sufficient to bypass hashing.
|
||||
(加入了情境判断:只要是在极速模式下,不管有没有图,只要有文字信息就无条件跳过,恢复了真正的极速。)
|
||||
|
||||
## 18. The Stale Dropdown Visual Residue (顽固的红框残留)
|
||||
**The Problem (问题)**: Even after a model path was auto-fixed, ComfyUI maintained a permanent red error border on the node if the frontend cache was stale.
|
||||
(底层其实已经修复并加载了正确的模型,但因为前端选项缓存没刷过来,ComfyUI 死死把节点标红,看着十分闹心。)
|
||||
**The Solution (方案)**: Forcefully execute `delete node.color; delete node.bgcolor;` to violently strip native error states without requiring a refresh.
|
||||
(既然修好了,就直接暴力删除节点的 `color` 属性,强行洗白红框,无需刷新网页。)
|
||||
|
||||
## 19. The Silent Subprocess Death (DEVNULL 黑洞)
|
||||
**The Problem (问题)**: `scraper.py` crashed due to Python indentation, but piping `stderr` to `DEVNULL` made it totally invisible. The UI waited forever.
|
||||
(后台脚本缩进写错了导致崩溃,但代码里把报错信息塞进了无尽深渊 `DEVNULL`,没有任何提示,前端界面只能干等着,陷入死局。)
|
||||
**The Solution (方案)**: Never pipe `stderr` to DEVNULL during active development. Let it log.
|
||||
(开发阶段绝对不要屏蔽底层报错,必须让 `stderr` 打印到控制台以便排查。)
|
||||
|
||||
## 20. Tiered Fallback Resolution for O(1) Scans (多级降级寻址打造 O(1) 扫描)
|
||||
**The Problem (问题)**: Unscanned 10GB models required full disk read to calculate SHA256, taking minutes.
|
||||
(即便是第一次扫描,遇到几十 GB 的新模型,完整计算 SHA256 也要几分钟,用户体验极度卡顿。)
|
||||
**The Solution (方案)**:
|
||||
1. 0ms: Read existing `.info`. (直接读取已有的 info)
|
||||
2. 0.01s: Extract 64KB JSON header from `.safetensors` via `struct` for pre-computed hash. (用 struct 撬开文件头瞬间提取内置的 Hash,降维打击)
|
||||
3. Fallback: Full SHA256 only for legacy models. (真找不到了才迫不得已做全量计算)
|
||||
|
||||
## 21. Zero-API Tensor Fingerprinting (零API脱机张量基因识别)
|
||||
**The Problem (问题)**: HuggingFace or private models returned 404 from Civitai, failing to generate `.info` and completely breaking UI integration.
|
||||
(从 HF 拿到的或者是自己练的私模,由于 C站 没有记录,API 直接报错 404,导致模型彻底被忽略,前端里连看都看不到。)
|
||||
**The Solution (方案)**: Read the first 500 Tensor Keys via struct. Identifying `double_blocks.0.img_attn` strictly confirms Flux.1 D architecture completely offline.
|
||||
(不管你有没有网,强行读取模型前 500 层的网络结构。只要看到里面有 `double_blocks`,直接盖章确认为 Flux 架构。完全脱机,无缝融入前端兼容雷达。)
|
||||
|
||||
## 22. Gemini-Style Popover UI (告别全屏遮罩,采用悬浮弹窗)
|
||||
**The Problem (问题)**: Center-screen settings modals with blur backgrounds felt heavy and disconnected users from the workspace.
|
||||
(原本的设置面板是个带有大面积模糊遮罩的全屏居中弹窗,显得特别笨重,像个垃圾网页游戏。)
|
||||
**The Solution (方案)**: Refactored to a lightweight side-popover (`left: 100%; bottom: 15px;`) imitating Gemini's web UI, maintaining deep workflow immersion.
|
||||
(改成绝对定位,像 Gemini 网页端那样从侧边栏右侧悄悄弹出一个悬浮气泡,点外侧自动收起,极大保证了沉浸感。)
|
||||
|
||||
## 23. Dock & Mode Lock (全宽底座与多状态锁死)
|
||||
**The Problem (问题)**: Floating capsules looked cramped, and entering fullscreen Gallery while the Sidebar menu remained clickable caused DOM rendering conflicts.
|
||||
(底部按钮挤在一起极其难看。而且进入全屏图库的时候,居然还能点左上角的汉堡菜单,导致页面状态直接崩溃交织在一起。)
|
||||
**The Solution (方案)**: Extended to a 100% width solid Dock. Disabled sidebar triggers during fullscreen views via strict State Locks.
|
||||
(把底部做成了满宽的实体底座,稳固了视觉重心。一旦进入图库这种排他性的全视图,强行给菜单上互斥锁,彻底断绝乱按引发的 Bug。)
|
||||
|
||||
## 24. Resolving "Missing" vs "Unscanned" Conflicts (防重叠逻辑拦截自相踩踏)
|
||||
**The Problem (问题)**: Deleting a model and reloading the workflow popped BOTH the "Missing Model" dark modal and the "Unscanned Model" orange toast.
|
||||
(把一个模型删掉后重新加载图片,系统一方面觉得“模型丢了”弹出黑框,一方面因为缓存里没它又觉得它是“没扫过的新模型”弹出橙黄色的警告,两个弹窗叠在一块极其弱智。)
|
||||
**The Solution (方案)**: The unscanned detector now ignores nodes already flagged with red error colors (`node.color === "#FF3333"`), preventing system overlapping.
|
||||
(加入了双轨防踩踏:未扫描检测器只要看到这个节点已经红了(彻底丢了),就主动闭嘴放弃拦截,边界极其清晰。)
|
||||
|
||||
## 25. The CSS `zoom` Devastation (Zoom 属性对坐标系的降维打击)
|
||||
**The Problem (问题)**: Using `zoom` to scale the UI broke bounding boxes. `container.offsetHeight > window.innerHeight` caused `Y` coordinates to become negative, permanently launching the window off-screen into the void.
|
||||
(早期为了偷懒,用 CSS `zoom` 属性来做 UI 缩放。结果它彻底破坏了浏览器的物理坐标系,一旦窗口过大,防越界系统就算出了个负数坐标,导致插件整个闪现飞出屏幕外,再也拖不回来了。)
|
||||
**The Solution (方案)**: Banned `zoom`. Transitioned to relative scaling via `font-size: calc(16px * var(--anomalous-scale))`. Fixed bounding clamps to execute `< 0` checks last.
|
||||
(彻底封杀 zoom。改用 `calc(16px * scale)` 的底层字体相对推算放大法。并且把防负数的保底检测代码调到了逻辑的最后一行,永远杜绝负数坐标。)
|
||||
|
||||
## 26. Vanilla JS "Fake" i18n Failure (原生状态管理漏斗下的假性汉化失效)
|
||||
**The Problem (问题)**: Newly added permanent buttons ("UI Scale", "Reset") remained in Chinese when English was toggled. Since we lacked React/Vue two-way binding, they missed the global state update and got stuck.
|
||||
(新加了“UI缩放”等按钮后,即使点了切换英文,它们还是中文。因为我们没有使用 Vue 的数据绑定,纯靠原生 JS。如果新加了常驻按钮,却忘了把它登记到顶层 `langBtn.onclick` 突变循环里,它就永远被隔离在旧时间线里了。)
|
||||
(只要是常驻的按钮,必须强制在语言切换中枢里注册重写一遍。同时开发过程中如果遇到“代码没变”的灵异现象,第一反应永远是按 Ctrl+F5 强制破除浏览器静态缓存。)
|
||||
|
||||
## 27. The Image Cropping Dilemma (图库的裁切与留白博弈)
|
||||
**The Problem (问题)**: Using `object-fit: cover` in the image gallery created perfectly uniform grid squares, but brutally decapitated tall images (like 9:16 vertical character portraits).
|
||||
(在图库排版时,为了让网格看起来绝对整齐,使用了 `object-fit: cover`。结果导致偏长的图片(如 9:16 的人物竖图)顶部和底部被无情裁掉,经常出现“砍头”现象。)
|
||||
**The Solution (方案)**: Changed to `object-fit: contain` and paired it with a deep black background (`#000`). The images now scale down to fit entirely, and the resulting letterboxing seamlessly blends into the dark UI as a natural frame.
|
||||
(改为 `object-fit: contain`,完整显示图片。由于底层卡片背景本身就是极黑(`#000`),缩小后产生的黑边完美融入了 UI 背景,不仅没有任何违和感,反而像是一圈高级的画框,一举两得。)
|
||||
|
||||
## 28. Vanilla JS Global Click-Outside (原生悬浮面板的全局点击收起)
|
||||
**The Problem (问题)**: The settings popover only closed if the user explicitly clicked the gear icon again. Clicking elsewhere on the canvas did nothing, making the popover feel rigid and intrusive.
|
||||
(点击齿轮打开设置悬浮气泡后,必须再点一次齿轮才能关掉。如果随手点一下旁边或者点画布,面板居然不收回,交互显得非常死板和占眼。)
|
||||
**The Solution (方案)**: Attached a temporary `mousedown` event listener to the `document` when the popover opens. If the click target is outside the modal, it triggers `display: none` and instantly `removeEventListener` to prevent memory leaks.
|
||||
(面板弹出时,立刻给全局 `document` 挂载一个监听器。只要检测到鼠标点击了面板外部区域,立刻收缩面板,并且**顺手注销掉这个监听器**,实现了极度顺滑的 Gemini 式收放体验且毫无性能负担。)
|
||||
@@ -0,0 +1,47 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# These components are normally referenced by stable ecosystem filenames.
|
||||
# Renaming them breaks existing workflows without providing meaningful value.
|
||||
PHYSICAL_RENAME_PROTECTED_TYPES = frozenset({
|
||||
"vae",
|
||||
"vae_approx",
|
||||
"clip",
|
||||
"text_encoders",
|
||||
"clip_vision",
|
||||
})
|
||||
|
||||
# Foundation components may be recovered under a different local filename, but
|
||||
# only when the workflow carries a cryptographic identity. File size alone is
|
||||
# too weak for shared VAE/text-encoder libraries.
|
||||
HASH_ONLY_RECOVERY_TYPES = PHYSICAL_RENAME_PROTECTED_TYPES
|
||||
|
||||
|
||||
def is_physical_rename_protected(folder_type=None, folder_path=None):
|
||||
"""Return True when a model category must keep its physical filename."""
|
||||
normalized_type = str(folder_type or "").strip().lower()
|
||||
if normalized_type in PHYSICAL_RENAME_PROTECTED_TYPES:
|
||||
return True
|
||||
|
||||
if not folder_path:
|
||||
return False
|
||||
|
||||
try:
|
||||
parts = {
|
||||
os.path.normcase(part).lower()
|
||||
for part in Path(os.path.abspath(folder_path)).parts
|
||||
}
|
||||
except (OSError, TypeError, ValueError):
|
||||
return False
|
||||
return bool(parts.intersection(PHYSICAL_RENAME_PROTECTED_TYPES))
|
||||
|
||||
|
||||
def requires_hash_for_model_recovery(folder_types):
|
||||
"""Return True when every requested category requires a hash match."""
|
||||
normalized = {
|
||||
str(folder_type or "").strip().lower()
|
||||
for folder_type in (folder_types or ())
|
||||
if str(folder_type or "").strip()
|
||||
}
|
||||
return bool(normalized) and normalized.issubset(HASH_ONLY_RECOVERY_TYPES)
|
||||
+234
-96
@@ -1,6 +1,9 @@
|
||||
import os
|
||||
import sys
|
||||
try:
|
||||
import os
|
||||
import sys
|
||||
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
if _SCRIPT_DIR not in sys.path:
|
||||
sys.path.insert(0, _SCRIPT_DIR)
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
except:
|
||||
pass
|
||||
@@ -12,27 +15,52 @@ import time
|
||||
import json
|
||||
import hashlib
|
||||
import re
|
||||
import requests
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import argparse
|
||||
import shutil
|
||||
from typing import Dict, Optional
|
||||
from typing import Dict, Optional
|
||||
from model_policies import is_physical_rename_protected
|
||||
|
||||
|
||||
# Fixed tuples avoid rebuilding long extension lists for every scanned model.
|
||||
# Exact-path checks are intentionally used instead of directory-wide globbing.
|
||||
MEDIA_EXTENSIONS = (".png", ".jpg", ".jpeg", ".webp", ".gif", ".avif", ".mp4", ".webm", ".mov", ".avi")
|
||||
PREVIEW_SUFFIXES = tuple(f".preview{ext}" for ext in MEDIA_EXTENSIONS)
|
||||
CIVITAI_BACKUP_SUFFIXES = tuple(f".civitai_bak{ext}" for ext in MEDIA_EXTENSIONS)
|
||||
COVER_SUFFIXES = MEDIA_EXTENSIONS + PREVIEW_SUFFIXES
|
||||
ACTIVE_COVER_SUFFIXES = PREVIEW_SUFFIXES + MEDIA_EXTENSIONS
|
||||
SIDECAR_SUFFIXES = (
|
||||
".info", ".civitai.info", ".json", ".txt", ".yaml",
|
||||
*MEDIA_EXTENSIONS,
|
||||
*PREVIEW_SUFFIXES,
|
||||
*CIVITAI_BACKUP_SUFFIXES,
|
||||
)
|
||||
|
||||
# ==============================================================================
|
||||
# CIVITAI API 配置读取
|
||||
# 请在插件目录 (Anomalous_Model_Browser) 下新建 config.json 文件:
|
||||
# { "CIVITAI_API_KEY": "你的KEY" }
|
||||
# ==============================================================================
|
||||
CIVITAI_API_KEY = None
|
||||
config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json")
|
||||
if os.path.exists(config_path):
|
||||
try:
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
cfg = json.load(f)
|
||||
CIVITAI_API_KEY = cfg.get("CIVITAI_API_KEY", "").strip()
|
||||
except Exception as e:
|
||||
print(f"[-] 读取 config.json 失败: {e}")
|
||||
else:
|
||||
print("[!] 未找到 config.json,本次请求将不使用 API Key。部分限制级模型或将无法获取图片。")
|
||||
CIVITAI_API_KEY = None
|
||||
plugin_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
config_paths = [os.path.join(plugin_dir, "api", "config.json"), os.path.join(plugin_dir, "config.json")]
|
||||
for config_path in config_paths:
|
||||
if not os.path.exists(config_path):
|
||||
continue
|
||||
try:
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
cfg = json.load(f)
|
||||
configured_key = cfg.get("CIVITAI_API_KEY", "")
|
||||
if isinstance(configured_key, str) and configured_key.strip():
|
||||
CIVITAI_API_KEY = configured_key.strip()
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"[-] 读取 config.json 失败: {e}")
|
||||
|
||||
if not CIVITAI_API_KEY:
|
||||
print("[!] 未配置 Civitai API Key。部分限制级模型或将无法获取图片。")
|
||||
|
||||
def calculate_sha256(file_path: str) -> str:
|
||||
"""计算文件的 SHA256 哈希值 (用于 Civitai 匹配)"""
|
||||
@@ -70,11 +98,6 @@ def extract_safetensors_hash(file_path: str) -> Optional[str]:
|
||||
if 'modelspec.hash.blake3' in metadata:
|
||||
return metadata['modelspec.hash.blake3']
|
||||
|
||||
# 优先级2: 其他带 hash 的键
|
||||
for k, v in metadata.items():
|
||||
if ('hash' in k.lower() or 'civitai' in k.lower()) and isinstance(v, str):
|
||||
if len(v) == 64 and all(c in '0123456789abcdefABCDEF' for c in v):
|
||||
return v
|
||||
except Exception as e:
|
||||
pass
|
||||
return None
|
||||
@@ -133,16 +156,18 @@ def fetch_civitai_info(file_hash: str, max_retries: int = 3) -> Optional[Dict]:
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
response = requests.get(url, headers=headers, timeout=15)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
elif response.status_code == 404:
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
with urllib.request.urlopen(req, 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
|
||||
else:
|
||||
print(f"[-] 请求异常,状态码: {response.status_code} (尝试 {attempt+1}/{max_retries})")
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"[-] 网络请求超时或异常: {e} (尝试 {attempt+1}/{max_retries})")
|
||||
print(f"[-] 请求异常,状态码: {e.code} (尝试 {attempt+1}/{max_retries})")
|
||||
except urllib.error.URLError as e:
|
||||
print(f"[-] 网络请求超时或异常: {e.reason} (尝试 {attempt+1}/{max_retries})")
|
||||
except Exception as e:
|
||||
print(f"[-] 未知异常: {e} (尝试 {attempt+1}/{max_retries})")
|
||||
|
||||
if attempt < max_retries - 1:
|
||||
time.sleep(2)
|
||||
@@ -160,8 +185,8 @@ def download_media(url: str, base_path: str, max_retries: int = 3):
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
response = requests.get(url, stream=True, headers=headers, timeout=15)
|
||||
if response.status_code == 200:
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
with urllib.request.urlopen(req, timeout=15) as response:
|
||||
content_type = response.headers.get("Content-Type", "").lower()
|
||||
ext = ".png" # default
|
||||
if "video/mp4" in content_type: ext = ".mp4"
|
||||
@@ -172,13 +197,18 @@ def download_media(url: str, base_path: str, max_retries: int = 3):
|
||||
|
||||
final_path = base_path + ext
|
||||
with open(final_path, 'wb') as f:
|
||||
for chunk in response.iter_content(8192):
|
||||
while True:
|
||||
chunk = response.read(8192)
|
||||
if not chunk:
|
||||
break
|
||||
f.write(chunk)
|
||||
return final_path
|
||||
else:
|
||||
print(f"[-] 媒体下载失败,状态码: {response.status_code} (尝试 {attempt+1}/{max_retries})")
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"[-] 媒体下载网络异常: {e} (尝试 {attempt+1}/{max_retries})")
|
||||
except urllib.error.HTTPError as e:
|
||||
print(f"[-] 媒体下载失败,状态码: {e.code} (尝试 {attempt+1}/{max_retries})")
|
||||
except urllib.error.URLError as e:
|
||||
print(f"[-] 媒体下载网络异常: {e.reason} (尝试 {attempt+1}/{max_retries})")
|
||||
except Exception as e:
|
||||
pass
|
||||
return None
|
||||
|
||||
def main():
|
||||
@@ -187,13 +217,24 @@ def main():
|
||||
parser.add_argument("--dry-run", action="store_true", help="空跑模式,仅打印将要执行的操作,不修改任何文件")
|
||||
parser.add_argument("--undo", action="store_true", help="根据 backup_rename_log.json 恢复文件名")
|
||||
parser.add_argument("--skip-rename", action="store_true", help="只下载信息文件,不重命名主文件")
|
||||
parser.add_argument("--virtual-rename", action="store_true", help="虚拟重命名:修改 JSON 注入标准名称,不修改底层物理文件名")
|
||||
parser.add_argument("--physical-rename", action="store_true", help="物理重命名:真实修改底层的 safetensors 及其附属文件名")
|
||||
parser.add_argument("--skip-media", action="store_true", help="不下载预览图或视频")
|
||||
args = parser.parse_args()
|
||||
parser.add_argument("--offline-only", action="store_true", help="跳过 Civitai 联网获取,强制使用本地脱机张量推断提取 Base Model")
|
||||
parser.add_argument("--force-overwrite", action="store_true", help="强制覆盖已存在的信息文件")
|
||||
parser.add_argument("--skip-local-metadata", action="store_true", help="忽略本地已有的.info / .json文件")
|
||||
parser.add_argument("--target-files", type=str, default="", help="仅扫描逗号分隔的具体文件(相对路径)")
|
||||
parser.add_argument("--folder-type", default="", help="由 ComfyUI 传入的模型目录类型,用于执行安全策略")
|
||||
args = parser.parse_args()
|
||||
|
||||
target_folder = args.folder
|
||||
if not os.path.isdir(target_folder):
|
||||
print(f"[-] 错误: 文件夹不存在 -> {target_folder}")
|
||||
sys.exit(1)
|
||||
if not os.path.isdir(target_folder):
|
||||
print(f"[-] 错误: 文件夹不存在 -> {target_folder}")
|
||||
sys.exit(1)
|
||||
|
||||
if args.physical_rename and is_physical_rename_protected(args.folder_type, target_folder):
|
||||
print("[*] 基础组件目录受保护:VAE、CLIP/Text Encoder 与 CLIP Vision 不执行物理重命名。")
|
||||
args.physical_rename = False
|
||||
|
||||
backup_log_path = os.path.join(target_folder, "backup_rename_log.json")
|
||||
|
||||
@@ -220,7 +261,7 @@ def main():
|
||||
old_base = os.path.splitext(old_path)[0]
|
||||
new_base = os.path.splitext(new_path)[0]
|
||||
|
||||
for ext in [".info", ".civitai.info", ".png", ".jpg", ".webp", ".mp4", ".webm", ".json", ".txt", ".yaml"]:
|
||||
for ext in SIDECAR_SUFFIXES:
|
||||
new_ext_path = new_base + ext
|
||||
old_ext_path = old_base + ext
|
||||
if os.path.exists(new_ext_path):
|
||||
@@ -241,6 +282,19 @@ def main():
|
||||
with open(backup_log_path, 'r', encoding='utf-8') as f:
|
||||
rename_log = json.load(f)
|
||||
|
||||
target_files_basenames = []
|
||||
target_file_path = os.path.join(target_folder, '.scan_targets.json')
|
||||
if os.path.exists(target_file_path):
|
||||
try:
|
||||
with open(target_file_path, 'r', encoding='utf-8') as f:
|
||||
target_files_basenames = [os.path.basename(t.strip()) for t in __import__('json').load(f)]
|
||||
os.remove(target_file_path)
|
||||
except:
|
||||
pass
|
||||
|
||||
if args.target_files:
|
||||
target_files_basenames.extend([os.path.basename(t.strip()) for t in args.target_files.split(',')])
|
||||
|
||||
print(f"[*] 开始扫描文件夹: {target_folder}")
|
||||
if args.dry_run:
|
||||
print("==================================================")
|
||||
@@ -252,50 +306,95 @@ def main():
|
||||
if not filename.endswith(".safetensors"):
|
||||
continue
|
||||
|
||||
if target_files_basenames and filename not in target_files_basenames:
|
||||
continue
|
||||
|
||||
file_path = os.path.join(root, filename)
|
||||
old_base = os.path.splitext(file_path)[0]
|
||||
|
||||
info_exists = os.path.exists(old_base + ".info") or os.path.exists(old_base + ".civitai.info")
|
||||
if args.force_overwrite:
|
||||
info_exists = False
|
||||
|
||||
preview_exists = args.skip_media
|
||||
if not preview_exists:
|
||||
for ext in [".png", ".preview.png", ".jpg", ".jpeg", ".webp", ".mp4", ".webm"]:
|
||||
for ext in COVER_SUFFIXES:
|
||||
if os.path.exists(old_base + ext):
|
||||
preview_exists = True
|
||||
break
|
||||
|
||||
if info_exists and preview_exists:
|
||||
needs_rename = False
|
||||
if not args.skip_rename and args.physical_rename:
|
||||
needs_rename = True
|
||||
elif args.virtual_rename:
|
||||
needs_rename = True
|
||||
|
||||
if info_exists and preview_exists and not needs_rename:
|
||||
print(f"[*] 已跳过 (信息满足要求): {filename}")
|
||||
continue
|
||||
|
||||
print(f"\n---> 处理文件: {filename} (位于 {root})")
|
||||
file_hash = extract_safetensors_hash(file_path)
|
||||
if file_hash:
|
||||
print(f"[*] 成功从头文件极速提取 Hash: {file_hash}")
|
||||
else:
|
||||
file_hash = calculate_sha256(file_path)
|
||||
print(f"[*] 全量物理 SHA256: {file_hash}")
|
||||
|
||||
civitai_data = fetch_civitai_info(file_hash)
|
||||
civitai_data = None
|
||||
if info_exists and needs_rename:
|
||||
info_path = old_base + ".info"
|
||||
if not os.path.exists(info_path):
|
||||
info_path = old_base + ".civitai.info"
|
||||
try:
|
||||
with open(info_path, 'r', encoding='utf-8') as f:
|
||||
civitai_data = json.load(f)
|
||||
print(f"[*] 本地信息存在,直接进入重命名流程")
|
||||
except:
|
||||
pass
|
||||
|
||||
if not civitai_data:
|
||||
# 尝试离线推断底模
|
||||
inferred_base = infer_base_model_from_header(file_path)
|
||||
if inferred_base != 'Unknown':
|
||||
print(f"[*] 离线推断底模成功 ({filename}): {inferred_base}")
|
||||
civitai_data = {
|
||||
"id": -1,
|
||||
"modelId": -1,
|
||||
"name": os.path.splitext(filename)[0],
|
||||
"baseModel": inferred_base,
|
||||
"description": "<p>Automatically inferred by Anomalous Local Engine.</p>",
|
||||
"model": {
|
||||
"name": os.path.splitext(filename)[0],
|
||||
"type": "LORA" if "lora" in root.lower() else "Checkpoint"
|
||||
},
|
||||
"files": [{"hashes": {"SHA256": file_hash}}]
|
||||
}
|
||||
file_hash = None
|
||||
|
||||
if not args.offline_only:
|
||||
# Fallback 1: Try header hash on Civitai
|
||||
header_hash = extract_safetensors_hash(file_path)
|
||||
if header_hash:
|
||||
print(f"[*] 成功从头文件提取 Hash: {header_hash},尝试请求 Civitai...")
|
||||
civitai_data = fetch_civitai_info(header_hash)
|
||||
if civitai_data:
|
||||
file_hash = header_hash
|
||||
|
||||
# Fallback 2: If header hash fails (or doesn't exist), compute full SHA256
|
||||
if not civitai_data:
|
||||
print(f"[*] 头文件 Hash 未命中或不存在,计算全量物理 SHA256...")
|
||||
full_hash = calculate_sha256(file_path)
|
||||
civitai_data = fetch_civitai_info(full_hash)
|
||||
file_hash = full_hash
|
||||
else:
|
||||
print(f"[*] Offline-only: 跳过 Civitai 获取,将强制使用脱机张量推断")
|
||||
file_hash = extract_safetensors_hash(file_path)
|
||||
if not file_hash:
|
||||
file_hash = calculate_sha256(file_path)
|
||||
|
||||
# Fallback 3: Local Offline Inference (if Civitai still fails or offline_only)
|
||||
if not civitai_data:
|
||||
if args.skip_local_metadata:
|
||||
print(f"[*] Civitai 获取失败,且禁用了本地元数据解析。跳过 {filename}")
|
||||
fail_count += 1
|
||||
continue
|
||||
# 尝试离线推断底模
|
||||
inferred_base = infer_base_model_from_header(file_path)
|
||||
if inferred_base == 'Unknown':
|
||||
inferred_base = ""
|
||||
|
||||
print(f"[*] 使用本地哈希重建基础元数据 ({filename})")
|
||||
civitai_data = {
|
||||
"id": -1,
|
||||
"modelId": -1,
|
||||
"name": os.path.splitext(filename)[0],
|
||||
"baseModel": inferred_base,
|
||||
"description": "<p>Automatically inferred by Anomalous Local Engine.</p>",
|
||||
"model": {
|
||||
"name": os.path.splitext(filename)[0],
|
||||
"type": "LORA" if "lora" in root.lower() else "Checkpoint"
|
||||
},
|
||||
"files": [{"hashes": {"SHA256": file_hash}}]
|
||||
}
|
||||
|
||||
# --- 额外获取模型主页的说明文字 ---
|
||||
model_id = civitai_data.get("modelId")
|
||||
@@ -306,14 +405,14 @@ def main():
|
||||
}
|
||||
if CIVITAI_API_KEY:
|
||||
headers["Authorization"] = f"Bearer {CIVITAI_API_KEY}"
|
||||
m_resp = requests.get(f"https://civitai.com/api/v1/models/{model_id}", headers=headers, timeout=10)
|
||||
if m_resp.status_code == 200:
|
||||
m_data = m_resp.json()
|
||||
main_desc = m_data.get("description")
|
||||
if main_desc:
|
||||
req = urllib.request.Request(f"https://civitai.com/api/v1/models/{model_id}", headers=headers)
|
||||
with urllib.request.urlopen(req, timeout=10) as m_resp:
|
||||
m_data = json.loads(m_resp.read().decode('utf-8'))
|
||||
if "description" in m_data and m_data["description"]:
|
||||
civitai_data["description"] = m_data["description"]
|
||||
if "model" not in civitai_data or not isinstance(civitai_data["model"], dict):
|
||||
civitai_data["model"] = {}
|
||||
civitai_data["model"]["description"] = main_desc
|
||||
civitai_data["model"]["description"] = m_data["description"]
|
||||
except Exception as e:
|
||||
print(f"[-] 获取模型主页详细说明失败: {e}")
|
||||
# ----------------------------------
|
||||
@@ -330,6 +429,9 @@ def main():
|
||||
# ==========================================
|
||||
info_data = civitai_data
|
||||
|
||||
if args.virtual_rename:
|
||||
info_data["anomalous_custom_name"] = f"{model_name}_{version_name}"
|
||||
|
||||
if not args.dry_run:
|
||||
info_path = old_base + ".info"
|
||||
with open(info_path, 'w', encoding='utf-8') as f:
|
||||
@@ -338,10 +440,10 @@ def main():
|
||||
else:
|
||||
print(f"[Dry-Run] 拟生成标准描述信息 -> .info")
|
||||
|
||||
images = civitai_data.get("images", [])
|
||||
if images and len(images) > 0:
|
||||
media_url = None
|
||||
for img_obj in images:
|
||||
media_url = None
|
||||
images = civitai_data.get("images", [])
|
||||
if images and len(images) > 0:
|
||||
for img_obj in images:
|
||||
if not args.skip_media:
|
||||
if img_obj.get("url"):
|
||||
media_url = img_obj.get("url")
|
||||
@@ -350,43 +452,79 @@ def main():
|
||||
if media_url and not args.skip_media:
|
||||
if not args.dry_run:
|
||||
print(f"[*] 正在下载预览媒体...")
|
||||
saved_path = download_media(media_url, old_base)
|
||||
saved_path = download_media(media_url, old_base + ".civitai_bak")
|
||||
if saved_path:
|
||||
print(f"[+] 媒体下载成功 -> {os.path.basename(saved_path)}")
|
||||
# Promote to .preview if no custom cover exists
|
||||
has_custom = False
|
||||
if not args.force_overwrite:
|
||||
for c_ext in ACTIVE_COVER_SUFFIXES:
|
||||
p = old_base + c_ext
|
||||
if os.path.exists(p) and not p.endswith('.civitai_bak' + c_ext):
|
||||
has_custom = True
|
||||
break
|
||||
if not has_custom:
|
||||
ext = os.path.splitext(saved_path)[1]
|
||||
import shutil
|
||||
if args.force_overwrite:
|
||||
for c_ext in ACTIVE_COVER_SUFFIXES:
|
||||
p = old_base + c_ext
|
||||
if os.path.exists(p) and not p.endswith('.civitai_bak' + c_ext):
|
||||
try:
|
||||
os.remove(p)
|
||||
print(f"[*] 强制覆盖: 已删除旧预览文件 {os.path.basename(p)}")
|
||||
except:
|
||||
pass
|
||||
preview_ext = ext if ext.startswith('.preview.') else f".preview{ext}"
|
||||
shutil.copy2(saved_path, old_base + preview_ext)
|
||||
else:
|
||||
print(f"[Dry-Run] 拟下载预览媒体...")
|
||||
|
||||
if args.skip_rename:
|
||||
print(f"[*] --skip-rename 开启,跳过主文件重命名。仅保存 .info。")
|
||||
if args.skip_rename or not args.physical_rename:
|
||||
print(f"[*] 物理重命名已跳过。仅保存 .info 及其可能包含的虚拟重命名。")
|
||||
success_count += 1
|
||||
elif file_path != new_file_path and new_filename != filename:
|
||||
if os.path.exists(new_file_path):
|
||||
print(f"\033[91m[-] 目标版本已存在 (重复模型),执行永久删除多余副本: {filename}\033[0m")
|
||||
if not args.dry_run:
|
||||
try:
|
||||
os.remove(file_path)
|
||||
for ext in [".info", ".civitai.info", ".png", ".jpg", ".webp", ".mp4", ".webm", ".json", ".txt", ".yaml"]:
|
||||
if os.path.exists(new_file_path):
|
||||
print(f"[*] 目标文件名已存在,正在验证内容是否完全相同: {filename}")
|
||||
try:
|
||||
files_identical = os.path.getsize(file_path) == os.path.getsize(new_file_path)
|
||||
if files_identical:
|
||||
files_identical = calculate_sha256(file_path) == calculate_sha256(new_file_path)
|
||||
except OSError:
|
||||
files_identical = False
|
||||
|
||||
if not files_identical:
|
||||
print(f"\033[91m[-] 目标名称冲突但文件内容不同,已保留两个模型并跳过重命名: {filename}\033[0m")
|
||||
fail_count += 1
|
||||
elif not args.dry_run:
|
||||
try:
|
||||
print(f"[*] Hash 一致,删除已确认的重复副本: {filename}")
|
||||
os.remove(file_path)
|
||||
for ext in SIDECAR_SUFFIXES:
|
||||
old_ext = old_base + ext
|
||||
if os.path.exists(old_ext):
|
||||
os.remove(old_ext)
|
||||
except Exception as e:
|
||||
print(f"[-] 删除多余副本失败 (可能文件被占用): {e}")
|
||||
else:
|
||||
print(f"[Dry-Run] 拟永久删除多余副本及其附属文件: {filename}")
|
||||
else:
|
||||
os.remove(old_ext)
|
||||
success_count += 1
|
||||
except Exception as e:
|
||||
print(f"[-] 删除多余副本失败 (可能文件被占用): {e}")
|
||||
fail_count += 1
|
||||
else:
|
||||
print(f"[Dry-Run] Hash 一致,拟删除重复副本及其附属文件: {filename}")
|
||||
else:
|
||||
if not args.dry_run:
|
||||
os.rename(file_path, new_file_path)
|
||||
rename_log[file_path] = new_file_path
|
||||
|
||||
for ext in [".info", ".civitai.info", ".png", ".jpg", ".webp", ".mp4", ".webm", ".json", ".txt", ".yaml"]:
|
||||
for ext in SIDECAR_SUFFIXES:
|
||||
old_ext = old_base + ext
|
||||
new_ext = new_base + ext
|
||||
if os.path.exists(old_ext):
|
||||
os.replace(old_ext, new_ext) # Safety: replace overwrites existing files on Windows
|
||||
os.replace(old_ext, new_ext)
|
||||
|
||||
print(f"[+] 重命名完成: {filename} ==> {new_filename}")
|
||||
print(f"[+] 物理重命名完成: {filename} ==> {new_filename}")
|
||||
success_count += 1
|
||||
else:
|
||||
print(f"[Dry-Run] 拟重命名文件: {filename} ==> {new_filename}")
|
||||
print(f"[Dry-Run] 拟物理重命名文件: {filename} ==> {new_filename}")
|
||||
print(f"[Dry-Run] 拟连带重命名附属文件 (.info / .png 等)")
|
||||
else:
|
||||
print("[*] 文件名已符合规范,无需重命名。")
|
||||
@@ -408,4 +546,4 @@ def main():
|
||||
print(f"\n[+] 重命名映射日志已保存至: {backup_log_path}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
main()
|
||||
|
||||
+332
-371
@@ -1,393 +1,359 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
|
||||
// Global cache for hashes: filename -> hash
|
||||
window.anomalous_hash_cache = window.anomalous_hash_cache || {};
|
||||
|
||||
// Function to update cache, can be called from main.js
|
||||
import { translate } from './modules/locales.js';
|
||||
import {
|
||||
inferModelFolderTypes,
|
||||
requiresHashForModelRecovery,
|
||||
} from './modules/model_policies.js';
|
||||
|
||||
// Global cache for hashes: filename -> hash
|
||||
window.anomalous_hash_cache = window.anomalous_hash_cache || {};
|
||||
|
||||
// Function to update cache, can be called from main.js
|
||||
window.anomalous_update_hash_cache = function (models) {
|
||||
if (!models) return;
|
||||
for (const m of models) {
|
||||
if (m.filename && m.metadata && m.metadata.hash) {
|
||||
window.anomalous_hash_cache[m.filename] = {
|
||||
hash: m.metadata.hash,
|
||||
size: m.size_bytes || ""
|
||||
};
|
||||
}
|
||||
if (!models) return;
|
||||
for (const m of models) {
|
||||
if (m.filename && m.metadata && m.metadata.hash) {
|
||||
window.anomalous_hash_cache[m.filename] = {
|
||||
hash: m.metadata.hash,
|
||||
size: m.size_bytes || ""
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.anomalous_resolve_all_missing_nodes = async function (is_manual = false, force_prompt = false) {
|
||||
if (!app.graph || !app.graph._nodes) return;
|
||||
function isModelFilename(value) {
|
||||
return typeof value === 'string' && /\.(safetensors|ckpt|pt|bin)$/i.test(value);
|
||||
}
|
||||
|
||||
// Fast path: if there are no missing nodes and it's not a manual check, abort early to save performance
|
||||
let has_missing = false;
|
||||
function inferExpectedModelTypes(node, widget) {
|
||||
return inferModelFolderTypes(node, widget).join(',');
|
||||
}
|
||||
|
||||
window.anomalous_resolve_all_missing_nodes = async function (is_manual = false, silent = false) {
|
||||
if (!is_manual && localStorage.getItem('anomalous_auto_scan_enabled') !== 'true') return;
|
||||
if (!app.graph || !app.graph._nodes) return;
|
||||
|
||||
// Fast path: if there are no missing nodes and it's not a manual check, abort early to save performance
|
||||
let has_missing = false;
|
||||
for (const node of app.graph._nodes) {
|
||||
if (node.color === "#FF3333" || node.bgcolor === "#FF3333" || node.color === "#f66" || (node.flags && node.flags.collapsed && node.color === "#FF3333")) {
|
||||
has_missing = true;
|
||||
break;
|
||||
}
|
||||
if (node.widgets) {
|
||||
for (let i = 0; i < node.widgets.length; i++) {
|
||||
const w = node.widgets[i];
|
||||
if (isModelFilename(w.value)) {
|
||||
if (w.options && w.options.values && !w.options.values.includes(w.value)) {
|
||||
has_missing = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (has_missing) break;
|
||||
}
|
||||
|
||||
if (!has_missing && !is_manual) return;
|
||||
|
||||
// Refresh ComfyUI's frontend folder paths cache to prevent false-positive red nodes
|
||||
if (app.refreshComboInNodes) {
|
||||
try {
|
||||
await app.refreshComboInNodes();
|
||||
} catch (e) {
|
||||
console.warn("[Anomalous Hash Resolver] Failed to refresh ComfyUI node combos", e);
|
||||
}
|
||||
}
|
||||
|
||||
const hashes = (app.graph.extra && app.graph.extra.anomalous_hashes) || {};
|
||||
const getWorkflowHash = (nodeId, val) => {
|
||||
if (!hashes || typeof val !== 'string') return null;
|
||||
const normVal = val.replace(/\\/g, '/');
|
||||
const winVal = val.replace(/\//g, '\\');
|
||||
return hashes[`${nodeId}_${val}`] ||
|
||||
hashes[`${nodeId}_${normVal}`] ||
|
||||
hashes[`${nodeId}_${winVal}`] ||
|
||||
hashes[val] ||
|
||||
hashes[normVal] ||
|
||||
hashes[winVal] ||
|
||||
null;
|
||||
};
|
||||
|
||||
let needsGlobalHashRefresh = false;
|
||||
provenanceCheck:
|
||||
for (const node of app.graph._nodes) {
|
||||
if (node.color === "#FF3333" || node.bgcolor === "#FF3333" || node.color === "#f66" || (node.flags && node.flags.collapsed && node.color === "#FF3333")) {
|
||||
has_missing = true;
|
||||
break;
|
||||
}
|
||||
if (node.widgets) {
|
||||
for (let i = 0; i < node.widgets.length; i++) {
|
||||
const w = node.widgets[i];
|
||||
if (typeof w.value === 'string' && (w.value.endsWith('.safetensors') || w.value.endsWith('.ckpt') || w.value.endsWith('.pt'))) {
|
||||
if (w.options && w.options.values && !w.options.values.includes(w.value)) {
|
||||
has_missing = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (const widget of (node.widgets || [])) {
|
||||
const value = widget.value;
|
||||
if (!isModelFilename(value)) continue;
|
||||
if (!getWorkflowHash(node.id, value)) {
|
||||
needsGlobalHashRefresh = true;
|
||||
break provenanceCheck;
|
||||
}
|
||||
}
|
||||
if (has_missing) break;
|
||||
}
|
||||
|
||||
if (!has_missing && !is_manual) return;
|
||||
|
||||
// Refresh ComfyUI's frontend folder paths cache to prevent false-positive red nodes
|
||||
if (app.refreshComboInNodes) {
|
||||
// Provenance-rich workflows already carry the identity data needed by the
|
||||
// doctor. Refresh the full local filename->hash cache only for legacy items
|
||||
// that actually need that fallback.
|
||||
if (needsGlobalHashRefresh) {
|
||||
try {
|
||||
await app.refreshComboInNodes();
|
||||
const res = await fetch('/anomalous/all_hashes?t=' + Date.now());
|
||||
const data = await res.json();
|
||||
const hashesObj = data.hashes ? data.hashes : data;
|
||||
Object.assign(window.anomalous_hash_cache, hashesObj);
|
||||
} catch (e) {
|
||||
console.warn("[Anomalous Hash Resolver] Failed to refresh ComfyUI node combos", e);
|
||||
console.warn("[Anomalous] Failed to fetch hashes for manual resolution", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Always fetch latest hashes before trying to resolve
|
||||
try {
|
||||
const res = await fetch('/anomalous/all_hashes');
|
||||
const data = await res.json();
|
||||
Object.assign(window.anomalous_hash_cache, data);
|
||||
} catch (e) {
|
||||
console.warn("[Anomalous] Failed to fetch hashes for manual resolution", e);
|
||||
}
|
||||
|
||||
let fixed_count = 0;
|
||||
const hashes = (app.graph.extra && app.graph.extra.anomalous_hashes) || {};
|
||||
|
||||
const findHashData = (node, widget, value) => {
|
||||
let hashData = getWorkflowHash(node.id, value);
|
||||
if (!hashData && !requiresHashForModelRecovery(node, widget) && window.anomalous_hash_cache && typeof value === 'string') {
|
||||
const parts = value.split(/[/\\]/);
|
||||
const basename = parts[parts.length - 1];
|
||||
const normVal = value.replace(/\\/g, '/');
|
||||
const cacheData = window.anomalous_hash_cache[value] || window.anomalous_hash_cache[normVal] || window.anomalous_hash_cache[basename];
|
||||
if (cacheData) hashData = typeof cacheData === 'string' ? { hash: cacheData, size: "" } : cacheData;
|
||||
}
|
||||
return hashData;
|
||||
};
|
||||
|
||||
// Resolve all model references in one request. The backend groups items by
|
||||
// required model category and scans each category once. Native slash-only
|
||||
// normalization remains in the existing loop below and needs no disk scan.
|
||||
const batchItems = [];
|
||||
for (const node of app.graph._nodes) {
|
||||
if (!node.widgets) continue;
|
||||
for (let i = 0; i < node.widgets.length; i++) {
|
||||
const widget = node.widgets[i];
|
||||
const value = widget.value;
|
||||
if (!isModelFilename(value)) continue;
|
||||
if (widget.options && widget.options.values && !widget.options.values.includes(value)) {
|
||||
const normalized = value.replace(/\\/g, '/');
|
||||
const nativeMatch = widget.options.values.find(v => typeof v === 'string' && v.replace(/\\/g, '/') === normalized);
|
||||
if (nativeMatch && nativeMatch !== value) continue;
|
||||
}
|
||||
const hashData = findHashData(node, widget, value);
|
||||
if (!hashData) continue;
|
||||
const hash = typeof hashData === 'string' ? hashData : (hashData.hash || "");
|
||||
const size = typeof hashData === 'string' ? "" : (hashData.size || "");
|
||||
if (!hash && !size) continue;
|
||||
batchItems.push({
|
||||
key: `${node.id}:${i}:${value}`,
|
||||
hash,
|
||||
size,
|
||||
type: inferExpectedModelTypes(node, widget)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const batchResults = new Map();
|
||||
if (batchItems.length) {
|
||||
try {
|
||||
for (let offset = 0; offset < batchItems.length; offset += 256) {
|
||||
const batchResponse = await fetch('/anomalous/resolve_hash_batch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ items: batchItems.slice(offset, offset + 256) })
|
||||
});
|
||||
if (!batchResponse.ok) throw new Error(`HTTP ${batchResponse.status}`);
|
||||
const batchData = await batchResponse.json();
|
||||
for (const entry of (batchData.results || [])) batchResults.set(entry.key, entry.result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[Anomalous Hash Resolver] Batch resolution failed; falling back to single-item requests.', error);
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of app.graph._nodes) {
|
||||
if (node.widgets) {
|
||||
for (let i = 0; i < node.widgets.length; i++) {
|
||||
const w = node.widgets[i];
|
||||
const val = w.value;
|
||||
if (typeof val === 'string' && (val.endsWith('.safetensors') || val.endsWith('.ckpt') || val.endsWith('.pt'))) {
|
||||
|
||||
// Native slash mismatch fix: if val isn't in options, but matches if we normalize slashes
|
||||
if (w.options && w.options.values && !w.options.values.includes(val)) {
|
||||
const normVal = val.replace(/\\/g, '/');
|
||||
const exactMatch = w.options.values.find(v => typeof v === 'string' && v.replace(/\\/g, '/') === normVal);
|
||||
if (exactMatch && exactMatch !== val) {
|
||||
console.log(`[Anomalous Hash Resolver] Fixed slash mismatch: ${val} -> ${exactMatch}`);
|
||||
w.value = exactMatch;
|
||||
if (node.color === "#FF3333" || node.bgcolor === "#FF3333" || node.color === "#f66") {
|
||||
delete node.color;
|
||||
delete node.bgcolor;
|
||||
}
|
||||
if (w.callback) {
|
||||
w.callback(w.value, app.canvas, node, app.canvas.graph_mouse, null);
|
||||
}
|
||||
app.graph.setDirtyCanvas(true, true);
|
||||
continue; // Already fixed natively, skip backend lookup
|
||||
}
|
||||
}
|
||||
|
||||
let hashData = hashes[`${node.id}_${val}`];
|
||||
|
||||
// Fallback: If graph wasn't saved with hashes, check if the global cache knows this filename
|
||||
if (!hashData && window.anomalous_hash_cache) {
|
||||
const parts = val.split(/[/\\]/);
|
||||
const basename = parts[parts.length - 1];
|
||||
const cache_data = window.anomalous_hash_cache[basename] || window.anomalous_hash_cache[val];
|
||||
if (cache_data) {
|
||||
hashData = typeof cache_data === 'string' ? { hash: cache_data, size: "" } : cache_data;
|
||||
}
|
||||
}
|
||||
|
||||
if (node.widgets) {
|
||||
for (let i = 0; i < node.widgets.length; i++) {
|
||||
const w = node.widgets[i];
|
||||
const val = w.value;
|
||||
if (isModelFilename(val)) {
|
||||
|
||||
// Native slash mismatch fix: if val isn't in options, but matches if we normalize slashes
|
||||
if (w.options && w.options.values && !w.options.values.includes(val)) {
|
||||
const normVal = val.replace(/\\/g, '/');
|
||||
const exactMatch = w.options.values.find(v => typeof v === 'string' && v.replace(/\\/g, '/') === normVal);
|
||||
if (exactMatch && exactMatch !== val) {
|
||||
console.log(`[Anomalous Hash Resolver] Fixed slash mismatch: ${val} -> ${exactMatch}`);
|
||||
w.value = exactMatch;
|
||||
const wIdx = node.widgets.indexOf(w);
|
||||
if (wIdx !== -1 && node.widgets_values) {
|
||||
node.widgets_values[wIdx] = exactMatch;
|
||||
}
|
||||
delete node.color;
|
||||
delete node.bgcolor;
|
||||
node.has_errors = false;
|
||||
|
||||
if (app.lastNodeErrors && app.lastNodeErrors[node.id]) {
|
||||
delete app.lastNodeErrors[node.id];
|
||||
}
|
||||
|
||||
if (w.callback) {
|
||||
w.callback(w.value, app.canvas, node, app.canvas.graph_mouse, null);
|
||||
}
|
||||
app.graph.setDirtyCanvas(true, true);
|
||||
fixed_count++; // Mark as fixed so the global graph update is triggered!
|
||||
continue; // Already fixed natively, skip backend lookup
|
||||
}
|
||||
}
|
||||
|
||||
const hashData = findHashData(node, w, val);
|
||||
|
||||
if (hashData) {
|
||||
let h = typeof hashData === 'string' ? hashData : hashData.hash;
|
||||
let s = typeof hashData === 'string' ? "" : (hashData.size || "");
|
||||
try {
|
||||
const res = await fetch(`/anomalous/resolve_hash?hash=${encodeURIComponent(h)}&size=${encodeURIComponent(s)}`);
|
||||
const resData = await res.json();
|
||||
|
||||
if (resData.found) {
|
||||
const normVal = val.replace(/\\/g, '/');
|
||||
const normRes = resData.filename.replace(/\\/g, '/');
|
||||
|
||||
let finalValue = resData.filename;
|
||||
let optionsCacheStale = false;
|
||||
if (w.options && w.options.values) {
|
||||
const exactMatch = w.options.values.find(v => typeof v === 'string' && v.replace(/\\/g, '/') === normRes);
|
||||
if (exactMatch) {
|
||||
finalValue = exactMatch;
|
||||
} else {
|
||||
optionsCacheStale = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (finalValue !== val || normRes !== normVal || optionsCacheStale || node.has_errors || node.color) {
|
||||
const expectedTypes = inferExpectedModelTypes(node, w);
|
||||
const typeQuery = expectedTypes ? `&type=${encodeURIComponent(expectedTypes)}` : '';
|
||||
const batchKey = `${node.id}:${i}:${val}`;
|
||||
let resData = batchResults.get(batchKey);
|
||||
if (!batchResults.has(batchKey)) {
|
||||
const res = await fetch(`/anomalous/resolve_hash?hash=${encodeURIComponent(h)}&size=${encodeURIComponent(s)}${typeQuery}`);
|
||||
resData = await res.json();
|
||||
}
|
||||
|
||||
if (resData.found) {
|
||||
const normVal = val.replace(/\\/g, '/');
|
||||
const normRes = resData.filename.replace(/\\/g, '/');
|
||||
|
||||
let finalValue = resData.filename;
|
||||
let optionsCacheStale = false;
|
||||
if (w.options && w.options.values) {
|
||||
const exactMatch = w.options.values.find(v => typeof v === 'string' && v.replace(/\\/g, '/') === normRes);
|
||||
if (exactMatch) {
|
||||
finalValue = exactMatch;
|
||||
} else {
|
||||
optionsCacheStale = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (finalValue !== val || normRes !== normVal || optionsCacheStale || node.has_errors || node.color) {
|
||||
if (optionsCacheStale) {
|
||||
console.log(`[Anomalous Hash Resolver] Found ${finalValue} on disk, but ComfyUI frontend dropdown is stale. Forcing backend cache clear...`);
|
||||
let refreshedMatch = null;
|
||||
try {
|
||||
await fetch('/anomalous/clear_cache', { method: 'POST' });
|
||||
await app.refreshComboInNodes();
|
||||
// Re-evaluate exact match after refreshing
|
||||
if (w.options && w.options.values) {
|
||||
const newMatch = w.options.values.find(v => typeof v === 'string' && v.replace(/\\/g, '/') === normRes);
|
||||
if (newMatch) finalValue = newMatch;
|
||||
else w.options.values.push(finalValue); // Absolute fallback
|
||||
refreshedMatch = w.options.values.find(v => typeof v === 'string' && v.replace(/\\/g, '/') === normRes) || null;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Failed to clear cache:", e);
|
||||
}
|
||||
if (!refreshedMatch) {
|
||||
console.warn(`[Anomalous Hash Resolver] Rejected cross-type or invalid model path for ${node.type}.${w.name}: ${finalValue}`);
|
||||
continue;
|
||||
}
|
||||
finalValue = refreshedMatch;
|
||||
}
|
||||
|
||||
console.log(`[Anomalous Hash Resolver] Auto-fixed missing model: ${val} -> ${finalValue}`);
|
||||
w.value = finalValue;
|
||||
delete node.color;
|
||||
delete node.bgcolor;
|
||||
node.has_errors = false;
|
||||
if (w.callback) {
|
||||
w.callback(w.value, app.canvas, node, app.canvas.graph_mouse, null);
|
||||
}
|
||||
app.graph.setDirtyCanvas(true, true);
|
||||
fixed_count++;
|
||||
}
|
||||
} else if (!is_manual || force_prompt) {
|
||||
showMissingModelPrompt(val, h);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Anomalous Hash Resolver] Error:", err);
|
||||
if (!is_manual || force_prompt) {
|
||||
showMissingModelPrompt(val, h);
|
||||
}
|
||||
}
|
||||
} else if (!is_manual || force_prompt) {
|
||||
showMissingModelPrompt(val, "Unknown (Not Scanned)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Anomalous Hash Resolver] Auto-fixed missing model: ${val} -> ${finalValue}`);
|
||||
w.value = finalValue;
|
||||
const wIdx = node.widgets.indexOf(w);
|
||||
if (wIdx !== -1 && node.widgets_values) {
|
||||
node.widgets_values[wIdx] = finalValue;
|
||||
}
|
||||
delete node.color;
|
||||
delete node.bgcolor;
|
||||
node.has_errors = false;
|
||||
node.anomalous_auto_resolved = true;
|
||||
node.anomalous_original_missing_val = val;
|
||||
if (w.callback) {
|
||||
w.callback(w.value, app.canvas, node, app.canvas.graph_mouse, null);
|
||||
}
|
||||
app.graph.setDirtyCanvas(true, true);
|
||||
fixed_count++;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Anomalous Hash Resolver] Error:", err);
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fixed_count > 0) {
|
||||
// Force ComfyUI v1 side panels (Workflow Overview/Parameters) to re-evaluate and clear errors
|
||||
if (app.graph && app.graph.change) app.graph.change();
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent("graphChanged"));
|
||||
} catch (e) { }
|
||||
|
||||
// Deep clear ComfyUI native error caches
|
||||
if (app.lastNodeErrors) {
|
||||
Object.keys(app.lastNodeErrors).forEach(key => delete app.lastNodeErrors[key]);
|
||||
}
|
||||
}
|
||||
|
||||
if (fixed_count > 0) {
|
||||
// Force ComfyUI v1 side panels (Workflow Overview/Parameters) to re-evaluate and clear errors
|
||||
if (app.graph && app.graph.change) app.graph.change();
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent("graphChanged"));
|
||||
} catch (e) { }
|
||||
|
||||
// Deep clear ComfyUI native error caches
|
||||
if (app.lastNodeErrors) app.lastNodeErrors = null;
|
||||
if (typeof app.clearErrors === 'function') app.clearErrors();
|
||||
|
||||
// Note: We removed the aggressive "ghost clicker" that automatically clicked the Refresh button in the Vue side panel.
|
||||
// It was too intrusive and could cause focus issues. Instead, we now gently remind the user via an alert.
|
||||
// 注:我们移除了主动点击 Vue 侧边栏刷新按钮的“幽灵连点器”,因为它侵入性过强且容易引发焦点问题。改为在弹窗中进行善意提醒。
|
||||
}
|
||||
|
||||
if (is_manual) {
|
||||
// Detect current UI language (Default to 'zh' if not set)
|
||||
// 获取当前界面语言(未设置时默认使用中文)
|
||||
const lang = (window.anomalous_browser_lang === 'en') ? 'en' : 'zh';
|
||||
|
||||
if (typeof app.clearErrors === 'function') app.clearErrors();
|
||||
|
||||
// Note: We removed the aggressive "ghost clicker" that automatically clicked the Refresh button in the Vue side panel.
|
||||
// It was too intrusive and could cause focus issues. Instead, we now gently remind the user via an alert.
|
||||
// 注:我们移除了主动点击 Vue 侧边栏刷新按钮的“幽灵连点器”,因为它侵入性过强且容易引发焦点问题。改为在弹窗中进行善意提醒。
|
||||
}
|
||||
|
||||
if (is_manual && !silent) {
|
||||
if (fixed_count > 0) {
|
||||
if (lang === 'en') {
|
||||
alert(`🪄 Anomalous successfully fixed ${fixed_count} missing model(s)!\n\n💡 Tip: If you still see errors in the "Workflow Overview" side panel, simply click its [Refresh] button to clear them.`);
|
||||
} else {
|
||||
alert(`🪄 Anomalous 成功修复了 ${fixed_count} 个缺失的模型!\n\n💡 提示:如果侧边栏【工作流总览】中依然显示红色报错,顺手点击一下该面板里的【刷新】按钮即可清除。`);
|
||||
}
|
||||
} else if (!force_prompt) {
|
||||
if (lang === 'en') {
|
||||
alert(`🪄 No models could be auto-fixed.\nIf nodes are still red, please open the Anomalous plugin window and click the 🔄 "Scan" button at the bottom to fetch missing hashes, or download the models from Civitai.`);
|
||||
} else {
|
||||
alert(`🪄 未发现可以自动修复的模型。\n如果依然有飘红,请点开 Anomalous 插件悬浮窗,点击底部的 🔄【扫描】按钮获取最新模型信息,或去 C站 下载缺失模型。`);
|
||||
}
|
||||
alert(translate('hashResolverFixed', { count: fixed_count }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function showMissingModelPrompt(filename, hash) {
|
||||
const currentLang = window.anomalous_browser_lang || 'zh';
|
||||
const existing = document.getElementById('anomalous-missing-prompt');
|
||||
let container;
|
||||
if (!existing) {
|
||||
container = document.createElement('div');
|
||||
container.id = 'anomalous-missing-prompt';
|
||||
container.style.position = 'fixed';
|
||||
container.style.top = '50%';
|
||||
container.style.left = '50%';
|
||||
container.style.transform = 'translate(-50%, -50%)';
|
||||
container.style.background = 'var(--comfy-menu-bg, #222)';
|
||||
container.style.border = '1px solid var(--border-color, #444)';
|
||||
container.style.padding = '20px';
|
||||
container.style.borderRadius = '8px';
|
||||
container.style.zIndex = '9999';
|
||||
container.style.color = '#fff';
|
||||
container.style.boxShadow = '0 4px 12px rgba(0,0,0,0.5)';
|
||||
container.style.minWidth = '350px';
|
||||
|
||||
const title = document.createElement('h3');
|
||||
title.innerText = currentLang === 'zh' ? '⚠️ 缺失模型提醒' : '⚠️ Missing Models Alert';
|
||||
title.style.marginTop = '0';
|
||||
title.style.color = '#ff6b6b';
|
||||
|
||||
const desc = document.createElement('p');
|
||||
desc.style.fontSize = '0.9em';
|
||||
desc.style.color = '#ccc';
|
||||
desc.innerHTML = currentLang === 'zh'
|
||||
? '点击下方的一键扫描会自动获取所有缺失模型的最新哈希信息。<br><br><span style="color:#ffc107">⚠️ 注意:</span>此操作<b>不会</b>重命名您的文件,也<b>不会</b>下载预览图,仅仅为了快速点亮红框节点。<br>如需获取完整的模型封面和规范化重命名,请打开 Anomalous 插件窗口,点击底部的 🔄 按钮执行标准扫描。'
|
||||
: 'Clicking Scan will automatically fetch the latest hash info for all missing models.<br><br><span style="color:#ffc107">⚠️ Note:</span> This action <b>will not</b> rename your files or download cover images. It is only meant to fix the red nodes quickly.<br>For full cover downloads and standard renaming, open the Anomalous plugin and run a full scan.';
|
||||
|
||||
container.appendChild(title);
|
||||
container.appendChild(desc);
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.id = 'anomalous-missing-list';
|
||||
list.style.maxHeight = '200px';
|
||||
list.style.overflowY = 'auto';
|
||||
list.style.marginBottom = '15px';
|
||||
container.appendChild(list);
|
||||
|
||||
const btnRow = document.createElement('div');
|
||||
btnRow.style.display = 'flex';
|
||||
btnRow.style.gap = '10px';
|
||||
btnRow.style.justifyContent = 'flex-end';
|
||||
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.innerText = currentLang === 'zh' ? '忽略并手动处理' : 'Ignore & Handle Manually';
|
||||
closeBtn.style.padding = '6px 12px';
|
||||
closeBtn.style.background = '#444';
|
||||
closeBtn.style.color = '#fff';
|
||||
closeBtn.style.border = 'none';
|
||||
closeBtn.style.borderRadius = '4px';
|
||||
closeBtn.style.cursor = 'pointer';
|
||||
closeBtn.onclick = () => container.remove();
|
||||
|
||||
const scanBtn = document.createElement('button');
|
||||
scanBtn.innerText = currentLang === 'zh' ? '一键扫描 (不改名)' : 'Quick Scan (No Rename)';
|
||||
scanBtn.style.padding = '6px 12px';
|
||||
scanBtn.style.background = '#007bff';
|
||||
scanBtn.style.color = '#fff';
|
||||
scanBtn.style.border = 'none';
|
||||
scanBtn.style.borderRadius = '4px';
|
||||
scanBtn.style.cursor = 'pointer';
|
||||
scanBtn.onclick = async () => {
|
||||
scanBtn.innerText = '启动中...';
|
||||
scanBtn.disabled = true;
|
||||
try {
|
||||
const res = await fetch('/anomalous/scan_all', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (data.status === 'ok') {
|
||||
scanBtn.innerText = currentLang === 'zh' ? '正在后台极速获取信息中,请稍候...' : 'Fetching in background, please wait...';
|
||||
|
||||
const pollTimer = setInterval(async () => {
|
||||
try {
|
||||
const statusRes = await fetch('/anomalous/global_scan_status');
|
||||
const statusData = await statusRes.json();
|
||||
if (!statusData.scanning) {
|
||||
clearInterval(pollTimer);
|
||||
container.remove();
|
||||
// Add a tiny delay to ensure disk is flushed
|
||||
setTimeout(async () => {
|
||||
alert(currentLang === 'zh' ? '信息获取完成!正在为您自动点亮缺失模型...' : 'Scan complete! Resolving missing models...');
|
||||
await window.anomalous_resolve_all_missing_nodes(true);
|
||||
}, 500);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Polling error", e);
|
||||
}
|
||||
}, 2000);
|
||||
} else {
|
||||
alert((currentLang === 'zh' ? '扫描启动失败: ' : 'Scan failed: ') + data.message);
|
||||
scanBtn.disabled = false;
|
||||
scanBtn.innerText = currentLang === 'zh' ? '一键扫描 (不改名)' : 'Quick Scan (No Rename)';
|
||||
}
|
||||
} catch (err) {
|
||||
alert(currentLang === 'zh' ? '请求失败' : 'Request failed');
|
||||
scanBtn.disabled = false;
|
||||
scanBtn.innerText = currentLang === 'zh' ? '一键扫描 (不改名)' : 'Quick Scan (No Rename)';
|
||||
}
|
||||
};
|
||||
|
||||
const copyBtn = document.createElement('button');
|
||||
copyBtn.innerText = currentLang === 'zh' ? '复制所有 C站 下载链接' : 'Copy Civitai Links';
|
||||
copyBtn.style.padding = '6px 12px';
|
||||
copyBtn.style.background = '#28a745';
|
||||
copyBtn.style.color = '#fff';
|
||||
copyBtn.style.border = 'none';
|
||||
copyBtn.style.borderRadius = '4px';
|
||||
copyBtn.style.cursor = 'pointer';
|
||||
copyBtn.onclick = () => {
|
||||
const links = Array.from(list.querySelectorAll('a')).map(a => a.href).join('\n');
|
||||
navigator.clipboard.writeText(links).then(() => {
|
||||
const oldText = copyBtn.innerText;
|
||||
copyBtn.innerText = '已复制!';
|
||||
setTimeout(() => copyBtn.innerText = oldText, 2000);
|
||||
});
|
||||
};
|
||||
|
||||
const openAllBtn = document.createElement('button');
|
||||
openAllBtn.innerText = currentLang === 'zh' ? '一键全部跳转' : 'Open All Links';
|
||||
openAllBtn.style.padding = '6px 12px';
|
||||
openAllBtn.style.background = '#17a2b8';
|
||||
openAllBtn.style.color = '#fff';
|
||||
openAllBtn.style.border = 'none';
|
||||
openAllBtn.style.borderRadius = '4px';
|
||||
openAllBtn.style.cursor = 'pointer';
|
||||
openAllBtn.onclick = () => {
|
||||
const links = Array.from(list.querySelectorAll('a')).map(a => a.href);
|
||||
if (links.length === 0) return;
|
||||
links.forEach(link => {
|
||||
window.open(link, '_blank');
|
||||
});
|
||||
};
|
||||
|
||||
btnRow.appendChild(closeBtn);
|
||||
btnRow.appendChild(scanBtn);
|
||||
btnRow.appendChild(copyBtn);
|
||||
btnRow.appendChild(openAllBtn);
|
||||
container.appendChild(btnRow);
|
||||
|
||||
document.body.appendChild(container);
|
||||
} else {
|
||||
container = existing;
|
||||
}
|
||||
|
||||
const list = container.querySelector('#anomalous-missing-list');
|
||||
const item = document.createElement('div');
|
||||
item.style.marginBottom = '8px';
|
||||
item.style.borderBottom = '1px solid #333';
|
||||
item.style.paddingBottom = '8px';
|
||||
item.innerHTML = `<strong style="word-break: break-all;">${filename}</strong> <br><span style="font-size:0.8em; color:#aaa;">Hash: ${hash}</span>
|
||||
<a href="https://civitai.com/search/models?sortBy=models_v9&query=${hash}" target="_blank" style="color:#4dabf7; font-size:0.8em; margin-left:10px; text-decoration:none;">${window.anomalous_current_lang === 'en' ? '[Download on Civitai]' : '[去C站下载]'}</a>`;
|
||||
list.appendChild(item);
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "Anomalous.ModelBrowser.HashResolver",
|
||||
|
||||
async setup() {
|
||||
// Pre-fetch all hashes on startup so that dragging generated images (without opening UI) still intercepts
|
||||
try {
|
||||
const resp = await fetch('/anomalous/all_hashes');
|
||||
const data = await resp.json();
|
||||
// api_get_all_hashes returns a flat dictionary, not {hashes: {...}}
|
||||
window.anomalous_hash_cache = data.hashes ? data.hashes : data;
|
||||
window.anomalous_is_empty_state = Object.keys(window.anomalous_hash_cache).length === 0;
|
||||
} catch (e) {
|
||||
window.anomalous_hash_cache = {};
|
||||
window.anomalous_is_empty_state = true;
|
||||
console.warn("[Anomalous] Failed to fetch initial hashes", e);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
app.registerExtension({
|
||||
name: "Anomalous.ModelBrowser.HashResolver",
|
||||
|
||||
async setup() {
|
||||
// Expose global reload function so scans can trigger it
|
||||
window.anomalous_reload_hashes = async function () {
|
||||
try {
|
||||
const resp = await fetch('/anomalous/all_hashes');
|
||||
const data = await resp.json();
|
||||
window.anomalous_hash_cache = data.hashes ? data.hashes : data;
|
||||
window.anomalous_is_empty_state = Object.keys(window.anomalous_hash_cache).length === 0;
|
||||
} catch (e) {
|
||||
window.anomalous_hash_cache = {};
|
||||
window.anomalous_is_empty_state = true;
|
||||
console.warn("[Anomalous] Failed to fetch hashes", e);
|
||||
}
|
||||
};
|
||||
|
||||
// Pre-fetch all hashes on startup so that dragging generated images (without opening UI) still intercepts
|
||||
await window.anomalous_reload_hashes();
|
||||
|
||||
// Intercept graph serialization to inject hashes. ComfyUI 0.27 no
|
||||
// longer guarantees a global LGraph symbol, so prefer the graph's
|
||||
// actual constructor and fail closed if the graph API is unavailable.
|
||||
// This resolver is optional and must never prevent the main browser
|
||||
// extension from registering its visible entry point.
|
||||
const graphClass = [app.graph?.constructor, globalThis.LGraph].find((candidate) => (
|
||||
candidate?.prototype && typeof candidate.prototype.serialize === 'function'
|
||||
));
|
||||
if (!graphClass) {
|
||||
console.warn('[Anomalous Hash Resolver] Graph serialization API is unavailable; resolver disabled for this session.');
|
||||
return;
|
||||
}
|
||||
if (graphClass.prototype.__anomalousHashResolverPatched) return;
|
||||
|
||||
|
||||
|
||||
// Intercept graph serialization to inject hashes
|
||||
const origSerialize = LGraph.prototype.serialize;
|
||||
const origSerialize = graphClass.prototype.serialize;
|
||||
window.anomalous_has_warned_unscanned = false;
|
||||
window.anomalous_unscanned_models = [];
|
||||
LGraph.prototype.serialize = function () {
|
||||
graphClass.prototype.__anomalousHashResolverPatched = true;
|
||||
graphClass.prototype.serialize = function () {
|
||||
const data = origSerialize.apply(this, arguments);
|
||||
|
||||
if (localStorage.getItem('anomalous_inject_hash') === 'false') {
|
||||
return data;
|
||||
}
|
||||
|
||||
// Clone extra to avoid mutating the live graph's extra object
|
||||
const extraObj = data.extra ? JSON.parse(JSON.stringify(data.extra)) : {};
|
||||
extraObj.anomalous_hashes = {};
|
||||
@@ -400,25 +366,31 @@ app.registerExtension({
|
||||
|
||||
if (node.widgets_values && node.widgets_values.length > 0) {
|
||||
for (const val of node.widgets_values) {
|
||||
if (typeof val === 'string' && (val.endsWith('.safetensors') || val.endsWith('.ckpt') || val.endsWith('.pt'))) {
|
||||
if (isModelFilename(val)) {
|
||||
const parts = val.split(/[/\\]/);
|
||||
const basename = parts[parts.length - 1];
|
||||
|
||||
let valIsMissing = false;
|
||||
let matchingWidget = null;
|
||||
if (liveNode && liveNode.widgets) {
|
||||
const matchingWidget = liveNode.widgets.find(w => w.value === val && w.type === "combo");
|
||||
matchingWidget = liveNode.widgets.find(w => w.value === val && w.type === "combo") || null;
|
||||
if (matchingWidget && matchingWidget.options && matchingWidget.options.values && !matchingWidget.options.values.includes(val)) {
|
||||
valIsMissing = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!valIsMissing) {
|
||||
const cache_data = window.anomalous_hash_cache[basename] || window.anomalous_hash_cache[val];
|
||||
const normVal = val.replace(/\\/g, '/');
|
||||
const cache_data = window.anomalous_hash_cache[val] || window.anomalous_hash_cache[normVal] || window.anomalous_hash_cache[basename];
|
||||
if (cache_data) {
|
||||
if (typeof cache_data === 'string') {
|
||||
extraObj.anomalous_hashes[`${node.id}_${val}`] = { hash: cache_data, size: "" };
|
||||
} else {
|
||||
extraObj.anomalous_hashes[`${node.id}_${val}`] = cache_data;
|
||||
const hashObj = typeof cache_data === 'string' ? { hash: cache_data, size: "" } : cache_data;
|
||||
if (matchingWidget && requiresHashForModelRecovery(liveNode, matchingWidget) && !hashObj.hash) {
|
||||
if (!unscanned_models.includes(basename)) unscanned_models.push(basename);
|
||||
continue;
|
||||
}
|
||||
extraObj.anomalous_hashes[`${node.id}_${val}`] = hashObj;
|
||||
if (normVal !== val) {
|
||||
extraObj.anomalous_hashes[`${node.id}_${normVal}`] = hashObj;
|
||||
}
|
||||
} else {
|
||||
if (!unscanned_models.includes(basename)) {
|
||||
@@ -431,29 +403,18 @@ app.registerExtension({
|
||||
}
|
||||
}
|
||||
}
|
||||
data.extra = extraObj;
|
||||
|
||||
data.extra = extraObj;
|
||||
window.anomalous_unscanned_models = unscanned_models;
|
||||
return data;
|
||||
};
|
||||
|
||||
|
||||
|
||||
// Intercept loadGraphData to resolve missing models
|
||||
const origLoadGraphData = app.loadGraphData;
|
||||
app.loadGraphData = function (graphData) {
|
||||
// Proceed with original loadGraphData synchronously first
|
||||
const ret = origLoadGraphData.apply(this, arguments);
|
||||
|
||||
// After graph is loaded, we async fix the missing nodes
|
||||
if (graphData && graphData.extra && graphData.extra.anomalous_hashes) {
|
||||
setTimeout(async () => {
|
||||
await window.anomalous_resolve_all_missing_nodes(false);
|
||||
}, 200); // Small delay to ensure ComfyUI has finished rendering/validating
|
||||
}
|
||||
|
||||
return ret;
|
||||
};
|
||||
if (typeof app.loadGraphData === 'function') {
|
||||
const origLoadGraphData = app.loadGraphData;
|
||||
app.loadGraphData = function (graphData) {
|
||||
// Proceed with original loadGraphData synchronously first
|
||||
return origLoadGraphData.apply(this, arguments);
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
+878
-2882
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,214 @@
|
||||
const MODEL_CHAIN_TYPES = Object.freeze(['MODEL', 'CLIP']);
|
||||
|
||||
function normalizeSlotType(type) {
|
||||
return String(type ?? '').trim().toUpperCase();
|
||||
}
|
||||
|
||||
function findSlotIndex(slots, requiredType) {
|
||||
const wanted = normalizeSlotType(requiredType);
|
||||
return Array.isArray(slots)
|
||||
? slots.findIndex(slot => normalizeSlotType(slot?.type) === wanted)
|
||||
: -1;
|
||||
}
|
||||
|
||||
function getGraphLink(graph, linkId) {
|
||||
if (linkId === null || linkId === undefined) return null;
|
||||
return graph?.links?.[linkId] || graph?._links?.[linkId] || null;
|
||||
}
|
||||
|
||||
function getGraphNode(graph, nodeId) {
|
||||
if (graph?.getNodeById) return graph.getNodeById(nodeId) || null;
|
||||
return Array.isArray(graph?._nodes)
|
||||
? graph._nodes.find(node => node?.id === nodeId) || null
|
||||
: null;
|
||||
}
|
||||
|
||||
function unsupported(direction, code, details = {}) {
|
||||
return { supported: false, direction, code, channels: [], ...details };
|
||||
}
|
||||
|
||||
export function analyzeModelChainInsertion(graph, anchorNode, direction) {
|
||||
if (!graph || !anchorNode) {
|
||||
return unsupported(direction, 'missing_graph_or_node');
|
||||
}
|
||||
if (direction !== 'before' && direction !== 'after') {
|
||||
return unsupported(direction, 'invalid_direction');
|
||||
}
|
||||
|
||||
const channels = [];
|
||||
for (const type of MODEL_CHAIN_TYPES) {
|
||||
const anchorSlot = direction === 'before'
|
||||
? findSlotIndex(anchorNode.inputs, type)
|
||||
: findSlotIndex(anchorNode.outputs, type);
|
||||
if (anchorSlot < 0) {
|
||||
return unsupported(direction, direction === 'before' ? 'missing_chain_inputs' : 'missing_chain_outputs', { missingType: type });
|
||||
}
|
||||
|
||||
if (direction === 'before') {
|
||||
const linkId = anchorNode.inputs[anchorSlot]?.link;
|
||||
const link = getGraphLink(graph, linkId);
|
||||
const sourceNode = link ? getGraphNode(graph, link.origin_id) : null;
|
||||
if (!link || !sourceNode) {
|
||||
return unsupported(direction, 'unconnected_chain_inputs', { missingType: type });
|
||||
}
|
||||
channels.push({
|
||||
type,
|
||||
anchorSlot,
|
||||
originalLinks: [{
|
||||
originNode: sourceNode,
|
||||
originSlot: link.origin_slot,
|
||||
targetNode: anchorNode,
|
||||
targetSlot: anchorSlot,
|
||||
}],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const linkIds = Array.isArray(anchorNode.outputs[anchorSlot]?.links)
|
||||
? anchorNode.outputs[anchorSlot].links.filter(id => id !== null && id !== undefined)
|
||||
: [];
|
||||
if (linkIds.length > 1) {
|
||||
return unsupported(direction, 'ambiguous_downstream_branches', { ambiguousType: type, branchCount: linkIds.length });
|
||||
}
|
||||
|
||||
const originalLinks = [];
|
||||
for (const linkId of linkIds) {
|
||||
const link = getGraphLink(graph, linkId);
|
||||
const targetNode = link ? getGraphNode(graph, link.target_id) : null;
|
||||
if (!link || !targetNode) {
|
||||
return unsupported(direction, 'invalid_downstream_link', { invalidType: type });
|
||||
}
|
||||
originalLinks.push({
|
||||
originNode: anchorNode,
|
||||
originSlot: anchorSlot,
|
||||
targetNode,
|
||||
targetSlot: link.target_slot,
|
||||
});
|
||||
}
|
||||
channels.push({ type, anchorSlot, originalLinks });
|
||||
}
|
||||
|
||||
return { supported: true, direction, code: 'ready', channels };
|
||||
}
|
||||
|
||||
export function getModelChainInsertionCapabilities(graph, anchorNode) {
|
||||
return {
|
||||
before: analyzeModelChainInsertion(graph, anchorNode, 'before'),
|
||||
after: analyzeModelChainInsertion(graph, anchorNode, 'after'),
|
||||
};
|
||||
}
|
||||
|
||||
function assertInsertedNodeSlots(insertedNode) {
|
||||
const slots = {};
|
||||
for (const type of MODEL_CHAIN_TYPES) {
|
||||
const input = findSlotIndex(insertedNode?.inputs, type);
|
||||
const output = findSlotIndex(insertedNode?.outputs, type);
|
||||
if (input < 0 || output < 0) {
|
||||
const error = new Error(`Inserted node does not expose ${type} input/output slots.`);
|
||||
error.code = 'inserted_node_missing_chain_slots';
|
||||
throw error;
|
||||
}
|
||||
slots[type] = { input, output };
|
||||
}
|
||||
return slots;
|
||||
}
|
||||
|
||||
function connectOrThrow(originNode, originSlot, targetNode, targetSlot, type) {
|
||||
const link = originNode?.connect?.(originSlot, targetNode, targetSlot);
|
||||
if (!link) {
|
||||
const error = new Error(`Failed to connect ${type} while inserting the model node.`);
|
||||
error.code = 'connection_failed';
|
||||
error.channelType = type;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function restoreConnections(originalConnections) {
|
||||
for (const connection of originalConnections) {
|
||||
connection.originNode?.connect?.(
|
||||
connection.originSlot,
|
||||
connection.targetNode,
|
||||
connection.targetSlot,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function placeInsertedNode(graph, anchorNode, insertedNode, direction) {
|
||||
const anchorX = Number(anchorNode?.pos?.[0]) || 0;
|
||||
const anchorY = Number(anchorNode?.pos?.[1]) || 0;
|
||||
const anchorWidth = Number(anchorNode?.size?.[0]) || 220;
|
||||
const insertedWidth = Number(insertedNode?.size?.[0]) || 220;
|
||||
const horizontalGap = 90;
|
||||
let x = direction === 'before'
|
||||
? anchorX - insertedWidth - horizontalGap
|
||||
: anchorX + anchorWidth + horizontalGap;
|
||||
let y = anchorY;
|
||||
|
||||
const overlaps = (candidateX, candidateY) => (graph?._nodes || []).some(node => {
|
||||
if (!node || node === anchorNode || node === insertedNode) return false;
|
||||
const nodeX = Number(node.pos?.[0]) || 0;
|
||||
const nodeY = Number(node.pos?.[1]) || 0;
|
||||
const nodeWidth = Number(node.size?.[0]) || 220;
|
||||
const nodeHeight = Number(node.size?.[1]) || 120;
|
||||
const insertedHeight = Number(insertedNode?.size?.[1]) || 120;
|
||||
return candidateX < nodeX + nodeWidth + 20
|
||||
&& candidateX + insertedWidth + 20 > nodeX
|
||||
&& candidateY < nodeY + nodeHeight + 20
|
||||
&& candidateY + insertedHeight + 20 > nodeY;
|
||||
});
|
||||
|
||||
for (const offset of [0, 80, -80, 160, -160, 240, -240]) {
|
||||
if (!overlaps(x, anchorY + offset)) {
|
||||
y = anchorY + offset;
|
||||
break;
|
||||
}
|
||||
}
|
||||
insertedNode.pos = [x, y];
|
||||
}
|
||||
|
||||
export function spliceModelChainNode({ graph, anchorNode, insertedNode, direction }) {
|
||||
const analysis = analyzeModelChainInsertion(graph, anchorNode, direction);
|
||||
if (!analysis.supported) {
|
||||
const error = new Error(`Model-chain insertion is not available: ${analysis.code}`);
|
||||
error.code = analysis.code;
|
||||
error.analysis = analysis;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const insertedSlots = assertInsertedNodeSlots(insertedNode);
|
||||
const originalConnections = analysis.channels.flatMap(channel => channel.originalLinks);
|
||||
let added = false;
|
||||
graph.beforeChange?.(anchorNode);
|
||||
try {
|
||||
graph.add(insertedNode);
|
||||
added = true;
|
||||
placeInsertedNode(graph, anchorNode, insertedNode, direction);
|
||||
|
||||
for (const channel of analysis.channels) {
|
||||
const slots = insertedSlots[channel.type];
|
||||
if (direction === 'before') {
|
||||
const original = channel.originalLinks[0];
|
||||
connectOrThrow(original.originNode, original.originSlot, insertedNode, slots.input, channel.type);
|
||||
connectOrThrow(insertedNode, slots.output, anchorNode, channel.anchorSlot, channel.type);
|
||||
continue;
|
||||
}
|
||||
|
||||
connectOrThrow(anchorNode, channel.anchorSlot, insertedNode, slots.input, channel.type);
|
||||
for (const original of channel.originalLinks) {
|
||||
connectOrThrow(insertedNode, slots.output, original.targetNode, original.targetSlot, channel.type);
|
||||
}
|
||||
}
|
||||
|
||||
graph.afterChange?.(anchorNode);
|
||||
graph.change?.();
|
||||
graph.setDirtyCanvas?.(true, true);
|
||||
return insertedNode;
|
||||
} catch (error) {
|
||||
if (added) graph.remove?.(insertedNode);
|
||||
restoreConnections(originalConnections);
|
||||
graph.afterChange?.(anchorNode);
|
||||
graph.change?.();
|
||||
graph.setDirtyCanvas?.(true, true);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,103 @@
|
||||
const MODEL_FILE_PATTERN = /\.(safetensors|ckpt|pt|bin|pth|sft)$/i;
|
||||
|
||||
export function inferPickerModelType(node, widget, options = {}) {
|
||||
const widgetName = String(widget?.name || '').toLowerCase();
|
||||
const nodeType = String(node?.type || '').toLowerCase();
|
||||
const explicitLabel = String(options.modelTypeLabel || '');
|
||||
const key = `${widgetName} ${nodeType} ${explicitLabel.toLowerCase()}`;
|
||||
if (widgetName.includes('vae') || nodeType === 'vaeloader' || key.includes('vae loader')) {
|
||||
return { label: 'VAE', folderTypes: ['vae'], isLora: false };
|
||||
}
|
||||
if (widgetName.includes('lora') || key.includes('lora')) {
|
||||
return { label: 'LoRA', folderTypes: ['loras'], isLora: true };
|
||||
}
|
||||
if (widgetName.includes('control_net') || widgetName.includes('controlnet') || key.includes('controlnet')) {
|
||||
return { label: 'ControlNet', folderTypes: ['controlnet'], isLora: false };
|
||||
}
|
||||
if (widgetName.includes('ckpt') || widgetName.includes('checkpoint') || key.includes('checkpoint')) {
|
||||
return { label: 'Checkpoint', folderTypes: ['checkpoints'], isLora: false };
|
||||
}
|
||||
if (widgetName.includes('unet') || widgetName.includes('diffusion_model') || key.includes('unet loader')) {
|
||||
return { label: 'UNET', folderTypes: ['diffusion_models', 'unet'], isLora: false };
|
||||
}
|
||||
return {
|
||||
label: explicitLabel || node?.type || widget?.name || 'Model',
|
||||
folderTypes: [],
|
||||
isLora: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function getBaseModelFamily(value) {
|
||||
const normalized = String(value || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '');
|
||||
if (!normalized) return '';
|
||||
if (normalized.includes('illustrious')) return 'illustrious';
|
||||
if (normalized.includes('pony')) return 'pony';
|
||||
if (normalized.includes('flux')) return 'flux';
|
||||
if (normalized.includes('sdxl') || normalized.includes('stablediffusionxl')) return 'sdxl';
|
||||
if (normalized.includes('sd35') || normalized.includes('stablediffusion35')) return 'sd35';
|
||||
if (normalized.includes('sd3') || normalized.includes('stablediffusion3')) return 'sd3';
|
||||
if (normalized.includes('sd21') || normalized.includes('stablediffusion21')) return 'sd21';
|
||||
if (normalized.includes('sd15') || normalized.includes('stablediffusion15')) return 'sd15';
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function getGraphNodeById(graph, nodeId) {
|
||||
return graph?.getNodeById?.(nodeId)
|
||||
|| graph?._nodes_by_id?.[nodeId]
|
||||
|| graph?._nodes?.find?.(candidate => candidate?.id === nodeId)
|
||||
|| null;
|
||||
}
|
||||
|
||||
export function collectMainModelContextRequests(graph, startNode) {
|
||||
if (!graph || !startNode) return [];
|
||||
const queue = [startNode];
|
||||
const visited = new Set();
|
||||
let depth = 0;
|
||||
while (queue.length && depth < 12) {
|
||||
const levelSize = queue.length;
|
||||
const found = [];
|
||||
for (let index = 0; index < levelSize; index += 1) {
|
||||
const current = queue.shift();
|
||||
if (!current || visited.has(current.id)) continue;
|
||||
visited.add(current.id);
|
||||
for (const widget of current.widgets || []) {
|
||||
if (typeof widget?.value !== 'string' || !MODEL_FILE_PATTERN.test(widget.value)) continue;
|
||||
const inferred = inferPickerModelType(current, widget);
|
||||
if (!inferred.isLora) {
|
||||
const request = { path: widget.value };
|
||||
if (inferred.folderTypes.length) request.folder_types = inferred.folderTypes;
|
||||
found.push(request);
|
||||
}
|
||||
}
|
||||
for (const input of current.inputs || []) {
|
||||
if (!['MODEL', 'CLIP'].includes(input?.type) || input.link == null) continue;
|
||||
const link = graph.links?.[input.link] || graph._links?.[input.link];
|
||||
const origin = getGraphNodeById(graph, link?.origin_id);
|
||||
if (origin && !visited.has(origin.id)) queue.push(origin);
|
||||
}
|
||||
}
|
||||
if (found.length) {
|
||||
const seen = new Set();
|
||||
return found.filter(item => {
|
||||
const key = `${(item.folder_types || []).join(',')}|${item.path}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
depth += 1;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function formatModelTypeLabel(type, fallback = 'Model') {
|
||||
const labels = {
|
||||
loras: 'LoRA',
|
||||
checkpoints: 'Checkpoint',
|
||||
diffusion_models: 'UNET',
|
||||
unet: 'UNET',
|
||||
controlnet: 'ControlNet',
|
||||
vae: 'VAE',
|
||||
};
|
||||
return labels[String(type || '').toLowerCase()] || fallback;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
const PHYSICAL_RENAME_PROTECTED_TYPES = new Set([
|
||||
'vae',
|
||||
'vae_approx',
|
||||
'clip',
|
||||
'text_encoders',
|
||||
'clip_vision',
|
||||
]);
|
||||
|
||||
export function isPhysicalRenameProtectedType(folderType) {
|
||||
return PHYSICAL_RENAME_PROTECTED_TYPES.has(String(folderType || '').trim().toLowerCase());
|
||||
}
|
||||
|
||||
export function inferModelFolderTypes(node, widget) {
|
||||
const widgetName = String(widget?.name || '').toLowerCase();
|
||||
const nodeType = String(node?.type || '').toLowerCase();
|
||||
const key = `${widgetName} ${nodeType}`;
|
||||
|
||||
if (key.includes('clip_vision') || key.includes('clip vision') || nodeType.includes('clipvision')) return ['clip_vision'];
|
||||
if (key.includes('vae_approx') || key.includes('vae approx')) return ['vae_approx'];
|
||||
if (widgetName.includes('vae') || nodeType === 'vaeloader' || key.includes('vae loader')) return ['vae'];
|
||||
if (widgetName.includes('lora') || key.includes('lora')) return ['loras'];
|
||||
if (widgetName.includes('control_net') || widgetName.includes('controlnet') || key.includes('controlnet')) return ['controlnet'];
|
||||
if (widgetName.includes('ckpt') || widgetName.includes('checkpoint') || key.includes('checkpoint')) return ['checkpoints'];
|
||||
if (widgetName.includes('unet') || widgetName.includes('diffusion_model') || key.includes('unet loader')) return ['diffusion_models', 'unet'];
|
||||
if (widgetName.includes('clip_name') || widgetName.includes('text_encoder') || nodeType.includes('cliploader')) return ['clip', 'text_encoders'];
|
||||
return [];
|
||||
}
|
||||
|
||||
export function requiresHashForModelRecovery(node, widget) {
|
||||
const types = inferModelFolderTypes(node, widget);
|
||||
return types.length > 0 && types.every(type => PHYSICAL_RENAME_PROTECTED_TYPES.has(type));
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
import { app } from '../../../scripts/app.js';
|
||||
|
||||
function cloneJson(value) {
|
||||
if (value === undefined) return undefined;
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function nodeType(node) {
|
||||
return String(node?.type || node?.class_type || '').trim();
|
||||
}
|
||||
|
||||
function savedNodeRecords(workflow) {
|
||||
return Array.isArray(workflow?.nodes) ? workflow.nodes.filter((node) => node && node.id !== undefined) : [];
|
||||
}
|
||||
|
||||
function nodeShape(node) {
|
||||
const inputs = Array.isArray(node?.inputs) ? node.inputs.length : -1;
|
||||
const outputs = Array.isArray(node?.outputs) ? node.outputs.length : -1;
|
||||
return `${nodeType(node)}|${inputs}|${outputs}`;
|
||||
}
|
||||
|
||||
function nodeTitle(node) {
|
||||
return String(node?.title || node?.properties?.['Node name for S&R'] || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function liveNodeById(graph, id) {
|
||||
return graph?.getNodeById?.(id)
|
||||
|| graph?.getNodeById?.(Number(id))
|
||||
|| graph?._nodes?.find((node) => String(node?.id) === String(id))
|
||||
|| null;
|
||||
}
|
||||
|
||||
function volatileWidget(node, widget, index) {
|
||||
const name = String(widget?.name || '').toLowerCase();
|
||||
if (/(^|[_\s-])(seed|noise_seed|random_seed|variation_seed|last_seed)([_\s-]|$)/i.test(name)) return true;
|
||||
const type = nodeType(node).toLowerCase();
|
||||
if (type === 'ksampler') return index === 0;
|
||||
if (type === 'ksampleradvanced') return index === 1;
|
||||
return false;
|
||||
}
|
||||
|
||||
function matchSkeleton(sourceWorkflow, candidateWorkflow) {
|
||||
const sourceNodes = savedNodeRecords(sourceWorkflow);
|
||||
const candidateNodes = savedNodeRecords(candidateWorkflow);
|
||||
const available = new Set(candidateNodes);
|
||||
const matches = new Map();
|
||||
const missing = [];
|
||||
for (const source of sourceNodes) {
|
||||
const exact = [...available].find((candidate) => (
|
||||
String(candidate.id) === String(source.id)
|
||||
&& nodeType(candidate) === nodeType(source)
|
||||
&& nodeShape(candidate) === nodeShape(source)
|
||||
));
|
||||
const sameTitle = [...available].find((candidate) => (
|
||||
nodeShape(candidate) === nodeShape(source)
|
||||
&& nodeTitle(candidate) === nodeTitle(source)
|
||||
&& nodeTitle(source)
|
||||
));
|
||||
const sameShape = [...available].find((candidate) => nodeShape(candidate) === nodeShape(source));
|
||||
const sameType = [...available].find((candidate) => nodeType(candidate) === nodeType(source));
|
||||
const target = exact || sameTitle || sameShape || sameType;
|
||||
if (!target) {
|
||||
missing.push(nodeType(source) || String(source.id));
|
||||
continue;
|
||||
}
|
||||
available.delete(target);
|
||||
matches.set(String(source.id), target);
|
||||
}
|
||||
return { matches, missing, sourceCount: sourceNodes.length, candidateCount: candidateNodes.length };
|
||||
}
|
||||
|
||||
export function assertRecipeSkeleton(sourceWorkflow, candidateWorkflow) {
|
||||
const result = matchSkeleton(sourceWorkflow, candidateWorkflow);
|
||||
if (result.missing.length) {
|
||||
const error = new Error(`Missing recipe skeleton nodes: ${result.missing.join(', ')}`);
|
||||
error.code = 'recipe_parameter_skeleton_mismatch';
|
||||
error.missing = result.missing;
|
||||
throw error;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Apply only saved widget values to a matching local graph, with rollback. */
|
||||
export function applyRecipeParametersToCanvas(source) {
|
||||
const graph = app.graph;
|
||||
const sourceWorkflow = source?.workflow;
|
||||
const candidateWorkflow = graph?.serialize?.();
|
||||
if (!graph || !sourceWorkflow || !candidateWorkflow) throw new Error('recipe_parameter_apply_unavailable');
|
||||
const skeleton = assertRecipeSkeleton(sourceWorkflow, candidateWorkflow);
|
||||
const sourceNodes = savedNodeRecords(sourceWorkflow);
|
||||
const changes = [];
|
||||
for (const sourceNode of sourceNodes) {
|
||||
const targetRecord = skeleton.matches.get(String(sourceNode.id));
|
||||
if (!targetRecord || !Array.isArray(sourceNode.widgets_values)) continue;
|
||||
// `candidateWorkflow` is a serialized graph and intentionally has no
|
||||
// live widget objects. Resolve the matched record back to ComfyUI's
|
||||
// runtime node before reading callbacks or writing widget values.
|
||||
const target = liveNodeById(graph, targetRecord.id);
|
||||
if (!target) {
|
||||
const error = new Error(`Could not resolve live node ${targetRecord.id} on ${nodeType(targetRecord)}`);
|
||||
error.code = 'recipe_parameter_node_unavailable';
|
||||
throw error;
|
||||
}
|
||||
const targetWidgets = Array.isArray(target.widgets) ? target.widgets : [];
|
||||
for (let index = 0; index < sourceNode.widgets_values.length; index += 1) {
|
||||
if (volatileWidget(sourceNode, targetWidgets[index], index)) continue;
|
||||
if (!targetWidgets[index]) {
|
||||
const error = new Error(`Missing widget ${index} on ${nodeType(target)}`);
|
||||
error.code = 'recipe_parameter_widget_mismatch';
|
||||
throw error;
|
||||
}
|
||||
changes.push({
|
||||
sourceNode,
|
||||
target,
|
||||
targetWidget: targetWidgets[index],
|
||||
index,
|
||||
value: cloneJson(sourceNode.widgets_values[index]),
|
||||
previousValue: cloneJson(targetWidgets[index].value),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const previous = changes.map((change) => ({ ...change, previousValue: cloneJson(change.previousValue) }));
|
||||
graph.beforeChange?.();
|
||||
try {
|
||||
for (const change of changes) {
|
||||
change.targetWidget.value = cloneJson(change.value);
|
||||
if (Array.isArray(change.target.widgets_values)) change.target.widgets_values[change.index] = cloneJson(change.value);
|
||||
if (typeof change.targetWidget.callback === 'function') {
|
||||
change.targetWidget.callback.call(change.targetWidget, change.targetWidget.value, app.canvas, change.target);
|
||||
}
|
||||
if (typeof change.target.onWidgetChanged === 'function') {
|
||||
// Current ComfyUI uses the fourth argument as the live widget
|
||||
// for its error-clearing hooks. Omitting it makes those hooks
|
||||
// evaluate `sourceNodeId in undefined`.
|
||||
change.target.onWidgetChanged(
|
||||
change.index,
|
||||
change.targetWidget.value,
|
||||
change.previousValue,
|
||||
change.targetWidget,
|
||||
);
|
||||
}
|
||||
}
|
||||
graph.change?.();
|
||||
graph.setDirtyCanvas?.(true, true);
|
||||
app.canvas?.setDirty?.(true, true);
|
||||
return { nodes: sourceNodes.length, widgets: changes.length };
|
||||
} catch (error) {
|
||||
for (const change of previous) {
|
||||
change.targetWidget.value = cloneJson(change.previousValue);
|
||||
if (Array.isArray(change.target.widgets_values)) change.target.widgets_values[change.index] = cloneJson(change.previousValue);
|
||||
}
|
||||
graph.change?.();
|
||||
graph.setDirtyCanvas?.(true, true);
|
||||
throw error;
|
||||
} finally {
|
||||
graph.afterChange?.();
|
||||
}
|
||||
}
|
||||
|
||||
function linkRecords(workflow) {
|
||||
if (Array.isArray(workflow?.links)) return workflow.links.filter((link) => Array.isArray(link) || link && typeof link === 'object');
|
||||
if (workflow?.links instanceof Map) return [...workflow.links.values()].filter(Boolean);
|
||||
if (workflow?.links && typeof workflow.links === 'object') return Object.values(workflow.links).filter(Boolean);
|
||||
return [];
|
||||
}
|
||||
|
||||
function savedGroupRecords(workflow) {
|
||||
return Array.isArray(workflow?.groups) ? workflow.groups.filter((group) => group && typeof group === 'object') : [];
|
||||
}
|
||||
|
||||
function graphNode(graph, id) {
|
||||
return graph?.getNodeById?.(id)
|
||||
|| graph?._nodes?.find((node) => String(node?.id) === String(id))
|
||||
|| null;
|
||||
}
|
||||
|
||||
function nextNodeId(graph, usedIds) {
|
||||
let max = 0;
|
||||
for (const node of graph?._nodes || []) max = Math.max(max, Number(node?.id) || 0);
|
||||
for (const id of usedIds) max = Math.max(max, Number(id) || 0);
|
||||
return max + 1;
|
||||
}
|
||||
|
||||
function liveBounds(graph) {
|
||||
const nodes = (graph?._nodes || []).filter(Boolean);
|
||||
if (!nodes.length) return null;
|
||||
return nodes.reduce((bounds, node) => {
|
||||
const x = Number(node.pos?.[0]) || 0;
|
||||
const y = Number(node.pos?.[1]) || 0;
|
||||
const width = Number(node.size?.[0]) || 220;
|
||||
const height = Number(node.size?.[1]) || 120;
|
||||
bounds.minX = Math.min(bounds.minX, x);
|
||||
bounds.minY = Math.min(bounds.minY, y);
|
||||
bounds.maxX = Math.max(bounds.maxX, x + width);
|
||||
bounds.maxY = Math.max(bounds.maxY, y + height);
|
||||
return bounds;
|
||||
}, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity });
|
||||
}
|
||||
|
||||
function savedBounds(nodes) {
|
||||
if (!nodes.length) return { minX: 0, minY: 0, maxX: 0, maxY: 0 };
|
||||
return nodes.reduce((bounds, node) => {
|
||||
const x = Number(node.pos?.[0]) || 0;
|
||||
const y = Number(node.pos?.[1]) || 0;
|
||||
const width = Number(node.size?.[0]) || 220;
|
||||
const height = Number(node.size?.[1]) || 120;
|
||||
bounds.minX = Math.min(bounds.minX, x);
|
||||
bounds.minY = Math.min(bounds.minY, y);
|
||||
bounds.maxX = Math.max(bounds.maxX, x + width);
|
||||
bounds.maxY = Math.max(bounds.maxY, y + height);
|
||||
return bounds;
|
||||
}, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity });
|
||||
}
|
||||
|
||||
function instantiateNode(nodeData) {
|
||||
const type = nodeType(nodeData);
|
||||
const creator = globalThis.LiteGraph?.createNode;
|
||||
if (typeof creator !== 'function' || !type) return null;
|
||||
const node = creator.call(globalThis.LiteGraph, type);
|
||||
if (!node) return null;
|
||||
node.configure?.(cloneJson(nodeData));
|
||||
return node;
|
||||
}
|
||||
|
||||
function instantiateGroup(groupData) {
|
||||
const GroupClass = globalThis.LiteGraph?.LGraphGroup;
|
||||
if (typeof GroupClass !== 'function') return null;
|
||||
const group = new GroupClass(String(groupData?.title || ''));
|
||||
group.configure?.(cloneJson(groupData));
|
||||
return group;
|
||||
}
|
||||
|
||||
function linkField(link, arrayIndex, objectKey) {
|
||||
return Array.isArray(link) ? link[arrayIndex] : link?.[objectKey];
|
||||
}
|
||||
|
||||
function connectLink(link, idMap, graph) {
|
||||
const originId = linkField(link, 1, 'origin_id');
|
||||
const targetId = linkField(link, 3, 'target_id');
|
||||
const originSlot = linkField(link, 2, 'origin_slot');
|
||||
const targetSlot = linkField(link, 4, 'target_slot');
|
||||
if (originId === undefined || targetId === undefined || originSlot === undefined || targetSlot === undefined) return false;
|
||||
const origin = graphNode(graph, idMap.get(String(originId)));
|
||||
const target = graphNode(graph, idMap.get(String(targetId)));
|
||||
if (!origin || !target) return false;
|
||||
const result = origin.connect?.(Number(originSlot), target, Number(targetSlot));
|
||||
return Boolean(result);
|
||||
}
|
||||
|
||||
/** Merge one serialized workflow into the live graph as one rollback-able action. */
|
||||
export function appendRecipeToCanvas(recipe) {
|
||||
const graph = app.graph;
|
||||
const savedNodes = savedNodeRecords(recipe?.workflow);
|
||||
const savedLinks = linkRecords(recipe?.workflow);
|
||||
const savedGroups = savedGroupRecords(recipe?.workflow);
|
||||
if (!graph || !savedNodes.length) throw new Error('recipe_append_empty');
|
||||
if (typeof globalThis.LiteGraph?.createNode !== 'function') throw new Error('recipe_append_unsupported');
|
||||
if (recipe?.workflow?.definitions?.subgraphs?.length) {
|
||||
throw new Error('recipe_append_subgraphs_unsupported');
|
||||
}
|
||||
if (savedGroups.length && typeof globalThis.LiteGraph?.LGraphGroup !== 'function') {
|
||||
throw new Error('recipe_append_groups_unsupported');
|
||||
}
|
||||
|
||||
const idMap = new Map();
|
||||
const inserted = [];
|
||||
const existingIds = new Set((graph._nodes || []).map((node) => String(node?.id)));
|
||||
let id = nextNodeId(graph, savedNodes.map((node) => node.id));
|
||||
const sourceBounds = savedBounds(savedNodes);
|
||||
const targetBounds = liveBounds(graph);
|
||||
const offsetX = targetBounds ? targetBounds.maxX + 140 - sourceBounds.minX : 40 - sourceBounds.minX;
|
||||
const offsetY = targetBounds ? targetBounds.minY - sourceBounds.minY : 40 - sourceBounds.minY;
|
||||
const insertedGroups = [];
|
||||
|
||||
graph.beforeChange?.();
|
||||
try {
|
||||
for (const nodeData of savedNodes) {
|
||||
const node = instantiateNode(nodeData);
|
||||
if (!node) {
|
||||
const error = new Error(`Missing node type: ${nodeType(nodeData)}`);
|
||||
error.code = 'recipe_append_missing_node';
|
||||
throw error;
|
||||
}
|
||||
while (existingIds.has(String(id))) id += 1;
|
||||
const oldId = String(nodeData.id);
|
||||
idMap.set(oldId, id);
|
||||
node.id = id;
|
||||
existingIds.add(String(id));
|
||||
const x = Number(nodeData.pos?.[0]) || 0;
|
||||
const y = Number(nodeData.pos?.[1]) || 0;
|
||||
node.pos = [x + offsetX, y + offsetY];
|
||||
graph.add(node);
|
||||
inserted.push(node);
|
||||
id += 1;
|
||||
}
|
||||
|
||||
for (const link of savedLinks) {
|
||||
if (!connectLink(link, idMap, graph)) {
|
||||
const error = new Error('Invalid link in recipe');
|
||||
error.code = 'recipe_append_invalid_link';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
for (const groupData of savedGroups) {
|
||||
const group = instantiateGroup(groupData);
|
||||
if (!group) {
|
||||
const error = new Error('Could not restore a recipe group.');
|
||||
error.code = 'recipe_append_groups_unsupported';
|
||||
throw error;
|
||||
}
|
||||
if (Array.isArray(group.bounding) && group.bounding.length >= 4) {
|
||||
group.bounding = [
|
||||
Number(group.bounding[0]) + offsetX,
|
||||
Number(group.bounding[1]) + offsetY,
|
||||
group.bounding[2],
|
||||
group.bounding[3],
|
||||
];
|
||||
}
|
||||
graph.add(group);
|
||||
insertedGroups.push(group);
|
||||
}
|
||||
graph.change?.();
|
||||
graph.setDirtyCanvas?.(true, true);
|
||||
app.canvas?.setDirty?.(true, true);
|
||||
app.canvas?.selectNodes?.(inserted);
|
||||
app.canvas?.selectItems?.(inserted);
|
||||
return { nodes: inserted.length, links: savedLinks.length, groups: insertedGroups.length };
|
||||
} catch (error) {
|
||||
for (const group of insertedGroups) graph.remove?.(group);
|
||||
for (const node of inserted) graph.remove?.(node);
|
||||
graph.change?.();
|
||||
graph.setDirtyCanvas?.(true, true);
|
||||
throw error;
|
||||
} finally {
|
||||
graph.afterChange?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
/** Pure, bounded semantic comparison for saved Workflow Recipes. */
|
||||
|
||||
const MAX_VALUE_LENGTH = 320;
|
||||
const MAX_CHANGES = 160;
|
||||
|
||||
function cloneSafe(value) {
|
||||
if (value === null || value === undefined || ['string', 'number', 'boolean'].includes(typeof value)) return value;
|
||||
try {
|
||||
const encoded = JSON.stringify(value);
|
||||
if (!encoded || encoded.length > MAX_VALUE_LENGTH * 8) return '[complex value]';
|
||||
return JSON.parse(encoded);
|
||||
} catch (error) {
|
||||
return '[complex value]';
|
||||
}
|
||||
}
|
||||
|
||||
function stable(value) {
|
||||
const cloned = cloneSafe(value);
|
||||
try { return JSON.stringify(cloned); } catch (error) { return String(cloned); }
|
||||
}
|
||||
|
||||
export function formatDiffValue(value) {
|
||||
if (value === null || value === undefined || value === '') return '—';
|
||||
let text;
|
||||
if (typeof value === 'string') text = value;
|
||||
else {
|
||||
try { text = JSON.stringify(value); } catch (error) { text = String(value); }
|
||||
}
|
||||
text = String(text).replace(/\s+/g, ' ').trim();
|
||||
return text.length > MAX_VALUE_LENGTH ? `${text.slice(0, MAX_VALUE_LENGTH - 1)}…` : text;
|
||||
}
|
||||
|
||||
function itemKey(item, fallback) {
|
||||
if (item?.key) return String(item.key);
|
||||
const node = item?.nodeId ?? item?.node_id ?? '';
|
||||
const index = item?.widgetIndex ?? item?.widget_index ?? '';
|
||||
const name = item?.widgetName ?? item?.widget_name ?? '';
|
||||
return `${node}:${index}:${name}:${fallback}`;
|
||||
}
|
||||
|
||||
function addMapChanges(changes, category, before, after, getKey, getValue, getLabel) {
|
||||
const left = new Map((before || []).map((item, index) => [getKey(item, index), item]));
|
||||
const right = new Map((after || []).map((item, index) => [getKey(item, index), item]));
|
||||
const keys = [...new Set([...left.keys(), ...right.keys()])].sort();
|
||||
for (const key of keys) {
|
||||
const leftItem = left.get(key);
|
||||
const rightItem = right.get(key);
|
||||
const leftValue = leftItem === undefined ? undefined : getValue(leftItem);
|
||||
const rightValue = rightItem === undefined ? undefined : getValue(rightItem);
|
||||
const same = leftItem !== undefined && rightItem !== undefined && stable(leftValue) === stable(rightValue);
|
||||
if (same) continue;
|
||||
changes.push({
|
||||
kind: leftItem === undefined ? 'added' : rightItem === undefined ? 'removed' : 'changed',
|
||||
category,
|
||||
key,
|
||||
label: getLabel(rightItem || leftItem),
|
||||
before: cloneSafe(leftValue),
|
||||
after: cloneSafe(rightValue),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function valueAt(recipe, path, fallback = null) {
|
||||
const parts = path.split('.');
|
||||
let value = recipe;
|
||||
for (const part of parts) {
|
||||
if (!value || typeof value !== 'object') return fallback;
|
||||
value = value[part];
|
||||
}
|
||||
return value === undefined ? fallback : value;
|
||||
}
|
||||
|
||||
function summaryValue(recipe, key) {
|
||||
const params = recipe?.params || {};
|
||||
const values = {
|
||||
baseModel: params.baseModel,
|
||||
steps: params.steps,
|
||||
cfg: params.cfg,
|
||||
sampler_name: params.sampler_name,
|
||||
scheduler: params.scheduler,
|
||||
denoise: params.denoise,
|
||||
seed: params.seed,
|
||||
resolution: params.resolution,
|
||||
nodeCount: params.nodeCount,
|
||||
loras: (params.loras || []).map((item) => ({
|
||||
name: item?.name,
|
||||
strength_model: item?.strength_model,
|
||||
strength_clip: item?.strength_clip,
|
||||
})),
|
||||
};
|
||||
return values[key];
|
||||
}
|
||||
|
||||
function addScalarChanges(changes, category, entries) {
|
||||
for (const entry of entries) {
|
||||
const before = cloneSafe(entry.before);
|
||||
const after = cloneSafe(entry.after);
|
||||
if (stable(before) === stable(after)) continue;
|
||||
changes.push({
|
||||
kind: before === undefined || before === null ? 'added' : after === undefined || after === null ? 'removed' : 'changed',
|
||||
category,
|
||||
key: entry.key,
|
||||
label: entry.label,
|
||||
before,
|
||||
after,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function graphSummary(recipe) {
|
||||
const graph = recipe?.workflow || {};
|
||||
return {
|
||||
nodes: Array.isArray(graph.nodes) ? graph.nodes.length : null,
|
||||
links: Array.isArray(graph.links) ? graph.links.length : graph.links && typeof graph.links === 'object' ? Object.keys(graph.links).length : null,
|
||||
fingerprint: recipe?.workflow_fingerprint?.value || null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Return deterministic UI-neutral changes between two recipe records. */
|
||||
export function buildRecipeDiff(beforeRecipe, afterRecipe) {
|
||||
const before = beforeRecipe || {};
|
||||
const after = afterRecipe || {};
|
||||
const changes = [];
|
||||
|
||||
addScalarChanges(changes, 'pinned', [
|
||||
{
|
||||
key: 'pinned',
|
||||
label: 'Pinned parameters',
|
||||
before: (before.params?.pinned || []).map((item) => ({
|
||||
key: itemKey(item, 'pinned'),
|
||||
value: item?.value,
|
||||
})),
|
||||
after: (after.params?.pinned || []).map((item) => ({
|
||||
key: itemKey(item, 'pinned'),
|
||||
value: item?.value,
|
||||
})),
|
||||
},
|
||||
]);
|
||||
|
||||
addMapChanges(
|
||||
changes,
|
||||
'prompts',
|
||||
before.params?.promptPositive || [],
|
||||
after.params?.promptPositive || [],
|
||||
(_item, index) => `positive:${index}`,
|
||||
(item) => item,
|
||||
() => 'Positive prompt',
|
||||
);
|
||||
addMapChanges(
|
||||
changes,
|
||||
'prompts',
|
||||
before.params?.promptNegative || [],
|
||||
after.params?.promptNegative || [],
|
||||
(_item, index) => `negative:${index}`,
|
||||
(item) => item,
|
||||
() => 'Negative prompt',
|
||||
);
|
||||
addMapChanges(
|
||||
changes,
|
||||
'models',
|
||||
before.params?.model_references || [],
|
||||
after.params?.model_references || [],
|
||||
(item, index) => itemKey(item, `model:${index}`),
|
||||
(item) => item?.saved_value,
|
||||
(item) => `${item?.category || 'model'} · ${item?.node_title || item?.node_type || 'loader'}`,
|
||||
);
|
||||
|
||||
const summaryKeys = ['baseModel', 'steps', 'cfg', 'sampler_name', 'scheduler', 'denoise', 'seed', 'resolution', 'nodeCount', 'loras'];
|
||||
addScalarChanges(changes, 'parameters', summaryKeys.map((key) => ({
|
||||
key,
|
||||
label: key,
|
||||
before: summaryValue(before, key),
|
||||
after: summaryValue(after, key),
|
||||
})));
|
||||
|
||||
const beforeGraph = graphSummary(before);
|
||||
const afterGraph = graphSummary(after);
|
||||
addScalarChanges(changes, 'workflow', [
|
||||
{ key: 'fingerprint', label: 'Workflow fingerprint', before: beforeGraph.fingerprint, after: afterGraph.fingerprint },
|
||||
{ key: 'node_count', label: 'Node count', before: beforeGraph.nodes, after: afterGraph.nodes },
|
||||
{ key: 'link_count', label: 'Link count', before: beforeGraph.links, after: afterGraph.links },
|
||||
]);
|
||||
|
||||
addScalarChanges(changes, 'presentation', [
|
||||
{ key: 'name', label: 'Recipe name', before: before.name, after: after.name },
|
||||
{ key: 'notes', label: 'Notes', before: before.notes, after: after.notes },
|
||||
{ key: 'tags', label: 'Tags', before: before.tags || [], after: after.tags || [] },
|
||||
{ key: 'cover', label: 'Cover', before: Boolean(before.thumbnail || before.source_image), after: Boolean(after.thumbnail || after.source_image) },
|
||||
{
|
||||
key: 'preview_snapshots',
|
||||
label: 'Model preview snapshots',
|
||||
before: (before.params?.model_references || []).filter((item) => item?.preview?.snapshot_asset_id).length,
|
||||
after: (after.params?.model_references || []).filter((item) => item?.preview?.snapshot_asset_id).length,
|
||||
},
|
||||
]);
|
||||
|
||||
return changes.slice(0, MAX_CHANGES);
|
||||
}
|
||||
|
||||
export function diffIsEmpty(changes) {
|
||||
return !Array.isArray(changes) || changes.length === 0;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/** Pure helpers for recipe model references and provenance labels. */
|
||||
|
||||
const MODEL_FILE_PATTERN = /\.(?:safetensors|ckpt|pt|bin|sft)$/i;
|
||||
|
||||
function nodeType(node) {
|
||||
return String(node?.type || node?.class_type || '').trim();
|
||||
}
|
||||
|
||||
function nodeTitle(node) {
|
||||
return String(node?._meta?.title || node?.title || nodeType(node) || 'Unknown node').trim();
|
||||
}
|
||||
|
||||
function modelSpecs(type) {
|
||||
const lowered = type.toLowerCase();
|
||||
if (/checkpointloader(simple)?$/.test(lowered)) return [[0, 'checkpoint', 'checkpoint']];
|
||||
if (lowered.endsWith('unetloader')) return [[0, 'unet', 'unet']];
|
||||
if (/loraloader/.test(lowered)) return [[0, 'lora', 'lora']];
|
||||
if (lowered.endsWith('vaeloader')) return [[0, 'vae', 'vae']];
|
||||
if (lowered.endsWith('clipvisionloader')) return [[0, 'clip_vision', 'clip_vision']];
|
||||
if (lowered.endsWith('controlnetloader')) return [[0, 'controlnet', 'controlnet']];
|
||||
if (/(^|[^a-z])(?:dual|triple)?cliploader$/.test(lowered)) {
|
||||
const specs = [[0, 'text_encoder', 'clip']];
|
||||
if (lowered.includes('dualclip') || lowered.includes('tripleclip')) specs.push([1, 'text_encoder', 'clip']);
|
||||
if (lowered.includes('tripleclip')) specs.push([2, 'text_encoder', 'clip']);
|
||||
return specs;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function statusFor(identity) {
|
||||
const status = identity?.status;
|
||||
return ['verified', 'unverified', 'unavailable'].includes(status) ? status : 'unavailable';
|
||||
}
|
||||
|
||||
export function normaliseIdentity(identity) {
|
||||
const result = { ...(identity || {}) };
|
||||
result.status = statusFor(identity);
|
||||
if (typeof result.sha256 !== 'string' || !/^[0-9a-f]{64}$/i.test(result.sha256)) delete result.sha256;
|
||||
if (!Number.isFinite(Number(result.size))) delete result.size;
|
||||
return result;
|
||||
}
|
||||
|
||||
export function deriveRecipeModelReferences(recipe) {
|
||||
const stored = recipe?.params?.model_references;
|
||||
if (Array.isArray(stored) && stored.length) {
|
||||
return stored.map((reference) => ({
|
||||
...reference,
|
||||
identity: normaliseIdentity(reference.identity),
|
||||
}));
|
||||
}
|
||||
|
||||
const references = [];
|
||||
for (const node of recipe?.workflow?.nodes || []) {
|
||||
const type = nodeType(node);
|
||||
const values = Array.isArray(node?.widgets_values) ? node.widgets_values : [];
|
||||
for (const [widgetIndex, category, widgetName] of modelSpecs(type)) {
|
||||
const savedValue = values[widgetIndex];
|
||||
if (typeof savedValue !== 'string' || !savedValue.trim()) continue;
|
||||
references.push({
|
||||
node_id: node?.id ?? null,
|
||||
node_type: type || 'Unknown',
|
||||
node_title: nodeTitle(node),
|
||||
widget_index: widgetIndex,
|
||||
widget_name: widgetName,
|
||||
saved_value: savedValue,
|
||||
category,
|
||||
base_model: recipe?.params?.baseModel || null,
|
||||
identity: { status: 'unverified' },
|
||||
});
|
||||
}
|
||||
}
|
||||
return references;
|
||||
}
|
||||
|
||||
export function shortHash(value) {
|
||||
if (typeof value !== 'string' || !value) return '';
|
||||
return value.length > 16 ? `${value.slice(0, 12)}...${value.slice(-4)}` : value;
|
||||
}
|
||||
|
||||
export function recipeReferenceKey(reference) {
|
||||
return [
|
||||
reference?.node_id ?? '',
|
||||
reference?.widget_index ?? '',
|
||||
reference?.category ?? '',
|
||||
reference?.saved_value ?? '',
|
||||
].join('\u001f');
|
||||
}
|
||||
|
||||
export function formatIdentitySize(value) {
|
||||
const size = Number(value);
|
||||
if (!Number.isFinite(size) || size < 0) return '';
|
||||
if (size < 1024) return `${size} B`;
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||||
if (size < 1024 * 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(size / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
export function isModelReference(reference) {
|
||||
return Boolean(reference?.saved_value && MODEL_FILE_PATTERN.test(reference.saved_value));
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
/**
|
||||
* Read-only helpers for turning the live LiteGraph canvas into a compact
|
||||
* Workflow Recipe summary. The saved workflow itself remains authoritative:
|
||||
* an unfamiliar custom node must never prevent a recipe from being saved.
|
||||
*/
|
||||
|
||||
const MAX_UPSTREAM_NODES = 96;
|
||||
const MAX_SUMMARY_NODES = 120;
|
||||
const MAX_WIDGETS_PER_NODE = 16;
|
||||
const MAX_WIDGET_TEXT = 320;
|
||||
const MAX_PINNED_VALUE_JSON = 2400;
|
||||
const MAX_PARAMETER_CHOICES = 5000;
|
||||
const SENSITIVE_WIDGET_NAME = /(?:api.?key|access.?token|auth|password|passwd|secret|credential)/i;
|
||||
const SUPPORTED_PROMPT_NODE_TYPES = new Set([
|
||||
'cliptextencode',
|
||||
]);
|
||||
const SUPPORTED_PROMPT_CONSUMERS = new Map([
|
||||
['ksampler', { positive: 'positive', negative: 'negative' }],
|
||||
['ksampleradvanced', { positive: 'positive', negative: 'negative' }],
|
||||
['cfgguider', { positive: 'positive', negative: 'negative' }],
|
||||
['basicguider', { conditioning: 'positive' }],
|
||||
['dualcfgguider', { cond1: 'positive', cond2: 'positive', negative: 'negative' }],
|
||||
]);
|
||||
const SUPPORTED_CONDITIONING_PASSTHROUGH = new Set([
|
||||
'conditioningaverage',
|
||||
'conditioningcombine',
|
||||
'conditioningconcat',
|
||||
'conditioningmultiply',
|
||||
'conditioningsetarea',
|
||||
'conditioningsetareapercentage',
|
||||
'conditioningsetareastrength',
|
||||
'conditioningsetmask',
|
||||
'conditioningsettimesteprange',
|
||||
'conditioningzeroout',
|
||||
]);
|
||||
|
||||
function nodeType(node) {
|
||||
return String(node?.type || node?.comfyClass || '');
|
||||
}
|
||||
|
||||
function normaliseName(value) {
|
||||
return String(value || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function isSupportedPromptNodeType(value) {
|
||||
return SUPPORTED_PROMPT_NODE_TYPES.has(normaliseName(value));
|
||||
}
|
||||
|
||||
function widgetValue(node, names, fallbackIndex = -1) {
|
||||
const wanted = new Set(names.map(normaliseName));
|
||||
const widgets = Array.isArray(node?.widgets) ? node.widgets : [];
|
||||
const named = widgets.find((widget) => wanted.has(normaliseName(widget?.name)));
|
||||
if (named && named.value !== undefined && named.value !== null) return named.value;
|
||||
|
||||
if (fallbackIndex >= 0) {
|
||||
const fallback = widgets[fallbackIndex]?.value;
|
||||
if (fallback !== undefined && fallback !== null) return fallback;
|
||||
const serialised = node?.widgets_values?.[fallbackIndex];
|
||||
if (serialised !== undefined && serialised !== null) return serialised;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function textValue(value) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function numberValue(value) {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function summariseWidgetValue(value) {
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > MAX_WIDGET_TEXT ? `${trimmed.slice(0, MAX_WIDGET_TEXT - 1)}…` : trimmed;
|
||||
}
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return value;
|
||||
if (Array.isArray(value) && value.length <= 12 && value.every((item) => ['string', 'number', 'boolean'].includes(typeof item))) {
|
||||
return value.map((item) => typeof item === 'string' && item.length > 80 ? `${item.slice(0, 79)}…` : item);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function clonePinnableValue(value) {
|
||||
if (value === null || ['string', 'number', 'boolean'].includes(typeof value)) {
|
||||
if (typeof value === 'string' && value.length > MAX_PINNED_VALUE_JSON) return null;
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
const encoded = JSON.stringify(value);
|
||||
if (!encoded || encoded.length > MAX_PINNED_VALUE_JSON) return null;
|
||||
return JSON.parse(encoded);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractGenericNodeSummary(node) {
|
||||
const widgets = [];
|
||||
for (const [index, widget] of (node?.widgets || []).entries()) {
|
||||
const name = textValue(widget?.name) || `widget_${index + 1}`;
|
||||
if (SENSITIVE_WIDGET_NAME.test(name) || normaliseName(widget?.type) === 'button') continue;
|
||||
const value = summariseWidgetValue(widget?.value);
|
||||
if (value === null || value === '') continue;
|
||||
widgets.push({ name, value, index });
|
||||
if (widgets.length >= MAX_WIDGETS_PER_NODE) break;
|
||||
}
|
||||
|
||||
const nodeData = node?.constructor?.nodeData || {};
|
||||
return {
|
||||
id: node?.id ?? null,
|
||||
type: nodeType(node) || 'Unknown',
|
||||
title: textValue(node?.title) || null,
|
||||
category: textValue(nodeData.category || node?.category) || null,
|
||||
module: textValue(nodeData.python_module || nodeData.module) || null,
|
||||
widgets,
|
||||
widgetCount: Array.isArray(node?.widgets) ? node.widgets.length : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Return safe, user-selectable widget values for manual key-parameter pins. */
|
||||
export function extractRecipeParameterChoices(graph) {
|
||||
const choices = [];
|
||||
if (!graph || !Array.isArray(graph._nodes)) return choices;
|
||||
for (const node of graph._nodes) {
|
||||
for (const [widgetIndex, widget] of (node?.widgets || []).entries()) {
|
||||
const widgetName = textValue(widget?.name) || `widget_${widgetIndex + 1}`;
|
||||
if (SENSITIVE_WIDGET_NAME.test(widgetName) || normaliseName(widget?.type) === 'button') continue;
|
||||
const value = clonePinnableValue(widget?.value);
|
||||
if (value === null || value === '') continue;
|
||||
choices.push({
|
||||
key: `${node?.id ?? 'unknown'}:${widgetIndex}:${widgetName}`,
|
||||
nodeId: node?.id ?? null,
|
||||
nodeType: nodeType(node) || 'Unknown',
|
||||
nodeTitle: textValue(node?.title) || null,
|
||||
widgetName,
|
||||
value,
|
||||
});
|
||||
if (choices.length >= MAX_PARAMETER_CHOICES) return choices;
|
||||
}
|
||||
}
|
||||
return choices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return editable/pinnable choices from an already saved recipe summary.
|
||||
* New summaries retain their original widget index; older recipes gracefully
|
||||
* fall back to the visible widget order.
|
||||
*/
|
||||
export function extractRecipeParameterChoicesFromMetadata(params, workflow = null) {
|
||||
const choices = [];
|
||||
const workflowNodes = new Map((workflow?.nodes || []).filter(Boolean).map((node) => [node.id, node]));
|
||||
for (const node of params?.nodes || []) {
|
||||
for (const [visibleIndex, widget] of (node?.widgets || []).entries()) {
|
||||
const widgetName = textValue(widget?.name) || `widget_${visibleIndex + 1}`;
|
||||
if (SENSITIVE_WIDGET_NAME.test(widgetName)) continue;
|
||||
const widgetIndex = Number.isInteger(widget?.index) ? widget.index : visibleIndex;
|
||||
const workflowValues = workflowNodes.get(node?.id)?.widgets_values;
|
||||
const fullValue = Array.isArray(workflowValues) && widgetIndex >= 0 && widgetIndex < workflowValues.length
|
||||
? workflowValues[widgetIndex]
|
||||
: widget?.value;
|
||||
const value = clonePinnableValue(fullValue);
|
||||
if (value === null || value === '') continue;
|
||||
choices.push({
|
||||
key: `${node?.id ?? 'unknown'}:${widgetIndex}:${widgetName}`,
|
||||
nodeId: node?.id ?? null,
|
||||
nodeType: node?.type || 'Unknown',
|
||||
nodeTitle: node?.title || null,
|
||||
widgetName,
|
||||
widgetIndex,
|
||||
value,
|
||||
});
|
||||
if (choices.length >= MAX_PARAMETER_CHOICES) return choices;
|
||||
}
|
||||
}
|
||||
return choices;
|
||||
}
|
||||
|
||||
function valuesMatch(left, right) {
|
||||
if (left === right) return true;
|
||||
try { return JSON.stringify(left) === JSON.stringify(right); } catch (error) { return false; }
|
||||
}
|
||||
|
||||
function findSummaryWidget(params, change) {
|
||||
const node = (params?.nodes || []).find((item) => item?.id === change.nodeId);
|
||||
if (!node) return null;
|
||||
const widget = (node.widgets || []).find((item) => Number.isInteger(change.widgetIndex) && item.index === change.widgetIndex)
|
||||
|| (node.widgets || []).find((item) => item.name === change.widgetName && valuesMatch(item.value, change.previousValue));
|
||||
return widget ? { node, widget } : null;
|
||||
}
|
||||
|
||||
function syncCommonRecipeMetadata(params, change) {
|
||||
const type = normaliseName(change.nodeType);
|
||||
const widget = normaliseName(change.widgetName);
|
||||
const value = change.value;
|
||||
if (/^(checkpointloader(simple)?|unetloader)$/.test(type) && /^(ckpt_name|checkpoint|unet_name|unet|model_name)$/.test(widget)) {
|
||||
params.baseModel = textValue(value) || null;
|
||||
if (params.baseModel) params.baseModels = [params.baseModel, ...(params.baseModels || []).filter((item) => item !== params.baseModel)];
|
||||
}
|
||||
if (/lora.*loader|loader.*lora/.test(type)) {
|
||||
const loraIndex = (params.nodes || []).filter((item) => /lora.*loader|loader.*lora/i.test(item?.type || '')).findIndex((item) => item.id === change.nodeId);
|
||||
const lora = params.loras?.[loraIndex];
|
||||
if (lora) {
|
||||
if (/^(lora_name|lora|model_name)$/.test(widget)) lora.name = textValue(value);
|
||||
if (/^(strength_model|model_strength)$/.test(widget)) lora.strength_model = numberValue(value);
|
||||
if (/^(strength_clip|clip_strength)$/.test(widget)) lora.strength_clip = numberValue(value);
|
||||
}
|
||||
}
|
||||
if (/^ksampler(advanced)?$|samplercustom/.test(type)) {
|
||||
const field = { seed: 'seed', noise_seed: 'seed', steps: 'steps', cfg: 'cfg', cfg_scale: 'cfg', sampler_name: 'sampler_name', sampler: 'sampler_name', scheduler: 'scheduler', denoise: 'denoise' }[widget];
|
||||
if (field) params[field] = ['steps', 'cfg', 'denoise'].includes(field) ? numberValue(value) : value;
|
||||
}
|
||||
if (isSupportedPromptNodeType(type) && /^(text|prompt)$/.test(widget)) {
|
||||
const positive = Array.isArray(params.promptPositive) ? params.promptPositive : (params.promptPositive = []);
|
||||
const negative = Array.isArray(params.promptNegative) ? params.promptNegative : (params.promptNegative = []);
|
||||
const position = positive.findIndex((item) => item === change.previousValue);
|
||||
if (position >= 0) {
|
||||
positive[position] = textValue(value);
|
||||
return;
|
||||
}
|
||||
const negativePosition = negative.findIndex((item) => item === change.previousValue);
|
||||
if (negativePosition >= 0) {
|
||||
negative[negativePosition] = textValue(value);
|
||||
return;
|
||||
}
|
||||
const summary = (params.nodes || []).find((item) => String(item?.id) === String(change.nodeId));
|
||||
const override = params.promptRoleOverrides?.[String(change.nodeId)]?.role;
|
||||
const role = ['positive', 'negative', 'both'].includes(override) ? override : summary?.role;
|
||||
const prompt = textValue(value);
|
||||
if (!prompt) return;
|
||||
if ((role === 'positive' || role === 'both') && !positive.includes(prompt)) positive.push(prompt);
|
||||
if ((role === 'negative' || role === 'both') && !negative.includes(prompt)) negative.push(prompt);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply direct safe-widget edits to a serialized recipe without instantiating
|
||||
* its graph. Unknown nodes stay opaque; only known widget slots are touched.
|
||||
*/
|
||||
export function applyRecipeWidgetChanges(params, workflow, changes) {
|
||||
for (const change of changes || []) {
|
||||
const found = findSummaryWidget(params, change);
|
||||
if (!found) continue;
|
||||
const { widget } = found;
|
||||
const workflowNode = (workflow?.nodes || []).find((node) => node?.id === change.nodeId);
|
||||
let widgetIndex = Number.isInteger(change.widgetIndex) ? change.widgetIndex : widget.index;
|
||||
if (!Number.isInteger(widgetIndex) && Array.isArray(workflowNode?.widgets_values)) {
|
||||
widgetIndex = workflowNode.widgets_values.findIndex((value) => valuesMatch(value, change.previousValue));
|
||||
}
|
||||
if (!Number.isInteger(widgetIndex) || widgetIndex < 0 || !Array.isArray(workflowNode?.widgets_values)) continue;
|
||||
widget.value = change.value;
|
||||
widget.index = widgetIndex;
|
||||
workflowNode.widgets_values[widgetIndex] = change.value;
|
||||
syncCommonRecipeMetadata(params, change);
|
||||
}
|
||||
return { params, workflow };
|
||||
}
|
||||
|
||||
function buildNodeIndex(graph) {
|
||||
return new Map((graph?._nodes || []).filter(Boolean).map((node) => [String(node.id), node]));
|
||||
}
|
||||
|
||||
function linkOriginId(link) {
|
||||
if (Array.isArray(link)) return link[1];
|
||||
return link?.origin_id;
|
||||
}
|
||||
|
||||
function findLink(graph, linkId) {
|
||||
if (linkId === null || linkId === undefined) return null;
|
||||
const links = graph?.links;
|
||||
if (Array.isArray(links)) return links.find((link) => Array.isArray(link) && link[0] === linkId) || null;
|
||||
if (links instanceof Map) return links.get(linkId) || links.get(String(linkId)) || null;
|
||||
return links?.[linkId] || null;
|
||||
}
|
||||
|
||||
function inputLinkOrigins(graph, node, inputNames) {
|
||||
const wanted = new Set(inputNames.map(normaliseName));
|
||||
const origins = [];
|
||||
for (const input of node?.inputs || []) {
|
||||
if (!wanted.has(normaliseName(input?.name)) || input?.link === null || input?.link === undefined) continue;
|
||||
const origin = linkOriginId(findLink(graph, input.link));
|
||||
if (origin !== null && origin !== undefined && !origins.includes(origin)) origins.push(origin);
|
||||
}
|
||||
return origins;
|
||||
}
|
||||
|
||||
function collectConditioningNodes(graph, startNodeId, nodeIndex) {
|
||||
const queue = [startNodeId];
|
||||
const visited = new Set();
|
||||
const clipNodes = [];
|
||||
|
||||
while (queue.length && visited.size < MAX_UPSTREAM_NODES) {
|
||||
const nodeId = queue.shift();
|
||||
if (nodeId === null || nodeId === undefined || visited.has(nodeId)) continue;
|
||||
visited.add(nodeId);
|
||||
const node = nodeIndex.get(String(nodeId));
|
||||
if (!node) continue;
|
||||
|
||||
if (isSupportedPromptNodeType(nodeType(node))) {
|
||||
if (!clipNodes.includes(node)) clipNodes.push(node);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unknown third-party conditioning nodes are deliberately opaque.
|
||||
// Users can label their text nodes manually in the recipe detail view.
|
||||
if (!SUPPORTED_CONDITIONING_PASSTHROUGH.has(normaliseName(nodeType(node)))) continue;
|
||||
|
||||
for (const input of node.inputs || []) {
|
||||
const inputType = normaliseName(input?.type);
|
||||
const inputName = normaliseName(input?.name);
|
||||
if (inputType !== 'conditioning' && !inputName.includes('conditioning')) continue;
|
||||
const originId = linkOriginId(findLink(graph, input?.link));
|
||||
if (originId !== null && originId !== undefined && !visited.has(originId)) {
|
||||
queue.push(originId);
|
||||
}
|
||||
}
|
||||
}
|
||||
return clipNodes;
|
||||
}
|
||||
|
||||
function appendUnique(target, values) {
|
||||
for (const value of values) {
|
||||
if (value && !target.includes(value)) target.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
function recipeSampler(node) {
|
||||
const type = nodeType(node);
|
||||
const isStandardSampler = /^ksampler$/i.test(type);
|
||||
const isAdvancedSampler = /^ksampleradvanced$/i.test(type);
|
||||
return {
|
||||
type,
|
||||
seed: widgetValue(node, ['seed', 'noise_seed'], (isStandardSampler || isAdvancedSampler) ? 0 : -1),
|
||||
steps: numberValue(widgetValue(node, ['steps'], (isStandardSampler || isAdvancedSampler) ? 2 : -1)),
|
||||
cfg: numberValue(widgetValue(node, ['cfg', 'cfg_scale'], (isStandardSampler || isAdvancedSampler) ? 3 : -1)),
|
||||
sampler_name: textValue(widgetValue(node, ['sampler_name', 'sampler'], (isStandardSampler || isAdvancedSampler) ? 4 : -1)) || null,
|
||||
scheduler: textValue(widgetValue(node, ['scheduler'], (isStandardSampler || isAdvancedSampler) ? 5 : -1)) || null,
|
||||
denoise: numberValue(widgetValue(node, ['denoise'], isStandardSampler ? 6 : -1)),
|
||||
};
|
||||
}
|
||||
|
||||
function mergeSampler(metadata, sampler) {
|
||||
metadata.samplers.push(sampler);
|
||||
for (const field of ['seed', 'steps', 'cfg', 'sampler_name', 'scheduler', 'denoise']) {
|
||||
if (metadata[field] === null && sampler[field] !== null && sampler[field] !== '') {
|
||||
metadata[field] = sampler[field];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a best-effort summary from the live LiteGraph graph without
|
||||
* modifying nodes, links, widgets, or graph state.
|
||||
*/
|
||||
export function extractRecipeMetadata(graph) {
|
||||
const metadata = {
|
||||
baseModel: null,
|
||||
baseModels: [],
|
||||
loras: [],
|
||||
promptPositive: [],
|
||||
promptNegative: [],
|
||||
nodes: [],
|
||||
nodeCount: graph?._nodes?.length || 0,
|
||||
samplers: [],
|
||||
seed: null,
|
||||
steps: null,
|
||||
cfg: null,
|
||||
sampler_name: null,
|
||||
scheduler: null,
|
||||
denoise: null,
|
||||
resolution: null,
|
||||
};
|
||||
if (!graph || !Array.isArray(graph._nodes)) return metadata;
|
||||
|
||||
const nodeIndex = buildNodeIndex(graph);
|
||||
const positiveNodes = [];
|
||||
const negativeNodes = [];
|
||||
|
||||
for (const node of graph._nodes) {
|
||||
const type = nodeType(node);
|
||||
if (metadata.nodes.length < MAX_SUMMARY_NODES) metadata.nodes.push(extractGenericNodeSummary(node));
|
||||
|
||||
if (/^(checkpointloader(simple)?|unetloader)$/i.test(type)) {
|
||||
const model = textValue(widgetValue(
|
||||
node,
|
||||
['ckpt_name', 'checkpoint', 'unet_name', 'unet', 'model_name'],
|
||||
0,
|
||||
));
|
||||
if (model && !metadata.baseModels.includes(model)) metadata.baseModels.push(model);
|
||||
if (!metadata.baseModel && model) metadata.baseModel = model;
|
||||
}
|
||||
|
||||
if (/lora.*loader|loader.*lora/i.test(type)) {
|
||||
const name = textValue(widgetValue(node, ['lora_name', 'lora', 'model_name'], 0));
|
||||
if (name) {
|
||||
metadata.loras.push({
|
||||
name,
|
||||
strength_model: numberValue(widgetValue(node, ['strength_model', 'model_strength'], 1)),
|
||||
strength_clip: numberValue(widgetValue(node, ['strength_clip', 'clip_strength'], 2)),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (/^ksampler(advanced)?$/i.test(type) || /samplercustom/i.test(type)) {
|
||||
mergeSampler(metadata, recipeSampler(node));
|
||||
}
|
||||
|
||||
const roleInputs = SUPPORTED_PROMPT_CONSUMERS.get(normaliseName(type));
|
||||
if (roleInputs) {
|
||||
for (const [inputName, role] of Object.entries(roleInputs)) {
|
||||
for (const origin of inputLinkOrigins(graph, node, [inputName])) {
|
||||
const clips = collectConditioningNodes(graph, origin, nodeIndex);
|
||||
const target = role === 'negative' ? negativeNodes : positiveNodes;
|
||||
for (const clip of clips) if (!target.includes(clip)) target.push(clip);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (/empty.*latent/i.test(type) && !metadata.resolution) {
|
||||
const width = numberValue(widgetValue(node, ['width'], 0));
|
||||
const height = numberValue(widgetValue(node, ['height'], 1));
|
||||
if (width && height) metadata.resolution = { width, height };
|
||||
}
|
||||
}
|
||||
|
||||
for (const summary of metadata.nodes) {
|
||||
if (!isSupportedPromptNodeType(summary.type)) continue;
|
||||
const node = nodeIndex.get(String(summary.id));
|
||||
const prompt = textValue(widgetValue(node, ['text', 'prompt'], 0));
|
||||
const isPositive = positiveNodes.includes(node);
|
||||
const isNegative = negativeNodes.includes(node);
|
||||
|
||||
if (isPositive && isNegative) {
|
||||
summary.role = 'both';
|
||||
summary.roleSource = 'topology';
|
||||
if (prompt) {
|
||||
appendUnique(metadata.promptPositive, [prompt]);
|
||||
appendUnique(metadata.promptNegative, [prompt]);
|
||||
}
|
||||
} else if (isPositive) {
|
||||
summary.role = 'positive';
|
||||
summary.roleSource = 'topology';
|
||||
if (prompt) appendUnique(metadata.promptPositive, [prompt]);
|
||||
} else if (isNegative) {
|
||||
summary.role = 'negative';
|
||||
summary.roleSource = 'topology';
|
||||
if (prompt) appendUnique(metadata.promptNegative, [prompt]);
|
||||
} else {
|
||||
summary.role = 'unknown';
|
||||
summary.roleSource = 'unresolved';
|
||||
}
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture the graph once at save time. The serialized workflow is the source
|
||||
* of truth; metadata is only a bounded presentation layer.
|
||||
*/
|
||||
export function captureRecipeDraft(graph) {
|
||||
const workflow = graph?.serialize?.();
|
||||
const nodes = Array.isArray(workflow?.nodes) ? workflow.nodes : [];
|
||||
const links = Array.isArray(workflow?.links)
|
||||
? workflow.links
|
||||
: (workflow?.links && typeof workflow.links === 'object' ? Object.values(workflow.links) : []);
|
||||
return {
|
||||
workflow,
|
||||
metadata: extractRecipeMetadata(graph),
|
||||
stats: {
|
||||
nodeCount: nodes.length,
|
||||
linkCount: links.length,
|
||||
groupCount: Array.isArray(workflow?.groups) ? workflow.groups.length : 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Capture a bounded canvas preview; the optional element makes this testable. */
|
||||
export function captureCanvasThumbnail(canvasEl = null) {
|
||||
const source = canvasEl || document.getElementById('comfy-canvas-element') || document.querySelector('canvas');
|
||||
if (!source?.width || !source?.height) return null;
|
||||
try {
|
||||
const maxEdge = 720;
|
||||
const scale = Math.min(1, maxEdge / Math.max(source.width, source.height));
|
||||
const thumbnail = document.createElement('canvas');
|
||||
thumbnail.width = Math.max(1, Math.round(source.width * scale));
|
||||
thumbnail.height = Math.max(1, Math.round(source.height * scale));
|
||||
const context = thumbnail.getContext('2d');
|
||||
if (!context) return null;
|
||||
context.drawImage(source, 0, 0, thumbnail.width, thumbnail.height);
|
||||
return thumbnail.toDataURL('image/jpeg', 0.65);
|
||||
} catch (error) {
|
||||
console.warn('Failed to capture recipe thumbnail:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
const ALLOWED_RICH_TAGS = new Set([
|
||||
'A', 'B', 'BLOCKQUOTE', 'BR', 'CODE', 'DEL', 'DIV', 'EM', 'H1', 'H2', 'H3',
|
||||
'H4', 'H5', 'H6', 'HR', 'I', 'LI', 'OL', 'P', 'PRE', 'S', 'SMALL', 'SPAN',
|
||||
'STRONG', 'SUB', 'SUP', 'U', 'UL'
|
||||
]);
|
||||
|
||||
export function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
export function setSafeRichHtml(element, value) {
|
||||
const template = document.createElement('template');
|
||||
template.innerHTML = String(value ?? '');
|
||||
|
||||
for (const node of Array.from(template.content.querySelectorAll('*'))) {
|
||||
if (!ALLOWED_RICH_TAGS.has(node.tagName)) {
|
||||
node.replaceWith(document.createTextNode(node.textContent || ''));
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const attribute of Array.from(node.attributes)) {
|
||||
const name = attribute.name.toLowerCase();
|
||||
const allowed = node.tagName === 'A' && (name === 'href' || name === 'title');
|
||||
if (!allowed) node.removeAttribute(attribute.name);
|
||||
}
|
||||
|
||||
if (node.tagName === 'A') {
|
||||
const href = node.getAttribute('href');
|
||||
if (href) {
|
||||
try {
|
||||
const url = new URL(href, window.location.href);
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') node.removeAttribute('href');
|
||||
} catch (_) {
|
||||
node.removeAttribute('href');
|
||||
}
|
||||
}
|
||||
node.setAttribute('target', '_blank');
|
||||
node.setAttribute('rel', 'noopener noreferrer');
|
||||
}
|
||||
}
|
||||
|
||||
element.replaceChildren(template.content.cloneNode(true));
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,255 @@
|
||||
import { translate } from './locales.js';
|
||||
|
||||
const t = (key, params) => translate(key, params);
|
||||
|
||||
export function anomalousAlert(message, title = 'Anomalous') {
|
||||
return new Promise((resolve) => {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.style.position = 'fixed';
|
||||
overlay.style.inset = '0';
|
||||
overlay.style.zIndex = '999999';
|
||||
overlay.style.display = 'flex';
|
||||
overlay.style.alignItems = 'center';
|
||||
overlay.style.justifyContent = 'center';
|
||||
overlay.style.background = 'rgba(0, 0, 0, 0.6)';
|
||||
overlay.style.backdropFilter = 'blur(4px)';
|
||||
overlay.style.padding = '20px';
|
||||
overlay.style.boxSizing = 'border-box';
|
||||
|
||||
const dialog = document.createElement('div');
|
||||
dialog.style.background = 'linear-gradient(145deg, rgba(48, 49, 55, 0.98), rgba(27, 28, 33, 0.98))';
|
||||
dialog.style.border = '1px solid rgba(255, 255, 255, 0.12)';
|
||||
dialog.style.borderRadius = '16px';
|
||||
dialog.style.padding = '24px';
|
||||
dialog.style.maxWidth = '400px';
|
||||
dialog.style.width = '100%';
|
||||
dialog.style.maxHeight = '90vh';
|
||||
dialog.style.overflowY = 'auto';
|
||||
dialog.style.boxShadow = '0 24px 70px rgba(0, 0, 0, 0.55)';
|
||||
dialog.style.color = '#f3f4f6';
|
||||
dialog.style.display = 'flex';
|
||||
dialog.style.flexDirection = 'column';
|
||||
dialog.style.gap = '16px';
|
||||
|
||||
const heading = document.createElement('h3');
|
||||
heading.textContent = title;
|
||||
heading.style.margin = '0';
|
||||
heading.style.fontSize = '1.25rem';
|
||||
|
||||
const text = document.createElement('p');
|
||||
text.textContent = message;
|
||||
text.style.margin = '0';
|
||||
text.style.lineHeight = '1.5';
|
||||
text.style.whiteSpace = 'pre-wrap';
|
||||
text.style.color = '#ccc';
|
||||
|
||||
const footer = document.createElement('div');
|
||||
footer.style.display = 'flex';
|
||||
footer.style.justifyContent = 'flex-end';
|
||||
footer.style.marginTop = '8px';
|
||||
|
||||
const okBtn = document.createElement('button');
|
||||
okBtn.textContent = t('dialogOk');
|
||||
okBtn.className = 'anomalous-btn-primary';
|
||||
okBtn.style.padding = '8px 24px';
|
||||
|
||||
const cleanup = () => {
|
||||
overlay.remove();
|
||||
resolve();
|
||||
};
|
||||
|
||||
okBtn.onclick = cleanup;
|
||||
|
||||
footer.appendChild(okBtn);
|
||||
dialog.append(heading, text, footer);
|
||||
overlay.appendChild(dialog);
|
||||
document.body.appendChild(overlay);
|
||||
okBtn.focus();
|
||||
});
|
||||
}
|
||||
|
||||
export function anomalousConfirm(message, title = 'Anomalous', options = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.style.position = 'fixed';
|
||||
overlay.style.inset = '0';
|
||||
overlay.style.zIndex = '999999';
|
||||
overlay.style.display = 'flex';
|
||||
overlay.style.alignItems = 'center';
|
||||
overlay.style.justifyContent = 'center';
|
||||
overlay.style.background = 'rgba(0, 0, 0, 0.6)';
|
||||
overlay.style.backdropFilter = 'blur(4px)';
|
||||
overlay.style.padding = '20px';
|
||||
overlay.style.boxSizing = 'border-box';
|
||||
|
||||
const dialog = document.createElement('div');
|
||||
dialog.style.background = 'linear-gradient(145deg, rgba(48, 49, 55, 0.98), rgba(27, 28, 33, 0.98))';
|
||||
dialog.style.border = '1px solid rgba(255, 255, 255, 0.12)';
|
||||
dialog.style.borderRadius = '16px';
|
||||
dialog.style.padding = '24px';
|
||||
dialog.style.maxWidth = '400px';
|
||||
dialog.style.width = '100%';
|
||||
dialog.style.maxHeight = '90vh';
|
||||
dialog.style.overflowY = 'auto';
|
||||
dialog.style.boxShadow = '0 24px 70px rgba(0, 0, 0, 0.55)';
|
||||
dialog.style.color = '#f3f4f6';
|
||||
dialog.style.display = 'flex';
|
||||
dialog.style.flexDirection = 'column';
|
||||
dialog.style.gap = '16px';
|
||||
|
||||
const heading = document.createElement('h3');
|
||||
heading.textContent = title;
|
||||
heading.style.margin = '0';
|
||||
heading.style.fontSize = '1.25rem';
|
||||
|
||||
const text = document.createElement('p');
|
||||
text.textContent = message;
|
||||
text.style.margin = '0';
|
||||
text.style.lineHeight = '1.5';
|
||||
text.style.whiteSpace = 'pre-wrap';
|
||||
text.style.color = '#ccc';
|
||||
|
||||
const footer = document.createElement('div');
|
||||
footer.style.display = 'flex';
|
||||
footer.style.justifyContent = 'flex-end';
|
||||
footer.style.gap = '12px';
|
||||
footer.style.marginTop = '8px';
|
||||
|
||||
const cancelBtn = document.createElement('button');
|
||||
cancelBtn.textContent = t('dialogCancel');
|
||||
cancelBtn.className = 'anomalous-btn-ghost';
|
||||
cancelBtn.style.padding = '8px 24px';
|
||||
|
||||
const okBtn = document.createElement('button');
|
||||
okBtn.textContent = t('dialogOk');
|
||||
okBtn.className = 'anomalous-btn-danger';
|
||||
okBtn.style.padding = '8px 24px';
|
||||
|
||||
const noBtn = options.noLabel ? document.createElement('button') : null;
|
||||
if (noBtn) {
|
||||
noBtn.textContent = options.noLabel;
|
||||
noBtn.className = 'anomalous-btn-ghost';
|
||||
noBtn.style.padding = '8px 24px';
|
||||
}
|
||||
|
||||
const finish = (result) => {
|
||||
overlay.remove();
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
cancelBtn.onclick = () => finish(null);
|
||||
noBtn?.addEventListener('click', () => finish(false));
|
||||
okBtn.onclick = () => finish(true);
|
||||
|
||||
footer.append(cancelBtn);
|
||||
if (noBtn) footer.append(noBtn);
|
||||
footer.append(okBtn);
|
||||
dialog.append(heading, text, footer);
|
||||
overlay.appendChild(dialog);
|
||||
document.body.appendChild(overlay);
|
||||
cancelBtn.focus();
|
||||
});
|
||||
}
|
||||
|
||||
export function anomalousPrompt(message, defaultValue = '', title = 'Anomalous') {
|
||||
return new Promise((resolve) => {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.style.position = 'fixed';
|
||||
overlay.style.inset = '0';
|
||||
overlay.style.zIndex = '999999';
|
||||
overlay.style.display = 'flex';
|
||||
overlay.style.alignItems = 'center';
|
||||
overlay.style.justifyContent = 'center';
|
||||
overlay.style.background = 'rgba(0, 0, 0, 0.6)';
|
||||
overlay.style.backdropFilter = 'blur(4px)';
|
||||
overlay.style.padding = '20px';
|
||||
overlay.style.boxSizing = 'border-box';
|
||||
|
||||
const dialog = document.createElement('div');
|
||||
dialog.style.background = 'linear-gradient(145deg, rgba(48, 49, 55, 0.98), rgba(27, 28, 33, 0.98))';
|
||||
dialog.style.border = '1px solid rgba(255, 255, 255, 0.12)';
|
||||
dialog.style.borderRadius = '16px';
|
||||
dialog.style.padding = '24px';
|
||||
dialog.style.maxWidth = '420px';
|
||||
dialog.style.width = '100%';
|
||||
dialog.style.boxShadow = '0 24px 70px rgba(0, 0, 0, 0.55)';
|
||||
dialog.style.color = '#f3f4f6';
|
||||
dialog.style.display = 'flex';
|
||||
dialog.style.flexDirection = 'column';
|
||||
dialog.style.gap = '16px';
|
||||
|
||||
const heading = document.createElement('h3');
|
||||
heading.textContent = title;
|
||||
heading.style.margin = '0';
|
||||
heading.style.fontSize = '1.25rem';
|
||||
|
||||
const text = document.createElement('p');
|
||||
text.textContent = message;
|
||||
text.style.margin = '0';
|
||||
text.style.lineHeight = '1.5';
|
||||
text.style.color = '#ccc';
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.value = defaultValue;
|
||||
input.maxLength = 200;
|
||||
input.style.width = '100%';
|
||||
input.style.padding = '10px 14px';
|
||||
input.style.borderRadius = '8px';
|
||||
input.style.border = '1px solid rgba(255, 255, 255, 0.18)';
|
||||
input.style.background = 'rgba(0, 0, 0, 0.35)';
|
||||
input.style.color = '#fff';
|
||||
input.style.fontSize = '14px';
|
||||
input.style.boxSizing = 'border-box';
|
||||
input.style.outline = 'none';
|
||||
input.onfocus = () => input.style.borderColor = '#1a73e8';
|
||||
input.onblur = () => input.style.borderColor = 'rgba(255, 255, 255, 0.18)';
|
||||
|
||||
const footer = document.createElement('div');
|
||||
footer.style.display = 'flex';
|
||||
footer.style.justifyContent = 'flex-end';
|
||||
footer.style.gap = '12px';
|
||||
footer.style.marginTop = '8px';
|
||||
|
||||
const cancelBtn = document.createElement('button');
|
||||
cancelBtn.textContent = t('dialogCancel');
|
||||
cancelBtn.className = 'anomalous-btn-ghost';
|
||||
cancelBtn.style.padding = '8px 24px';
|
||||
|
||||
const okBtn = document.createElement('button');
|
||||
okBtn.textContent = t('dialogOk');
|
||||
okBtn.className = 'anomalous-btn-primary';
|
||||
okBtn.style.padding = '8px 24px';
|
||||
|
||||
const finish = (result) => {
|
||||
overlay.remove();
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
cancelBtn.onclick = () => finish(null);
|
||||
okBtn.onclick = () => {
|
||||
const val = input.value.trim();
|
||||
finish(val);
|
||||
};
|
||||
|
||||
input.onkeydown = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
okBtn.click();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
cancelBtn.click();
|
||||
}
|
||||
};
|
||||
|
||||
footer.append(cancelBtn, okBtn);
|
||||
dialog.append(heading, text, input, footer);
|
||||
overlay.appendChild(dialog);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
setTimeout(() => {
|
||||
input.focus();
|
||||
input.select();
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,589 @@
|
||||
/**
|
||||
* ui_gallery.js
|
||||
* Extracted Gallery Viewer methods.
|
||||
*/
|
||||
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { translate } from './locales.js';
|
||||
|
||||
const t = (key, params) => translate(key, params);
|
||||
|
||||
export async function refreshGalleryImages() {
|
||||
if (this.galleryLoading || this.galleryRefreshLoading) return;
|
||||
this.galleryRefreshLoading = true;
|
||||
try {
|
||||
const scrollTop = this.galleryGrid?.scrollTop || 0;
|
||||
await this.loadGalleryImages(1, true);
|
||||
if (this.galleryGrid) this.galleryGrid.scrollTop = scrollTop;
|
||||
} catch (error) {
|
||||
console.warn('Could not refresh output gallery:', error);
|
||||
} finally {
|
||||
this.galleryRefreshLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
export async function loadGalleryImages(page = 1, reset = false) {
|
||||
if (this.galleryLoading) return;
|
||||
this.galleryLoading = true;
|
||||
this.gallerySentinel.textContent = t('galleryLoading');
|
||||
|
||||
try {
|
||||
const res = await fetch(`/anomalous/gallery_images?page=${page}&limit=50`);
|
||||
const data = await res.json();
|
||||
|
||||
if (reset) {
|
||||
// Clear existing cards
|
||||
const cards = this.galleryGrid.querySelectorAll('.anomalous-gallery-card');
|
||||
cards.forEach(c => c.remove());
|
||||
this.galleryLoaded = true;
|
||||
}
|
||||
|
||||
if (data.images && data.images.length > 0) {
|
||||
data.images.forEach(imgData => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'anomalous-gallery-card';
|
||||
|
||||
const q_sub = encodeURIComponent(imgData.subfolder);
|
||||
const q_file = encodeURIComponent(imgData.filename);
|
||||
const imgUrl = `/view?filename=${q_file}&subfolder=${q_sub}&type=output`;
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = imgUrl;
|
||||
img.loading = 'lazy';
|
||||
img.draggable = true;
|
||||
|
||||
// Drag and drop support for ComfyUI
|
||||
img.addEventListener('dragstart', (e) => {
|
||||
const fullUrl = new URL(imgUrl, window.location.href).href;
|
||||
e.dataTransfer.setData('text/uri-list', fullUrl);
|
||||
e.dataTransfer.setData('text/plain', fullUrl);
|
||||
|
||||
// Fix for Chromium failing to initiate drag for extremely large (Hires Fix) images
|
||||
if (window.anomalousDragGhostImg) {
|
||||
e.dataTransfer.setDragImage(window.anomalousDragGhostImg, 40, 40);
|
||||
}
|
||||
});
|
||||
|
||||
// Click to view
|
||||
img.onclick = () => {
|
||||
if (this.gallerySelectModel) {
|
||||
const model = this.gallerySelectModel;
|
||||
fetch('/anomalous/set_custom_cover', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: this.currentType,
|
||||
path_idx: this.currentPathIdx,
|
||||
subfolder: this.currentSubfolder,
|
||||
filename: model.filename,
|
||||
source_image: imgData.subfolder ? imgData.subfolder + '/' + imgData.filename : imgData.filename
|
||||
})
|
||||
}).then(res => res.json()).then(async data => {
|
||||
if (data.status === 'success') {
|
||||
const tempModel = this.gallerySelectModel;
|
||||
this.gallerySelectModel = null;
|
||||
const banner = document.getElementById('anomalous-gallery-select-banner');
|
||||
if (banner) banner.style.display = 'none';
|
||||
this.galleryPanel.style.display = 'none';
|
||||
|
||||
await this.loadModels();
|
||||
const updatedModel = this.models.find(m => m.filename === model.filename);
|
||||
|
||||
if (this.currentDetailModel && this.currentDetailModel.filename === model.filename) {
|
||||
this.detailPanel.style.display = 'flex';
|
||||
if (updatedModel) this.showDetail(updatedModel);
|
||||
} else {
|
||||
this.grid.style.display = 'grid';
|
||||
// Grid was already refreshed by loadModels
|
||||
}
|
||||
|
||||
|
||||
} else {
|
||||
alert(t('galleryErrorPrefix') + data.message);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.showGalleryViewer(imgUrl);
|
||||
};
|
||||
|
||||
const delBtn = document.createElement('button');
|
||||
delBtn.className = 'anomalous-gallery-delete';
|
||||
delBtn.innerHTML = '🗑️';
|
||||
delBtn.title = t('galleryDelete');
|
||||
|
||||
delBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.style.position = 'absolute';
|
||||
overlay.style.top = '0';
|
||||
overlay.style.left = '0';
|
||||
overlay.style.width = '100%';
|
||||
overlay.style.height = '100%';
|
||||
overlay.style.background = 'rgba(0,0,0,0.85)';
|
||||
overlay.style.display = 'flex';
|
||||
overlay.style.flexDirection = 'column';
|
||||
overlay.style.alignItems = 'center';
|
||||
overlay.style.justifyContent = 'center';
|
||||
overlay.style.gap = '15px';
|
||||
overlay.style.zIndex = '10';
|
||||
|
||||
const msg = document.createElement('div');
|
||||
const msgTitle = document.createElement('div');
|
||||
msgTitle.textContent = t('galleryDeleteConfirm');
|
||||
const msgHint = document.createElement('div');
|
||||
msgHint.textContent = t('galleryDeleteConfirmHint');
|
||||
msgHint.style.fontSize = '0.8em';
|
||||
msgHint.style.color = '#aaa';
|
||||
msg.append(msgTitle, msgHint);
|
||||
msg.style.color = '#fff';
|
||||
msg.style.fontWeight = 'bold';
|
||||
msg.style.textAlign = 'center';
|
||||
|
||||
const btnRow = document.createElement('div');
|
||||
btnRow.style.display = 'flex';
|
||||
btnRow.style.gap = '12px';
|
||||
|
||||
const confirmBtn = document.createElement('button');
|
||||
confirmBtn.textContent = `🗑️ ${t('galleryDelete')}`;
|
||||
confirmBtn.style.background = '#dc3545';
|
||||
confirmBtn.style.color = '#fff';
|
||||
confirmBtn.style.border = 'none';
|
||||
confirmBtn.style.padding = '10px 16px';
|
||||
confirmBtn.style.borderRadius = '6px';
|
||||
confirmBtn.style.cursor = 'pointer';
|
||||
confirmBtn.style.fontWeight = 'bold';
|
||||
confirmBtn.style.transition = 'background 0.2s';
|
||||
confirmBtn.onmouseover = () => confirmBtn.style.background = '#ff0000';
|
||||
confirmBtn.onmouseout = () => confirmBtn.style.background = '#dc3545';
|
||||
|
||||
const cancelBtn = document.createElement('button');
|
||||
cancelBtn.textContent = t('galleryCancel');
|
||||
cancelBtn.style.background = '#444';
|
||||
cancelBtn.style.color = '#fff';
|
||||
cancelBtn.style.border = 'none';
|
||||
cancelBtn.style.padding = '10px 16px';
|
||||
cancelBtn.style.borderRadius = '6px';
|
||||
cancelBtn.style.cursor = 'pointer';
|
||||
cancelBtn.style.transition = 'background 0.2s';
|
||||
cancelBtn.onmouseover = () => cancelBtn.style.background = '#666';
|
||||
cancelBtn.onmouseout = () => cancelBtn.style.background = '#444';
|
||||
|
||||
cancelBtn.onclick = (ce) => {
|
||||
ce.stopPropagation();
|
||||
overlay.remove();
|
||||
};
|
||||
|
||||
confirmBtn.onclick = async (ce) => {
|
||||
ce.stopPropagation();
|
||||
confirmBtn.textContent = t('galleryDeleting');
|
||||
confirmBtn.disabled = true;
|
||||
try {
|
||||
const dr = await fetch('/anomalous/delete_gallery_image', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ filename: imgData.filename, subfolder: imgData.subfolder })
|
||||
});
|
||||
const dd = await dr.json();
|
||||
if (dd.status === 'success') {
|
||||
card.remove();
|
||||
} else {
|
||||
alert(t('galleryDeleteFailed') + dd.message);
|
||||
overlay.remove();
|
||||
}
|
||||
} catch (err) {
|
||||
alert(t('galleryErrorPrefix') + err);
|
||||
overlay.remove();
|
||||
}
|
||||
};
|
||||
|
||||
btnRow.appendChild(cancelBtn);
|
||||
btnRow.appendChild(confirmBtn);
|
||||
overlay.appendChild(msg);
|
||||
overlay.appendChild(btnRow);
|
||||
|
||||
card.appendChild(overlay);
|
||||
};
|
||||
|
||||
card.appendChild(img);
|
||||
card.appendChild(delBtn);
|
||||
this.galleryGrid.insertBefore(card, this.gallerySentinel);
|
||||
});
|
||||
|
||||
this.galleryCurrentPage = page;
|
||||
this.galleryHasMore = page < data.pages;
|
||||
|
||||
if (!this.galleryHasMore) {
|
||||
this.gallerySentinel.textContent = t('galleryNoMore');
|
||||
} else {
|
||||
this.gallerySentinel.textContent = t('galleryScrollMore');
|
||||
}
|
||||
} else {
|
||||
this.galleryHasMore = false;
|
||||
this.gallerySentinel.textContent = reset ? t('galleryEmpty') : t('galleryNoMore');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load gallery images', e);
|
||||
this.gallerySentinel.textContent = t('galleryLoadFailed');
|
||||
}
|
||||
|
||||
this.galleryLoading = false;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
export async function showGeneratedGallery(model) {
|
||||
let overlay = document.getElementById('anomalous-generated-gallery-overlay');
|
||||
if (!overlay) {
|
||||
overlay = document.createElement('div');
|
||||
overlay.id = 'anomalous-generated-gallery-overlay';
|
||||
overlay.style.position = 'absolute';
|
||||
overlay.style.top = '0';
|
||||
overlay.style.left = '0';
|
||||
overlay.style.width = '100%';
|
||||
overlay.style.height = '100%';
|
||||
overlay.style.backgroundColor = 'rgba(0, 0, 0, 0.85)';
|
||||
overlay.style.zIndex = '999999';
|
||||
overlay.style.display = 'flex';
|
||||
overlay.style.alignItems = 'center';
|
||||
overlay.style.justifyContent = 'center';
|
||||
|
||||
const modalBox = document.createElement('div');
|
||||
modalBox.id = 'anomalous-generated-gallery-modal';
|
||||
modalBox.style.width = '95%';
|
||||
modalBox.style.maxHeight = '95%';
|
||||
modalBox.style.backgroundColor = 'var(--comfy-menu-bg, #222)';
|
||||
modalBox.style.borderRadius = '12px';
|
||||
modalBox.style.display = 'flex';
|
||||
modalBox.style.flexDirection = 'column';
|
||||
modalBox.style.overflow = 'hidden';
|
||||
modalBox.style.boxShadow = '0 10px 40px rgba(0, 0, 0, 0.8)';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.style.padding = '15px 25px';
|
||||
header.style.background = '#333';
|
||||
header.style.display = 'flex';
|
||||
header.style.justifyContent = 'space-between';
|
||||
header.style.alignItems = 'center';
|
||||
header.style.borderBottom = '1px solid #444';
|
||||
|
||||
const title = document.createElement('h2');
|
||||
title.id = 'anomalous-generated-gallery-title';
|
||||
title.style.margin = '0';
|
||||
title.style.color = '#fff';
|
||||
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.textContent = `✖ ${t('galleryClose')}`;
|
||||
closeBtn.style.padding = '8px 15px';
|
||||
closeBtn.style.background = '#dc3545';
|
||||
closeBtn.style.color = '#fff';
|
||||
closeBtn.style.border = 'none';
|
||||
closeBtn.style.borderRadius = '5px';
|
||||
closeBtn.style.cursor = 'pointer';
|
||||
closeBtn.style.fontWeight = 'bold';
|
||||
closeBtn.onmouseover = () => closeBtn.style.background = '#c82333';
|
||||
closeBtn.onmouseout = () => closeBtn.style.background = '#dc3545';
|
||||
closeBtn.onclick = () => {
|
||||
overlay.style.display = 'none';
|
||||
};
|
||||
|
||||
header.appendChild(title);
|
||||
header.appendChild(closeBtn);
|
||||
modalBox.appendChild(header);
|
||||
|
||||
const contentCont = document.createElement('div');
|
||||
contentCont.id = 'anomalous-generated-gallery-content';
|
||||
contentCont.style.flex = '1';
|
||||
contentCont.style.overflowY = 'auto';
|
||||
contentCont.style.padding = '20px';
|
||||
contentCont.style.display = 'grid';
|
||||
contentCont.style.gridTemplateColumns = 'repeat(auto-fill, minmax(220px, 1fr))';
|
||||
contentCont.style.gap = '25px';
|
||||
contentCont.style.rowGap = '40px';
|
||||
contentCont.style.alignContent = 'start';
|
||||
modalBox.appendChild(contentCont);
|
||||
|
||||
overlay.appendChild(modalBox);
|
||||
document.getElementById('anomalous-container').appendChild(overlay);
|
||||
}
|
||||
|
||||
const title = document.getElementById('anomalous-generated-gallery-title');
|
||||
title.textContent = t('galleryHistoryTitle', { name: model.name || model.filename });
|
||||
|
||||
const contentCont = document.getElementById('anomalous-generated-gallery-content');
|
||||
contentCont.innerHTML = '';
|
||||
|
||||
const loading = document.createElement('div');
|
||||
loading.textContent = t('galleryScanning');
|
||||
loading.style.textAlign = 'center';
|
||||
loading.style.gridColumn = '1 / -1';
|
||||
loading.style.padding = '50px';
|
||||
loading.style.color = '#aaa';
|
||||
contentCont.appendChild(loading);
|
||||
|
||||
overlay.style.display = 'flex';
|
||||
|
||||
try {
|
||||
const res = await fetch('/anomalous/model_images?model_name=' + encodeURIComponent(model.filename) + '&t=' + Date.now());
|
||||
const data = await res.json();
|
||||
contentCont.innerHTML = '';
|
||||
|
||||
if (!data.images || data.images.length === 0) {
|
||||
const emptyMsg = document.createElement('div');
|
||||
emptyMsg.textContent = t('galleryNoModelImages');
|
||||
emptyMsg.style.textAlign = 'center';
|
||||
emptyMsg.style.gridColumn = '1 / -1';
|
||||
emptyMsg.style.padding = '50px';
|
||||
emptyMsg.style.color = '#888';
|
||||
contentCont.appendChild(emptyMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
data.images.forEach(img => {
|
||||
const imgCont = document.createElement('div');
|
||||
imgCont.className = 'anomalous-card';
|
||||
imgCont.style.cursor = 'pointer';
|
||||
|
||||
const el = document.createElement('img');
|
||||
el.src = img.url || img; // Support both just in case
|
||||
el.loading = 'lazy';
|
||||
el.draggable = true;
|
||||
|
||||
el.addEventListener('dragstart', (e) => {
|
||||
const fullUrl = new URL(el.src, window.location.href).href;
|
||||
e.dataTransfer.setData('text/uri-list', fullUrl);
|
||||
e.dataTransfer.setData('text/plain', fullUrl);
|
||||
if (window.anomalousDragGhostImg) {
|
||||
e.dataTransfer.setDragImage(window.anomalousDragGhostImg, 40, 40);
|
||||
}
|
||||
});
|
||||
|
||||
let source_image = "";
|
||||
let filenameText = "";
|
||||
if (img.url) {
|
||||
try {
|
||||
const urlParams = new URLSearchParams(img.url.split('?')[1]);
|
||||
filenameText = urlParams.get('filename') || '';
|
||||
const sub = urlParams.get('subfolder') || '';
|
||||
source_image = sub ? sub + '/' + filenameText : filenameText;
|
||||
} catch (e) { }
|
||||
} else {
|
||||
filenameText = img.split('/').pop().split('?')[0];
|
||||
source_image = filenameText;
|
||||
}
|
||||
|
||||
const titleDiv = document.createElement('div');
|
||||
titleDiv.className = 'anomalous-card-title';
|
||||
titleDiv.innerText = filenameText;
|
||||
|
||||
const setCoverBtn = document.createElement('button');
|
||||
setCoverBtn.textContent = t('gallerySetCover');
|
||||
setCoverBtn.style.position = 'absolute';
|
||||
setCoverBtn.style.bottom = '40px';
|
||||
setCoverBtn.style.right = '5px';
|
||||
setCoverBtn.style.background = 'rgba(40, 167, 69, 0.85)';
|
||||
setCoverBtn.style.color = '#fff';
|
||||
setCoverBtn.style.border = '1px solid rgba(255,255,255,0.3)';
|
||||
setCoverBtn.style.borderRadius = '4px';
|
||||
setCoverBtn.style.padding = '4px 8px';
|
||||
setCoverBtn.style.cursor = 'pointer';
|
||||
setCoverBtn.style.zIndex = '10';
|
||||
setCoverBtn.style.fontSize = '12px';
|
||||
|
||||
setCoverBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
if (!confirm(t('gallerySetCoverConfirm'))) return;
|
||||
|
||||
fetch('/anomalous/set_custom_cover', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: this.currentType,
|
||||
path_idx: this.currentPathIdx,
|
||||
subfolder: this.currentSubfolder,
|
||||
filename: model.filename,
|
||||
source_image: source_image
|
||||
})
|
||||
}).then(res => res.json()).then(async result => {
|
||||
if (result.status === 'success') {
|
||||
alert(t('galleryCoverSuccess'));
|
||||
await this.loadModels();
|
||||
const updatedModel = this.models.find(m => m.filename === model.filename);
|
||||
if (updatedModel && this.currentDetailModel && this.currentDetailModel.filename === model.filename) {
|
||||
this.showDetail(updatedModel);
|
||||
}
|
||||
} else {
|
||||
alert(t('galleryErrorPrefix') + result.message);
|
||||
}
|
||||
}).catch(err => {
|
||||
alert(t('galleryErrorPrefix') + err.message);
|
||||
});
|
||||
};
|
||||
|
||||
imgCont.onclick = () => {
|
||||
this.showGalleryViewer(img.url || img);
|
||||
};
|
||||
|
||||
imgCont.appendChild(el);
|
||||
imgCont.appendChild(setCoverBtn);
|
||||
imgCont.appendChild(titleDiv);
|
||||
contentCont.appendChild(imgCont);
|
||||
});
|
||||
} catch (e) {
|
||||
const error = document.createElement('div');
|
||||
error.textContent = t('galleryLoadImagesError');
|
||||
error.style.color = 'red';
|
||||
error.style.textAlign = 'center';
|
||||
error.style.gridColumn = '1 / -1';
|
||||
error.style.padding = '50px';
|
||||
contentCont.replaceChildren(error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function showGallerySelectMode(model) {
|
||||
this.gallerySelectModel = model;
|
||||
this.grid.style.display = 'none';
|
||||
this.detailPanel.style.display = 'none';
|
||||
this.galleryPanel.style.display = 'flex';
|
||||
let banner = document.getElementById('anomalous-gallery-select-banner');
|
||||
if (!banner) {
|
||||
banner = document.createElement('div');
|
||||
banner.id = 'anomalous-gallery-select-banner';
|
||||
banner.style.background = '#28a745';
|
||||
banner.style.color = '#fff';
|
||||
banner.style.padding = '10px';
|
||||
banner.style.textAlign = 'center';
|
||||
banner.style.fontWeight = 'bold';
|
||||
banner.style.position = 'sticky';
|
||||
banner.style.top = '0';
|
||||
banner.style.zIndex = '1000';
|
||||
this.galleryPanel.insertBefore(banner, this.galleryPanel.firstChild);
|
||||
}
|
||||
banner.style.display = 'block';
|
||||
banner.replaceChildren();
|
||||
const bannerPrefix = document.createTextNode(t('gallerySelectingCoverPrefix'));
|
||||
const bannerModel = document.createElement('span');
|
||||
bannerModel.textContent = model.filename;
|
||||
bannerModel.style.color = '#ff0';
|
||||
const bannerSuffix = document.createTextNode(t('gallerySelectingCoverSuffix'));
|
||||
const cancelSelect = document.createElement('button');
|
||||
cancelSelect.id = 'anomalous-cancel-select';
|
||||
cancelSelect.textContent = t('galleryCancel');
|
||||
cancelSelect.style.marginLeft = '15px';
|
||||
cancelSelect.style.color = '#000';
|
||||
cancelSelect.style.background = '#fff';
|
||||
cancelSelect.style.border = 'none';
|
||||
cancelSelect.style.padding = '2px 8px';
|
||||
cancelSelect.style.borderRadius = '4px';
|
||||
cancelSelect.style.cursor = 'pointer';
|
||||
banner.append(bannerPrefix, bannerModel, bannerSuffix, cancelSelect);
|
||||
|
||||
cancelSelect.onclick = () => {
|
||||
const tempModel = this.gallerySelectModel;
|
||||
this.gallerySelectModel = null;
|
||||
banner.style.display = 'none';
|
||||
this.galleryPanel.style.display = 'none';
|
||||
if (this.currentDetailModel) {
|
||||
this.detailPanel.style.display = 'flex';
|
||||
} else {
|
||||
this.grid.style.display = 'grid';
|
||||
}
|
||||
if (tempModel) {
|
||||
this.showEditModal(tempModel);
|
||||
}
|
||||
};
|
||||
|
||||
if (!this.galleryLoaded) {
|
||||
this.loadGalleryImages(1, true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function showGalleryViewer(src) {
|
||||
let viewer = document.getElementById('anomalous-gallery-viewer');
|
||||
if (!viewer) {
|
||||
viewer = document.createElement('dialog');
|
||||
viewer.id = 'anomalous-gallery-viewer';
|
||||
viewer.className = 'anomalous-gallery-viewer';
|
||||
|
||||
const closeBtn = document.createElement('div');
|
||||
closeBtn.className = 'anomalous-gallery-viewer-close';
|
||||
closeBtn.innerHTML = '×';
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.id = 'anomalous-gallery-viewer-img';
|
||||
|
||||
viewer.appendChild(img);
|
||||
viewer.appendChild(closeBtn);
|
||||
|
||||
let scale = 1;
|
||||
let translateX = 0;
|
||||
let translateY = 0;
|
||||
let isDragging = false;
|
||||
let startX = 0, startY = 0;
|
||||
|
||||
const resetImgTransform = () => {
|
||||
scale = 1; translateX = 0; translateY = 0;
|
||||
img.style.transform = `translate(0px, 0px) scale(1)`;
|
||||
img.style.cursor = 'grab';
|
||||
};
|
||||
|
||||
closeBtn.onclick = () => {
|
||||
viewer.close();
|
||||
resetImgTransform();
|
||||
};
|
||||
|
||||
viewer.onclick = (e) => {
|
||||
if (e.target === viewer) {
|
||||
viewer.close();
|
||||
resetImgTransform();
|
||||
}
|
||||
};
|
||||
|
||||
viewer.addEventListener('wheel', (e) => {
|
||||
e.preventDefault();
|
||||
const zoomFactor = 0.1;
|
||||
if (e.deltaY < 0) scale += zoomFactor;
|
||||
else scale = Math.max(0.1, scale - zoomFactor);
|
||||
img.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scale})`;
|
||||
});
|
||||
|
||||
img.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
isDragging = true;
|
||||
startX = e.clientX - translateX;
|
||||
startY = e.clientY - translateY;
|
||||
img.style.cursor = 'grabbing';
|
||||
});
|
||||
|
||||
window.addEventListener('mousemove', (e) => {
|
||||
if (!isDragging) return;
|
||||
translateX = e.clientX - startX;
|
||||
translateY = e.clientY - startY;
|
||||
img.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scale})`;
|
||||
});
|
||||
|
||||
window.addEventListener('mouseup', () => {
|
||||
isDragging = false;
|
||||
img.style.cursor = 'grab';
|
||||
});
|
||||
|
||||
document.body.appendChild(viewer);
|
||||
}
|
||||
|
||||
const img = document.getElementById('anomalous-gallery-viewer-img');
|
||||
img.src = src;
|
||||
|
||||
// Reset scale and translation when opening a new image
|
||||
img.style.transform = `translate(0px, 0px) scale(1)`;
|
||||
img.style.cursor = 'grab';
|
||||
|
||||
if (!viewer.open) viewer.showModal();
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* ui_grid.js
|
||||
* Extracted Grid methods.
|
||||
*/
|
||||
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { translate } from './locales.js';
|
||||
|
||||
const t = (key, params) => translate(key, params);
|
||||
|
||||
export function cardPreviewUrl(previewUrl, thumbnailMode) {
|
||||
if (!previewUrl || thumbnailMode === 'original') return previewUrl;
|
||||
return `${previewUrl}${previewUrl.includes('?') ? '&' : '?'}variant=card`;
|
||||
}
|
||||
|
||||
function modelCardDisplayName(model) {
|
||||
const metadata = model?.metadata || {};
|
||||
return metadata.custom_name || model?.filename || '';
|
||||
}
|
||||
|
||||
|
||||
|
||||
export async function loadModels() {
|
||||
try {
|
||||
if (this._modelLoadController) this._modelLoadController.abort();
|
||||
this._modelRenderGeneration = (this._modelRenderGeneration || 0) + 1;
|
||||
const loadController = new AbortController();
|
||||
this._modelLoadController = loadController;
|
||||
const params = new URLSearchParams({ type: this.currentType, path_idx: this.currentPathIdx, subfolder: this.currentSubfolder });
|
||||
const res = await fetch('/anomalous/models?' + params.toString(), { signal: loadController.signal });
|
||||
const data = await res.json();
|
||||
if (this._modelLoadController !== loadController) return;
|
||||
|
||||
if (window.anomalous_update_hash_cache && data.models) {
|
||||
window.anomalous_update_hash_cache(data.models);
|
||||
}
|
||||
|
||||
this.models = data.models || [];
|
||||
const renderGeneration = this._modelRenderGeneration;
|
||||
if (this._modelMediaObserver) this._modelMediaObserver.disconnect();
|
||||
this._modelMediaObserver = typeof IntersectionObserver === 'function'
|
||||
? new IntersectionObserver((entries, observer) => {
|
||||
entries.forEach(entry => {
|
||||
const media = entry.target;
|
||||
const shouldAutoplay = media.tagName === 'VIDEO' && media.dataset.anomalousAutoplay === 'true';
|
||||
if (!entry.isIntersecting) {
|
||||
if (media.tagName === 'VIDEO') {
|
||||
media.pause();
|
||||
if (media.getAttribute('src')) {
|
||||
media.removeAttribute('src');
|
||||
media.load();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (media.tagName === 'VIDEO' && !shouldAutoplay) return;
|
||||
const pendingSrc = media.dataset.anomalousSrc;
|
||||
if (pendingSrc && !media.getAttribute('src')) media.src = pendingSrc;
|
||||
if (shouldAutoplay) media.play().catch(() => {});
|
||||
});
|
||||
}, { rootMargin: '300px' })
|
||||
: null;
|
||||
stopMediaInContainer(this.grid);
|
||||
this.grid.replaceChildren();
|
||||
|
||||
if (!data.models || data.models.length === 0) {
|
||||
this.grid.innerHTML = `<div style="color:white; padding:20px;">${t('noModels')}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
let renderIndex = 0;
|
||||
const renderChunk = () => {
|
||||
if (this._modelRenderGeneration !== renderGeneration) return;
|
||||
const fragment = document.createDocumentFragment();
|
||||
const end = Math.min(renderIndex + 40, data.models.length);
|
||||
for (; renderIndex < end; renderIndex++) {
|
||||
const model = data.models[renderIndex];
|
||||
const card = document.createElement('div');
|
||||
card.className = 'anomalous-card';
|
||||
if (model.preview_url) {
|
||||
const isVideo = model.preview_url.match(/\.mp4(?:&|$)/i) || model.preview_url.match(/\.webm(?:&|$)/i);
|
||||
if (isVideo) {
|
||||
const video = document.createElement('video');
|
||||
video.dataset.anomalousSrc = model.preview_url;
|
||||
video.muted = true; video.loop = true; video.playsInline = true;
|
||||
video.preload = 'metadata';
|
||||
const ensureVideoSource = () => {
|
||||
if (!video.getAttribute('src')) video.src = video.dataset.anomalousSrc;
|
||||
};
|
||||
if (this.energySaving) {
|
||||
video.pause();
|
||||
card.addEventListener('mouseenter', () => {
|
||||
ensureVideoSource();
|
||||
video.play().catch(() => {});
|
||||
});
|
||||
card.addEventListener('mouseleave', () => { video.pause(); video.currentTime = 0; });
|
||||
} else {
|
||||
video.dataset.anomalousAutoplay = 'true';
|
||||
}
|
||||
if (this._modelMediaObserver) {
|
||||
this._modelMediaObserver.observe(video);
|
||||
} else {
|
||||
ensureVideoSource();
|
||||
if (!this.energySaving) video.autoplay = true;
|
||||
}
|
||||
card.appendChild(video);
|
||||
} else {
|
||||
const img = document.createElement('img');
|
||||
img.loading = 'lazy';
|
||||
img.decoding = 'async';
|
||||
img.src = cardPreviewUrl(model.preview_url, this.cardThumbnailMode);
|
||||
card.appendChild(img);
|
||||
}
|
||||
} else {
|
||||
const ph = document.createElement('div');
|
||||
ph.className = 'anomalous-card-placeholder';
|
||||
ph.innerHTML = `<div style="text-align:center;color:#666;margin-top:80px;">${t('noPreview')}</div><div style="font-size:0.8em;text-align:center;opacity:0.5;margin-top:5px">${t('clickScan')}</div>`;
|
||||
card.appendChild(ph);
|
||||
}
|
||||
if (model.metadata && model.metadata.baseModel) {
|
||||
const badge = document.createElement('div');
|
||||
badge.innerText = model.metadata.baseModel;
|
||||
badge.style.position = 'absolute';
|
||||
badge.style.top = '6px';
|
||||
badge.style.left = '6px';
|
||||
badge.style.background = 'rgba(0,0,0,0.85)';
|
||||
badge.style.color = '#00ffcc';
|
||||
badge.style.padding = '3px 6px';
|
||||
badge.style.borderRadius = '4px';
|
||||
badge.style.fontSize = '0.75em';
|
||||
badge.style.fontWeight = 'bold';
|
||||
badge.style.border = '1px solid rgba(0,255,204,0.3)';
|
||||
badge.style.pointerEvents = 'none';
|
||||
badge.style.zIndex = '10';
|
||||
card.appendChild(badge);
|
||||
}
|
||||
const labels = document.createElement('div');
|
||||
labels.className = 'anomalous-card-labels';
|
||||
const title = document.createElement('div');
|
||||
title.className = 'anomalous-card-title';
|
||||
title.textContent = modelCardDisplayName(model);
|
||||
labels.appendChild(title);
|
||||
|
||||
const physicalName = document.createElement('div');
|
||||
physicalName.className = 'anomalous-card-filename';
|
||||
physicalName.textContent = model.filename;
|
||||
physicalName.title = model.file_path || model.filename;
|
||||
labels.appendChild(physicalName);
|
||||
card.appendChild(labels);
|
||||
|
||||
card.onclick = () => {
|
||||
this.recipeModelReturn = null;
|
||||
this.historyStack = [];
|
||||
this.currentDetailModel = model;
|
||||
this.showDetail(model);
|
||||
};
|
||||
|
||||
const applyBtn = document.createElement('button');
|
||||
applyBtn.innerHTML = '➕';
|
||||
applyBtn.title = t('applyToCanvas');
|
||||
applyBtn.style.position = 'absolute';
|
||||
applyBtn.style.top = '6px';
|
||||
applyBtn.style.right = '6px';
|
||||
applyBtn.style.background = 'rgba(0,0,0,0.7)';
|
||||
applyBtn.style.color = '#fff';
|
||||
applyBtn.style.border = '1px solid rgba(255,255,255,0.2)';
|
||||
applyBtn.style.borderRadius = '4px';
|
||||
applyBtn.style.cursor = 'pointer';
|
||||
applyBtn.style.padding = '4px 6px';
|
||||
applyBtn.style.zIndex = '20';
|
||||
applyBtn.style.fontSize = '1em';
|
||||
applyBtn.style.display = 'none';
|
||||
|
||||
const singleScanBtn = document.createElement('button');
|
||||
singleScanBtn.innerHTML = '🎯';
|
||||
singleScanBtn.title = t('scanModelPrecisely');
|
||||
singleScanBtn.style.position = 'absolute';
|
||||
singleScanBtn.style.top = '6px';
|
||||
singleScanBtn.style.right = '40px';
|
||||
singleScanBtn.style.background = 'rgba(0,0,0,0.7)';
|
||||
singleScanBtn.style.color = '#fff';
|
||||
singleScanBtn.style.border = '1px solid rgba(255,255,255,0.2)';
|
||||
singleScanBtn.style.borderRadius = '4px';
|
||||
singleScanBtn.style.cursor = 'pointer';
|
||||
singleScanBtn.style.padding = '4px 6px';
|
||||
singleScanBtn.style.zIndex = '20';
|
||||
singleScanBtn.style.fontSize = '1em';
|
||||
singleScanBtn.style.display = 'none';
|
||||
|
||||
singleScanBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
createWizardModal(false, model.filename);
|
||||
};
|
||||
card.appendChild(singleScanBtn);
|
||||
|
||||
const editBtn = document.createElement('button');
|
||||
editBtn.innerHTML = '⚙️';
|
||||
editBtn.title = t('editModel');
|
||||
editBtn.style.position = 'absolute';
|
||||
editBtn.style.top = '6px';
|
||||
editBtn.style.right = '74px'; // shifted left for new button
|
||||
editBtn.style.width = '26px';
|
||||
editBtn.style.height = '26px';
|
||||
editBtn.style.borderRadius = '50%';
|
||||
editBtn.style.border = 'none';
|
||||
editBtn.style.background = 'rgba(0,0,0,0.7)';
|
||||
editBtn.style.color = '#fff';
|
||||
editBtn.style.cursor = 'pointer';
|
||||
editBtn.style.display = 'none';
|
||||
editBtn.style.alignItems = 'center';
|
||||
editBtn.style.justifyContent = 'center';
|
||||
editBtn.style.fontSize = '14px';
|
||||
editBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
this.showEditModal(model);
|
||||
};
|
||||
card.appendChild(editBtn);
|
||||
|
||||
card.addEventListener('mouseenter', () => {
|
||||
applyBtn.style.display = 'block';
|
||||
editBtn.style.display = 'flex';
|
||||
singleScanBtn.style.display = 'block';
|
||||
});
|
||||
card.addEventListener('mouseleave', () => {
|
||||
applyBtn.style.display = 'none';
|
||||
editBtn.style.display = 'none';
|
||||
singleScanBtn.style.display = 'none';
|
||||
});
|
||||
|
||||
applyBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
this.applyModelToCanvas(this.currentType, this.currentSubfolder, model);
|
||||
};
|
||||
card.appendChild(applyBtn);
|
||||
|
||||
|
||||
fragment.appendChild(card);
|
||||
}
|
||||
this.grid.appendChild(fragment);
|
||||
if (renderIndex < data.models.length) requestAnimationFrame(renderChunk);
|
||||
};
|
||||
requestAnimationFrame(renderChunk);
|
||||
} catch (e) {
|
||||
if (e && e.name !== 'AbortError') console.error('[Anomalous] Failed to load models', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
export function applyModelToCanvas(type, subfolder, model) {
|
||||
const nodeTypeMap = {
|
||||
'checkpoints': 'CheckpointLoaderSimple',
|
||||
'loras': 'LoraLoader',
|
||||
'unet': 'UNETLoader',
|
||||
'diffusion_models': 'UNETLoader'
|
||||
};
|
||||
const nodeType = nodeTypeMap[type];
|
||||
if (!nodeType) {
|
||||
alert(t('unsupportedAutoApply'));
|
||||
return;
|
||||
}
|
||||
|
||||
const node = LiteGraph.createNode(nodeType);
|
||||
if (!node) {
|
||||
alert(t('createNodeFailed') + nodeType);
|
||||
return;
|
||||
}
|
||||
|
||||
if (app.canvas && app.canvas.graph_mouse) {
|
||||
node.pos = [
|
||||
app.canvas.graph_mouse[0] || (window.innerWidth / 2),
|
||||
app.canvas.graph_mouse[1] || (window.innerHeight / 2)
|
||||
];
|
||||
} else {
|
||||
node.pos = [window.innerWidth / 2, window.innerHeight / 2];
|
||||
}
|
||||
|
||||
app.graph.add(node);
|
||||
|
||||
const sub = subfolder.replace(/^\/+/, '').replace(/\/+$/, '');
|
||||
const relPath = sub ? `${sub}/${model.filename}` : model.filename;
|
||||
|
||||
this.setWidgetValuePath(node, relPath);
|
||||
|
||||
this.close();
|
||||
|
||||
// 粘到鼠标上的逻辑
|
||||
let isSticking = true;
|
||||
const stickHandler = (e) => {
|
||||
if (!isSticking || !app.canvas) return;
|
||||
const canvas = app.canvas;
|
||||
|
||||
// LiteGraph内置了坐标转换,它会完美处理缩放和偏移带来的坐标偏移问题
|
||||
let canvasX, canvasY;
|
||||
if (canvas.convertEventToCanvasOffset) {
|
||||
const pos = canvas.convertEventToCanvasOffset(e);
|
||||
canvasX = pos[0];
|
||||
canvasY = pos[1];
|
||||
} else {
|
||||
// 如果API不可用,使用标准降级计算
|
||||
const rect = canvas.canvas.getBoundingClientRect();
|
||||
canvasX = (e.clientX - rect.left - canvas.ds.offset[0]) / canvas.ds.scale;
|
||||
canvasY = (e.clientY - rect.top - canvas.ds.offset[1]) / canvas.ds.scale;
|
||||
}
|
||||
|
||||
const w = (node.size && node.size[0]) ? node.size[0] : 200;
|
||||
node.pos = [canvasX - w / 2, canvasY - 20];
|
||||
canvas.setDirty(true, true);
|
||||
};
|
||||
const dropHandler = (e) => {
|
||||
if (!isSticking) return;
|
||||
isSticking = false;
|
||||
window.removeEventListener('mousemove', stickHandler, true);
|
||||
window.removeEventListener('pointerdown', dropHandler, true);
|
||||
window.removeEventListener('mousedown', dropHandler, true);
|
||||
window.removeEventListener('click', dropHandler, true);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
window.addEventListener('mousemove', stickHandler, true);
|
||||
setTimeout(() => {
|
||||
window.addEventListener('pointerdown', dropHandler, true);
|
||||
window.addEventListener('mousedown', dropHandler, true);
|
||||
window.addEventListener('click', dropHandler, true);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
export function stopMediaInContainer(container) {
|
||||
if (!container) return;
|
||||
const mediaElements = container.querySelectorAll('video, audio');
|
||||
mediaElements.forEach(media => {
|
||||
media.pause();
|
||||
media.removeAttribute('src');
|
||||
media.load();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,830 @@
|
||||
/**
|
||||
* ui_notebooks.js
|
||||
* Extracted Notebooks methods.
|
||||
*/
|
||||
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { translate } from './locales.js';
|
||||
import { escapeHtml } from './safe_dom.js';
|
||||
|
||||
const t = (key, params) => translate(key, params);
|
||||
|
||||
function restoreWorkspaceReturnPanel(owner) {
|
||||
const state = owner.workspaceReturnState;
|
||||
owner.workspaceReturnState = null;
|
||||
const panels = [
|
||||
['grid', owner.grid],
|
||||
['detail', owner.detailPanel],
|
||||
['gallery', owner.galleryPanel],
|
||||
['doctor', owner.doctorPanel],
|
||||
['assistant', owner.assistantPanel],
|
||||
];
|
||||
if (state) {
|
||||
for (const [key, panel] of panels) {
|
||||
if (panel && Object.prototype.hasOwnProperty.call(state, key)) panel.style.display = state[key];
|
||||
}
|
||||
}
|
||||
const hasVisiblePanel = panels.some(([, panel]) => panel && panel.style.display !== 'none');
|
||||
if (!hasVisiblePanel && owner.grid) owner.grid.style.display = 'grid';
|
||||
}
|
||||
|
||||
export function closeWorkspace() {
|
||||
this.recipeDetailFinish?.('closed');
|
||||
const abandonedRecipeModel = typeof this.recipeModelReturn === 'function';
|
||||
this.recipeModelReturn = null;
|
||||
if (abandonedRecipeModel) {
|
||||
this.recipeReturnState = null;
|
||||
delete this.recipeDetailPayload;
|
||||
if (this.recipeListContainer) this.recipeListContainer.style.display = '';
|
||||
const actionbar = this.recipeView?.querySelector('.anomalous-recipe-actionbar');
|
||||
if (actionbar) actionbar.style.display = '';
|
||||
if (this.detailPanel) {
|
||||
this.stopMediaInContainer?.(this.detailPanel);
|
||||
this.detailPanel.replaceChildren();
|
||||
this.detailPanel.style.display = 'none';
|
||||
}
|
||||
this.currentDetailModel = null;
|
||||
this.historyStack = [];
|
||||
}
|
||||
if (this.paramPanel) this.paramPanel.style.display = 'none';
|
||||
if (this.recipeView) this.recipeView.style.display = 'none';
|
||||
if (this.notebookBody) this.notebookBody.style.display = 'none';
|
||||
if (this.nbPanel) this.nbPanel.style.display = 'none';
|
||||
restoreWorkspaceReturnPanel(this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
export async function showNotebooks() {
|
||||
if (this.nbInitialized) {
|
||||
this.nbPanel.style.display = 'flex';
|
||||
if (this.notebookBody) this.notebookBody.style.display = 'flex';
|
||||
if (this.recipeView) this.recipeView.style.display = 'none';
|
||||
this.notebookNotesTab?.classList.add('active');
|
||||
this.notebookRecipesTab?.classList.remove('active');
|
||||
this.refreshNotebooks(true);
|
||||
return;
|
||||
}
|
||||
this.nbInitialized = true;
|
||||
|
||||
this.nbPanel.innerHTML = '';
|
||||
|
||||
const nbContainer = document.createElement('div');
|
||||
nbContainer.className = 'anomalous-nb-container';
|
||||
|
||||
const nbHeader = document.createElement('div');
|
||||
nbHeader.className = 'anomalous-nb-header';
|
||||
const headerMain = document.createElement('div');
|
||||
headerMain.className = 'anomalous-nb-header-main';
|
||||
const heading = document.createElement('h2');
|
||||
heading.textContent = t('workspaceTitle');
|
||||
const sectionTabs = document.createElement('div');
|
||||
sectionTabs.className = 'anomalous-nb-section-tabs';
|
||||
const notesTab = document.createElement('button');
|
||||
notesTab.type = 'button';
|
||||
notesTab.className = 'anomalous-nb-section-tab active';
|
||||
notesTab.textContent = t('promptNotes');
|
||||
const recipesTab = document.createElement('button');
|
||||
recipesTab.type = 'button';
|
||||
recipesTab.className = 'anomalous-nb-section-tab';
|
||||
recipesTab.textContent = t('recipeTitle');
|
||||
sectionTabs.append(notesTab, recipesTab);
|
||||
headerMain.append(heading, sectionTabs);
|
||||
nbHeader.appendChild(headerMain);
|
||||
const closeNb = document.createElement('span');
|
||||
closeNb.className = 'anomalous-nb-close';
|
||||
closeNb.innerHTML = '×';
|
||||
closeNb.onclick = () => this.closeWorkspace();
|
||||
nbHeader.appendChild(closeNb);
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'anomalous-nb-body';
|
||||
this.notebookBody = body;
|
||||
this.notebookContainer = nbContainer;
|
||||
this.notebookNotesTab = notesTab;
|
||||
this.notebookRecipesTab = recipesTab;
|
||||
|
||||
notesTab.onclick = () => {
|
||||
this.notebookBody.style.display = 'flex';
|
||||
if (this.recipeView) this.recipeView.style.display = 'none';
|
||||
notesTab.classList.add('active');
|
||||
recipesTab.classList.remove('active');
|
||||
this.refreshNotebooks(true);
|
||||
};
|
||||
recipesTab.onclick = () => this.showRecipes();
|
||||
|
||||
// Sidebar for notebooks list
|
||||
const sidebar = document.createElement('div');
|
||||
sidebar.className = 'anomalous-nb-sidebar';
|
||||
|
||||
const nbList = document.createElement('div');
|
||||
nbList.className = 'anomalous-nb-list';
|
||||
|
||||
const btnRow = document.createElement('div');
|
||||
btnRow.className = 'anomalous-nb-create-row';
|
||||
btnRow.style.padding = '10px';
|
||||
btnRow.style.display = 'flex';
|
||||
btnRow.style.gap = '5px';
|
||||
|
||||
const createBtn = document.createElement('button');
|
||||
createBtn.innerHTML = `➕ <span class="anomalous-nb-create-text">${t('createNotebook')}</span>`;
|
||||
createBtn.className = 'anomalous-btn-primary';
|
||||
|
||||
const createInput = document.createElement('input');
|
||||
createInput.className = 'anomalous-nb-create-input';
|
||||
createInput.type = 'text';
|
||||
createInput.placeholder = t('newNotebookName');
|
||||
createInput.style.display = 'none';
|
||||
createInput.style.flex = '1';
|
||||
createInput.style.padding = '4px';
|
||||
createInput.style.background = '#222';
|
||||
createInput.style.color = '#fff';
|
||||
createInput.style.border = '1px solid #555';
|
||||
createInput.style.borderRadius = '4px';
|
||||
|
||||
createBtn.onclick = () => {
|
||||
if (createInput.style.display === 'none') {
|
||||
createInput.style.display = 'block';
|
||||
createBtn.innerHTML = '✓';
|
||||
createInput.focus();
|
||||
} else {
|
||||
const name = createInput.value.trim();
|
||||
if (name) {
|
||||
this.currentNotebook = { filename: name + '.json', name: name, data: { baseModel: '', mainModel: null, loras: [], promptEn: '', promptZh: '' } };
|
||||
this.saveCurrentNotebook();
|
||||
this.renderNotebookEditor();
|
||||
createInput.value = '';
|
||||
}
|
||||
createInput.style.display = 'none';
|
||||
createBtn.innerHTML = `➕ <span class="anomalous-nb-create-text">${t('createNotebook')}</span>`;
|
||||
}
|
||||
};
|
||||
|
||||
btnRow.appendChild(createInput);
|
||||
btnRow.appendChild(createBtn);
|
||||
sidebar.appendChild(btnRow);
|
||||
sidebar.appendChild(nbList);
|
||||
|
||||
// Editor area
|
||||
this.nbEditor = document.createElement('div');
|
||||
this.nbEditor.className = 'anomalous-nb-editor';
|
||||
|
||||
body.appendChild(sidebar);
|
||||
body.appendChild(this.nbEditor);
|
||||
|
||||
nbContainer.appendChild(nbHeader);
|
||||
nbContainer.appendChild(body);
|
||||
|
||||
this.nbPanel.appendChild(nbContainer);
|
||||
|
||||
this.nbListEl = nbList;
|
||||
this.refreshNotebooks(true);
|
||||
}
|
||||
|
||||
|
||||
|
||||
export async function refreshNotebooks(autoOpenFirst = false) {
|
||||
try {
|
||||
const res = await fetch('/anomalous/notebooks');
|
||||
const data = await res.json();
|
||||
this.nbListEl.innerHTML = '';
|
||||
|
||||
if (data.notebooks && data.notebooks.length > 0) {
|
||||
if (autoOpenFirst) {
|
||||
if (!this.currentNotebook) {
|
||||
this.currentNotebook = data.notebooks[0];
|
||||
}
|
||||
if (this.currentNotebook) {
|
||||
this.renderNotebookEditor();
|
||||
}
|
||||
}
|
||||
|
||||
data.notebooks.forEach(nb => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'anomalous-nb-item';
|
||||
if (this.currentNotebook && this.currentNotebook.filename === nb.filename) {
|
||||
item.classList.add('active');
|
||||
}
|
||||
item.innerHTML = `<span class="anomalous-nb-item-icon">📄 </span><span class="anomalous-nb-item-text">${escapeHtml(nb.name)}</span>`;
|
||||
item.onclick = () => {
|
||||
this.currentNotebook = nb;
|
||||
this.renderNotebookEditor();
|
||||
this.refreshNotebooks();
|
||||
};
|
||||
this.nbListEl.appendChild(item);
|
||||
});
|
||||
}
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
|
||||
|
||||
export async function saveCurrentNotebook() {
|
||||
if (!this.currentNotebook) return;
|
||||
try {
|
||||
await fetch('/anomalous/save_notebook', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(this.currentNotebook)
|
||||
});
|
||||
this.refreshNotebooks();
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
|
||||
|
||||
export async function deleteCurrentNotebook(skipConfirm = false) {
|
||||
if (!this.currentNotebook) return;
|
||||
if (!skipConfirm && !confirm(t('deleteNotebook') + ' ?')) return;
|
||||
try {
|
||||
await fetch('/anomalous/delete_notebook', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ filename: this.currentNotebook.filename })
|
||||
});
|
||||
this.currentNotebook = null;
|
||||
this.nbEditor.innerHTML = '';
|
||||
this.refreshNotebooks();
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function renderNotebookEditor() {
|
||||
this.nbEditor.innerHTML = '';
|
||||
if (!this.currentNotebook) return;
|
||||
|
||||
const data = this.currentNotebook.data || {};
|
||||
if (!data.loras) data.loras = [];
|
||||
|
||||
// Toolbar
|
||||
const tb = document.createElement('div');
|
||||
tb.className = 'anomalous-nb-toolbar';
|
||||
|
||||
const titleArea = document.createElement('h3');
|
||||
titleArea.textContent = this.currentNotebook.name;
|
||||
titleArea.style.margin = '0';
|
||||
|
||||
const rightBtns = document.createElement('div');
|
||||
|
||||
const saveBtn = document.createElement('button');
|
||||
saveBtn.innerHTML = t('saveNotebook');
|
||||
saveBtn.className = 'anomalous-btn-primary';
|
||||
saveBtn.onclick = async () => {
|
||||
const orig = saveBtn.innerHTML;
|
||||
saveBtn.innerHTML = '⏳...';
|
||||
await this.saveCurrentNotebook();
|
||||
saveBtn.innerHTML = '✅';
|
||||
saveBtn.style.background = '#2e8b57';
|
||||
setTimeout(() => {
|
||||
saveBtn.innerHTML = orig;
|
||||
saveBtn.style.background = '';
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
let delTimer = null;
|
||||
const delContainer = document.createElement('span');
|
||||
delContainer.style.display = 'inline-flex';
|
||||
delContainer.style.alignItems = 'center';
|
||||
|
||||
const delBtn = document.createElement('button');
|
||||
delBtn.innerHTML = t('deleteNotebook');
|
||||
delBtn.className = 'anomalous-btn-danger';
|
||||
|
||||
const cancelDelBtn = document.createElement('button');
|
||||
cancelDelBtn.innerHTML = '✕';
|
||||
cancelDelBtn.className = 'anomalous-btn-danger';
|
||||
cancelDelBtn.style.display = 'none';
|
||||
cancelDelBtn.style.background = '#555';
|
||||
cancelDelBtn.style.marginLeft = '2px';
|
||||
cancelDelBtn.style.padding = '6px 8px';
|
||||
|
||||
delContainer.appendChild(delBtn);
|
||||
delContainer.appendChild(cancelDelBtn);
|
||||
|
||||
const resetDel = () => {
|
||||
clearTimeout(delTimer);
|
||||
delBtn.innerHTML = t('deleteNotebook');
|
||||
delBtn.style.background = '';
|
||||
cancelDelBtn.style.display = 'none';
|
||||
};
|
||||
|
||||
delBtn.onclick = () => {
|
||||
if (delBtn.innerHTML === t('deleteNotebook')) {
|
||||
delBtn.innerHTML = t('delSure');
|
||||
delBtn.style.background = '#800';
|
||||
cancelDelBtn.style.display = 'block';
|
||||
delTimer = setTimeout(resetDel, 4000);
|
||||
} else {
|
||||
resetDel();
|
||||
this.deleteCurrentNotebook(true);
|
||||
}
|
||||
};
|
||||
|
||||
cancelDelBtn.onclick = resetDel;
|
||||
|
||||
const sendBtn = document.createElement('button');
|
||||
sendBtn.innerHTML = t('sendToCanvas');
|
||||
sendBtn.className = 'anomalous-btn-success';
|
||||
sendBtn.onclick = () => this.sendNotebookToCanvas();
|
||||
|
||||
rightBtns.appendChild(saveBtn);
|
||||
rightBtns.appendChild(sendBtn);
|
||||
rightBtns.appendChild(delContainer);
|
||||
|
||||
tb.appendChild(titleArea);
|
||||
tb.appendChild(rightBtns);
|
||||
|
||||
// Settings / Models
|
||||
const modelSection = document.createElement('div');
|
||||
modelSection.className = 'anomalous-nb-section';
|
||||
|
||||
// Base Model
|
||||
const baseRow = document.createElement('div');
|
||||
baseRow.className = 'anomalous-nb-row';
|
||||
baseRow.innerHTML = `<strong>${t('baseModel')}</strong>`;
|
||||
const baseSelect = document.createElement('select');
|
||||
baseSelect.className = 'anomalous-nb-select';
|
||||
const buildSelect = (bases) => {
|
||||
baseSelect.innerHTML = '';
|
||||
bases.forEach(b => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = b; opt.text = b;
|
||||
if (data.baseModel === b) opt.selected = true;
|
||||
baseSelect.appendChild(opt);
|
||||
});
|
||||
if (!data.baseModel && bases.length > 0) data.baseModel = bases[0];
|
||||
};
|
||||
|
||||
if (this.baseModelsCache) {
|
||||
buildSelect(this.baseModelsCache);
|
||||
} else {
|
||||
const tempBases = ['SD 1.5', 'SD 2.1', 'SDXL', 'SD 3.0', 'SD 3.5', 'Flux.1', 'Pony', 'HunyuanVideo', 'LTX-Video', 'OmniGen'];
|
||||
buildSelect(tempBases);
|
||||
fetch('/anomalous/base_models').then(r => r.json()).then(d => {
|
||||
if (d.base_models && d.base_models.length > 0) {
|
||||
this.baseModelsCache = d.base_models;
|
||||
buildSelect(this.baseModelsCache);
|
||||
}
|
||||
}).catch(e => { });
|
||||
}
|
||||
if (!data.baseModel) data.baseModel = 'SDXL';
|
||||
baseSelect.onchange = () => {
|
||||
data.baseModel = baseSelect.value;
|
||||
data.mainModel = null;
|
||||
data.loras = [];
|
||||
this.saveCurrentNotebook();
|
||||
this.renderNotebookEditor();
|
||||
};
|
||||
baseRow.appendChild(baseSelect);
|
||||
|
||||
// Main Model (Card Selection)
|
||||
const mainBox = document.createElement('div');
|
||||
mainBox.className = 'anomalous-nb-gallery-box';
|
||||
const mainRow = document.createElement('div');
|
||||
mainRow.className = 'anomalous-nb-row';
|
||||
mainRow.innerHTML = `<strong>${t('mainModel')}</strong>`;
|
||||
|
||||
const mainGallery = document.createElement('div');
|
||||
mainGallery.className = 'anomalous-nb-gallery-wrap';
|
||||
|
||||
mainBox.appendChild(mainRow);
|
||||
mainBox.appendChild(mainGallery);
|
||||
|
||||
// Loras (Card Selection)
|
||||
const loraBox = document.createElement('div');
|
||||
loraBox.className = 'anomalous-nb-gallery-box';
|
||||
const loraRow = document.createElement('div');
|
||||
loraRow.className = 'anomalous-nb-row';
|
||||
loraRow.innerHTML = `<strong>Loras</strong>`;
|
||||
|
||||
const loraGallery = document.createElement('div');
|
||||
loraGallery.className = 'anomalous-nb-gallery-wrap';
|
||||
|
||||
loraBox.appendChild(loraRow);
|
||||
loraBox.appendChild(loraGallery);
|
||||
|
||||
modelSection.appendChild(baseRow);
|
||||
modelSection.appendChild(mainBox);
|
||||
modelSection.appendChild(loraBox);
|
||||
|
||||
// Prompt Section
|
||||
const promptSec = document.createElement('div');
|
||||
promptSec.className = 'anomalous-nb-section';
|
||||
|
||||
// Toolbar
|
||||
const pToolbar = document.createElement('div');
|
||||
pToolbar.className = 'anomalous-nb-prompt-toolbar';
|
||||
|
||||
const langSelect = document.createElement('select');
|
||||
langSelect.className = 'anomalous-nb-select';
|
||||
const langs = [
|
||||
{ v: 'zh-CN', l: '🇨🇳 中文 (zh-CN)' }, { v: 'en', l: '🇬🇧 English (en)' },
|
||||
{ v: 'ja', l: '🇯🇵 日本语 (ja)' }, { v: 'ko', l: '🇰🇷 한국어 (ko)' },
|
||||
{ v: 'fr', l: '🇫🇷 Français (fr)' }, { v: 'de', l: '🇩🇪 Deutsch (de)' },
|
||||
{ v: 'es', l: '🇪🇸 Español (es)' }, { v: 'ru', l: '🇷🇺 Русский (ru)' }
|
||||
];
|
||||
langs.forEach(lg => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = lg.v; opt.text = lg.l;
|
||||
if ((data.targetLang || 'zh-CN') === lg.v) opt.selected = true;
|
||||
langSelect.appendChild(opt);
|
||||
});
|
||||
langSelect.onchange = () => {
|
||||
data.targetLang = langSelect.value;
|
||||
data.translations = {}; // Clear translation cache on lang change
|
||||
this.saveCurrentNotebook();
|
||||
updateVisualTags();
|
||||
};
|
||||
|
||||
const findInput = document.createElement('input');
|
||||
findInput.className = 'anomalous-nb-select';
|
||||
findInput.placeholder = t('findPlaceholder');
|
||||
findInput.style.flex = '1';
|
||||
|
||||
const replaceInput = document.createElement('input');
|
||||
replaceInput.className = 'anomalous-nb-select';
|
||||
replaceInput.placeholder = t('replacePlaceholder');
|
||||
replaceInput.style.flex = '1';
|
||||
|
||||
const replaceBtn = document.createElement('button');
|
||||
replaceBtn.className = 'anomalous-btn-primary';
|
||||
replaceBtn.innerHTML = t('replaceAll');
|
||||
|
||||
pToolbar.appendChild(langSelect);
|
||||
pToolbar.appendChild(findInput);
|
||||
pToolbar.appendChild(replaceInput);
|
||||
pToolbar.appendChild(replaceBtn);
|
||||
|
||||
// Raw Input Toggle
|
||||
const toggleRow = document.createElement('div');
|
||||
toggleRow.style.display = 'flex';
|
||||
toggleRow.style.justifyContent = 'space-between';
|
||||
toggleRow.style.marginBottom = '5px';
|
||||
toggleRow.innerHTML = `<strong>Prompt Editor</strong>`;
|
||||
const rawBtn = document.createElement('button');
|
||||
rawBtn.className = 'anomalous-btn-primary';
|
||||
rawBtn.innerHTML = t('editRaw');
|
||||
toggleRow.appendChild(rawBtn);
|
||||
|
||||
// Raw Textarea
|
||||
const rawArea = document.createElement('textarea');
|
||||
rawArea.className = 'anomalous-nb-textarea';
|
||||
rawArea.value = data.promptEn || '';
|
||||
rawArea.style.display = 'none';
|
||||
rawArea.style.height = '150px';
|
||||
|
||||
// Visual Dual Pane
|
||||
const dualPane = document.createElement('div');
|
||||
dualPane.className = 'anomalous-nb-dual-pane';
|
||||
|
||||
if (!data.translations) data.translations = {};
|
||||
|
||||
const updateVisualTags = () => {
|
||||
dualPane.innerHTML = '';
|
||||
const txt = rawArea.value;
|
||||
data.promptEn = txt;
|
||||
this.saveCurrentNotebook();
|
||||
if (!txt.trim()) return;
|
||||
|
||||
const tags = txt.split(',').map(s => s.trim()).filter(s => s);
|
||||
tags.forEach((tag, idx) => {
|
||||
const tagRow = document.createElement('div');
|
||||
tagRow.className = 'anomalous-nb-tag-row';
|
||||
|
||||
const tagL = document.createElement('div');
|
||||
tagL.className = 'anomalous-nb-visual-tag';
|
||||
tagL.style.flex = '1';
|
||||
tagL.style.justifyContent = 'space-between';
|
||||
const txtL = document.createElement('span');
|
||||
txtL.innerText = tag;
|
||||
const copyL = document.createElement('span');
|
||||
copyL.className = 'anomalous-nb-copy-btn';
|
||||
copyL.innerHTML = '📋';
|
||||
copyL.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(tag).then(() => { copyL.innerHTML = '✅'; setTimeout(() => copyL.innerHTML = '📋', 1000); });
|
||||
};
|
||||
tagL.appendChild(txtL);
|
||||
tagL.appendChild(copyL);
|
||||
|
||||
const tagR = document.createElement('div');
|
||||
tagR.className = 'anomalous-nb-visual-tag';
|
||||
tagR.style.flex = '1';
|
||||
tagR.style.justifyContent = 'space-between';
|
||||
const transTxt = data.translations[tag] ? data.translations[tag] : '...';
|
||||
const txtR = document.createElement('span');
|
||||
txtR.innerText = transTxt;
|
||||
const copyR = document.createElement('span');
|
||||
copyR.className = 'anomalous-nb-copy-btn';
|
||||
copyR.innerHTML = '📋';
|
||||
copyR.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(txtR.innerText).then(() => { copyR.innerHTML = '✅'; setTimeout(() => copyR.innerHTML = '📋', 1000); });
|
||||
};
|
||||
tagR.appendChild(txtR);
|
||||
tagR.appendChild(copyR);
|
||||
|
||||
tagL.onmouseenter = () => { tagL.classList.add('hover'); tagR.classList.add('hover'); };
|
||||
tagL.onmouseleave = () => { tagL.classList.remove('hover'); tagR.classList.remove('hover'); };
|
||||
tagR.onmouseenter = () => { tagL.classList.add('hover'); tagR.classList.add('hover'); };
|
||||
tagR.onmouseleave = () => { tagL.classList.remove('hover'); tagR.classList.remove('hover'); };
|
||||
|
||||
tagL.onclick = () => {
|
||||
const inp = document.createElement('input');
|
||||
inp.value = tag; inp.className = 'anomalous-nb-tag-edit';
|
||||
tagL.innerHTML = ''; tagL.appendChild(inp); inp.focus();
|
||||
const finish = () => {
|
||||
tags[idx] = inp.value.trim();
|
||||
rawArea.value = tags.join(', ');
|
||||
updateVisualTags();
|
||||
};
|
||||
inp.onblur = finish;
|
||||
inp.onkeydown = (e) => { if (e.key === 'Enter') inp.blur(); };
|
||||
};
|
||||
|
||||
tagR.onclick = () => {
|
||||
const inp = document.createElement('input');
|
||||
inp.value = data.translations[tag] || ''; inp.className = 'anomalous-nb-tag-edit';
|
||||
tagR.innerHTML = ''; tagR.appendChild(inp); inp.focus();
|
||||
const finish = () => {
|
||||
data.translations[tag] = inp.value.trim();
|
||||
this.saveCurrentNotebook();
|
||||
updateVisualTags();
|
||||
};
|
||||
inp.onblur = finish;
|
||||
inp.onkeydown = (e) => { if (e.key === 'Enter') inp.blur(); };
|
||||
};
|
||||
|
||||
tagRow.appendChild(tagL);
|
||||
tagRow.appendChild(tagR);
|
||||
dualPane.appendChild(tagRow);
|
||||
|
||||
if (!data.translations[tag]) {
|
||||
fetch('/anomalous/translate', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: tag, target_lang: data.targetLang || 'zh-CN' })
|
||||
}).then(r => r.json()).then(d => {
|
||||
if (d.translated) {
|
||||
data.translations[tag] = d.translated;
|
||||
txtR.innerText = d.translated;
|
||||
this.saveCurrentNotebook();
|
||||
}
|
||||
}).catch(() => { });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
rawBtn.onclick = () => {
|
||||
if (rawArea.style.display === 'none') {
|
||||
rawArea.style.display = 'block';
|
||||
dualPane.style.display = 'none';
|
||||
rawBtn.innerHTML = '👁️ Done Editing';
|
||||
pToolbar.style.display = 'none';
|
||||
} else {
|
||||
rawArea.style.display = 'none';
|
||||
dualPane.style.display = 'flex';
|
||||
rawBtn.innerHTML = '📝 Edit Raw / Paste';
|
||||
pToolbar.style.display = 'flex';
|
||||
updateVisualTags();
|
||||
}
|
||||
};
|
||||
|
||||
replaceBtn.onclick = () => {
|
||||
const findStr = findInput.value;
|
||||
const repStr = replaceInput.value;
|
||||
if (!findStr) return;
|
||||
const newTxt = rawArea.value.split(findStr).join(repStr);
|
||||
rawArea.value = newTxt;
|
||||
updateVisualTags();
|
||||
};
|
||||
|
||||
rawArea.oninput = () => {
|
||||
clearTimeout(this.pTimeout);
|
||||
this.pTimeout = setTimeout(() => { data.promptEn = rawArea.value; this.saveCurrentNotebook(); }, 500);
|
||||
};
|
||||
|
||||
updateVisualTags();
|
||||
|
||||
promptSec.appendChild(pToolbar);
|
||||
promptSec.appendChild(toggleRow);
|
||||
promptSec.appendChild(rawArea);
|
||||
promptSec.appendChild(dualPane);
|
||||
|
||||
this.nbEditor.appendChild(tb);
|
||||
this.nbEditor.appendChild(modelSection);
|
||||
this.nbEditor.appendChild(promptSec);
|
||||
|
||||
// Fetch compatible models and fill galleries
|
||||
this.fillNotebookGalleries(data.baseModel, mainGallery, loraGallery, data);
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function fillNotebookGalleries(baseModel, mainGallery, loraGallery, data) {
|
||||
if (!baseModel) return;
|
||||
|
||||
const buildThumbHtml = (m) => {
|
||||
let thumb = '';
|
||||
if (m.preview_url) {
|
||||
const isVid = m.preview_url.match(/\.mp4(?:&|$)/i) || m.preview_url.match(/\.webm(?:&|$)/i);
|
||||
if (isVid) thumb = `<video src="${m.preview_url}" muted loop playsinline></video>`;
|
||||
else thumb = `<img src="${m.preview_url}" />`;
|
||||
} else {
|
||||
thumb = `<div style="width:30px; height:30px; background:#222; border-radius:4px; display:flex; align-items:center; justify-content:center; font-size:10px; color:#555;">?</div>`;
|
||||
}
|
||||
return thumb;
|
||||
};
|
||||
|
||||
fetch(`/anomalous/compatible_models?base_model=${encodeURIComponent(baseModel)}&target_type=checkpoints,unet,diffusion_models`)
|
||||
.then(r => r.json()).then(d => {
|
||||
const buildMainDOM = (models) => {
|
||||
mainGallery.innerHTML = '';
|
||||
if (!models || !models.length) {
|
||||
mainGallery.innerHTML = '<span style="color:#666;">No compatible main models found.</span>';
|
||||
} else {
|
||||
models.forEach(m => {
|
||||
const isSelected = (data.mainModel && data.mainModel.filename === m.filename);
|
||||
const card = document.createElement('div');
|
||||
card.className = 'anomalous-nb-minicheck ' + (isSelected ? 'selected' : '');
|
||||
card.innerHTML = `${buildThumbHtml(m)}<div class="anomalous-nb-minicheck-name" title="${escapeHtml(m.filename)}">${escapeHtml(m.filename)}</div>`;
|
||||
|
||||
if (m.preview_url && (m.preview_url.match(/\.mp4(?:&|$)/i) || m.preview_url.match(/\.webm(?:&|$)/i))) {
|
||||
card.onmouseenter = () => { const v = card.querySelector('video'); if (v) v.play().catch(e => { }); };
|
||||
card.onmouseleave = () => { const v = card.querySelector('video'); if (v) { v.pause(); v.currentTime = 0; } };
|
||||
}
|
||||
|
||||
card.onclick = () => {
|
||||
data.mainModel = m;
|
||||
this.saveCurrentNotebook();
|
||||
buildMainDOM(models); // re-render just the main gallery
|
||||
};
|
||||
mainGallery.appendChild(card);
|
||||
});
|
||||
}
|
||||
};
|
||||
buildMainDOM(d.models || []);
|
||||
});
|
||||
|
||||
fetch(`/anomalous/compatible_models?base_model=${encodeURIComponent(baseModel)}&target_type=loras`)
|
||||
.then(r => r.json()).then(d => {
|
||||
const buildLoraDOM = (models) => {
|
||||
loraGallery.innerHTML = '';
|
||||
if (!models || !models.length) {
|
||||
loraGallery.innerHTML = '<span style="color:#666;">No compatible Loras found.</span>';
|
||||
} else {
|
||||
models.forEach(m => {
|
||||
const loraIndex = data.loras.findIndex(l => l.filename === m.filename);
|
||||
const isSelected = loraIndex !== -1;
|
||||
const card = document.createElement('div');
|
||||
card.className = 'anomalous-nb-minilora ' + (isSelected ? 'selected' : '');
|
||||
card.style.position = 'relative'; // for badge positioning
|
||||
|
||||
let badgeHtml = '';
|
||||
if (isSelected) {
|
||||
badgeHtml = `<div style="position:absolute; top:-5px; right:-5px; background:#00ffcc; color:#000; border-radius:50%; width:20px; height:20px; font-size:12px; display:flex; align-items:center; justify-content:center; font-weight:bold; z-index:10; box-shadow: 0 2px 4px rgba(0,0,0,0.5);">${loraIndex + 1}</div>`;
|
||||
}
|
||||
|
||||
card.innerHTML = `${badgeHtml}${buildThumbHtml(m)}<div class="anomalous-nb-minilora-name" title="${escapeHtml(m.filename)}">${escapeHtml(m.filename)}</div>`;
|
||||
|
||||
if (m.preview_url && (m.preview_url.match(/\.mp4(?:&|$)/i) || m.preview_url.match(/\.webm(?:&|$)/i))) {
|
||||
card.onmouseenter = () => { const v = card.querySelector('video'); if (v) v.play().catch(e => { }); };
|
||||
card.onmouseleave = () => { const v = card.querySelector('video'); if (v) { v.pause(); v.currentTime = 0; } };
|
||||
}
|
||||
|
||||
card.onclick = () => {
|
||||
if (isSelected) {
|
||||
data.loras = data.loras.filter(l => l.filename !== m.filename);
|
||||
} else {
|
||||
data.loras.push(m);
|
||||
}
|
||||
this.saveCurrentNotebook();
|
||||
buildLoraDOM(models); // re-render just the lora gallery
|
||||
};
|
||||
loraGallery.appendChild(card);
|
||||
});
|
||||
}
|
||||
};
|
||||
buildLoraDOM(d.models || []);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function sendNotebookToCanvas() {
|
||||
if (!this.currentNotebook) return;
|
||||
const data = this.currentNotebook.data || {};
|
||||
if (!data.mainModel) {
|
||||
alert(t('notebookSelectMain'));
|
||||
return;
|
||||
}
|
||||
|
||||
const groupNodes = [];
|
||||
const isUnet = data.mainModel.type === 'unet' || data.mainModel.type === 'diffusion_models';
|
||||
|
||||
const ckptNode = LiteGraph.createNode(isUnet ? "UNETLoader" : "CheckpointLoaderSimple");
|
||||
app.graph.add(ckptNode);
|
||||
groupNodes.push({ node: ckptNode, relX: 0, relY: 0 });
|
||||
|
||||
const sub = data.mainModel.subfolder.replace(/^\/+/, '').replace(/\/+$/, '');
|
||||
const relPath = sub ? `${sub}/${data.mainModel.filename}` : data.mainModel.filename;
|
||||
this.setWidgetValuePath(ckptNode, relPath);
|
||||
|
||||
let lastNode = ckptNode;
|
||||
let lastModelSlot = isUnet ? 0 : 0;
|
||||
let lastClipSlot = isUnet ? null : 1;
|
||||
|
||||
let relX = 350;
|
||||
let relY = 0;
|
||||
|
||||
data.loras.forEach((lora, idx) => {
|
||||
const loraNode = LiteGraph.createNode("LoraLoader");
|
||||
app.graph.add(loraNode);
|
||||
groupNodes.push({ node: loraNode, relX: relX, relY: relY });
|
||||
|
||||
const lsub = lora.subfolder.replace(/^\/+/, '').replace(/\/+$/, '');
|
||||
const lrelPath = lsub ? `${lsub}/${lora.filename}` : lora.filename;
|
||||
this.setWidgetValuePath(loraNode, lrelPath);
|
||||
|
||||
lastNode.connect(lastModelSlot, loraNode, 0);
|
||||
if (lastClipSlot !== null) lastNode.connect(lastClipSlot, loraNode, 1);
|
||||
|
||||
lastNode = loraNode;
|
||||
lastModelSlot = 0;
|
||||
lastClipSlot = 1;
|
||||
relX += 350;
|
||||
});
|
||||
|
||||
if (data.promptEn) {
|
||||
const posNode = LiteGraph.createNode("CLIPTextEncode");
|
||||
posNode.title = "CLIP Text Encode (Positive)";
|
||||
app.graph.add(posNode);
|
||||
groupNodes.push({ node: posNode, relX: relX, relY: 0 });
|
||||
|
||||
if (posNode.widgets && posNode.widgets.length > 0) {
|
||||
const tw = posNode.widgets.find(w => w.name === 'text' || w.type === 'customtext');
|
||||
if (tw) tw.value = data.promptEn;
|
||||
}
|
||||
if (lastClipSlot !== null) {
|
||||
lastNode.connect(lastClipSlot, posNode, 0);
|
||||
}
|
||||
|
||||
const negNode = LiteGraph.createNode("CLIPTextEncode");
|
||||
negNode.title = "CLIP Text Encode (Negative)";
|
||||
app.graph.add(negNode);
|
||||
groupNodes.push({ node: negNode, relX: relX, relY: 250 });
|
||||
|
||||
if (negNode.widgets && negNode.widgets.length > 0) {
|
||||
const tw = negNode.widgets.find(w => w.name === 'text' || w.type === 'customtext');
|
||||
if (tw) tw.value = "text, watermark, ugly, bad anatomy";
|
||||
}
|
||||
if (lastClipSlot !== null) {
|
||||
lastNode.connect(lastClipSlot, negNode, 0);
|
||||
}
|
||||
}
|
||||
|
||||
this.nbPanel.style.display = 'none';
|
||||
this.close();
|
||||
|
||||
// Magnetic Sticking Logic
|
||||
let isSticking = true;
|
||||
const stickHandler = (e) => {
|
||||
if (!isSticking || !app.canvas) return;
|
||||
const canvas = app.canvas;
|
||||
|
||||
let canvasX, canvasY;
|
||||
if (canvas.convertEventToCanvasOffset) {
|
||||
const pos = canvas.convertEventToCanvasOffset(e);
|
||||
canvasX = pos[0];
|
||||
canvasY = pos[1];
|
||||
} else {
|
||||
const rect = canvas.canvas.getBoundingClientRect();
|
||||
canvasX = (e.clientX - rect.left - canvas.ds.offset[0]) / canvas.ds.scale;
|
||||
canvasY = (e.clientY - rect.top - canvas.ds.offset[1]) / canvas.ds.scale;
|
||||
}
|
||||
|
||||
groupNodes.forEach(item => {
|
||||
const w = (item.node.size && item.node.size[0]) ? item.node.size[0] : 200;
|
||||
item.node.pos = [canvasX - (w / 2) + item.relX, canvasY - 20 + item.relY];
|
||||
});
|
||||
canvas.setDirty(true, true);
|
||||
};
|
||||
|
||||
const dropHandler = (e) => {
|
||||
if (!isSticking) return;
|
||||
isSticking = false;
|
||||
window.removeEventListener('mousemove', stickHandler, true);
|
||||
window.removeEventListener('pointerdown', dropHandler, true);
|
||||
window.removeEventListener('mousedown', dropHandler, true);
|
||||
window.removeEventListener('click', dropHandler, true);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', stickHandler, true);
|
||||
setTimeout(() => {
|
||||
window.addEventListener('pointerdown', dropHandler, true);
|
||||
window.addEventListener('mousedown', dropHandler, true);
|
||||
window.addEventListener('click', dropHandler, true);
|
||||
}, 100);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,981 @@
|
||||
/** Workflow Recipes UI, built on the same modal/card language as Notebooks. */
|
||||
|
||||
import { app } from '../../../scripts/app.js';
|
||||
import { translate } from './locales.js';
|
||||
import { anomalousAlert, anomalousConfirm } from './ui_dialog.js';
|
||||
import {
|
||||
captureCanvasThumbnail,
|
||||
captureRecipeDraft,
|
||||
} from './recipe_parser.js';
|
||||
import {
|
||||
appendRecipeOnCanvas,
|
||||
showRecipeDetail,
|
||||
} from './ui_recipe_detail.js';
|
||||
|
||||
const t = (key, params) => translate(key, params);
|
||||
const RECIPE_PRESENTATION_DEFAULTS = Object.freeze({
|
||||
saveModelPreviewSnapshots: true,
|
||||
});
|
||||
|
||||
function formatRecipeText(key, values = {}) {
|
||||
return Object.entries(values).reduce((text, [name, value]) => text.replaceAll(`{${name}}`, String(value)), t(key));
|
||||
}
|
||||
|
||||
async function runRecipeCardAction(actionButton, action, errorKey) {
|
||||
if (!actionButton || actionButton.disabled) return;
|
||||
actionButton.disabled = true;
|
||||
actionButton.classList.add('is-busy');
|
||||
try {
|
||||
await action();
|
||||
} catch (error) {
|
||||
console.error('Workflow Recipe action failed:', error);
|
||||
await anomalousAlert(t(errorKey));
|
||||
} finally {
|
||||
actionButton.disabled = false;
|
||||
actionButton.classList.remove('is-busy');
|
||||
}
|
||||
}
|
||||
|
||||
function appendText(parent, tagName, text, className = '') {
|
||||
const element = document.createElement(tagName);
|
||||
if (className) element.className = className;
|
||||
element.textContent = text;
|
||||
parent.appendChild(element);
|
||||
return element;
|
||||
}
|
||||
|
||||
async function copyCardValueWithFeedback(buttonElement, value) {
|
||||
const original = buttonElement.textContent;
|
||||
try {
|
||||
await navigator.clipboard.writeText(String(value));
|
||||
buttonElement.textContent = `✓ ${t('recipeCopied')}`;
|
||||
buttonElement.classList.add('copied-success');
|
||||
} catch (error) {
|
||||
console.warn('Could not copy recipe parameter:', error);
|
||||
buttonElement.textContent = `! ${t('recipeCopyFailed')}`;
|
||||
buttonElement.classList.add('copied-failure');
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
buttonElement.textContent = original;
|
||||
buttonElement.classList.remove('copied-success', 'copied-failure');
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
function safeThumbnail(value) {
|
||||
return typeof value === 'string' && /^data:image\/(?:png|jpeg|webp);base64,/i.test(value)
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
function outputImageUrl(image) {
|
||||
if (!image || image.type !== 'output' || typeof image.filename !== 'string') return null;
|
||||
const query = new URLSearchParams({ filename: image.filename, type: 'output' });
|
||||
if (image.subfolder) query.set('subfolder', image.subfolder);
|
||||
return `/view?${query.toString()}`;
|
||||
}
|
||||
|
||||
function recipeAssetUrl(filename, assetId) {
|
||||
if (!filename || !assetId) return null;
|
||||
return `/anomalous/recipe_asset?filename=${encodeURIComponent(filename)}&asset=${encodeURIComponent(assetId)}`;
|
||||
}
|
||||
|
||||
function previewIsVideo(url) {
|
||||
return /\.(?:mp4|webm)(?:$|\?|&|#)/i.test(url || '');
|
||||
}
|
||||
|
||||
function appendRecipeCover(parent, url, alt) {
|
||||
if (!url) return;
|
||||
if (previewIsVideo(url)) {
|
||||
const video = document.createElement('video');
|
||||
video.className = 'anomalous-recipe-thumbnail';
|
||||
video.src = url;
|
||||
video.muted = true;
|
||||
video.loop = true;
|
||||
video.playsInline = true;
|
||||
video.preload = 'metadata';
|
||||
video.onpointerenter = () => video.play().catch(() => {});
|
||||
video.onpointerleave = () => {
|
||||
video.pause();
|
||||
video.currentTime = 0;
|
||||
};
|
||||
parent.appendChild(video);
|
||||
return;
|
||||
}
|
||||
const image = document.createElement('img');
|
||||
image.className = 'anomalous-recipe-thumbnail';
|
||||
image.src = url;
|
||||
image.alt = alt;
|
||||
image.loading = 'lazy';
|
||||
parent.appendChild(image);
|
||||
}
|
||||
|
||||
async function exportRecipePackage(filename) {
|
||||
const choice = { noLabel: t('recipeDialogNo') };
|
||||
const includeSnapshots = await anomalousConfirm(t('recipeExportSnapshotsConfirm'), 'Anomalous', choice);
|
||||
if (includeSnapshots === null) return;
|
||||
const includeHistory = await anomalousConfirm(t('recipeExportHistoryConfirm'), 'Anomalous', choice);
|
||||
if (includeHistory === null) return;
|
||||
const redactIdentity = await anomalousConfirm(t('recipeExportRedactIdentityConfirm'), 'Anomalous', choice);
|
||||
if (redactIdentity === null) return;
|
||||
const includeIdentity = !redactIdentity;
|
||||
const response = await fetch('/anomalous/export_recipe_package', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
filename,
|
||||
include_snapshots: includeSnapshots,
|
||||
include_history: includeHistory,
|
||||
include_identity: includeIdentity,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error('recipe export failed');
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${filename.replace(/\.json$/i, '')}.anomalous-recipe.zip`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function importRecipePackage(owner, file) {
|
||||
const inspectResponse = await fetch('/anomalous/import_recipe_package_inspect', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/zip' },
|
||||
body: file,
|
||||
});
|
||||
const inspectPayload = await inspectResponse.json();
|
||||
if (!inspectResponse.ok || inspectPayload.status !== 'success') throw new Error('recipe import inspection failed');
|
||||
const recipeName = inspectPayload.recipe?.name || t('recipeUntitled');
|
||||
const summary = `${t('recipeImportSummary')}\n\n${recipeName}\n${t('recipeImportAssets')}: ${inspectPayload.asset_count || 0}\n${t('recipeImportHistory')}: ${inspectPayload.history_count || 0}`;
|
||||
if (!await anomalousConfirm(summary)) return;
|
||||
let name = recipeName;
|
||||
if ((inspectPayload.existing_names || []).includes(name)) {
|
||||
name = prompt(t('recipeImportRenamePrompt'), `${name} (Imported)`);
|
||||
if (!name?.trim()) return;
|
||||
}
|
||||
const commitResponse = await fetch('/anomalous/import_recipe_package_commit', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: inspectPayload.token, collision: 'rename', name: name.trim() }),
|
||||
});
|
||||
const commitPayload = await commitResponse.json();
|
||||
if (!commitResponse.ok || commitPayload.status !== 'success') throw new Error('recipe import commit failed');
|
||||
await owner.refreshRecipes();
|
||||
}
|
||||
|
||||
async function captureOutputThumbnail(image) {
|
||||
const url = outputImageUrl(image);
|
||||
if (!url) return null;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) return null;
|
||||
const blob = await response.blob();
|
||||
if (previewIsVideo(url) || /^video\//i.test(blob.type)) {
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const video = document.createElement('video');
|
||||
video.src = objectUrl;
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.preload = 'auto';
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
video.onloadeddata = resolve;
|
||||
video.onerror = reject;
|
||||
video.load();
|
||||
});
|
||||
if (video.duration > 0.2) {
|
||||
await new Promise((resolve) => {
|
||||
video.onseeked = resolve;
|
||||
video.currentTime = 0.1;
|
||||
});
|
||||
}
|
||||
const maxEdge = 720;
|
||||
const scale = Math.min(1, maxEdge / Math.max(video.videoWidth, video.videoHeight));
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.max(1, Math.round(video.videoWidth * scale));
|
||||
canvas.height = Math.max(1, Math.round(video.videoHeight * scale));
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) return null;
|
||||
context.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
return canvas.toDataURL('image/webp', 0.72);
|
||||
} finally {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
}
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
try {
|
||||
const maxEdge = 720;
|
||||
const scale = Math.min(1, maxEdge / Math.max(bitmap.width, bitmap.height));
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
|
||||
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) return null;
|
||||
context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
||||
return canvas.toDataURL('image/jpeg', 0.72);
|
||||
} finally {
|
||||
bitmap.close?.();
|
||||
}
|
||||
}
|
||||
|
||||
function compactText(value, limit = 110) {
|
||||
const text = String(value || '').trim().replace(/\s+/g, ' ');
|
||||
return text.length > limit ? `${text.slice(0, limit - 1)}…` : text;
|
||||
}
|
||||
|
||||
function modelDisplayName(value) {
|
||||
const path = String(value || '').replace(/\\/g, '/');
|
||||
const filename = path.split('/').pop() || '';
|
||||
return filename.replace(/\.(?:safetensors|ckpt|pt|bin|sft)$/i, '');
|
||||
}
|
||||
|
||||
function normaliseSearchText(value) {
|
||||
const text = String(value || '').trim().toLocaleLowerCase();
|
||||
try { return text.normalize('NFKC'); } catch (error) { return text; }
|
||||
}
|
||||
|
||||
function recipeMatchesFilter(data, query, selectedTags) {
|
||||
const terms = normaliseSearchText(query).split(/\s+/).filter(Boolean);
|
||||
const haystack = normaliseSearchText([
|
||||
data?.name || '',
|
||||
data?.notes || '',
|
||||
...(Array.isArray(data?.tags) ? data.tags : []),
|
||||
].join(' '));
|
||||
if (terms.some((term) => !haystack.includes(term))) return false;
|
||||
const tags = new Set((Array.isArray(data?.tags) ? data.tags : []).map(normaliseSearchText));
|
||||
for (const tag of selectedTags || []) if (!tags.has(normaliseSearchText(tag))) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function updateRecipeFilterControls(owner, recipes) {
|
||||
if (!owner.recipeTagBar) return;
|
||||
owner.recipeTagBar.replaceChildren();
|
||||
const tags = [...new Set((recipes || []).flatMap((item) => item?.data?.tags || []))]
|
||||
.filter(Boolean)
|
||||
.sort((left, right) => String(left).localeCompare(String(right)));
|
||||
for (const tag of tags) {
|
||||
const chip = appendText(owner.recipeTagBar, 'button', tag, 'anomalous-recipe-filter-tag');
|
||||
chip.type = 'button';
|
||||
chip.classList.toggle('active', owner.recipeSelectedTags?.has(tag));
|
||||
chip.onclick = () => {
|
||||
if (!owner.recipeSelectedTags) owner.recipeSelectedTags = new Set();
|
||||
if (owner.recipeSelectedTags.has(tag)) owner.recipeSelectedTags.delete(tag);
|
||||
else owner.recipeSelectedTags.add(tag);
|
||||
owner.renderRecipeList(owner.recipeRecords || []);
|
||||
};
|
||||
}
|
||||
if (!tags.length) appendText(owner.recipeTagBar, 'small', t('recipeNoTags'), 'anomalous-recipe-detail-muted');
|
||||
}
|
||||
|
||||
function summaryValue(value, fallback = '—') {
|
||||
return value === null || value === undefined || value === '' ? fallback : String(value);
|
||||
}
|
||||
|
||||
function createBadge(text, kind = '') {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = `anomalous-recipe-badge${kind ? ` anomalous-recipe-badge-${kind}` : ''}`;
|
||||
badge.textContent = text;
|
||||
return badge;
|
||||
}
|
||||
|
||||
function displayWidgetValue(value) {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
try { return JSON.stringify(value); } catch (error) { return String(value); }
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
async function fetchRecipeBundle(filename) {
|
||||
const [fullResponse, historyResponse] = await Promise.all([
|
||||
fetch(`/anomalous/recipe_full?filename=${encodeURIComponent(filename)}`),
|
||||
fetch(`/anomalous/recipe_history?filename=${encodeURIComponent(filename)}`),
|
||||
]);
|
||||
const payload = await fullResponse.json();
|
||||
const historyPayload = historyResponse.ok ? await historyResponse.json() : { versions: [] };
|
||||
if (!fullResponse.ok || payload.status !== 'success' || !payload.data?.workflow) throw new Error('recipe missing workflow');
|
||||
return { data: JSON.parse(JSON.stringify(payload.data)), history: historyPayload.versions || [] };
|
||||
}
|
||||
|
||||
async function fetchRecipeData(filename) {
|
||||
const response = await fetch(`/anomalous/recipe_full?filename=${encodeURIComponent(filename)}`);
|
||||
const payload = await response.json();
|
||||
if (!response.ok || payload.status !== 'success' || !payload.data?.workflow) throw new Error('recipe missing workflow');
|
||||
return JSON.parse(JSON.stringify(payload.data));
|
||||
}
|
||||
|
||||
async function editRecipe(owner, recipe, filename, history = null) {
|
||||
try {
|
||||
const bundle = history ? { data: JSON.parse(JSON.stringify(recipe)), history } : await fetchRecipeBundle(filename);
|
||||
const editable = bundle.data;
|
||||
const result = await showRecipeEditDialog(owner, editable, filename, bundle.history);
|
||||
if (!result || result.mode === 'restored') return;
|
||||
if (result.mode === 'canvas') {
|
||||
if (!await anomalousConfirm(t('recipeEditCanvasConfirm'))) return;
|
||||
app.loadGraphData(editable.workflow);
|
||||
app.canvas?.setDirty?.(true, true);
|
||||
owner.recipeEditing = { filename, data: editable };
|
||||
const saveButton = owner.recipeView?.querySelector('[data-recipe-save-current]');
|
||||
if (saveButton) saveButton.textContent = t('recipeUpdateCurrent');
|
||||
return;
|
||||
}
|
||||
|
||||
editable.name = result.name;
|
||||
editable.tags = result.tags;
|
||||
editable.notes = result.notes;
|
||||
const response = await fetch('/anomalous/update_recipe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ filename, ...editable }),
|
||||
});
|
||||
const updatedPayload = await response.json();
|
||||
if (!response.ok || updatedPayload.status !== 'success') throw new Error('recipe update failed');
|
||||
await owner.refreshRecipes();
|
||||
} catch (error) {
|
||||
console.error('Could not edit Workflow Recipe:', error);
|
||||
await anomalousAlert(t('recipeUpdateError'));
|
||||
}
|
||||
}
|
||||
|
||||
function showRecipeSaveDialog(owner, canvasThumbnail, initial = null) {
|
||||
return new Promise((resolve) => {
|
||||
const selection = {
|
||||
thumbnail: safeThumbnail(initial?.thumbnail) || safeThumbnail(canvasThumbnail),
|
||||
sourceImage: initial?.source_image || null,
|
||||
// A saved snapshot is what makes the cover portable in an export.
|
||||
// Preserve an explicit opt-out, but enable the sharing-oriented
|
||||
// behavior for new and legacy recipes without a stored preference.
|
||||
saveModelPreviewSnapshots: initial?.presentation?.save_model_preview_snapshots
|
||||
?? RECIPE_PRESENTATION_DEFAULTS.saveModelPreviewSnapshots,
|
||||
};
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'anomalous-recipe-dialog-overlay';
|
||||
overlay.setAttribute('role', 'dialog');
|
||||
overlay.setAttribute('aria-modal', 'true');
|
||||
|
||||
const dialog = document.createElement('div');
|
||||
dialog.className = 'anomalous-recipe-dialog';
|
||||
appendText(dialog, 'h3', t('recipeSaveTitle'));
|
||||
|
||||
const nameLabel = appendText(dialog, 'label', t('recipeName'));
|
||||
const nameInput = document.createElement('input');
|
||||
nameInput.className = 'anomalous-nb-select';
|
||||
nameInput.type = 'text';
|
||||
nameInput.maxLength = 120;
|
||||
nameInput.value = initial?.name || t('recipeDefaultName');
|
||||
nameLabel.appendChild(nameInput);
|
||||
|
||||
const tagsLabel = appendText(dialog, 'label', t('recipeTags'));
|
||||
const tagsInput = document.createElement('input');
|
||||
tagsInput.className = 'anomalous-nb-select';
|
||||
tagsInput.type = 'text';
|
||||
tagsInput.maxLength = 300;
|
||||
tagsInput.placeholder = t('recipeTagsHint');
|
||||
tagsInput.value = Array.isArray(initial?.tags) ? initial.tags.join(', ') : '';
|
||||
tagsLabel.appendChild(tagsInput);
|
||||
|
||||
const notesLabel = appendText(dialog, 'label', t('recipeNotes'));
|
||||
const notesInput = document.createElement('textarea');
|
||||
notesInput.className = 'anomalous-nb-textarea';
|
||||
notesInput.maxLength = 3000;
|
||||
notesInput.placeholder = t('recipeNotesHint');
|
||||
notesInput.value = initial?.notes || '';
|
||||
notesLabel.appendChild(notesInput);
|
||||
|
||||
const coverSection = document.createElement('section');
|
||||
coverSection.className = 'anomalous-recipe-save-section';
|
||||
appendText(coverSection, 'strong', t('recipeBindImage'));
|
||||
const coverChoices = document.createElement('div');
|
||||
coverChoices.className = 'anomalous-recipe-cover-choices';
|
||||
const coverPreview = document.createElement('img');
|
||||
coverPreview.className = 'anomalous-recipe-dialog-preview';
|
||||
coverPreview.alt = t('recipeThumbnail');
|
||||
const initialPreview = selection.thumbnail || outputImageUrl(selection.sourceImage);
|
||||
if (initialPreview) coverPreview.src = initialPreview;
|
||||
else coverPreview.style.display = 'none';
|
||||
|
||||
const choiceButtons = [];
|
||||
const selectCover = (button, sourceImage, previewUrl, thumbnailValue) => {
|
||||
for (const choice of choiceButtons) choice.classList.toggle('selected', choice === button);
|
||||
selection.sourceImage = sourceImage;
|
||||
selection.thumbnail = thumbnailValue;
|
||||
if (previewUrl) {
|
||||
coverPreview.src = previewUrl;
|
||||
coverPreview.style.display = 'block';
|
||||
} else {
|
||||
coverPreview.removeAttribute('src');
|
||||
coverPreview.style.display = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
const noneChoice = appendText(coverChoices, 'button', t('recipeNoImage'), 'anomalous-recipe-cover-choice');
|
||||
noneChoice.type = 'button';
|
||||
choiceButtons.push(noneChoice);
|
||||
noneChoice.onclick = () => selectCover(noneChoice, null, null, null);
|
||||
if (safeThumbnail(initial?.thumbnail) || initial?.source_image) {
|
||||
const existingChoice = appendText(coverChoices, 'button', t('recipeKeepImage'), 'anomalous-recipe-cover-choice selected');
|
||||
existingChoice.type = 'button';
|
||||
choiceButtons.push(existingChoice);
|
||||
existingChoice.onclick = () => selectCover(
|
||||
existingChoice,
|
||||
initial?.source_image || null,
|
||||
safeThumbnail(initial?.thumbnail) || outputImageUrl(initial?.source_image),
|
||||
safeThumbnail(initial?.thumbnail),
|
||||
);
|
||||
}
|
||||
if (safeThumbnail(canvasThumbnail)) {
|
||||
const canvasChoice = document.createElement('button');
|
||||
canvasChoice.type = 'button';
|
||||
canvasChoice.className = `anomalous-recipe-cover-choice${initial ? '' : ' selected'}`;
|
||||
const canvasImage = document.createElement('img');
|
||||
canvasImage.src = canvasThumbnail;
|
||||
canvasImage.alt = t('recipeCanvasPreview');
|
||||
appendText(canvasChoice, 'span', t('recipeCanvasPreview'));
|
||||
canvasChoice.prepend(canvasImage);
|
||||
choiceButtons.push(canvasChoice);
|
||||
canvasChoice.onclick = () => selectCover(canvasChoice, null, canvasThumbnail, safeThumbnail(canvasThumbnail));
|
||||
} else if (!initial) {
|
||||
noneChoice.classList.add('selected');
|
||||
}
|
||||
|
||||
const recentStatus = appendText(coverSection, 'small', t('recipeLoadingRecentImages'), 'anomalous-recipe-node-hint');
|
||||
coverSection.append(coverChoices, coverPreview);
|
||||
dialog.appendChild(coverSection);
|
||||
|
||||
fetch('/anomalous/gallery_images?page=1&limit=12')
|
||||
.then((response) => response.ok ? response.json() : Promise.reject(new Error('image list failed')))
|
||||
.then((payload) => {
|
||||
recentStatus.textContent = t('recipeRecentImages');
|
||||
for (const imageData of payload.images || []) {
|
||||
const url = outputImageUrl(imageData);
|
||||
if (!url) continue;
|
||||
const choice = document.createElement('button');
|
||||
choice.type = 'button';
|
||||
choice.className = 'anomalous-recipe-cover-choice anomalous-recipe-output-choice';
|
||||
const image = document.createElement('img');
|
||||
image.src = url;
|
||||
image.loading = 'lazy';
|
||||
image.alt = imageData.filename;
|
||||
choice.appendChild(image);
|
||||
choice.title = imageData.filename;
|
||||
choiceButtons.push(choice);
|
||||
choice.onclick = () => selectCover(choice, {
|
||||
filename: imageData.filename,
|
||||
subfolder: imageData.subfolder || '',
|
||||
type: 'output',
|
||||
}, url, null);
|
||||
coverChoices.appendChild(choice);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('Could not load recent recipe images:', error);
|
||||
recentStatus.textContent = t('recipeRecentImagesUnavailable');
|
||||
});
|
||||
|
||||
const error = appendText(dialog, 'div', '', 'anomalous-recipe-dialog-error');
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'anomalous-recipe-actions';
|
||||
const cancel = appendText(actions, 'button', t('recipeCancel'), 'anomalous-btn-danger');
|
||||
const save = appendText(actions, 'button', t('recipeSave'), 'anomalous-btn-primary');
|
||||
cancel.type = 'button';
|
||||
save.type = 'button';
|
||||
|
||||
const close = (value) => {
|
||||
overlay.remove();
|
||||
resolve(value);
|
||||
};
|
||||
cancel.onclick = () => close(null);
|
||||
overlay.onclick = (event) => {
|
||||
if (event.target === overlay) close(null);
|
||||
};
|
||||
save.onclick = () => {
|
||||
const name = nameInput.value.trim();
|
||||
if (!name) {
|
||||
error.textContent = t('recipeNameRequired');
|
||||
nameInput.focus();
|
||||
return;
|
||||
}
|
||||
const tags = [...new Set(tagsInput.value.split(',').map((tag) => tag.trim()).filter(Boolean))].slice(0, 20);
|
||||
close({
|
||||
name,
|
||||
tags,
|
||||
notes: notesInput.value.trim(),
|
||||
thumbnail: selection.thumbnail,
|
||||
sourceImage: selection.sourceImage,
|
||||
saveModelPreviewSnapshots: selection.saveModelPreviewSnapshots,
|
||||
});
|
||||
};
|
||||
dialog.appendChild(actions);
|
||||
overlay.appendChild(dialog);
|
||||
(owner.nbPanel || document.body).appendChild(overlay);
|
||||
nameInput.focus();
|
||||
nameInput.select();
|
||||
});
|
||||
}
|
||||
|
||||
function showRecipeEditDialog(owner, recipeData, filename, history) {
|
||||
return new Promise((resolve) => {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'anomalous-recipe-dialog-overlay';
|
||||
overlay.setAttribute('role', 'dialog');
|
||||
overlay.setAttribute('aria-modal', 'true');
|
||||
const dialog = document.createElement('div');
|
||||
dialog.className = 'anomalous-recipe-dialog anomalous-recipe-edit-dialog';
|
||||
appendText(dialog, 'h3', t('recipeEditTitle'));
|
||||
|
||||
const nameLabel = appendText(dialog, 'label', t('recipeName'));
|
||||
const nameInput = document.createElement('input');
|
||||
nameInput.className = 'anomalous-nb-select';
|
||||
nameInput.type = 'text';
|
||||
nameInput.maxLength = 120;
|
||||
nameInput.value = recipeData.name || '';
|
||||
nameLabel.appendChild(nameInput);
|
||||
|
||||
const tagsLabel = appendText(dialog, 'label', t('recipeTags'));
|
||||
const tagsInput = document.createElement('input');
|
||||
tagsInput.className = 'anomalous-nb-select';
|
||||
tagsInput.type = 'text';
|
||||
tagsInput.maxLength = 300;
|
||||
tagsInput.value = Array.isArray(recipeData.tags) ? recipeData.tags.join(', ') : '';
|
||||
tagsInput.placeholder = t('recipeTagsHint');
|
||||
tagsLabel.appendChild(tagsInput);
|
||||
|
||||
const notesLabel = appendText(dialog, 'label', t('recipeNotes'));
|
||||
const notesInput = document.createElement('textarea');
|
||||
notesInput.className = 'anomalous-nb-textarea';
|
||||
notesInput.maxLength = 3000;
|
||||
notesInput.value = recipeData.notes || '';
|
||||
notesInput.placeholder = t('recipeNotesHint');
|
||||
notesLabel.appendChild(notesInput);
|
||||
|
||||
const historyDetails = document.createElement('details');
|
||||
historyDetails.className = 'anomalous-recipe-node-details';
|
||||
const historySummary = document.createElement('summary');
|
||||
historySummary.textContent = `${t('recipeHistory')} (${history.length})`;
|
||||
historyDetails.appendChild(historySummary);
|
||||
const historyList = document.createElement('div');
|
||||
historyList.className = 'anomalous-recipe-history-list';
|
||||
if (!history.length) {
|
||||
appendText(historyList, 'small', t('recipeHistoryEmpty'), 'anomalous-recipe-node-hint');
|
||||
} else {
|
||||
for (const version of history) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'anomalous-recipe-history-row';
|
||||
const date = Number.isFinite(Number(version.timestamp))
|
||||
? new Date(Number(version.timestamp)).toLocaleString()
|
||||
: t('recipeUnknownVersion');
|
||||
appendText(row, 'span', `${date} · ${version.name || t('recipeUntitled')}`);
|
||||
const restore = appendText(row, 'button', t('recipeRestoreVersion'), 'anomalous-btn-danger');
|
||||
restore.type = 'button';
|
||||
restore.onclick = async () => {
|
||||
if (!await anomalousConfirm(t('recipeRestoreVersionConfirm'))) return;
|
||||
try {
|
||||
const response = await fetch('/anomalous/restore_recipe_version', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ filename, version: version.version }),
|
||||
});
|
||||
if (!response.ok) throw new Error('recipe history restore failed');
|
||||
overlay.remove();
|
||||
await owner.refreshRecipes();
|
||||
resolve({ mode: 'restored' });
|
||||
} catch (error) {
|
||||
console.error('Could not restore Workflow Recipe version:', error);
|
||||
await anomalousAlert(t('recipeUpdateError'));
|
||||
}
|
||||
};
|
||||
row.appendChild(restore);
|
||||
historyList.appendChild(row);
|
||||
}
|
||||
}
|
||||
historyDetails.appendChild(historyList);
|
||||
dialog.appendChild(historyDetails);
|
||||
|
||||
const error = appendText(dialog, 'div', '', 'anomalous-recipe-dialog-error');
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'anomalous-recipe-actions';
|
||||
const cancel = appendText(actions, 'button', t('recipeCancel'), 'anomalous-btn-danger');
|
||||
const canvas = appendText(actions, 'button', t('recipeEditCanvas'), 'anomalous-btn-primary');
|
||||
const save = appendText(actions, 'button', t('recipeUpdate'), 'anomalous-btn-success');
|
||||
for (const button of [cancel, canvas, save]) button.type = 'button';
|
||||
const close = (value) => { overlay.remove(); resolve(value); };
|
||||
cancel.onclick = () => close(null);
|
||||
overlay.onclick = (event) => { if (event.target === overlay) close(null); };
|
||||
canvas.onclick = () => close({ mode: 'canvas' });
|
||||
save.onclick = () => {
|
||||
const name = nameInput.value.trim();
|
||||
if (!name) {
|
||||
error.textContent = t('recipeNameRequired');
|
||||
nameInput.focus();
|
||||
return;
|
||||
}
|
||||
close({
|
||||
mode: 'save',
|
||||
name,
|
||||
tags: [...new Set(tagsInput.value.split(',').map((tag) => tag.trim()).filter(Boolean))].slice(0, 20),
|
||||
notes: notesInput.value.trim(),
|
||||
});
|
||||
};
|
||||
dialog.appendChild(actions);
|
||||
overlay.appendChild(dialog);
|
||||
(owner.nbPanel || document.body).appendChild(overlay);
|
||||
nameInput.focus();
|
||||
nameInput.select();
|
||||
});
|
||||
}
|
||||
|
||||
export async function showRecipes() {
|
||||
if (!this.notebookContainer) {
|
||||
this.nbPanel.style.display = 'flex';
|
||||
await this.showNotebooks();
|
||||
}
|
||||
if (typeof this.recipeModelReturn === 'function') {
|
||||
const returnToRecipe = this.recipeModelReturn;
|
||||
this.recipeModelReturn = null;
|
||||
returnToRecipe();
|
||||
return;
|
||||
}
|
||||
this.recipeDetailFinish?.('closed');
|
||||
this.notebookBody.style.display = 'none';
|
||||
this.notebookNotesTab?.classList.remove('active');
|
||||
this.notebookRecipesTab?.classList.add('active');
|
||||
if (this.recipeDetailView) {
|
||||
this.recipeDetailView.remove();
|
||||
this.recipeDetailView = null;
|
||||
this.recipeListContainer.style.display = '';
|
||||
this.recipeView.querySelector('.anomalous-recipe-actionbar').style.display = '';
|
||||
}
|
||||
if (this.recipeView) {
|
||||
this.recipeView.style.display = 'flex';
|
||||
if (!this.recipeDetailView) {
|
||||
if (this.recipeListContainer) this.recipeListContainer.style.display = '';
|
||||
const actionbar = this.recipeView.querySelector('.anomalous-recipe-actionbar');
|
||||
if (actionbar) actionbar.style.display = '';
|
||||
this.recipeReturnState = null;
|
||||
delete this.recipeDetailPayload;
|
||||
}
|
||||
}
|
||||
if (this.recipesInitialized) {
|
||||
await this.refreshRecipes();
|
||||
return;
|
||||
}
|
||||
this.recipesInitialized = true;
|
||||
this.recipeSelectedTags = this.recipeSelectedTags || new Set();
|
||||
this.recipeSearchQuery = this.recipeSearchQuery || '';
|
||||
|
||||
this.recipeView = document.createElement('div');
|
||||
this.recipeView.className = 'anomalous-recipe-body';
|
||||
const actionBar = document.createElement('div');
|
||||
actionBar.className = 'anomalous-recipe-actionbar';
|
||||
const search = document.createElement('input');
|
||||
search.type = 'search';
|
||||
search.className = 'anomalous-recipe-search';
|
||||
search.placeholder = t('recipeSearchPlaceholder');
|
||||
search.value = this.recipeSearchQuery || '';
|
||||
search.oninput = () => {
|
||||
this.recipeSearchQuery = search.value;
|
||||
this.renderRecipeList(this.recipeRecords || []);
|
||||
};
|
||||
this.recipeSearchInput = search;
|
||||
actionBar.appendChild(search);
|
||||
|
||||
const clearFilters = appendText(actionBar, 'button', t('recipeClearFilters'), 'anomalous-btn-danger');
|
||||
clearFilters.type = 'button';
|
||||
clearFilters.onclick = () => {
|
||||
this.recipeSearchQuery = '';
|
||||
this.recipeSelectedTags = new Set();
|
||||
search.value = '';
|
||||
updateRecipeFilterControls(this, this.recipeRecords || []);
|
||||
this.renderRecipeList(this.recipeRecords || []);
|
||||
};
|
||||
|
||||
this.recipeTagBar = document.createElement('div');
|
||||
this.recipeTagBar.className = 'anomalous-recipe-filter-tags';
|
||||
actionBar.appendChild(this.recipeTagBar);
|
||||
|
||||
this.recipeFilterSummary = appendText(actionBar, 'small', '0/0', 'anomalous-recipe-filter-summary');
|
||||
|
||||
const save = appendText(actionBar, 'button', t('recipeSaveCurrent'), 'anomalous-btn-primary');
|
||||
save.dataset.recipeSaveCurrent = 'true';
|
||||
save.type = 'button';
|
||||
save.onclick = () => this.handleSaveRecipe();
|
||||
this.recipeSaveStatus = appendText(actionBar, 'small', '', 'anomalous-recipe-save-status');
|
||||
const importButton = appendText(actionBar, 'button', t('recipeImport'), 'anomalous-btn-primary');
|
||||
importButton.type = 'button';
|
||||
importButton.onclick = () => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.zip,.anomalous-recipe.zip,application/zip';
|
||||
input.onchange = async () => {
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
await importRecipePackage(this, file);
|
||||
} catch (error) {
|
||||
console.error('Could not import Workflow Recipe package:', error);
|
||||
await anomalousAlert(t('recipeImportError'));
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
this.recipeView.appendChild(actionBar);
|
||||
|
||||
const betaNotice = document.createElement('div');
|
||||
betaNotice.className = 'anomalous-beta-notice anomalous-recipe-beta-notice';
|
||||
const betaBadge = appendText(betaNotice, 'strong', t('betaFeature'), 'anomalous-beta-badge');
|
||||
betaBadge.dataset.anomalousI18nKey = 'betaFeature';
|
||||
const betaText = appendText(betaNotice, 'span', t('recipeBetaNotice'));
|
||||
betaText.dataset.anomalousI18nKey = 'recipeBetaNotice';
|
||||
this.recipeView.appendChild(betaNotice);
|
||||
|
||||
this.recipeListContainer = document.createElement('div');
|
||||
this.recipeListContainer.className = 'anomalous-recipe-list';
|
||||
this.recipeView.appendChild(this.recipeListContainer);
|
||||
this.notebookContainer.appendChild(this.recipeView);
|
||||
await this.refreshRecipes();
|
||||
}
|
||||
|
||||
export async function refreshRecipes() {
|
||||
if (!this.recipeListContainer) return;
|
||||
try {
|
||||
const response = await fetch('/anomalous/recipes');
|
||||
if (!response.ok) throw new Error('recipe list request failed');
|
||||
const payload = await response.json();
|
||||
this.recipeRecords = payload.recipes || [];
|
||||
updateRecipeFilterControls(this, this.recipeRecords);
|
||||
this.renderRecipeList(this.recipeRecords);
|
||||
} catch (error) {
|
||||
console.error('Could not load Workflow Recipes:', error);
|
||||
this.recipeListContainer.replaceChildren();
|
||||
appendText(this.recipeListContainer, 'p', t('recipeLoadError'), 'anomalous-recipe-empty');
|
||||
}
|
||||
}
|
||||
|
||||
export function renderRecipeList(recipes) {
|
||||
this.recipeListContainer.replaceChildren();
|
||||
const records = Array.isArray(recipes) ? recipes : [];
|
||||
const selectedTags = this.recipeSelectedTags || new Set();
|
||||
const filtered = records.filter((recipe) => recipeMatchesFilter(
|
||||
recipe?.data || {},
|
||||
this.recipeSearchQuery || '',
|
||||
selectedTags,
|
||||
));
|
||||
if (this.recipeFilterSummary) this.recipeFilterSummary.textContent = `${filtered.length}/${records.length}`;
|
||||
if (!records.length) {
|
||||
appendText(this.recipeListContainer, 'p', t('recipeEmpty'), 'anomalous-recipe-empty');
|
||||
return;
|
||||
}
|
||||
if (!filtered.length) {
|
||||
appendText(this.recipeListContainer, 'p', t('recipeNoMatches'), 'anomalous-recipe-empty');
|
||||
return;
|
||||
}
|
||||
for (const recipe of filtered) {
|
||||
const data = recipe?.data || {};
|
||||
const card = document.createElement('article');
|
||||
card.className = 'anomalous-recipe-card';
|
||||
appendText(card, 'h3', data.name || t('recipeUntitled'));
|
||||
|
||||
const sourceImageUrl = outputImageUrl(data.source_image);
|
||||
const savedCover = recipeAssetUrl(recipe.filename, data.presentation?.cover_asset_id);
|
||||
const thumbnail = savedCover || (previewIsVideo(sourceImageUrl)
|
||||
? sourceImageUrl
|
||||
: safeThumbnail(data.thumbnail) || sourceImageUrl);
|
||||
appendRecipeCover(card, thumbnail, data.name || t('recipeThumbnail'));
|
||||
if (Array.isArray(data.tags) && data.tags.length) {
|
||||
const tags = document.createElement('div');
|
||||
tags.className = 'anomalous-recipe-tags';
|
||||
for (const tag of data.tags.slice(0, 8)) {
|
||||
const tagButton = appendText(tags, 'button', compactText(tag, 32), 'anomalous-recipe-badge anomalous-recipe-badge-tag');
|
||||
tagButton.type = 'button';
|
||||
tagButton.title = t('recipeFilterByTag');
|
||||
tagButton.onclick = (event) => {
|
||||
event.stopPropagation();
|
||||
if (!this.recipeSelectedTags) this.recipeSelectedTags = new Set();
|
||||
this.recipeSelectedTags.add(tag);
|
||||
this.renderRecipeList(this.recipeRecords || []);
|
||||
updateRecipeFilterControls(this, this.recipeRecords || []);
|
||||
};
|
||||
}
|
||||
card.appendChild(tags);
|
||||
}
|
||||
if (data.notes) appendText(card, 'p', compactText(data.notes, 180), 'anomalous-recipe-notes');
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'anomalous-recipe-actions';
|
||||
|
||||
// Make card clickable for details
|
||||
card.style.cursor = 'pointer';
|
||||
card.onclick = () => runRecipeCardAction(card, async () => {
|
||||
const bundle = await fetchRecipeBundle(recipe.filename);
|
||||
const result = await showRecipeDetail(this, {
|
||||
recipe: bundle.data,
|
||||
filename: recipe.filename,
|
||||
history: bundle.history,
|
||||
});
|
||||
if (result?.mode === 'edit') await editRecipe(this, bundle.data, recipe.filename, bundle.history);
|
||||
}, 'recipeLoadError');
|
||||
|
||||
// Secondary actions (Icon buttons)
|
||||
const secondaryActions = document.createElement('div');
|
||||
secondaryActions.className = 'anomalous-recipe-actions-secondary';
|
||||
|
||||
const edit = appendText(secondaryActions, 'button', '✏️', 'anomalous-btn-icon anomalous-btn-edit');
|
||||
edit.type = 'button';
|
||||
edit.title = t('recipeEdit');
|
||||
edit.onclick = (e) => { e.stopPropagation(); runRecipeCardAction(edit, () => editRecipe(this, recipe?.data || {}, recipe.filename), 'recipeUpdateError'); };
|
||||
|
||||
const exportButton = appendText(secondaryActions, 'button', '📥', 'anomalous-btn-icon anomalous-btn-export');
|
||||
exportButton.type = 'button';
|
||||
exportButton.title = t('recipeExport');
|
||||
exportButton.onclick = (e) => { e.stopPropagation(); runRecipeCardAction(exportButton, () => exportRecipePackage(recipe.filename), 'recipeExportError'); };
|
||||
|
||||
const remove = appendText(secondaryActions, 'button', '🗑️', 'anomalous-btn-icon anomalous-btn-delete');
|
||||
remove.type = 'button';
|
||||
remove.title = t('recipeDelete');
|
||||
remove.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
runRecipeCardAction(remove, async () => {
|
||||
if (!await anomalousConfirm(t('recipeDeleteConfirm'))) return;
|
||||
const response = await fetch('/anomalous/delete_recipe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ filename: recipe.filename }),
|
||||
});
|
||||
if (!response.ok) throw new Error('recipe deletion failed');
|
||||
await this.refreshRecipes();
|
||||
}, 'recipeDeleteError');
|
||||
};
|
||||
|
||||
// Primary actions (Main buttons)
|
||||
const primaryActions = document.createElement('div');
|
||||
primaryActions.className = 'anomalous-recipe-actions-primary';
|
||||
|
||||
const append = appendText(primaryActions, 'button', t('recipeAppendCanvas'), 'anomalous-btn-ghost');
|
||||
append.type = 'button';
|
||||
append.onclick = (e) => { e.stopPropagation(); runRecipeCardAction(append, async () => {
|
||||
const data = await fetchRecipeData(recipe.filename);
|
||||
if (!await appendRecipeOnCanvas(this, data)) throw new Error('recipe append failed');
|
||||
}, 'recipeAppendError'); };
|
||||
|
||||
secondaryActions.append(edit, exportButton, remove);
|
||||
primaryActions.append(append);
|
||||
actions.append(secondaryActions, primaryActions);
|
||||
|
||||
card.appendChild(actions);
|
||||
this.recipeListContainer.appendChild(card);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleSaveRecipe() {
|
||||
if (!app.graph?.serialize) {
|
||||
await anomalousAlert(t('recipeSaveError'));
|
||||
return;
|
||||
}
|
||||
const draft = captureRecipeDraft(app.graph);
|
||||
if (!draft.workflow || !Array.isArray(draft.workflow.nodes)) {
|
||||
await anomalousAlert(t('recipeSaveError'));
|
||||
return;
|
||||
}
|
||||
const canvasThumbnail = captureCanvasThumbnail(app.canvas?.canvas);
|
||||
const editing = this.recipeEditing || null;
|
||||
const details = await showRecipeSaveDialog(this, canvasThumbnail, editing?.data || null);
|
||||
if (!details) return;
|
||||
|
||||
// Prompt-role labels belong to the recipe skeleton. Preserve labels whose
|
||||
// node id and type still match when an existing recipe is refreshed from
|
||||
// the live canvas; stale labels are discarded safely.
|
||||
const previousPromptRoles = editing?.data?.params?.promptRoleOverrides;
|
||||
if (previousPromptRoles && typeof previousPromptRoles === 'object') {
|
||||
const workflowNodes = new Map((draft.workflow.nodes || []).map((node) => [String(node?.id), node]));
|
||||
const retained = {};
|
||||
for (const [nodeId, override] of Object.entries(previousPromptRoles)) {
|
||||
const node = workflowNodes.get(String(nodeId));
|
||||
if (!node || !override || typeof override !== 'object') continue;
|
||||
if (override.nodeType && override.nodeType !== node.type) continue;
|
||||
retained[nodeId] = { ...override, nodeType: node.type || override.nodeType || 'Unknown' };
|
||||
}
|
||||
if (Object.keys(retained).length) draft.metadata.promptRoleOverrides = retained;
|
||||
}
|
||||
|
||||
const saveButton = this.recipeView?.querySelector('[data-recipe-save-current]');
|
||||
if (saveButton) {
|
||||
saveButton.disabled = true;
|
||||
saveButton.textContent = t('recipeSaving');
|
||||
}
|
||||
try {
|
||||
let thumbnail = details.thumbnail;
|
||||
if (details.sourceImage) {
|
||||
try {
|
||||
thumbnail = await captureOutputThumbnail(details.sourceImage);
|
||||
} catch (error) {
|
||||
console.warn('Could not persist bound recipe image thumbnail:', error);
|
||||
}
|
||||
}
|
||||
const response = await fetch(editing ? '/anomalous/update_recipe' : '/anomalous/save_recipe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...(editing ? { filename: editing.filename } : {}),
|
||||
name: details.name,
|
||||
tags: details.tags,
|
||||
notes: details.notes,
|
||||
params: draft.metadata,
|
||||
workflow: draft.workflow,
|
||||
thumbnail,
|
||||
source_image: details.sourceImage,
|
||||
presentation: { save_model_preview_snapshots: details.saveModelPreviewSnapshots },
|
||||
}),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok || payload.status !== 'success') throw new Error('recipe save request failed');
|
||||
|
||||
const recipeFilename = payload.filename || editing?.filename;
|
||||
if (recipeFilename) {
|
||||
try {
|
||||
const parameterResponse = await fetch('/anomalous/save_parameter', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: details.name,
|
||||
tags: details.tags,
|
||||
notes: details.notes,
|
||||
params: draft.metadata,
|
||||
workflow: draft.workflow,
|
||||
recipe_filename: recipeFilename,
|
||||
}),
|
||||
});
|
||||
if (!parameterResponse.ok) throw new Error('parameter snapshot request failed');
|
||||
} catch (error) {
|
||||
// Recipe persistence is already successful; a snapshot failure
|
||||
// must not make the user retry and create a duplicate recipe.
|
||||
console.warn('Could not save the recipe parameter snapshot:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const receipt = payload.receipt || {};
|
||||
const receiptMatchesDraft = receipt.node_count === draft.stats.nodeCount
|
||||
&& receipt.link_count === draft.stats.linkCount
|
||||
&& receipt.group_count === draft.stats.groupCount;
|
||||
if (this.recipeSaveStatus) {
|
||||
this.recipeSaveStatus.textContent = receiptMatchesDraft
|
||||
? formatRecipeText('recipeSaveReceipt', {
|
||||
nodes: receipt.node_count,
|
||||
links: receipt.link_count,
|
||||
groups: receipt.group_count,
|
||||
})
|
||||
: t('recipeSaveReceiptMismatch');
|
||||
this.recipeSaveStatus.classList.toggle('is-warning', !receiptMatchesDraft);
|
||||
}
|
||||
this.recipeEditing = null;
|
||||
await this.refreshRecipes();
|
||||
} catch (error) {
|
||||
console.error('Could not save Workflow Recipe:', error);
|
||||
await anomalousAlert(t('recipeSaveError'));
|
||||
} finally {
|
||||
if (saveButton) {
|
||||
saveButton.disabled = false;
|
||||
saveButton.textContent = this.recipeEditing ? t('recipeUpdateCurrent') : t('recipeSaveCurrent');
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+2421
-22
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user