Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57bb9dac8e | ||
|
|
fa5e71a5e4 | ||
|
|
cf2866dc10 | ||
|
|
3ce52bd9d6 |
+145
-98
@@ -1,32 +1,66 @@
|
||||
# 📈 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.
|
||||
|
||||
### 🐛 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.
|
||||
# 📈 Anomalous Model Browser Changelog
|
||||
|
||||
## 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 +68,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 +89,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`).
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Anomalous Model Browser
|
||||
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md) [](https://github.com/ltdrdata/ComfyUI-Manager)
|
||||
|
||||
[English](#english) | [中文](#中文)
|
||||
|
||||
@@ -8,126 +8,106 @@
|
||||
|
||||
<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.
|
||||
A highly professional, multi-functional ComfyUI model browser plugin. It integrates advanced model management, workflow healing, an intelligent drafting notebook, and a native output gallery.
|
||||
|
||||
### Core Feature 1: Hash-Level Workflow Repair (Gene-Level Self-Healing)
|
||||
### 🌟 Core Capabilities
|
||||
|
||||
**Workflows and images generated or saved while this plugin is active will have their models' Civitai Hashes automatically embedded.**
|
||||
1. **Comprehensive Model Management**: Automatically extract Base Model architectures, cover images, trigger words, and author info by scanning model hashes. It allows you to customize model names, notes, and covers. You can quickly view detailed info, history gallery, and trigger words. Furthermore, you can replace models visually with cover icons instead of complex file paths, and safely delete unwanted models.
|
||||
2. **Workflow Node Repair**: As long as a workflow or image was exported with this plugin active (Identity Hash enabled), the **Model Doctor (🩺)** can auto-detect missing nodes and replace them with the correct local paths. For stubborn models, it provides a "Deep Hash Scan" or links directly to Civitai.
|
||||
3. **Smart Notebook (Drafting Area 📑)**: Leveraging scanned local architectures, the Notebook offers rapid matching between Checkpoints, UNet, and compatible LoRA models. It features a built-in translation tool, auto-chunks prompts into bilingual tags, and supports 1-click deployment to the ComfyUI canvas.
|
||||
4. **History Gallery**: Natively reads your local `output` folder. Supports 1-click viewing, deleting, and mouse-wheel zooming. You can drag and drop any generated image directly onto the canvas to instantly load its embedded workflow.
|
||||
5. **Additional Features**: Click the top-right `➕` of any model card to deploy it to the canvas. The UI supports side-docking to leave space for your canvas operations.
|
||||
|
||||
> ⚠️ **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.
|
||||
### 📖 Step-by-Step Operating Guide
|
||||
|
||||
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.
|
||||
#### 1. Initialization (Scan Wizard)
|
||||
* **Location**: The Scan icon (**🔄**) at the bottom left of the plugin interface.
|
||||
* **Operation**: This is the most important prerequisite. Open the Scan Wizard, select your configuration, and execute the scan. Ensure your network is connected (entering an API-KEY in the settings is recommended for fetching specific Civitai model data).
|
||||
* **Result**: This activates all core features of the plugin by building your local model database.
|
||||
|
||||
*(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.)*
|
||||
#### 2. Workflow Repair (Model Doctor)
|
||||
* **Location**: The second Stethoscope icon (**🩺**) from the bottom left.
|
||||
* **Operation**: When you import someone else's workflow (or an image exported with this plugin) and the nodes turn red due to missing paths, click the Model Doctor. It will intelligently identify all broken nodes and replace them with your correct local paths.
|
||||
|
||||
### Core Feature 2: Smart Notebook (Workflow Drafting)
|
||||
#### 3. Visual Swapping (Node Assistant)
|
||||
* **Location**: The third Robot icon (**🤖**) from the bottom left.
|
||||
* **Operation**: After opening, select any model node on your ComfyUI canvas. The Assistant will recognize the node's info, displaying its history gallery, notes, cover, details, and trigger words. It also allows you to quickly visually replace it with other models based on cover images.
|
||||
|
||||
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.
|
||||
#### 4. Settings Panel
|
||||
* **Location**: The Gear icon (**⚙️**) at the bottom left.
|
||||
* **Operation**: Here you can adjust the UI language (Language is adjusted here), main UI font size, and video cover auto-play modes.
|
||||
|
||||
### Core Feature 3: Deep Scanning & Metadata Management
|
||||
#### 5. Top Navigation & Smart Notebook
|
||||
* **Location**: The top tabs: **Models (📦)**, **Gallery (🖼️)**, **Notebook (📑)**, and **Dock Side (◧)**.
|
||||
* **Notebook Operation**:
|
||||
1. Click the **Notebook (📑)** tab, then click the **➕ (New)** button and confirm.
|
||||
2. Select a Base Model (e.g., SD1.5). You can then select compatible Main Models and LoRAs.
|
||||
3. Click **📝 Edit Raw / Paste** to paste your prompts. Select the language at the top-left of the input box for bilingual translation.
|
||||
4. After confirming, the plugin will automatically chunk your prompt into tags. You can find/replace or directly edit individual tags.
|
||||
5. Finally, click **🚀 Send to Canvas** to automatically wire and deploy the Checkpoint, LoRA, and CLIP Text Encode directly to your canvas!
|
||||
|
||||
* **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.
|
||||
---
|
||||
|
||||
### Other Practical Features
|
||||
|
||||
* **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.
|
||||
|
||||
### Installation Guide
|
||||
|
||||
Open your terminal, navigate to the ComfyUI `custom_nodes` folder, and run the following commands:
|
||||
### 📦 Installation
|
||||
|
||||
Open your terminal, go to your ComfyUI `custom_nodes` folder, and run:
|
||||
```bash
|
||||
cd custom_nodes
|
||||
git clone https://github.com/your-username/Anomalous_Model_Browser.git
|
||||
git clone https://github.com/DemonGatanjieu/Anomalous_Model_Browser.git
|
||||
```
|
||||
*(Note: Restart ComfyUI after cloning to load the plugin)*
|
||||
|
||||
### Quick Start
|
||||
|
||||
**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.
|
||||
*(Restart ComfyUI after cloning. Alternatively, search for `Anomalous Model Browser` inside the ComfyUI Manager and click Install!)*
|
||||
|
||||
---
|
||||
|
||||
<h2 id="中文">中文</h2>
|
||||
|
||||
一个不到 250KB、零依赖、集成了哈希级工作流修复、智能笔记本、深度本地扫描与图库管理的 ComfyUI 模型浏览器插件。
|
||||
一个高度专业、多功能的 ComfyUI 模型浏览器插件。集成了模型管理、工作流自愈修复、智能草稿本与原生图库管理。
|
||||
|
||||
### 核心特性一:哈希级工作流修复(基因级自愈)
|
||||
### 🌟 核心功能特性
|
||||
|
||||
**在启用本插件的状态下,生成或保存的工作流与图片,都会被自动注入模型对应的 Civitai Hash 和文件信息。**
|
||||
1. **全方位模型管理**:通过扫描哈希值,自动获取模型的 Base Model (基础架构)、封面、提示词、作者简介等信息,方便您快速比对并调节操作流程。支持自定义模型名称、备注和封面。提供快捷查看详细信息与历史生成图片的功能。此外,点开模型即刻呈现封面图标,方便您快捷替换模型,彻底告别复杂的模型路径带来的头疼;遇到不想用的模型,还提供了极其便捷的快捷删除功能。
|
||||
2. **节点智能修复功能**:凡是由本插件导出的工作流和图片(只要开启了哈希身份证),在载入他人或旧工作流时出现节点爆红,只需点开**模型医生 (🩺)**,即可自动根据工作流内嵌的身份信息识别出本地正确的模型路径并一键替换。遇到简单扫描未能识别的顽固模型,可选择“深度扫描”进一步识别,若本地确实缺失,还提供了相关的 Civitai C站下载链接。
|
||||
3. **智能笔记本 (📑) 功能**:基于扫描获取的本地模型基础架构,笔记本提供了快速的架构匹配功能,让您极速选择兼容的 Checkpoint、UNet 模型与对应的 LoRA 模型。内置强大的翻译功能,能自动将提示词分块打上标签,实现多语言提示词完美对照,并支持一键发布组装到工作流画布中。
|
||||
4. **原生图库功能**:支持无缝读取本地的 `output` 输出文件夹。支持鼠标滚轮放大缩小,一键查看和安全删除历史图像。更绝的是,您可以直接将图片拖动到画布上,瞬间加载内嵌的工作流。
|
||||
5. **其它便捷功能**:点击模型卡片右上角的 `➕` 号,可以快捷将相关的节点发布粘贴到画布。支持侧边栏停靠功能,将界面吸附在左侧,为您的画布留出充足的操作空间。
|
||||
|
||||
> ⚠️ **重要前置条件**:安装插件后,请务必先在界面内运行一次**【扫描】**,建立本地哈希数据库。此后,您导出的所有工作流与图片才会自带模型特征,享有自愈能力。
|
||||
### 📖 标准操作说明
|
||||
|
||||
当你拖入一张由此插件保存的图片或工作流时,如果因为环境不同导致模型找不到、节点全红报错:
|
||||
1. 点一下界面上的**修复工作流**按钮。
|
||||
2. 插件会自动根据内置的 Hash 数据,在你的本地目录中精准寻找对应的模型。
|
||||
3. 找到后自动重新连线替换,哪怕你把模型改成了任意名字。
|
||||
为了让插件发挥最大效能,请按照以下流程进行操作:
|
||||
|
||||
*(注意:系统依赖模型哈希进行精准匹配。如果在没有安装本插件前生成的旧工作流,或他人未使用本插件生成的图片,由于缺失哈希信息,将无法触发全自动修复,仍需手动重选。)*
|
||||
#### 1. 前置准备 (扫描向导)
|
||||
* **具体位置**:界面左侧底部的 **扫描图标 (🔄)**。
|
||||
* **操作步骤**:这是最重要的前置说明!请先打开扫描向导,根据需求选择扫描配置并执行扫描。**切记保持网络畅通**。部分 C站(Civitai)限制级模型信息需要在设置中填入 API-KEY。
|
||||
* **功能激活**:扫描完成后,插件的各项核心功能即被全面激活,您的本地数据库已建立完毕。
|
||||
|
||||
### 核心特性二:智能笔记本(工作流草稿本)
|
||||
#### 2. 拯救爆红 (模型医生)
|
||||
* **具体位置**:左侧底部第二个 **听诊器按钮 (🩺)**。
|
||||
* **操作步骤**:当导入别人使用该插件输出的工作流或者图片时,若发现模型路径爆红报错,点击模型医生,它就能智能识别当前所有的报错节点,并一键实现正确路径的替换。
|
||||
|
||||
得益于本地扫描功能可以精准识别并提取模型的基础架构 (Base Model),笔记本模块提供了极为强大的草稿与组装功能:
|
||||
* **架构隔离筛选**:在笔记本中选定 Base Model 后,系统会自动为你筛选出与之完全兼容的主模型和 Lora,杜绝搭配错误。
|
||||
* **可视化挑选**:直接在笔记本页面中显示模型的关联预览图(利用抓取的 C站 数据)。
|
||||
* **提示词翻译对照**:粘贴提示词后,支持一键翻译,形成双语对照,方便打草稿和修改。
|
||||
* **免连线一键发布**:配置好模型和提示词后,点击“发送到画布”,整套节点配置会被直接打包投放到画布并自动完成连线,彻底告别一个个拉节点和连线的折磨。
|
||||
#### 3. 选中交互 (节点助手)
|
||||
* **具体位置**:左侧底部第三个 **机器人按钮 (🤖)**。
|
||||
* **操作步骤**:打开节点助手后,在 ComfyUI 画布上点击选择任意一个模型节点。助手便会瞬间识别到该节点的各项信息,支持查看它的历史图库、备注、封面、模型详细页、触发词等。最强大的是,它能让您通过视觉化的模型封面,直接在侧边栏快捷替换其他模型。
|
||||
|
||||
### 核心特性三:深度扫描与元数据管理
|
||||
#### 4. 个性化配置 (设置面板)
|
||||
* **具体位置**:左侧底部的 **齿轮按钮 (⚙️)**。
|
||||
* **操作步骤**:在此可以调节语言(多语言切换在这里调节)、主页面字体大小、以及视频封面的播放方式(节能或自动播放)等。
|
||||
|
||||
* **O(1) 极速哈希提取**:无需计算几十 GB 模型的完整哈希。插件直接解析 `.safetensors` 文件头,瞬间截获 Hash 值,并自动去 C站 下载对应的 `.info` 说明与预览图。
|
||||
* **脱机张量推断(万能 Base 提取)**:即便是不在 C站 上的私模或其它平台模型,插件会强行解析网络结构(如发现 `double_blocks` 则识别为 Flux),在本地生成虚拟配置。让孤儿模型也能完美参与笔记本的 UI 筛选匹配。
|
||||
* **物理版本去重**:自动识别并清理不同名字但 Hash 完全相同的冗余 `.safetensors` 文件。
|
||||
* **快捷详情查阅**:在模型页面可以直接查看模型使用说明,并支持**一键复制触发词 (Trigger Words)**。
|
||||
#### 5. 顶部导航与笔记本 (Notebook) 实战
|
||||
* **具体位置**:右侧顶部的按钮分别为 **模型 (📦)**、**图库 (🖼️)**、**笔记本 (📑)**、**停靠侧边栏 (◧)**。
|
||||
* **笔记本操作步骤**:
|
||||
1. 点击**笔记本 (📑)** 按钮,点击新建 **➕** 号,确认后进入草稿本。
|
||||
2. 首先选择基础模型(例如 SD1.5),随后系统会为你过滤出兼容的的主模型 (Checkpoint) 和对应的 LoRA 模型供你选择。
|
||||
3. 点击下方的 **📝 纯文本/粘贴**,把你的提示词复制进去,选择输入框左上角的语言,即可实现双语对照翻译。
|
||||
4. 确认后,插件会自动将提示词分块生成标签 (Tags),支持查找替换,也支持直接双击修改单个标签。
|
||||
5. 最后,点击下方的 **🚀 发送到画布** 按钮,系统会将你打包好的主模型、LoRA 以及 CLIP 文本编码器,一键自动连线并发布到画布上!
|
||||
|
||||
### 其他实用功能
|
||||
---
|
||||
|
||||
* **侧边栏停靠与快捷投放**:支持将插件停靠在侧边栏。点击模型卡片右上角的 **`+`** 号图标,即可瞬间将该模型的加载器 (Loader) 节点拍到画布上。
|
||||
* **前端极致性能**:不安装 Node.js,列表滚动性能极佳。提供节能模式(悬浮播放)与自动播放开关。
|
||||
* **原生生成图库 (Gallery)**:无缝浏览 output 文件夹的历史记录,无限懒加载,支持将图片直接拖拽至画布瞬间还原工作流。
|
||||
* **内置空间管理**:支持在界面安全删除模型,并自动清理配套的垃圾信息文件及预览图。
|
||||
|
||||
### 安装指南
|
||||
|
||||
请打开命令行终端,进入 ComfyUI 的 `custom_nodes` 文件夹,执行以下命令即可完成安装:
|
||||
### 📦 安装指南
|
||||
|
||||
打开命令行终端,进入 ComfyUI 的 `custom_nodes` 文件夹,执行以下命令:
|
||||
```bash
|
||||
cd custom_nodes
|
||||
git clone https://github.com/your-username/Anomalous_Model_Browser.git
|
||||
git clone https://github.com/DemonGatanjieu/Anomalous_Model_Browser.git
|
||||
```
|
||||
*(提示:克隆完成后,重启 ComfyUI 即可加载插件)*
|
||||
|
||||
### 快速上手 (Quick Start)
|
||||
|
||||
**初始配置 (必做):**
|
||||
1. **第一步**:点击界面主菜单或侧边栏的 **Model Browser** 按钮打开面板。
|
||||
2. **第二步**:务必运行一次**【扫描】**(可全局或分文件夹扫描),建立本地模型哈希数据库。此后保存的工作流才会具备自愈基因。
|
||||
|
||||
**常规使用 (浏览与添加模型):**
|
||||
- 浏览模型库,点击任意模型卡片右上角的 **`+` (加号)** 图标,即可将该模型的加载节点直接丢到画布上。
|
||||
|
||||
**修复报错工作流:**
|
||||
1. **第一步**:将带有 Hash 信息的图片(须在使用本插件且扫描建库后生成)拖入 ComfyUI 画布。
|
||||
2. **第二步**:若发生模型路径不匹配导致的飘红报错,点击提示的 **一键修复工作流** 按钮,等待插件在本地检索 Hash 并自动恢复节点连接。
|
||||
*(克隆完成后,重启 ComfyUI 即可。您也可以直接在 ComfyUI Manager(管理器)中搜索 `Anomalous Model Browser` 并一键点击安装!)*
|
||||
|
||||
@@ -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,37 @@
|
||||
|
||||
from .models import *
|
||||
from .scanner import *
|
||||
from .config import *
|
||||
from .notebooks import *
|
||||
from .utils import *
|
||||
|
||||
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/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)
|
||||
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)
|
||||
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)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,48 @@
|
||||
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
|
||||
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)})
|
||||
|
||||
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import urllib.parse
|
||||
import subprocess
|
||||
import threading
|
||||
import asyncio
|
||||
from aiohttp import web
|
||||
import folder_paths
|
||||
import struct
|
||||
|
||||
def _extract_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']
|
||||
|
||||
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:
|
||||
pass
|
||||
return None
|
||||
|
||||
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": "",
|
||||
"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}"
|
||||
|
||||
|
||||
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
|
||||
|
||||
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 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
|
||||
|
||||
|
||||
+786
@@ -0,0 +1,786 @@
|
||||
from .metadata import get_metadata
|
||||
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_folders(request):
|
||||
types = ['checkpoints', 'loras', 'unet', 'diffusion_models', 'controlnet', 'vae']
|
||||
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', '.preview.jpg', '.preview.jpeg', '.preview.webp', '.preview.gif', '.preview.avif', '.preview.mp4', '.preview.webm', '.preview.mov', '.preview.avi', '.png', '.jpg', '.jpeg', '.webp', '.gif', '.avif', '.mp4', '.webm', '.mov', '.avi']:
|
||||
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)
|
||||
try:
|
||||
mtime = int(os.path.getmtime(os.path.join(target_dir, preview_file)))
|
||||
except:
|
||||
mtime = 0
|
||||
preview_url = f"/anomalous/image?type={q_type}&path_idx={q_idx}&subfolder={q_sub}&filename={q_file}&t={mtime}"
|
||||
|
||||
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_find_model(request):
|
||||
search = request.query.get('search', '').lower()
|
||||
if not search:
|
||||
return web.json_response({"status": "error", "message": "No search query provided"})
|
||||
|
||||
for folder_type in folder_paths.folder_names_and_paths.keys():
|
||||
paths = folder_paths.get_folder_paths(folder_type)
|
||||
if not paths: continue
|
||||
for path_idx, base_dir in enumerate(paths):
|
||||
if not os.path.exists(base_dir): continue
|
||||
for root, dirs, files in os.walk(base_dir):
|
||||
for f in files:
|
||||
if f.endswith('.safetensors') or f.endswith('.ckpt') or f.endswith('.pt') or f.endswith('.bin') or f.endswith('.sft'):
|
||||
search_norm = search.replace(os.sep, '/')
|
||||
abs_path_norm = os.path.join(root, f).replace(os.sep, '/').lower()
|
||||
if search_norm in f.lower() or search_norm in abs_path_norm:
|
||||
rel_subfolder = os.path.relpath(root, base_dir)
|
||||
if rel_subfolder == '.': rel_subfolder = '/'
|
||||
|
||||
file_path = os.path.join(root, f)
|
||||
meta = get_metadata(file_path)
|
||||
|
||||
base_name = os.path.splitext(f)[0]
|
||||
preview_file = None
|
||||
for ext in ['.preview.png', '.preview.jpg', '.preview.jpeg', '.preview.webp', '.preview.gif', '.preview.avif', '.preview.mp4', '.preview.webm', '.preview.mov', '.preview.avi', '.png', '.jpg', '.jpeg', '.webp', '.gif', '.avif', '.mp4', '.webm', '.mov', '.avi']:
|
||||
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(folder_type)
|
||||
q_idx = str(path_idx)
|
||||
q_sub = urllib.parse.quote(rel_subfolder)
|
||||
q_file = urllib.parse.quote(preview_file)
|
||||
try: mtime = int(os.path.getmtime(os.path.join(root, preview_file)))
|
||||
except: mtime = 0
|
||||
preview_url = f"/anomalous/image?type={q_type}&path_idx={q_idx}&subfolder={q_sub}&filename={q_file}&t={mtime}"
|
||||
|
||||
try: size_mb = round(os.path.getsize(file_path) / (1024 * 1024), 1)
|
||||
except: size_mb = 0
|
||||
|
||||
modelData = {
|
||||
"filename": f,
|
||||
"size_mb": size_mb,
|
||||
"metadata": meta,
|
||||
"preview_url": preview_url
|
||||
}
|
||||
return web.json_response({
|
||||
"status": "success",
|
||||
"model": modelData,
|
||||
"type": folder_type,
|
||||
"path_idx": path_idx,
|
||||
"subfolder": rel_subfolder
|
||||
})
|
||||
|
||||
return web.json_response({"status": "error", "message": "Model not found"})
|
||||
|
||||
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": f"Invalid request parameters. filename={repr(filename)}, subfolder={repr(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('/'))
|
||||
|
||||
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_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', '.preview.jpg', '.preview.jpeg', '.preview.webp', '.preview.gif', '.preview.avif', '.preview.mp4', '.preview.webm', '.preview.mov', '.preview.avi', '.png', '.jpg', '.jpeg', '.webp', '.gif', '.avif', '.mp4', '.webm', '.mov', '.avi']:
|
||||
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_base_models(request):
|
||||
target_types = ['checkpoints', 'loras', 'unet', 'diffusion_models', 'controlnet', 'vae']
|
||||
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_resolve_hash(request):
|
||||
from .metadata import _extract_safetensors_hash
|
||||
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', 'controlnet', 'vae']
|
||||
|
||||
# Layer 1: Fast Size Match (O(1) filesystem check)
|
||||
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
|
||||
})
|
||||
|
||||
# Layer 2: Metadata Hash Match (Reads .info, falls back to safetensors header)
|
||||
if target_hash and target_hash != "UNKNOWN":
|
||||
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)
|
||||
|
||||
# Check .info or header hash
|
||||
if meta.get("hash", "").upper() == target_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('\\', '/')
|
||||
return web.json_response({
|
||||
"found": True,
|
||||
"type": t,
|
||||
"filename": rel_path
|
||||
})
|
||||
|
||||
# Additional direct check just in case get_metadata missed it
|
||||
if file.endswith('.safetensors'):
|
||||
header_hash = _extract_safetensors_hash(file_path)
|
||||
if header_hash and header_hash.upper() == target_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('\\', '/')
|
||||
return web.json_response({
|
||||
"found": True,
|
||||
"type": t,
|
||||
"filename": rel_path,
|
||||
"matched_by_header": 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', 'controlnet', 'vae']
|
||||
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)
|
||||
|
||||
async def api_update_metadata(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
folder_type = data.get('type', 'checkpoints')
|
||||
subfolder = data.get('subfolder', '/')
|
||||
filename = data.get('filename', '')
|
||||
custom_name = data.get('custom_name', '')
|
||||
custom_notes = data.get('custom_notes', '')
|
||||
physical_rename = data.get('physical_rename', False)
|
||||
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": f"Invalid request parameters. filename={repr(filename)}, subfolder={repr(subfolder)}"})
|
||||
|
||||
paths = folder_paths.get_folder_paths(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]
|
||||
target_dir = base_dir if subfolder == '/' else os.path.join(base_dir, subfolder.strip('/'))
|
||||
file_path = os.path.join(target_dir, filename)
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
return web.json_response({"status": "error", "message": "Model not found"})
|
||||
|
||||
base_name = os.path.splitext(file_path)[0]
|
||||
ext = os.path.splitext(file_path)[1]
|
||||
info_file = f"{base_name}.civitai.info"
|
||||
|
||||
info_data = {}
|
||||
if os.path.exists(info_file):
|
||||
parsed = False
|
||||
for enc in ['utf-8', 'utf-8-sig', 'mbcs', 'latin-1']:
|
||||
try:
|
||||
with open(info_file, 'r', encoding=enc) as f:
|
||||
info_data = json.load(f)
|
||||
parsed = True
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
if not parsed:
|
||||
return web.json_response({"status": "error", "message": "Failed to parse existing .civitai.info file due to encoding or corruption. Rename aborted to prevent data loss."})
|
||||
|
||||
info_data["anomalous_custom_name"] = custom_name
|
||||
info_data["anomalous_custom_notes"] = custom_notes
|
||||
|
||||
reset_cover = data.get('reset_cover', False)
|
||||
if reset_cover:
|
||||
# 1. Delete any custom active cover (.preview.*)
|
||||
for ext in ['.preview.png', '.preview.jpg', '.preview.jpeg', '.preview.webp', '.preview.gif', '.preview.avif', '.preview.mp4', '.preview.webm', '.preview.mov', '.preview.avi']:
|
||||
p = os.path.join(target_dir, f"{base_name}{ext}")
|
||||
if os.path.exists(p):
|
||||
try: os.remove(p)
|
||||
except: pass
|
||||
|
||||
# 2. If a Civitai backup exists, copy it back to .preview.* so standard ComfyUI can see it
|
||||
for ext in ['.png', '.jpg', '.jpeg', '.webp', '.gif', '.avif', '.mp4', '.webm', '.mov', '.avi']:
|
||||
civitai_bak = os.path.join(target_dir, f"{base_name}.civitai_bak{ext}")
|
||||
if os.path.exists(civitai_bak):
|
||||
restored_p = os.path.join(target_dir, f"{base_name}.preview{ext}")
|
||||
import shutil
|
||||
try: shutil.copy2(civitai_bak, restored_p)
|
||||
except: pass
|
||||
|
||||
with open(info_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(info_data, f, indent=4, ensure_ascii=False)
|
||||
|
||||
new_filename = filename
|
||||
|
||||
if physical_rename and custom_name:
|
||||
import re
|
||||
safe_name = re.sub(r'[<>:"/\\|?*]', '_', custom_name)
|
||||
new_file_path = os.path.join(target_dir, f"{safe_name}{ext}")
|
||||
|
||||
if new_file_path != file_path and not os.path.exists(new_file_path):
|
||||
os.rename(file_path, new_file_path)
|
||||
|
||||
new_info = os.path.join(target_dir, f"{safe_name}.civitai.info")
|
||||
os.rename(info_file, new_info)
|
||||
|
||||
preview_png = f"{base_name}.preview.png"
|
||||
if os.path.exists(preview_png):
|
||||
os.rename(preview_png, os.path.join(target_dir, f"{safe_name}.preview.png"))
|
||||
|
||||
civitai_bak = f"{base_name}.civitai_bak.png"
|
||||
if os.path.exists(civitai_bak):
|
||||
os.rename(civitai_bak, os.path.join(target_dir, f"{safe_name}.civitai_bak.png"))
|
||||
|
||||
new_filename = f"{safe_name}{ext}"
|
||||
elif os.path.exists(new_file_path) and new_file_path != file_path:
|
||||
return web.json_response({"status": "error", "message": "A file with the target physical name already exists."})
|
||||
|
||||
return web.json_response({"status": "success", "new_filename": new_filename})
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)})
|
||||
|
||||
async def _handle_custom_cover(target_dir, filename, save_func, source_ext='.png'):
|
||||
base_name = os.path.splitext(filename)[0]
|
||||
|
||||
# Always save custom covers with a .preview.[ext] suffix so standard nodes recognize them as covers.
|
||||
if source_ext.startswith('.preview.'):
|
||||
preview_ext = source_ext
|
||||
else:
|
||||
preview_ext = f".preview{source_ext}"
|
||||
|
||||
dest_path = os.path.join(target_dir, f"{base_name}{preview_ext}")
|
||||
|
||||
# Delete any existing .preview.* files to ensure only one custom cover is active
|
||||
for ext in ['.preview.png', '.preview.jpg', '.preview.jpeg', '.preview.webp', '.preview.gif', '.preview.avif', '.preview.mp4', '.preview.webm', '.preview.mov', '.preview.avi']:
|
||||
p = os.path.join(target_dir, f"{base_name}{ext}")
|
||||
if os.path.exists(p) and p != dest_path:
|
||||
try: os.remove(p)
|
||||
except: pass
|
||||
|
||||
await save_func(dest_path)
|
||||
|
||||
async def api_set_custom_cover(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
folder_type = data.get('type', 'checkpoints')
|
||||
subfolder = data.get('subfolder', '/')
|
||||
filename = data.get('filename', '')
|
||||
source_image = data.get('source_image', '')
|
||||
try: path_idx = int(data.get('path_idx', 0))
|
||||
except: path_idx = 0
|
||||
|
||||
if not filename or not source_image or '..' in filename or '..' in subfolder:
|
||||
return web.json_response({"status": "error", "message": f"Invalid request parameters. filename={repr(filename)}, subfolder={repr(subfolder)}"})
|
||||
|
||||
paths = folder_paths.get_folder_paths(folder_type)
|
||||
if not paths or path_idx >= len(paths):
|
||||
return web.json_response({"status": "error", "message": "Invalid path index"})
|
||||
|
||||
target_dir = paths[path_idx] if subfolder == '/' else os.path.join(paths[path_idx], subfolder.strip('/'))
|
||||
|
||||
# Resolve source_image
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
src_path = os.path.join(output_dir, source_image)
|
||||
if not os.path.exists(src_path):
|
||||
return web.json_response({"status": "error", "message": "Source image not found in output directory"})
|
||||
|
||||
source_ext = os.path.splitext(src_path)[1].lower()
|
||||
|
||||
async def save_copy(dest_path):
|
||||
import shutil
|
||||
import asyncio
|
||||
await asyncio.to_thread(shutil.copy2, src_path, dest_path)
|
||||
|
||||
await _handle_custom_cover(target_dir, filename, save_copy, source_ext)
|
||||
|
||||
return web.json_response({"status": "success"})
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)})
|
||||
|
||||
async def api_upload_custom_cover(request):
|
||||
try:
|
||||
data = await request.post()
|
||||
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
|
||||
|
||||
image_field = data.get('image')
|
||||
|
||||
if not filename or '..' in filename or '..' in subfolder or image_field is None:
|
||||
return web.json_response({"status": "error", "message": f"Invalid request parameters"})
|
||||
|
||||
paths = folder_paths.get_folder_paths(folder_type)
|
||||
if not paths or path_idx >= len(paths):
|
||||
return web.json_response({"status": "error", "message": "Invalid path index"})
|
||||
|
||||
target_dir = paths[path_idx] if subfolder == '/' else os.path.join(paths[path_idx], subfolder.strip('/'))
|
||||
|
||||
image_data = image_field.file.read()
|
||||
|
||||
upload_filename = image_field.filename
|
||||
source_ext = os.path.splitext(upload_filename)[1].lower()
|
||||
if not source_ext:
|
||||
source_ext = '.png'
|
||||
|
||||
async def save_upload(dest_path):
|
||||
def write_file():
|
||||
with open(dest_path, 'wb') as f:
|
||||
f.write(image_data)
|
||||
import asyncio
|
||||
await asyncio.to_thread(write_file)
|
||||
|
||||
await _handle_custom_cover(target_dir, filename, save_upload, source_ext)
|
||||
|
||||
return web.json_response({"status": "success"})
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)})
|
||||
|
||||
|
||||
import struct
|
||||
|
||||
async def api_resolve_paths_to_previews(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
paths = data.get('paths', [])
|
||||
except:
|
||||
return web.json_response({"previews": {}})
|
||||
|
||||
previews = {}
|
||||
all_models = {}
|
||||
|
||||
for folder_type in folder_paths.folder_names_and_paths.keys():
|
||||
folder_dirs = folder_paths.get_folder_paths(folder_type)
|
||||
if not folder_dirs: continue
|
||||
for path_idx, base_dir in enumerate(folder_dirs):
|
||||
if not os.path.exists(base_dir): continue
|
||||
for root, dirs, files in os.walk(base_dir):
|
||||
for f in files:
|
||||
if f.endswith('.safetensors') or f.endswith('.ckpt') or f.endswith('.pt') or f.endswith('.bin') or f.endswith('.sft'):
|
||||
rel_path = os.path.relpath(os.path.join(root, f), base_dir).replace(os.sep, '/')
|
||||
base_name = os.path.splitext(f)[0]
|
||||
preview_file = None
|
||||
for ext in ['.preview.png', '.preview.jpg', '.preview.jpeg', '.preview.webp', '.preview.gif', '.preview.avif', '.preview.mp4', '.preview.webm', '.preview.mov', '.preview.avi', '.png', '.jpg', '.jpeg', '.webp', '.gif', '.avif', '.mp4', '.webm', '.mov', '.avi']:
|
||||
if os.path.exists(os.path.join(root, base_name + ext)):
|
||||
preview_file = base_name + ext
|
||||
break
|
||||
|
||||
preview_url = ""
|
||||
if preview_file:
|
||||
rel_subfolder = os.path.relpath(root, base_dir)
|
||||
if rel_subfolder == '.': rel_subfolder = '/'
|
||||
q_type = urllib.parse.quote(folder_type)
|
||||
q_idx = str(path_idx)
|
||||
q_sub = urllib.parse.quote(rel_subfolder)
|
||||
q_file = urllib.parse.quote(preview_file)
|
||||
try: mtime = int(os.path.getmtime(os.path.join(root, preview_file)))
|
||||
except: mtime = 0
|
||||
preview_url = f"/anomalous/image?type={q_type}&path_idx={q_idx}&subfolder={q_sub}&filename={q_file}&t={mtime}"
|
||||
|
||||
all_models[rel_path.lower()] = preview_url
|
||||
all_models[f.lower()] = preview_url
|
||||
|
||||
for p in paths:
|
||||
p_norm = p.replace(os.sep, '/').lower()
|
||||
if p_norm in all_models:
|
||||
previews[p] = all_models[p_norm]
|
||||
else:
|
||||
fname = p.split('/')[-1].split('\\')[-1].lower()
|
||||
if fname in all_models:
|
||||
previews[p] = all_models[fname]
|
||||
|
||||
return web.json_response({"previews": previews})
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
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_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)})
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"baseModel": "SD 1.5",
|
||||
"mainModel": null,
|
||||
"loras": [],
|
||||
"promptEn": "",
|
||||
"promptZh": "",
|
||||
"translations": {}
|
||||
}
|
||||
+379
@@ -0,0 +1,379 @@
|
||||
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_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
|
||||
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 = data.get("physical_rename", False)
|
||||
|
||||
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:
|
||||
cmd = [sys.executable, scraper_path, target_dir]
|
||||
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")
|
||||
|
||||
subprocess.run(
|
||||
cmd,
|
||||
cwd=plugin_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):
|
||||
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')
|
||||
|
||||
if os.path.exists(marker_file):
|
||||
return web.json_response({"status": "error", "message": "Global scan already 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)
|
||||
|
||||
try:
|
||||
with open(marker_file, 'w') as f: f.write('1')
|
||||
|
||||
def run_global_bg():
|
||||
try:
|
||||
types = ['checkpoints', 'loras', 'unet', 'diffusion_models', 'controlnet', 'vae']
|
||||
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}")
|
||||
cmd = [sys.executable, scraper_path, base_dir, "--skip-media"]
|
||||
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 not use_local_metadata:
|
||||
cmd.append("--skip-local-metadata")
|
||||
subprocess.run(
|
||||
cmd,
|
||||
cwd=plugin_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):
|
||||
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:
|
||||
types = ['checkpoints', 'loras', 'unet', 'diffusion_models', 'controlnet', 'vae']
|
||||
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)
|
||||
|
||||
GLOBAL_SCAN_STATE = {
|
||||
"scanning": False,
|
||||
"total": 0,
|
||||
"current": 0,
|
||||
"filename": "",
|
||||
"error": ""
|
||||
}
|
||||
|
||||
async def api_global_scan_status(request):
|
||||
return web.json_response(GLOBAL_SCAN_STATE)
|
||||
|
||||
async def api_scan_missing_models(request):
|
||||
if GLOBAL_SCAN_STATE["scanning"]:
|
||||
return web.json_response({"status": "error", "message": "Scan already in progress"})
|
||||
|
||||
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"})
|
||||
|
||||
def run_deep_scan():
|
||||
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:
|
||||
types = ['checkpoints', 'loras', 'unet', 'diffusion_models', 'controlnet', 'vae']
|
||||
files_to_scan = []
|
||||
|
||||
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'):
|
||||
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 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)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
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:
|
||||
pass
|
||||
|
||||
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()
|
||||
|
||||
threading.Thread(target=run_deep_scan, daemon=True).start()
|
||||
return web.json_response({"status": "success", "message": "Deep scan started in background"})
|
||||
+273
@@ -0,0 +1,273 @@
|
||||
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_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',
|
||||
'.mp4': 'video/mp4',
|
||||
'.webm': 'video/webm',
|
||||
'.mov': 'video/quicktime',
|
||||
'.avi': 'video/x-msvideo'
|
||||
}
|
||||
content_type = content_types.get(ext, 'application/octet-stream')
|
||||
|
||||
return web.FileResponse(file_path, headers={'Content-Type': content_type})
|
||||
|
||||
|
||||
|
||||
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_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 = 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)
|
||||
|
||||
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': []})
|
||||
|
||||
matched_images = []
|
||||
|
||||
# Search all PNGs in output_dir
|
||||
for root, _, files in os.walk(output_dir):
|
||||
for file in files:
|
||||
if file.lower().endswith('.png'):
|
||||
full_path = os.path.join(root, file)
|
||||
prompt_text = read_png_text_fast(full_path)
|
||||
if prompt_text:
|
||||
try:
|
||||
prompt_data = json.loads(prompt_text)
|
||||
matched = False
|
||||
for node_id, node in prompt_data.items():
|
||||
if 'class_type' in node:
|
||||
inputs = node.get('inputs', {})
|
||||
for k, v in inputs.items():
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
if os.path.basename(v).lower() == base_target:
|
||||
matched = True
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
if matched:
|
||||
break
|
||||
|
||||
if matched:
|
||||
# Match found
|
||||
rel_path = os.path.relpath(root, output_dir).replace('\\', '/')
|
||||
if rel_path == '.': rel_path = ''
|
||||
|
||||
# Standard comfyUI /view format:
|
||||
# /view?filename=XX.png&type=output&subfolder=YY
|
||||
url = f'/view?filename={urllib.parse.quote(file)}&type=output'
|
||||
if rel_path:
|
||||
url += f'&subfolder={urllib.parse.quote(rel_path)}'
|
||||
|
||||
mtime = os.path.getmtime(full_path)
|
||||
matched_images.append({
|
||||
'url': url,
|
||||
'mtime': mtime
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Sort by mtime descending (newest first)
|
||||
matched_images.sort(key=lambda x: x['mtime'], reverse=True)
|
||||
|
||||
return web.json_response({'images': matched_images})
|
||||
|
||||
+272
-175
@@ -1,175 +1,272 @@
|
||||
# 📖 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 式收放体验且毫无性能负担。)
|
||||
# 📖 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. The ComfyUI Manager Auto-Update File Wipe (Manager自动更新导致文件丢失)
|
||||
**The Problem (问题)**: A custom documentation file (`docs/核心按钮操作指南_大白话版.md`) generated by AI locally was silently deleted when clicking "Update" on the node inside ComfyUI Manager.
|
||||
(本地让 AI 写的说明文件,在点击 ComfyUI Manager 的更新按钮后离奇失踪了。)
|
||||
**The Solution (方案)**: ComfyUI Manager's update mechanism often performs a raw `git pull` or hard reset. If a local file is not tracked in the remote GitHub repository (i.e., you haven't committed and pushed it), it is highly likely to be overwritten or wiped during the sync process. Always commit and push your documentation, or keep personal untracked notes outside the extension directory!
|
||||
(ComfyUI Manager 的更新机制依赖 Git 同步(类似 `git reset --hard` 或强制 pull)。如果你在本地创建了文件却没有提交并推送到 GitHub,更新时 Manager 会强行把本地状态与云端对齐,导致这些“未追踪(untracked)”的文件被直接抹杀。教训:重要的文档一定要及时 push,或者别放在插件目录下!)
|
||||
|
||||
## 5. "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.
|
||||
(在点击删除时,立马在旁边弹出 `[✕]` 取消按钮。点击取消直接清空倒计时,立刻把控制权还给用户。)
|
||||
|
||||
## 6. 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`.
|
||||
(加入了智能寻路逻辑,插件首次加载时自动扫描目录树,直接定位并打开第一个存有模型的文件夹。)
|
||||
|
||||
## 7. 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 布局,把中英文双语硬性锁死在同一个拉伸行内,无论字数多少,几何结构绝对稳定。)
|
||||
|
||||
## 8. 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.
|
||||
(在弹窗初始化时主动校验缓存,强制触发重新渲染,实现了完美的“会话级记忆”。)
|
||||
|
||||
## 9. 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 秒,给用户极强的心理安全感。)
|
||||
|
||||
## 10. 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).
|
||||
(加入了偏移矩阵计算,并绑定鼠标移动。用户可以直观地拖着排成流水的节点序列在画布上移动,然后再点击放下。)
|
||||
|
||||
## 11. 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` 变量来统治中英文环境。)
|
||||
|
||||
## 12. 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.
|
||||
(做了一个拦截器,强行把系统内的选项和我们要赋的值全部统一转成正斜杠再进行比较,直接绕过操作系统的路径陷阱。)
|
||||
|
||||
## 13. 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.
|
||||
(优雅地接受这个现实。不需要过度工程化地去做离线队列,画图和管理模型本来就不该同时进行。)
|
||||
|
||||
## 14. 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 节点,强迫浏览器立马回收内存,保持零负担。)
|
||||
|
||||
## 15. 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.
|
||||
(在启动时强制对比当前屏幕大小,一旦发现越界,立刻把它抓回到右下角的安全区内。)
|
||||
|
||||
## 16. 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 没有任何编译容错,任何语法拼写都必须如履薄冰,及时修复了那个反引号。)
|
||||
|
||||
## 17. 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` 去重机制,无论虚拟路径怎么重叠,物理层面上都将其归一化。)
|
||||
|
||||
## 18. 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.
|
||||
(加入了情境判断:只要是在极速模式下,不管有没有图,只要有文字信息就无条件跳过,恢复了真正的极速。)
|
||||
|
||||
## 19. 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` 属性,强行洗白红框,无需刷新网页。)
|
||||
|
||||
## 20. 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` 打印到控制台以便排查。)
|
||||
|
||||
## 21. 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. (真找不到了才迫不得已做全量计算)
|
||||
|
||||
## 22. 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 架构。完全脱机,无缝融入前端兼容雷达。)
|
||||
|
||||
## 23. 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 网页端那样从侧边栏右侧悄悄弹出一个悬浮气泡,点外侧自动收起,极大保证了沉浸感。)
|
||||
|
||||
## 24. 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。)
|
||||
|
||||
## 25. 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.
|
||||
(加入了双轨防踩踏:未扫描检测器只要看到这个节点已经红了(彻底丢了),就主动闭嘴放弃拦截,边界极其清晰。)
|
||||
|
||||
## 26. 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)` 的底层字体相对推算放大法。并且把防负数的保底检测代码调到了逻辑的最后一行,永远杜绝负数坐标。)
|
||||
|
||||
## 27. 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 强制破除浏览器静态缓存。)
|
||||
|
||||
## 28. 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 背景,不仅没有任何违和感,反而像是一圈高级的画框,一举两得。)
|
||||
|
||||
## 29. 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 式收放体验且毫无性能负担。)
|
||||
|
||||
## 30. Context Preservation vs UX Intention (上下文保留与用户意图的冲突)
|
||||
**The Problem (问题)**: After picking a new cover from the Gallery while inside the Model Edit Modal, the UI destroyed the modal, showed the gallery, and then *restored* the modal upon completion to 'preserve context'. However, users who just wanted to change the cover felt confused, thinking they still needed to click 'Save' to apply it.
|
||||
(在编辑面板中点击更换封面后,UI 会先销毁面板,进入图库,选完后再把编辑面板弹回来以“保留上下文”。但用户其实只想换个封面,重新弹出的面板反而让他们误以为必须再点一次“保存”才算数。)
|
||||
**The Solution (方案)**: Respect the primary intention. When the cover is changed, directly return to the model card/grid. Do not stubbornly restore intermediate modals unless necessary.
|
||||
(顺应主要意图。换完封面直接回到网格卡片,干脆利落。不要去死板地恢复中间态面板。)
|
||||
|
||||
## 31. The Missing Reference Crash (引用丢失导致的状态黑洞)
|
||||
**The Problem (问题)**: A refactor failed to assign `this.models = data.models` in the load function. When a UI callback later tried to find the updated model using `this.models.find()`, JS threw an `undefined` error and aborted execution midway. This left the UI with both the gallery and grid hidden, resulting in a completely blank screen.
|
||||
(重构时忘了一行赋值代码,导致 `this.models` 为空。后续 UI 切换时试图从中查找模型直接报错崩溃。由于崩溃发生在显示/隐藏面板的切换中途,导致所有面板都被隐藏,屏幕变成了彻底的“真空空白”。)
|
||||
**The Solution (方案)**: Always ensure foundational state variables are populated before executing callbacks that depend on them.
|
||||
(底层状态变量必须绝对可靠,一旦中途崩溃,UI 的半途状态是毁灭性的。)
|
||||
|
||||
## 32. Backup Entanglement of Non-Platform Models (非平台模型的备份污染)
|
||||
**The Problem (问题)**: To ensure a 'Reset' always had a fallback, changing any cover backed up the original file to `.civitai_bak`. For non-Civitai models, this meant user's manual `.png` covers were forcefully renamed to `.civitai_bak`, causing severe semantic confusion.
|
||||
(为了保证“重置”功能永远有退路,之前的逻辑会把任何被替换的封面强行改名为 `.civitai_bak`。这就导致用户自己放的非 C 站模型封面,也被莫名其妙打上了 C 站的烙印,逻辑极其混乱。)
|
||||
**The Solution (方案)**: Decouple custom covers by strictly saving them as `.preview.*` and leaving original standard `.png` files untouched. Resetting now just deletes the `.preview.*` file, naturally falling back to the untouched original, or copying the true `.civitai_bak` if it actually came from Civitai.
|
||||
(让自定义封面独占 `.preview.*` 后缀,绝不触碰原有的标准封面。重置时只需删掉自定义后缀,系统就会自然回退。彻底切断了非 C 站模型与 C 站备份标识的无理纠缠。)
|
||||
|
||||
|
||||
## 33. DOM Video Audio Leaks on innerHTML Clear (DOM 销毁导致的幽灵音频泄漏)
|
||||
**The Problem (问题)**: When navigating from a model detail view containing an actively playing <video> to another model, setting container.innerHTML = '' removed the video from the DOM, but its audio continued playing infinitely in the background because the browser engine did not immediately garbage-collect the orphaned playing media.
|
||||
(在模型详细页如果有视频正在播放,当用户点击兼容模型跳转到另一个模型时,虽然通过 innerHTML = '' 清空了面板,DOM 节点被移除了,但由于浏览器没有立刻回收这个还在播放的媒体对象,导致旧视频的音频在后台像幽灵一样继续循环播放,并且再也无法通过 UI 停止。)
|
||||
**The Solution (方案)**: Implemented a strict cleanup lifecycle. Before destroying or overwriting a container's DOM, explicitly query all ideo and udio elements within it, invoke .pause(), and remove their src attributes to force the browser to release the media resources instantly.
|
||||
(引入了严格的 DOM 销毁生命周期。在清空任何面板前,必须先获取里面所有的音视频元素,显式调用 .pause() 并剥夺它们的 src,强制浏览器当场释放媒体资源,彻底杜绝后台泄漏。)
|
||||
|
||||
## 34. The Search Bar "Hidden State" Trap (搜索框的隐藏状态盲区)
|
||||
**The Problem (问题)**: Users typing in the global search bar while viewing a Model Detail panel thought search was broken because the filtered results were successfully generated but the grid container was hidden (`display: none`).
|
||||
(用户在模型详细页里直接使用全局搜索框搜索,后台成功过滤了网格卡片,但因为当前网格容器是隐藏状态,用户看不见结果,误以为搜索功能坏了。)
|
||||
**The Solution (方案)**: Added an explicit state-exit in the `searchInput.oninput` event. If a detail panel is open, it destroys the panel and forces the grid back to `display: grid` so results are immediately visible.
|
||||
(在搜索输入事件中强制介入。只要开始搜索,立刻销毁详情面板并强行把网格视图切换回可见状态,保证搜索结果永远“所见即所得”。)
|
||||
|
||||
## 35. The Metadata Overwrite Paradox (伪元数据强行覆盖的惨案)
|
||||
**The Problem (问题)**: A background script updating a model's custom name without fetching full Civitai data wrote a `.civitai.info` file containing *only* the custom name. This obliterated the real Civitai data (description, tags) and caused the frontend to render an empty detail page.
|
||||
(后台在更新自定义名字时,遇到了一个没有 `.info` 的模型,直接暴力生成了一个只包含自定义名字的 `.civitai.info`。结果反而把原本应有的全量 C 站详情数据给“排挤”掉了,导致详细页一片空白。)
|
||||
**The Solution (方案)**: Never trust partial updates to structural files. If it's corrupted or just a stub, delete it so the frontend can freshly pull real data from the Civitai API via the Scan Wizard.
|
||||
(对于结构性文件,宁缺毋滥。发现被污染成只有两个空字段的残疾文件后直接删除,让向导重新去 C 站抓取原汁原味的完整数据。)
|
||||
|
||||
## 36. The API Response Mismatch & Cache Deadlock (前后端格式割裂与缓存死锁)
|
||||
**The Problem (问题)**: `api.py` was updated to send an array of strings (`['/view?...']`) but `main.js` expected objects (`[{url: '/view?...'}]`). When trying to fix `main.js`, the browser's aggressive caching of ComfyUI extensions meant the fix never propagated. Changing `api.py` to match the cached JS failed until the ComfyUI server was restarted, causing a "nothing works" deadlock.
|
||||
(后端发送字符串,前端却按对象去读取,导致图片全部变成 `undefined` 的隐形方块。试图修改前端修复时,遇到浏览器死忠缓存;试图修改后端去迎合缓存时,又遇到 ComfyUI 不重启不重新加载后端的死锁,导致两头受堵。)
|
||||
**The Solution (方案)**: Implement "Double-Blind Compatibility". The backend strictly returns structural objects `{'url': ...}` to satisfy legacy cached frontends, and the frontend JS falls back via `el.src = img.url || img;` to parse anything. Most importantly: ALWAYS restart ComfyUI completely when touching backend Python APIs.
|
||||
(实施"双盲兼容"。后端退回发送对象格式来迎合那些死活不肯刷新的旧版缓存;前端同时加上 `img.url || img` 双轨读取能力。最核心的教训:只要改了后端的 Python 文件,别折腾了,必须彻底重启整个控制台黑框。)
|
||||
|
||||
## 37. The Identifier Collision Catastrophe (变量重名引发的插件暴毙)
|
||||
**The Problem (问题)**: When moving the "Model Doctor" button from TopNav to the Sidebar, a new `const doctorBtn` declaration was injected without removing the original one. Since both lived in the same function scope, JavaScript threw `SyntaxError: Identifier 'doctorBtn' has already been declared` at parse time, which silently killed the **entire** plugin before any code executed — including the trigger icon.
|
||||
(把"模型医生"按钮从顶部导航栏搬到底部侧边栏时,新代码里声明了一个新的 `const doctorBtn`,但旧的那个忘删了。由于两个 `const` 在同一个函数作用域里,JS 直接在编译阶段就报语法错误,整个插件的代码一行都不会执行——连那个 📦 入口图标都跟着蒸发了。)
|
||||
**The Solution (方案)**: When relocating UI elements across DOM zones, always perform a full-text search for the old declaration and its associated event handlers. Remove **both** the element creation AND its `appendChild` call. A single leftover `const` can nuke an entire 5000-line module.
|
||||
(搬家 UI 元素时,必须全局搜索旧的变量声明和所有关联调用(创建、事件绑定、appendChild),全部清理干净。一个多余的 `const` 就能让整个 5000 行模块原地爆炸。)
|
||||
|
||||
## 38. The `this` vs Closure Scope Mismatch (this 指向错误导致的静默失败)
|
||||
**The Problem (问题)**: `hideAllPanels()` was defined as a plain `const` closure function inside `createDOM()`, but the doctor button's click handler called it as `this.hideAllPanels()`. Since it was never defined as a class method, `this.hideAllPanels` resolved to `undefined`, and calling `undefined()` threw a TypeError — silently aborting the entire click handler. The doctor panel never became visible, and the node listener never activated, making it look like the button "did nothing".
|
||||
(`hideAllPanels()` 是 `createDOM()` 内部的一个普通闭包函数,但医生按钮的点击回调里错误地用 `this.hideAllPanels()` 去调用它。由于它并不是类方法,`this.hideAllPanels` 为 `undefined`,直接抛出 TypeError。这个错误静默中断了整个 onclick 执行链——面板没打开、节点监听没激活,用户只看到"按钮点了没反应"。)
|
||||
**The Solution (方案)**: Audit every function reference in click handlers. If it's a closure-scoped `const`, call it directly (`hideAllPanels()`). If it's a class method, call via `this.`. Mixing the two is the most common silent-failure trap in Vanilla JS class patterns.
|
||||
(必须逐一审计点击回调里的每个函数引用。闭包函数直接裸调,类方法才走 `this.`。两者混用是原生 JS 类模式中最常见的静默失败陷阱。)
|
||||
|
||||
## 39. The Tool-Escaped Backslash Corruption (编辑工具转义反斜杠的连环陷阱)
|
||||
**The Problem (问题)**: When fixing a Python `str.replace('\\', '/')` call via code-editing tools, the tool's internal escaping mechanism quadrupled the backslashes: the file ended up containing `replace('\\\\', '/')`, which searches for a **four-character** literal string `\\\\` that never exists in real Windows paths. The fix was invisible because the diff output *looked* correct due to terminal rendering also escaping backslashes. This wasted two full restart-debug cycles.
|
||||
(通过代码编辑工具修复 Python 的 `replace('\\', '/')` 时,工具内部的转义机制把反斜杠翻了四倍:文件里实际写入的是 `replace('\\\\\\\\', '/')`,也就是在搜索一个根本不存在的四字符字符串。更可怕的是,diff 输出看起来是对的,因为终端渲染本身也会做转义,所以肉眼完全看不出问题。白白浪费了两轮重启调试。)
|
||||
**The Solution (方案)**: **Never use hardcoded backslash literals in Python path operations when editing through intermediary tools.** Use `os.sep` or `os.path.normpath()` instead. These are immune to any escaping chain and are also cross-platform correct. Always verify the actual file content with `view_file` after editing path-related strings.
|
||||
(通过中间工具编辑路径代码时,**永远不要手写反斜杠字面量**。用 `os.sep` 或 `os.path.normpath()` 代替,它们对任何转义链路完全免疫,且天然跨平台。编辑完路径相关字符串后,务必用 `view_file` 验证文件里的实际内容。)
|
||||
|
||||
## 40. The Uninitialized State Guard (未初始化状态的跨入口崩溃)
|
||||
**The Problem (问题)**: When a user clicked "View Model Profile" from the Model Doctor panel *without ever having opened the main browser grid*, `showDetail()` → `renderSidebar()` was called. But `renderSidebar()` internally accessed `this.foldersData.length`, and since `loadFolders()` (which populates `foldersData`) only runs when the main grid initializes, `foldersData` was `undefined`. The resulting `Cannot read properties of undefined (reading 'length')` error aborted the entire transition. Similarly, `this.historyStack` could be undefined if accessed before the class constructor's initialization path completed.
|
||||
(用户直接从模型医生面板点击"查看模型档案",但此时**从未打开过主浏览器网格**。`showDetail()` 内部调用了 `renderSidebar()`,而 `renderSidebar()` 访问了 `this.foldersData.length`。由于 `loadFolders()`(负责填充 `foldersData`)只在主网格初始化时运行,此时 `foldersData` 还是 `undefined`,直接报错崩溃。类似地,`historyStack` 如果在构造函数初始化路径完成前被访问,也会 undefined。)
|
||||
**The Solution (方案)**: Any cross-entry-point function (accessible from multiple UI paths like Doctor → Detail, or Grid → Detail) must defensively guard ALL state dependencies. Use `this.historyStack = this.historyStack || [];` and `if (this.foldersData) this.renderSidebar();`. Never assume the "happy path" initialization order.
|
||||
(任何可以从多个 UI 入口调用的函数,必须对所有状态依赖做防御性守卫。用 `this.x = this.x || []` 和 `if (this.x)` 兜底。永远不要假设"正常流程"的初始化顺序。)
|
||||
|
||||
## 41. Ternary String Replace Crash (暴力替换引发的连锁崩溃)
|
||||
**The Problem (问题)**: Using Python `code.replace()` to globally replace localized strings with `window.anomalous_browser_lang === 'zh' ? ... : ...` was efficient but dangerous. A previous script accidentally deleted the declaration of `const checkUnscannedBtnRef` in the language toggle handler while doing a replacement. The missing declaration caused a `ReferenceError`, which crashed the `langBtn.onclick` event silently halfway through execution. This left the user permanently stuck in an English interface with no way to switch back, as the re-render code was never reached.
|
||||
(在使用脚本暴力全局替换双语三元运算符时,不小心干掉了某一个不相干按钮的 const 声明,导致点击语言切换时直接触发 `ReferenceError` 崩溃。最终结果是,界面再也无法切换回中文,用户被死死锁在英文界面里且没有任何明显报错。)
|
||||
**The Solution (方案)**: Always test interactive elements (like Language toggles) thoroughly after bulk string replacements. If a bulk replace is needed, verify syntax with `node -c web/main.js` and double-check variable scopes.
|
||||
(每次用脚本跑完正则替换后,一定要检查所有的点击事件是否还能完整跑完。或者用 `node -c` 检查语法。)
|
||||
|
||||
## 42. The CSS ID "Display None" Trap (幽灵般的 CSS ID 冲突)
|
||||
**The Problem (问题)**: We tried to fix a dynamic translation bug by adding an ID to a DOM element (`settingsBtn.id = 'anomalous-settings-btn'`). However, the original developer had previously written a CSS rule `#anomalous-settings-btn { display: none !important; }` in `styles.css` (inside a narrow-mode `@container` query). By adding the ID, we unintentionally activated the CSS trap, causing the settings button to vanish completely!
|
||||
(为了让 JS 能够抓取齿轮按钮更新中英文,我们给它加上了 `anomalous-settings-btn` 这个 ID。没想到 CSS 样式表里竟然潜伏着一条专门针对这个 ID 的 `display: none` 隐藏规则。加上 ID 的瞬间,齿轮按钮就因为命中规则而原地蒸发了!)
|
||||
**The Solution (方案)**: Never assign generic DOM IDs without `grep` searching the `.css` files first! We changed the ID to `anomalous-global-settings-btn`, completely sidestepping the hidden CSS rule.
|
||||
(在给已有组件加上新的 ID 时,必须先搜索整个 `.css` 文件确认是否撞名!最终我们用 `anomalous-global-settings-btn` 作为新 ID 绕过了这个地雷。)
|
||||
|
||||
## 43. The Hash Injection Toggle Misunderstanding (哈希注入总闸的认知错位)
|
||||
**The Problem (问题)**: The system relied on `anomalous_inject_hash` (Model Provenance Binding) to inject and read hashes. Users misunderstood this toggle, thinking they could turn it off and still use the Model Doctor to auto-fix nodes. Without the injected hashes, the Model Doctor was completely blind.
|
||||
(用户误以为关掉“模型溯源绑定”不影响模型医生的工作,结果关掉后生成的工作流完全没有哈希值,导致不论怎么用深度扫描,医生都无法替换爆红节点,形成了一个逻辑认知死锁。)
|
||||
**The Solution (方案)**: Explicitly documented in the README and Help Panel that the Model Doctor STRICTLY REQUIRES workflows to be exported with this plugin's hash injection enabled.
|
||||
(在所有文档和帮助面板中加上极其醒目的警告,明确指出模型医生的前提条件,打破用户的认知盲区。)
|
||||
|
||||
## 44. UI Documentation Factual Disconnect (UI 文档的物理错位)
|
||||
**The Problem (问题)**: The `Deep Hash Scan` button was physically moved to the `Model Doctor` panel, but the README and in-app Help Panel still told users to find it in the `Scan Wizard` / `Settings` panel.
|
||||
(把“深度哈希扫描”按钮从设置面板搬到了模型医生面板里,但说明书和 UI 帮助文本没跟着改,还在让用户去设置面板里找,导致用户完全找不到。)
|
||||
**The Solution (方案)**: Restructured the documentation into a strict visual-flow-based SOP (Standard Operating Procedure). Every UI feature mentioned in the docs is now paired with its exact corresponding Emoji icon (e.g., 🩺, 🤖) and location, completely eliminating navigational ambiguity.
|
||||
(按视觉动线重构了整份说明书。给文档里提到的每一个按钮都加上了精准的 Emoji 图标(如 🩺、🤖)和精确位置定位,实现了文档与界面的 100% 物理映射。)
|
||||
|
||||
+122
-56
@@ -12,7 +12,9 @@ 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
|
||||
@@ -133,16 +135,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 +164,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 +176,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,7 +196,10 @@ 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="不下载预览图或视频")
|
||||
parser.add_argument("--offline-only", action="store_true", help="跳过 Civitai 联网获取,强制使用本地脱机张量推断提取 Base Model")
|
||||
args = parser.parse_args()
|
||||
|
||||
target_folder = args.folder
|
||||
@@ -220,7 +232,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 [".info", ".civitai.info", ".json", ".txt", ".yaml", ".png", ".jpg", ".jpeg", ".webp", ".gif", ".avif", ".mp4", ".webm", ".mov", ".avi", ".preview.png", ".preview.jpg", ".preview.jpeg", ".preview.webp", ".preview.gif", ".preview.avif", ".preview.mp4", ".preview.webm", ".preview.mov", ".preview.avi", ".civitai_bak.png", ".civitai_bak.jpg", ".civitai_bak.jpeg", ".civitai_bak.webp", ".civitai_bak.gif", ".civitai_bak.avif", ".civitai_bak.mp4", ".civitai_bak.webm", ".civitai_bak.mov", ".civitai_bak.avi"]:
|
||||
new_ext_path = new_base + ext
|
||||
old_ext_path = old_base + ext
|
||||
if os.path.exists(new_ext_path):
|
||||
@@ -258,44 +270,83 @@ def main():
|
||||
info_exists = os.path.exists(old_base + ".info") or os.path.exists(old_base + ".civitai.info")
|
||||
preview_exists = args.skip_media
|
||||
if not preview_exists:
|
||||
for ext in [".png", ".preview.png", ".jpg", ".jpeg", ".webp", ".mp4", ".webm"]:
|
||||
for ext in [".png", ".jpg", ".jpeg", ".webp", ".gif", ".avif", ".mp4", ".webm", ".mov", ".avi", ".preview.png", ".preview.jpg", ".preview.jpeg", ".preview.webp", ".preview.gif", ".preview.avif", ".preview.mp4", ".preview.webm", ".preview.mov", ".preview.avi"]:
|
||||
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 +357,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 +381,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:
|
||||
@@ -350,14 +404,26 @@ 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
|
||||
for c_ext in ['.preview.png', '.preview.jpg', '.preview.jpeg', '.preview.webp', '.preview.gif', '.preview.avif', '.preview.mp4', '.preview.webm', '.preview.mov', '.preview.avi', '.png', '.jpg', '.jpeg', '.webp', '.gif', '.avif', '.mp4', '.webm', '.mov', '.avi']:
|
||||
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
|
||||
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):
|
||||
@@ -365,7 +431,7 @@ def main():
|
||||
if not args.dry_run:
|
||||
try:
|
||||
os.remove(file_path)
|
||||
for ext in [".info", ".civitai.info", ".png", ".jpg", ".webp", ".mp4", ".webm", ".json", ".txt", ".yaml"]:
|
||||
for ext in [".info", ".civitai.info", ".json", ".txt", ".yaml", ".png", ".jpg", ".jpeg", ".webp", ".gif", ".avif", ".mp4", ".webm", ".mov", ".avi", ".preview.png", ".preview.jpg", ".preview.jpeg", ".preview.webp", ".preview.gif", ".preview.avif", ".preview.mp4", ".preview.webm", ".preview.mov", ".preview.avi", ".civitai_bak.png", ".civitai_bak.jpg", ".civitai_bak.jpeg", ".civitai_bak.webp", ".civitai_bak.gif", ".civitai_bak.avif", ".civitai_bak.mp4", ".civitai_bak.webm", ".civitai_bak.mov", ".civitai_bak.avi"]:
|
||||
old_ext = old_base + ext
|
||||
if os.path.exists(old_ext):
|
||||
os.remove(old_ext)
|
||||
@@ -378,15 +444,15 @@ def main():
|
||||
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 [".info", ".civitai.info", ".json", ".txt", ".yaml", ".png", ".jpg", ".jpeg", ".webp", ".gif", ".avif", ".mp4", ".webm", ".mov", ".avi", ".preview.png", ".preview.jpg", ".preview.jpeg", ".preview.webp", ".preview.gif", ".preview.avif", ".preview.mp4", ".preview.webm", ".preview.mov", ".preview.avi", ".civitai_bak.png", ".civitai_bak.jpg", ".civitai_bak.jpeg", ".civitai_bak.webp", ".civitai_bak.gif", ".civitai_bak.avif", ".civitai_bak.mp4", ".civitai_bak.webm", ".civitai_bak.mov", ".civitai_bak.avi"]:
|
||||
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}")
|
||||
else:
|
||||
print(f"[Dry-Run] 拟重命名文件: {filename} ==> {new_filename}")
|
||||
print(f"[Dry-Run] 拟物理重命名文件: {filename} ==> {new_filename}")
|
||||
print(f"[Dry-Run] 拟连带重命名附属文件 (.info / .png 等)")
|
||||
else:
|
||||
print("[*] 文件名已符合规范,无需重命名。")
|
||||
|
||||
+295
-459
@@ -1,459 +1,295 @@
|
||||
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
|
||||
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 || ""
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.anomalous_resolve_all_missing_nodes = async function (is_manual = false, force_prompt = false) {
|
||||
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 (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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// 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) || {};
|
||||
|
||||
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 (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) {
|
||||
if (optionsCacheStale) {
|
||||
console.log(`[Anomalous Hash Resolver] Found ${finalValue} on disk, but ComfyUI frontend dropdown is stale. Forcing backend cache clear...`);
|
||||
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
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Failed to clear cache:", e);
|
||||
}
|
||||
}
|
||||
|
||||
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)");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 (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站 下载缺失模型。`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Intercept graph serialization to inject hashes
|
||||
const origSerialize = LGraph.prototype.serialize;
|
||||
window.anomalous_has_warned_unscanned = false;
|
||||
window.anomalous_unscanned_models = [];
|
||||
LGraph.prototype.serialize = function () {
|
||||
const data = origSerialize.apply(this, arguments);
|
||||
|
||||
// Clone extra to avoid mutating the live graph's extra object
|
||||
const extraObj = data.extra ? JSON.parse(JSON.stringify(data.extra)) : {};
|
||||
extraObj.anomalous_hashes = {};
|
||||
let unscanned_models = [];
|
||||
|
||||
if (data.nodes) {
|
||||
const liveNodes = this._nodes || [];
|
||||
for (const node of data.nodes) {
|
||||
const liveNode = liveNodes.find(n => n.id === node.id);
|
||||
|
||||
if (node.widgets_values && node.widgets_values.length > 0) {
|
||||
for (const val of node.widgets_values) {
|
||||
if (typeof val === 'string' && (val.endsWith('.safetensors') || val.endsWith('.ckpt') || val.endsWith('.pt'))) {
|
||||
const parts = val.split(/[/\\]/);
|
||||
const basename = parts[parts.length - 1];
|
||||
|
||||
let valIsMissing = false;
|
||||
if (liveNode && liveNode.widgets) {
|
||||
const matchingWidget = liveNode.widgets.find(w => w.value === val && w.type === "combo");
|
||||
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];
|
||||
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;
|
||||
}
|
||||
} else {
|
||||
if (!unscanned_models.includes(basename)) {
|
||||
unscanned_models.push(basename);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
};
|
||||
}
|
||||
});
|
||||
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
|
||||
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 || ""
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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 (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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Always fetch latest hashes before trying to resolve (using cache-buster to avoid stale hashes)
|
||||
try {
|
||||
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] Failed to fetch hashes for manual resolution", e);
|
||||
}
|
||||
|
||||
let fixed_count = 0;
|
||||
const hashes = (app.graph.extra && app.graph.extra.anomalous_hashes) || {};
|
||||
|
||||
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 (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) {
|
||||
if (optionsCacheStale) {
|
||||
console.log(`[Anomalous Hash Resolver] Found ${finalValue} on disk, but ComfyUI frontend dropdown is stale. Forcing backend cache clear...`);
|
||||
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
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Failed to clear cache:", e);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Anomalous Hash Resolver] Auto-fixed missing model: ${val} -> ${finalValue}`);
|
||||
w.value = 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) 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 && !silent) {
|
||||
// Detect current UI language (Default to 'zh' if not set)
|
||||
// 获取当前界面语言(未设置时默认使用中文)
|
||||
const lang = (window.anomalous_browser_lang === 'en') ? 'en' : 'zh';
|
||||
|
||||
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💡 提示:如果侧边栏【工作流总览】中依然显示红色报错,顺手点击一下该面板里的【刷新】按钮即可清除。`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
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
|
||||
const origSerialize = LGraph.prototype.serialize;
|
||||
window.anomalous_has_warned_unscanned = false;
|
||||
window.anomalous_unscanned_models = [];
|
||||
LGraph.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 = {};
|
||||
let unscanned_models = [];
|
||||
|
||||
if (data.nodes) {
|
||||
const liveNodes = this._nodes || [];
|
||||
for (const node of data.nodes) {
|
||||
const liveNode = liveNodes.find(n => n.id === node.id);
|
||||
|
||||
if (node.widgets_values && node.widgets_values.length > 0) {
|
||||
for (const val of node.widgets_values) {
|
||||
if (typeof val === 'string' && (val.endsWith('.safetensors') || val.endsWith('.ckpt') || val.endsWith('.pt'))) {
|
||||
const parts = val.split(/[/\\]/);
|
||||
const basename = parts[parts.length - 1];
|
||||
|
||||
let valIsMissing = false;
|
||||
if (liveNode && liveNode.widgets) {
|
||||
const matchingWidget = liveNode.widgets.find(w => w.value === val && w.type === "combo");
|
||||
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];
|
||||
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;
|
||||
}
|
||||
} else {
|
||||
if (!unscanned_models.includes(basename)) {
|
||||
unscanned_models.push(basename);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
|
||||
return ret;
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
+2721
-347
File diff suppressed because it is too large
Load Diff
+4373
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,159 @@
|
||||
export const i18n = {
|
||||
zh: {
|
||||
title: '📦 Anomalous 模型浏览器',
|
||||
scan: '🔄 扫描目录并同步 C 站数据',
|
||||
scanTitle: '扫描目录',
|
||||
scanConfirm: '注意:\n扫描过程将会自动比对 Civitai 并重命名本地模型,同时清理损坏文件。\n确定要开始扫描吗?',
|
||||
scanning: '扫描中...',
|
||||
scanBg: '🚀 扫描后台启动!',
|
||||
scanDone: '完成',
|
||||
scanCompleteMsg: '✅ 扫描完成!\n\n⚠️ 提示:由于部分模型已被重命名或去重,请点击 ComfyUI 的 [Refresh] 按钮以同步最新的模型列表。',
|
||||
eco: '🔋 视频悬浮播放 (节能模式)',
|
||||
autoPlay: '🎬 自动播放视频封面 (高能耗)',
|
||||
togglePlayTitle: '切换播放模式',
|
||||
clean: '🧹 清理无效的 .info 残留文件',
|
||||
cleanTitle: '清理重复 Info',
|
||||
cleanConfirm: '将扫描并删除所有无用的 .civitai.info 残留文件。是否继续?',
|
||||
cleaning: '清理中',
|
||||
cleanDone: '✅ 清理完毕!删除了',
|
||||
files: '个文件。',
|
||||
cleanFail: '❌ 清理失败: ',
|
||||
cleanErr: '❌ 清理出错: ',
|
||||
folders: '📂 文件夹',
|
||||
collapseAll: '➖ 收起全部',
|
||||
expandAll: '➕ 展开全部',
|
||||
noModels: '此文件夹中没有找到模型。',
|
||||
noPreview: '暂无预览图',
|
||||
clickScan: '点击扫描',
|
||||
back: '⬅️ 返回网格',
|
||||
delModel: '🗑️ 删除',
|
||||
delConfirm: '确定彻底删除',
|
||||
delConfirm2: '及其所有关联配置、预览图和缓存文件吗?此操作不可逆!',
|
||||
delSuccess: '✅ 删除成功!\n',
|
||||
delNote: '\n\n⚠️ 提示:请重启 ComfyUI,否则由于缓存继续使用会报错',
|
||||
delFail: '❌ 删除失败: ',
|
||||
copyAll: '📋 复制全部',
|
||||
copied: '✅ 已复制!',
|
||||
clickToCopy: '点击复制: ',
|
||||
compatibleModels: '🔗 兼容模型',
|
||||
loadingCompatible: '加载中...',
|
||||
backToPrev: '🔙 返回上一层',
|
||||
applyToCanvas: '➕ 插入节点',
|
||||
applySuccess: '✅ 已添加至工作流',
|
||||
helpContent: `
|
||||
<div style="line-height: 1.6; font-size: 0.95em; color: #eee; padding: 10px;">
|
||||
<h3 style="color:#00ffcc; margin-top:0;">📖 标准操作说明</h3>
|
||||
<p><strong>1. 🔄 前置准备 (扫描向导)</strong><br>具体位置:界面左侧底部的 <strong>扫描向导 (🔄)</strong>。<br>这是最重要的前置操作!请先打开扫描向导执行扫描。切记保持网络畅通。部分 C站限制级模型信息需要在设置中填入 API-KEY。扫描完成后,插件的各项功能基本激活。</p>
|
||||
<p><strong>2. 🩺 拯救爆红 (模型医生)</strong><br>具体位置:左侧底部第二个 <strong>听诊器 (🩺)</strong> 按钮。<br>当导入别人使用该插件输出的工作流或者图片时,若模型路径爆红,点击模型医生就能智能识别当前所有节点实现正确路径的替换。</p>
|
||||
<p><strong>3. 🤖 选中交互 (节点助手)</strong><br>具体位置:左侧第三个 <strong>机器人 (🤖)</strong> 按钮。<br>打开后在画布上选择模型节点,助手便能识别该节点的各项信息。支持查看历史图库、备注、封面、模型详细页、提示词等。还能快捷根据模型封面替换其他模型。</p>
|
||||
<p><strong>4. ⚙️ 个性化配置 (设置面板)</strong><br>具体位置:左侧底部的 <strong>齿轮 (⚙️)</strong> 按钮。<br>可以调节语言(语言在这里调节)、主页面字体大小、视频封面播放方式等。</p>
|
||||
<p><strong>5. 📑 顶部导航与笔记本功能</strong><br>具体位置:右侧顶部的按钮分别为 <strong>模型 (📦)</strong>、<strong>图库 (🖼️)</strong>、<strong>笔记本 (📑)</strong>、<strong>停靠侧边栏 (◧)</strong>。<br>点击笔记本,点击新建 <strong>➕</strong> 号。确认后选择基础模型(如SD1.5),接着选择主模型和对应的 lora。点击下方的 <strong>📝纯文本/粘贴</strong> 填入提示词,选择左上角的语言即可实现双语对照。确认后自动分块标签,支持查找替换和直接修改单个标签。最后点击 <strong>🚀 发送到画布</strong> 即可一键连线!</p>
|
||||
</div>`,
|
||||
notebooks: '笔记本',
|
||||
notebookTitle: '笔记本管理',
|
||||
createNotebook: '新建',
|
||||
saveNotebook: '💾 保存',
|
||||
deleteNotebook: '🗑️ 删除',
|
||||
renameNotebook: '✏️ 重命名',
|
||||
baseModel: 'Base Model (基础模型)',
|
||||
mainModel: 'Main Model (主模型)',
|
||||
sendToCanvas: '🚀 发送到画布',
|
||||
translate: '翻译',
|
||||
newNotebookName: '新笔记本名称',
|
||||
noBaseModel: '请先选择 Base Model',
|
||||
closeHelp: '关闭说明',
|
||||
delSure: '⚠️ 确定吗?',
|
||||
dockTitle: '侧边停靠',
|
||||
replaceAll: '全部替换',
|
||||
editRaw: '📝 纯文本/粘贴',
|
||||
findPlaceholder: '查找...',
|
||||
replacePlaceholder: '替换为...',
|
||||
models: '模型',
|
||||
gallery: '图库',
|
||||
apiKeyConfig: '🔑 Civitai API 密钥配置 (用于下载封面)',
|
||||
apiKeyPrompt: '请输入你的 Civitai API Key:',
|
||||
settingsBtn: '设置',
|
||||
closeSettings: '✖ 关闭设置面板',
|
||||
doctorBtn: '节点助手',
|
||||
importBtn: '📥 预检导入工作流',
|
||||
|
||||
},
|
||||
en: {
|
||||
title: '📦 Anomalous Model Browser',
|
||||
scan: '🔄 Scan folder and sync Civitai data',
|
||||
scanTitle: 'Scan Folder',
|
||||
scanConfirm: 'Notice:\nThe scan process will automatically compare with Civitai and rename your local files.\nStart scan?',
|
||||
scanning: 'Scanning...',
|
||||
scanBg: '🚀 Scan started in background!',
|
||||
scanDone: 'Done',
|
||||
scanCompleteMsg: '✅ Scan Complete!\n\n⚠️ Note: Please click [Refresh] to sync the model list since files were renamed.',
|
||||
eco: '🔋 Eco Mode (Play video on hover)',
|
||||
autoPlay: '🎬 AutoPlay Videos (High perf cost)',
|
||||
togglePlayTitle: 'Toggle Play Mode',
|
||||
clean: '🧹 Clean orphaned .info files',
|
||||
cleanTitle: 'Clean Duplicate Info',
|
||||
cleanConfirm: 'This will globally scan and delete all redundant .civitai.info files. Continue?',
|
||||
cleaning: 'Cleaning',
|
||||
cleanDone: '✅ Clean Complete! Deleted',
|
||||
files: 'files.',
|
||||
cleanFail: '❌ Failed: ',
|
||||
cleanErr: '❌ Error: ',
|
||||
folders: '📂 Folders',
|
||||
collapseAll: '➖ Collapse All',
|
||||
expandAll: '➕ Expand All',
|
||||
noModels: 'No models found in this folder.',
|
||||
noPreview: 'No preview available',
|
||||
clickScan: 'Click Scan',
|
||||
back: '⬅️ Back to grid',
|
||||
delModel: '🗑️ Delete',
|
||||
delConfirm: 'Are you sure you want to permanently delete',
|
||||
delConfirm2: ' and all its associated configs, previews, and cache files? This action is irreversible!',
|
||||
delSuccess: '✅ Delete Complete!\n',
|
||||
delNote: '\n\n⚠️ Note: Please restart ComfyUI, otherwise continuing to use it due to cache will cause errors.',
|
||||
delFail: '❌ Delete Failed: ',
|
||||
copyAll: '📋 Copy All',
|
||||
copied: '✅ Copied!',
|
||||
clickToCopy: 'Click to copy: ',
|
||||
compatibleModels: '🔗 Compatible Models',
|
||||
loadingCompatible: 'Loading...',
|
||||
backToPrev: '🔙 Back to prev',
|
||||
applyToCanvas: '➕ Add Node',
|
||||
applySuccess: '✅ Added to workflow',
|
||||
helpContent: `
|
||||
<div style="line-height: 1.6; font-size: 0.95em; color: #eee; padding: 10px;">
|
||||
<h3 style="color:#00ffcc; margin-top:0;">📖 Step-by-Step Guide</h3>
|
||||
<p><strong>1. 🔄 Prerequisites (Scan Wizard)</strong><br>Location: The <strong>Scan (🔄)</strong> icon at the bottom left.<br>This is the most crucial step! Open the Scan Wizard and execute a scan. Keep your network connected. (API-KEY may be needed in Settings for NSFW Civitai models). Scanning activates all core features.</p>
|
||||
<p><strong>2. 🩺 Fix Broken Nodes (Model Doctor)</strong><br>Location: The 2nd icon <strong>(Stethoscope 🩺)</strong> from the bottom left.<br>When importing a workflow/image exported by this plugin and nodes turn red, open the Model Doctor to intelligently identify and replace them with correct local paths.</p>
|
||||
<p><strong>3. 🤖 Visual Info (Node Assistant)</strong><br>Location: The 3rd icon <strong>(Robot 🤖)</strong> from the bottom left.<br>Open it and select any model node on the canvas. It identifies the node, shows history gallery, notes, covers, details, and prompts. It also allows quick visual model swapping.</p>
|
||||
<p><strong>4. ⚙️ Settings Panel</strong><br>Location: The <strong>Gear (⚙️)</strong> icon at the bottom left.<br>Adjust language (Language is adjusted here), UI font size, and video autoplay behavior.</p>
|
||||
<p><strong>5. 📑 Top Navigation & Notebook</strong><br>Location: Top right tabs <strong>Models (📦)</strong>, <strong>Gallery (🖼️)</strong>, <strong>Notebook (📑)</strong>, <strong>Dock (◧)</strong>.<br>Click Notebook -> click <strong>➕</strong> -> select Base Model (e.g. SD1.5) -> pick Main Model & LoRA. Click <strong>📝 Edit Raw/Paste</strong> to input prompts, select language on top-left for bilingual translation. Confirm to auto-chunk tags (supports find/replace & direct edit). Finally, click <strong>🚀 Send to Canvas</strong>!</p>
|
||||
</div>`,
|
||||
notebooks: 'Notebooks',
|
||||
notebookTitle: 'Notebook Manager',
|
||||
createNotebook: 'New',
|
||||
saveNotebook: '💾 Save',
|
||||
deleteNotebook: '🗑️ Delete',
|
||||
renameNotebook: '✏️ Rename',
|
||||
baseModel: 'Base Model',
|
||||
mainModel: 'Main Model',
|
||||
sendToCanvas: '🚀 Send to Canvas',
|
||||
translate: 'Translate',
|
||||
newNotebookName: 'New Notebook Name',
|
||||
noBaseModel: 'Select a Base Model first',
|
||||
closeHelp: 'Close Manual',
|
||||
delSure: '⚠️ Sure?',
|
||||
dockTitle: 'Dock to Left',
|
||||
replaceAll: 'Replace All',
|
||||
editRaw: '📝 Edit Raw / Paste',
|
||||
findPlaceholder: 'Find...',
|
||||
replacePlaceholder: 'Replace with...',
|
||||
models: 'Models',
|
||||
gallery: 'Gallery',
|
||||
apiKeyConfig: '🔑 Civitai API Key Config (For downloading previews)',
|
||||
apiKeyPrompt: 'Enter your Civitai API Key:',
|
||||
settingsBtn: 'Settings',
|
||||
closeSettings: '✖ Close Settings',
|
||||
doctorBtn: 'Node Assistant',
|
||||
importBtn: '📥 Preflight Import',
|
||||
}
|
||||
};
|
||||
+1
-11
@@ -155,16 +155,6 @@
|
||||
/* Sidebar toggle logic for top buttons ONLY in narrow mode */
|
||||
@container (max-width: 900px) {
|
||||
|
||||
#anomalous-models-btn,
|
||||
#anomalous-settings-btn {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.anomalous-sidebar-closed #anomalous-models-btn,
|
||||
.anomalous-sidebar-closed #anomalous-settings-btn {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
#anomalous-notebook-btn .anomalous-btn-text {
|
||||
display: none;
|
||||
}
|
||||
@@ -963,7 +953,7 @@
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(0, 0, 0, 0.95);
|
||||
z-index: 10002;
|
||||
z-index: 9999999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
Reference in New Issue
Block a user