diff --git a/CHANGELOG.md b/CHANGELOG.md index cbf0edd..b82c26f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,251 +1,251 @@ -# 📈 Anomalous Model Browser Changelog - -## Unreleased - -### 🧭 New layout (新布局) -- **Icon rail (左侧图标栏)**: every page now has one place on the left: Home, Models, Gallery, Workflows, Materials, then Voices and Audio for the audio side, then scan, Model Doctor, Node Assistant and the toolbox, with settings at the bottom. It replaces the tabs at the top, the image/audio switch and the tool bar under the folder list. -- **Folder list opens and closes per page (列表按页面收放)**: click the current page's icon again, or the button at the top left, to hide or show its list; each page remembers your choice. Pages without a list (Home, Gallery) give the space to the content. In a narrow or docked window the list starts closed and opens as a drawer against the rail, over a dimmed page; picking a folder or character, pressing the dimmed page or Esc closes it. -- **Home page (主页)**: "What would you like to do?" with a card per task (find a model, fix missing models, give a character a voice, look through outputs, keep workflows, organize materials) and first steps (scan model folders, tour the interface, what's new). The first time you open the browser it starts here; after that it reopens the page you used last. +# 📈 Anomalous Model Browser Changelog + +## Unreleased + +### 🧭 New layout (新布局) +- **Icon rail (左侧图标栏)**: every page now has one place on the left: Home, Models, Gallery, Workflows, Materials, then Voices and Audio for the audio side, then scan, Model Doctor, Node Assistant and the toolbox, with settings at the bottom. It replaces the tabs at the top, the image/audio switch and the tool bar under the folder list. +- **Folder list opens and closes per page (列表按页面收放)**: click the current page's icon again, or the button at the top left, to hide or show its list; each page remembers your choice. Pages without a list (Home, Gallery) give the space to the content. In a narrow or docked window the list starts closed and opens as a drawer against the rail, over a dimmed page; picking a folder or character, pressing the dimmed page or Esc closes it. +- **Home page (主页)**: "What would you like to do?" with a card per task (find a model, fix missing models, give a character a voice, look through outputs, keep workflows, organize materials) and first steps (scan model folders, tour the interface, what's new). The first time you open the browser it starts here; after that it reopens the page you used last. - The header shows the current page's name; workflow and material workspaces keep the rail visible, so you can switch pages without closing them first. - **Up-to-date guides (说明跟上新界面)**: the help page, the **!** "What's new" guide and the README now describe the rail, Home, model types, the activity log and character voices instead of the old tabs and bottom bar. The help page also says where the Civitai API key really goes (Advanced on the scan page). -- **Deleting goes to the Recycle Bin (删除进回收站)**: deleting a model (with its info and covers), an output image, generated audio, a recipe (with its versions and images), a material, a prompt note or a parameter note, replacing or resetting a cover, and the duplicate copy a file rename finds, all move the files to the system Recycle Bin instead of erasing them. On a USB stick or network drive, which has no Recycle Bin, nothing is deleted and the message says to delete it by hand. The confirmations say where things go. An unused route that could erase every `.civitai.info` in the library (with the names you set) is gone. -- **Scan page (扫描页)**: the rail's scan button and Home's "Scan model folders" open a page instead of the old seven-switch dialog. It shows how many models are matched on Civitai, not matched, and not scanned yet; one button scans the new ones, with progress in the page (it runs in the background, so the window can be closed). Offline scanning, renaming files on disk, fetching everything again, picking models and the Civitai API key are under **Advanced**; the two risky switches turn themselves off after each scan. The "record model fingerprints in workflows" switch moved to Settings → Model settings. -- **Unmatched models can be looked up again (没匹配上的模型可以重新查找)**: a model that Civitai did not know, or that was scanned while Civitai could not be reached, used to stay "local only" for good unless every model was fetched again (which also replaced your own covers). **Look up the N unmatched models again** retries just those, keeps your covers, and reuses the saved hash when the file is unchanged, so it takes seconds instead of re-hashing gigabytes. -- **Your edits come first (你改的永远优先)**: names, notes and download links you set in the model editor now have a file of their own next to the model (`.anomalous.json`) that scans never write, and they win over Civitai's information, which wins over what is inferred from the file. Edits saved by earlier versions keep working. Editing a model that was never scanned used to make it look scanned, so scans skipped it for good; it is now scanned like any new model. -- **Covers you chose are never replaced (你换的封面不会被替换)**: a cover counts as Civitai's only while it is the very image the scan downloaded; any other cover is yours. **Fetch scanned models again** now keeps your covers (and names and notes), updates only Civitai's own, and asks before it starts. -- **Where a model's information comes from (信息来源标注)**: cards look as before for models matched on Civitai; a base model inferred from the file shows as "≈ SDXL" with a dashed outline, and a model not scanned yet says so, each with a tooltip on what to do. The detail header adds the source (Civitai, inferred from the file, not scanned) and which fields you edited. -- **Faster rescans (重复扫描更快)**: a scan no longer downloads every cover and Civitai model page again for models whose information is already on disk; only new or missing ones go to the network. -- **Model types at the top (模型页顶部类型标签)**: the models page starts with a chip per model type (Checkpoint, LoRA, VAE…) and how many models it holds; a chip shows that whole type, subfolders included, so the grid no longer looks like it holds one folder's models. Picking a folder in the list narrows the grid to that folder, shown as a chip with ✕ that goes back to the whole type. The browser remembers the type you picked last. - +- **Deleting goes to the Recycle Bin (删除进回收站)**: deleting a model (with its info and covers), an output image, generated audio, a recipe (with its versions and images), a material, a prompt note or a parameter note, replacing or resetting a cover, and the duplicate copy a file rename finds, all move the files to the system Recycle Bin instead of erasing them. On a USB stick or network drive, which has no Recycle Bin, nothing is deleted and the message says to delete it by hand. The confirmations say where things go. An unused route that could erase every `.civitai.info` in the library (with the names you set) is gone. +- **Scan page (扫描页)**: the rail's scan button and Home's "Scan model folders" open a page instead of the old seven-switch dialog. It shows how many models are matched on Civitai, not matched, and not scanned yet; one button scans the new ones, with progress in the page (it runs in the background, so the window can be closed). Offline scanning, renaming files on disk, fetching everything again, picking models and the Civitai API key are under **Advanced**; the two risky switches turn themselves off after each scan. The "record model fingerprints in workflows" switch moved to Settings → Model settings. +- **Unmatched models can be looked up again (没匹配上的模型可以重新查找)**: a model that Civitai did not know, or that was scanned while Civitai could not be reached, used to stay "local only" for good unless every model was fetched again (which also replaced your own covers). **Look up the N unmatched models again** retries just those, keeps your covers, and reuses the saved hash when the file is unchanged, so it takes seconds instead of re-hashing gigabytes. +- **Your edits come first (你改的永远优先)**: names, notes and download links you set in the model editor now have a file of their own next to the model (`.anomalous.json`) that scans never write, and they win over Civitai's information, which wins over what is inferred from the file. Edits saved by earlier versions keep working. Editing a model that was never scanned used to make it look scanned, so scans skipped it for good; it is now scanned like any new model. +- **Covers you chose are never replaced (你换的封面不会被替换)**: a cover counts as Civitai's only while it is the very image the scan downloaded; any other cover is yours. **Fetch scanned models again** now keeps your covers (and names and notes), updates only Civitai's own, and asks before it starts. +- **Where a model's information comes from (信息来源标注)**: cards look as before for models matched on Civitai; a base model inferred from the file shows as "≈ SDXL" with a dashed outline, and a model not scanned yet says so, each with a tooltip on what to do. The detail header adds the source (Civitai, inferred from the file, not scanned) and which fields you edited. +- **Faster rescans (重复扫描更快)**: a scan no longer downloads every cover and Civitai model page again for models whose information is already on disk; only new or missing ones go to the network. +- **Model types at the top (模型页顶部类型标签)**: the models page starts with a chip per model type (Checkpoint, LoRA, VAE…) and how many models it holds; a chip shows that whole type, subfolders included, so the grid no longer looks like it holds one folder's models. Picking a folder in the list narrows the grid to that folder, shown as a chip with ✕ that goes back to the whole type. The browser remembers the type you picked last. + ### 🪶 Lighter on memory (更省内存) - The output gallery shows 512 px thumbnails instead of full images (cached like model cards), so scrolling through hundreds of outputs no longer fills the browser's memory and graphics memory with full-size pictures. Dragging an image to the canvas and the large viewer still use the original. When the browser has been closed for 90 seconds, its gallery images are released too, like the model grid. -### 🕘 Activity log (操作记录) -- **See what Anomalous changed (看 Anomalous 改了什么)**: the new **Activity** page on the rail lists, by day, every change the plugin made: on the canvas, which node settings changed (from → to), which nodes were added or removed, and which workflow was opened; in files, models, covers, recipes, materials, notes, presets, images, audio and imported characters. Filter by canvas or files; open a canvas entry for its details and **Find on canvas** to jump to the node. Your own edits on the canvas are not recorded as the plugin's. The home page shows the latest few. -- It only records; nothing is undone for you. **Clear** removes the log itself, never your changes. The newest 500 entries (at most 2 MB) are kept in ComfyUI's user folder. - -### 🎙️ Audio & Voice Studio (音频与声音工作台) +### 🕘 Activity log (操作记录) +- **See what Anomalous changed (看 Anomalous 改了什么)**: the new **Activity** page on the rail lists, by day, every change the plugin made: on the canvas, which node settings changed (from → to), which nodes were added or removed, and which workflow was opened; in files, models, covers, recipes, materials, notes, presets, images, audio and imported characters. Filter by canvas or files; open a canvas entry for its details and **Find on canvas** to jump to the node. Your own edits on the canvas are not recorded as the plugin's. The home page shows the latest few. +- It only records; nothing is undone for you. **Clear** removes the log itself, never your changes. The newest 500 entries (at most 2 MB) are kept in ComfyUI's user folder. + +### 🎙️ Audio & Voice Studio (音频与声音工作台) - **Generate right in the Script Director (剧本台直接生成)**: for GPT-SoVITS characters, **Generate** runs the script in ComfyUI without touching the canvas (it waits in the queue while ComfyUI is busy), plays the result and saves it to `output/audio//`. **↻** on a line card gives just that line another take while the others come from cache; **New take of all** starts over with a new seed. Language, speed and the sampling parameters (one folded **Generation settings** line, with a hint on each) start from the character's saved defaults; when a take sounds right, **Save these settings as this character's default** keeps them for next time. The drawer is wider: the line cards get the full height with generating in a column beside them, and in a narrow or docked browser the drawer covers the studio with generating below the cards; either way only the cards scroll, however long the script is. Needs Anomalous_TTS interface 11. - **Pronunciation table (读音表)**: **Pronunciation** on a GPT-SoVITS character card lists what the script says and what the character should read instead (`C站 → 西站`); each line can be heard as the character reads it now and as replaced, without filling the audio gallery. -- **Audio page tour (音频页遮罩导览)**: the tour offered in the audio page's "!" guide now walks through the audio page itself (import, dragging a character onto a node, emotions and pronunciation, emotion tags, Script Director, audio gallery, GPT-SoVITS settings) instead of the image-side tour, whose buttons are not there. Tour steps whose button is not on screen are skipped. -- **Suggested folder layout (推荐的文件夹放法)**: the "many characters" import screen shows, folded, how to arrange a folder so it sorts cleanly: one folder per character, named after it, with its GPT and SoVITS models and 3–10 s clips (and same-name line files) inside. +- **Audio page tour (音频页遮罩导览)**: the tour offered in the audio page's "!" guide now walks through the audio page itself (import, dragging a character onto a node, emotions and pronunciation, emotion tags, Script Director, audio gallery, GPT-SoVITS settings) instead of the image-side tour, whose buttons are not there. Tour steps whose button is not on screen are skipped. +- **Suggested folder layout (推荐的文件夹放法)**: the "many characters" import screen shows, folded, how to arrange a folder so it sorts cleanly: one folder per character, named after it, with its GPT and SoVITS models and 3–10 s clips (and same-name line files) inside. - **Emotion tag buttons (情绪标签按钮)**: a clip's copy button now reads "Copy {happy}" and its tooltip says what pasting it into the speech text does; characters with only a main voice have no copy button, since their text needs no tag. - **Audio page tabs (音频页顶部标签)**: the "Audio Workflows" tab is gone from the audio page; generating now happens in the Script Director, and saved workflows stay under **Workflows** on the image side. -- **Script Director (剧本台)**: paste a script for one character; each sentence becomes a card with emotion chips, inline editing, split at cursor, merge and delete. Generate it right there (below), or push the tagged script to the selected GPT-SoVITS node or drag it onto one. -- **Audio gallery (音频库)**: lists generated audio with its speech text, voice and seed read from the file itself, supports search, and can save unsaved previews into `output/audio/`. -- **Clear drag rules (拖动规则明确)**: drag a character card's header onto a GPT-SoVITS node to set its character. The hint says what releasing will do; unsupported nodes are refused with the reason instead of being guessed. -- **Usage guide (使用说明)**: in the audio page, the header **!** opens a five-step how-to; the full guide is `docs/guides/audio-studio.md`. -- **GPT-SoVITS characters (GPT-SoVITS 角色)**: the audio page manages characters of the separate Anomalous_TTS node pack, detected at runtime; while it is missing, the page shows what it does, how to install it (ComfyUI Manager or the project page) and nothing else depends on it. The node's character menu has **Import or edit characters**, which opens this page on that character. Preview reference clips, pick a reference clip per emotion ("Edit emotions", saved through the node's API), drag a character onto the node, and write or generate scripts in the Script Director. +- **Script Director (剧本台)**: paste a script for one character; each sentence becomes a card with emotion chips, inline editing, split at cursor, merge and delete. Generate it right there (below), or push the tagged script to the selected GPT-SoVITS node or drag it onto one. +- **Audio gallery (音频库)**: lists generated audio with its speech text, voice and seed read from the file itself, supports search, and can save unsaved previews into `output/audio/`. +- **Clear drag rules (拖动规则明确)**: drag a character card's header onto a GPT-SoVITS node to set its character. The hint says what releasing will do; unsupported nodes are refused with the reason instead of being guessed. +- **Usage guide (使用说明)**: in the audio page, the header **!** opens a five-step how-to; the full guide is `docs/guides/audio-studio.md`. +- **GPT-SoVITS characters (GPT-SoVITS 角色)**: the audio page manages characters of the separate Anomalous_TTS node pack, detected at runtime; while it is missing, the page shows what it does, how to install it (ComfyUI Manager or the project page) and nothing else depends on it. The node's character menu has **Import or edit characters**, which opens this page on that character. Preview reference clips, pick a reference clip per emotion ("Edit emotions", saved through the node's API), drag a character onto the node, and write or generate scripts in the Script Director. - **Audio sidebar (音频侧栏)**: characters are grouped by language in foldable groups; a character unfolds into its clips, and a click plays one. Characters drag onto nodes from the sidebar too, a red dot marks characters that need a look, a search box appears from six characters on, and files dropped on a character open **Add files** for it. -- **GPT-SoVITS setup and import (GPT-SoVITS 准备与导入)**: an empty studio is a drop area with **Import character**, which also sits in the toolbar. **GPT-SoVITS settings** at the bottom of the sidebar opens a dialog with where characters are kept, pretrained files and missing Python packages; missing pretrained files only get a small dot on that entry, with "don't remind me", once your characters need them, and the import form notes what the chosen language will download. **Batch import (批量导入)**: the import window takes one or many characters. It first asks what you have (one character, many characters or a package, or files for a character you already have), with three steps at the top and a spotlight tour the first time ("?" replays it). Each character card leads with a checklist that lights up the next step (GPT model, SoVITS model, clips, main voice, name) and offers its button right there; the card says how many steps are left. Drop files or whole folders, or pick a folder on this PC (a GPT-SoVITS package): a folder holding one set of GPT + SoVITS weights and its clips is one character named after the folder (two characters whose weights are both called `model` stay apart; `派蒙/v2` becomes "派蒙 v2"); weights in a package's weight folders pair up by name (latest epoch kept); clips and line files follow their character folder, the weights in their folder or the character their folder or file name points at; an annotation file (`.list`) no character claims goes to every character; two clips with the same name (`开心/01.wav`, `难过/01.wav`) both go in, the second as `难过_01.wav`; anything unclear waits in a card of its own, one line per folder that can be assigned at once, and is uploaded only once it is put into a character. Clips outside 3–10 s are left out with their line files. Python environments and base models (`runtime`, `pretrained_models`, `venv`…) are skipped, and a package's program and training folders (`GPT_SoVITS`, `logs`, `output`, `TEMP`, `tools`) only inside a package: your own folders called `output` or `GPT_SoVITS`, and the folder you chose or dropped, are always read; the window says which folders were skipped or too deep (6 levels) instead of files going missing quietly. A big add opens 20 characters (about 800 files) at a time; **Next batch** opens the rest, and it opens by itself once a batch is imported, so a huge folder does not freeze the window. Every clip has **▶** to listen while importing — uploading, unassigned or picked on this PC — and clicking another clip switches to it for quick comparing. Long clip lists show the first 60, and a very large upload first suggests picking the folder on this PC instead; uploads run three at a time. Each character is a card: one line with its name, a short status and what it holds, unfolding into its model tiles and clips; files move between characters with the picker on each row. **Import N characters** imports each ready one on its own, so one failure leaves the others alone. Status is colour-coded throughout: green ready, amber still missing something, red a failed or refused file, blue uploading; **Import** stays at the bottom. Adding to a character skips files it already has and merges annotation files (Anomalous_TTS interface 5). There is one storage place (default `models/gpt_sovits`); changing it can move the existing characters there with progress. Download pretrained files with progress or use an existing GPT-SoVITS package, all without editing `extra_model_paths.yaml` or restarting. **Import character** builds a character from dropped or picked GPT / SoVITS weights, reference clips and annotation files: the name, SoVITS version, clip lengths and lines are filled in automatically, and everything is copied (originals stay). Drop files on a card, or use **Add files**, to add clips and emotions to an existing character. Lines are found in same-name `.txt` / `.lab` files, annotation files or, as a marked guess, the file name; a text file dropped on a clip's line box fills it. Needs Anomalous_TTS with interface version 11; file actions only work on the computer running ComfyUI. - -### Fixes -- All-in-one loader recipes and cross-node prompt injection use one verified adapter table; prompt text never crosses between positive and negative. -- The Workflows, Prompt Notes and Material Library workspace no longer covers the header: the Models / Gallery / Workflows tabs stay clickable while it is open, and **Esc** closes it (back to what was open before) unless a dialog is above it or a text field has focus. Closing the Prompt Translator brings back the browser it folded away. - -## v1.57.3 Beta (Hotfix: Offline Scan, Prompt Workshop) — 2026-09-29 - -### 🔌 Offline scan and small fixes (离线扫描与小修复) -- **Offline scan stays offline (离线扫描不再联网)**: "Offline fast read" no longer sends the greyed-out rename and overwrite options, and an offline scan never fetches Civitai model pages or preview images, even for models whose `.info` came from Civitai. Scan requests without options are refused instead of starting an online scan. -- Clicking the drop zone at the end of the Prompt Workshop's assembly track focuses the card search again instead of failing silently. -- Malformed requests to translation, deletion, scanning and image-material routes get a clear 400 instead of a server error. - -## v1.57.2 Beta (Hotfix: Canvas Performance, Saving Workflows) — 2026-09-27 - -### 💾 Saving workflows and Prompt Notes (保存工作流与提示词笔记) -- **Saving a workflow works again (保存工作流失败)**: since v1.57, **Save Current Workflow** failed with "Could not save this Workflow Recipe" for any workflow with a model loader, because splitting the backend into smaller files left the save path calling a function that was no longer imported. The same split also made model cards load full-size cover images instead of thumbnails (slow on big folders), and made editing details of a model that had moved fail. -- **Prompt Notes with UNet models (提示词笔记与 UNet 模型)**: sending a note with a UNet / diffusion model to the canvas built a graph that could not run, because its LoRAs waited for a CLIP the UNet does not have. LoRAs of a UNet now load model-only, and the note says to connect your own text encoder to the two prompt nodes. Models whose metadata names no base model (most models not downloaded from Civitai) were missing from every group; they now appear under **No base model listed**. Listing models no longer blocks ComfyUI while it reads the folders. -- Installed v1.57.2 before 2026-09-27 through the version panel? Pick v1.57.2 there once more to get this build; updating through ComfyUI Manager gets it as usual. - -### ⚡ Smooth canvas dragging again (画布拖动卡顿修复) -- **Dragging and zooming the canvas no longer slows down with this plugin installed (安装插件后拖动画布掉帧)**: one stylesheet rule, which only hid the sidebar tooltip while the Toolbox or Settings popup was open, made the browser re-check the whole page every time any element's inline style changed. ComfyUI moves every text-box widget on every frame while you pan or zoom, so each frame paid for it, even with the browser closed. Measured on a 300-node workflow in ComfyUI: style work while panning drops from about 11× the no-plugin cost back to the same as without the plugin. -- **Large workflows with ComfyUI Prompt Assistant (与提示词小助手同时安装时的严重卡顿)**: the cost grew with the number of page elements, and Prompt Assistant adds a row of buttons to every text box, so together style work took about 19 ms per frame (more than a whole 60 fps frame): single-digit frame rates or freezes on big workflows. It is now back to the level of Prompt Assistant alone. The two plugins never conflicted otherwise. -- The tooltip still stays hidden while the Toolbox or Settings popup is open; the popups already hide it when they open and it does not reappear until they close. - -### 🧩 Material Library with a node selected (选中节点时素材库卡片错位) -- **One apply button per card (每张卡片只有一个应用按钮)**: with a node selected, the Material Library showed every card's **Apply Scheme** button twice, each with a doubled ⚡ icon. -- **No more overlapping text (卡片文字不再重叠)**: the extra button squeezed the badges, date, node types and tags on top of each other. Cards in apply mode now get a taller row with the button at the bottom; applying works as before. - -## v1.57.1 Beta (Version Panel: Update, Switch & Roll Back) — 2026-09-23 - -### 🏷️ Version & Updates Panel (版本号与一键更新 / 版本切换) -- **Version entry in the update guide (更新引导顶部的版本号与更新入口)**: the installed version (e.g. `v1.57.1-beta`) and a **Check for updates / switch version** button now appear at the top of the update guide (opened via the header **!** button), avoiding accidental clicks near the tool dock or crowding the header in docked/scaled layouts. Reading it is local only; nothing goes online. -- **Manual "Check for updates" (手动检查更新)**: the plugin never checks by itself. Clicking **Check for updates / switch version** opens the panel, and only the **Check for updates** button contacts GitHub. Each published release shows its notes, date and a GitHub link; pre-releases are hidden by default and are never offered as the latest version. -- **One-click update to published releases (一键更新到正式发布版本)**: updates only target releases published on GitHub, not in-progress commits, so what you install is a released build. -- **Switch or roll back (切换 / 回退版本)**: any published release can be installed from the list. Rolling back warns that older versions may not read settings or recipes saved by newer ones; nothing is deleted, and it all works again after returning. -- **Back to latest & undo (回到最新版 / 撤销上次切换)**: **Back to latest** returns to the default branch and pulls the newest code; **Undo last switch** returns to exactly where you were before. Versions older than v1.57.1 have no panel, so from those use **Update** for this plugin in ComfyUI Manager to come back. -- **Safe by default (安全保护)**: every switch is refused while plugin files have local modifications (the files are listed), and a local branch with unpublished commits is never overwritten. -- **Restart (重启生效)**: after a switch the panel offers to restart ComfyUI through ComfyUI Manager; without Manager it asks you to restart by hand. - -## v1.57.0 Beta (Toolbox Architecture, Gallery Search & Polymorphic Drag) — 2026-09-23 - -### 🎨 Pure Vector Iconography Overhaul (纯代码矢量图标体系全面升级) -- **Universal Code-Native SVG Icons (全套规范化纯代码矢量 SVG 图标)**: - - Completely phased out legacy emojis and pixel icons in favor of lightweight, mathematically crisp 24x24 code-native vector SVGs (`stroke-width: 2`, rounded joins/caps). - - Redesigned the floating entry trigger orb with the **Anomalous Hypercube Core** (`1b985ba`) and frosted glass lighting effect, maintaining smooth rendering and subtle hover micro-interactions. - - Sidebar and toolbox action buttons upgraded with distinct vector geometry: 3D Asset Cube for Toolbox, Radar Pulse for Scan Wizard, Stethoscope for Model Doctor, Four-Point Spark for Node Assistant, Layered Sheets for Material Library, and Obsidian Gear for Settings. - - Eliminated icon flicker, CSS pseudo-element clipping, and alignment shifts across all display scales. - -### 🔍 Multi-Dimensional Gallery Search (历史出图图库多维检索与标签块) -- **Multi-Field Real-Time Search (提示词/模型/种子/哈希多维检索)**: - - Upgraded the native output gallery header with an intelligent search bar capable of parsing prompts, model file names, exact numeric seeds, and cryptographic SHA-256 model hashes simultaneously (`1476faf`). - - Search terms are dynamically converted into visually distinct, standalone **Search Blocks (搜索标签块)** (`e077689`). - - Each search block clearly displays its matched category and provides an accessible `×` button for one-click removal, enabling flexible compound filtering without retyping. - - Backed by optimized chunk parsing (`api/image_search.py`) that reads only header metadata prior to pixel buffers, ensuring zero-lag gallery navigation. - -### 🚀 Polymorphic Canvas Drag & Drop (全域多态画布拖拽架构) -- **Universal Canvas Drag (全域多态画布拖拽)**: - - Dragging workflow materials onto empty canvas automatically reconstructs and opens the full workflow. - - Dragging prompt-only materials onto empty canvas auto-creates native `CLIPTextEncode` nodes with pure prompt text, bilingual titles, and standard ComfyUI dark theme colors (`#532323` for negative / `#235327` for positive). - - Dragging materials onto existing canvas nodes prioritizes the target node, injecting matching widget values and prompt text via semantic widget sniffing while strictly filtering out model file paths and loader blocks. -- **Floating Trigger Pre-Mount & Anti-FOUC (悬浮入口预挂载防闪烁与安全吸附)**: - - Completely eliminated FOUC flicker on page refresh via pre-mount positioning and smooth opacity reveal (`53c9f1b`). - - Hardened PointerEvents persistence, safe coordinate validation, and boundary clamping to prevent trigger entrapment or collapsing to the viewport origin (`09b06d1`). - -### 🧰 Toolbox Catalog & Dedicated Bottom Dock (实用工具箱与底部固定快捷栏架构) -- **Dedicated Bottom Action Bar (结构化底部快捷栏)**: - - Streamlined the lower-left sidebar into a dedicated 6-button utility bar: - 1. **Toolbox (🧰 实用工具箱)**: Anchored as the primary launcher on the left to access secondary tools. - 2. **Scan Wizard (🔄 扫描向导)**: Direct shortcut to launch model library scanning. - 3. **Model Doctor (🩺 模型医生)**: Direct shortcut to diagnose and auto-heal missing workflow models. - 4. **Node Assistant (🤖 节点助手)**: Direct shortcut for canvas node inspection, model swapping, and parameter injection. - 5. **Material Library (✨ 素材库)**: Direct shortcut to manage curated material bundles and polymorphic canvas drag-and-drop. - 6. **Settings (⚙️ 设置)**: Fixed anchor on the right for interface preferences. -- **Toolbox Modal Catalog (实用工具箱浮窗面板)**: - - Clicking the first icon (**🧰 实用工具箱**) opens a dedicated popup panel housing 5 secondary tools not on the main dock: - 1. **Workflow Transfer Center (导入导出)**: Lossless AMB format workflow share code import and export. - 2. **Prompt Studio (提示词工坊)**: Modular prompt mixer deck for lego-block prompt extraction, reordering, and assembly. - 3. **Prompt Translator (翻译助手)**: Bilingual real-time English/Chinese prompt translation. - 4. **Model Source Hub (模型来源)**: Online model source URL inspector with instant canvas Note node generation. - 5. **Prompt Notes (提示词笔记)**: Lightweight notebook for drafting and organizing reusable prompt snippets (`workflows/anomalous_notebooks`). -- **Enhanced Sidebar Tooltips & Reveal Timing (侧边栏操作提示与动画优化)**: - - 100ms quick hover text replacement on shortcut buttons and 600ms rich singleton floating tooltip bubble (`#anomalous-sidebar-tooltip-bubble`) with bold title and muted explanation, cleanly avoiding CSS pseudo-element clipping issues. - - Clicking or pressing any action button immediately suppresses hover text and floating tooltips. - - Maintained elegant obsidian glass / dark monochromatic aesthetics without harsh or distracting bright colors. - -### Navigation and update guide -- Sidebar icons reveal compact text labels on hover or keyboard focus while keeping their existing button size and actions. Detailed hints explain each tool; scan-state and language changes retain labels. Unclipped tooltips ensure explanations are fully readable. -- Update guide opens only from the persistent top-right exclamation button or Help. Dismissing never removes the entry. Sidebar labels slide upward on hover/focus; duplicate source-panel and obsolete notice styles were removed. See `docs/architecture/update-guide.md`. - -### Model Source Hub (模型来源中控中心) -- Added a unified Model Source Hub under Toolbox for dual-scope (current workflow and full library) model source URL inspection, one-click browser navigation to Civitai/HuggingFace/Liblib/ModelScope, custom source editing, workflow metadata synchronization (`extra.anomalous_model_sources`), and instant canvas `Note` node generation for non-intrusive workflow documentation. - -### Export availability -- Removed Prompt Studio's JSON export action and closed the Material Library file-import entry; copying prompts and local saves remain available. -- Recipe package import/export is disabled in the UI and API, including both upload inspection and import commit. The verified AMB0/AMB1 workflow Import / Export Center is restored in Toolbox as the only active import/export center. Ordinary downloads and image/workflow hash metadata are unchanged. -- Studio source cards automatically follow library additions, edits and deletions, display their origin, and cannot be deleted from the studio. Failed synchronization preserves existing cards; mixer copies remain editable. - -### Material Library -- Fixed grouped material filters, Recipe scope/tag filters, and prompt studio imports using actual detail payloads. Fixed side-studio draft reset/return state and guarded recipe drops against canvas changes. Recipe model status no longer treats saved hash verification as current availability. -- Separated the lower-left Material Library from the Recipes/Prompt Notes workspace. Added canvas drag-and-drop: parameter materials replace matching node values, while the simplified prompt panel inserts text at the beginning or end, with undo. -- Material Library now opens from the lower-left control; Workflow Recipes stays at the upper right. The workflow Import / Export Center is available in Toolbox. -- Added Prompt Combinations: ordered general/specific fragments, enable switches, separate positive/negative content, and saved snapshots. -- Library cards can apply matching parameters to one selected node through the same transaction as Node Assistant, with seed preservation, native-option validation and guarded undo. Prompt combinations support explicit text replacement or append. -- Material details initially expand prompt cards only; other node parameters render on first expansion. Details omit the complete source workflow, which loads separately when opening a full snapshot. Selected-node materials never return their hidden source graph. -- Material capture supports PNG workflows, Recipe parameters and Prompt Notes. Applying reference images to LoadImage remains future work. -- Added search by name, tag, or node type, type/tag filters, and pagination. Material names and tags can be edited in place without changing their workflow. -- Saving the same source image and node selection now prompts before creating another copy. Save labels distinguish full image/workflow snapshots, node parameters, and Prompt Notes. -- Shared parameter inspection between the library and image workbench, improved keyboard card access and clipboard failure feedback, and fixed confirmation dialogs being covered by the workbench. - -### Reliability -- Material browsing reuses signature-validated summaries and reads full workflows only for matching node types. Gallery pagination shares a short-lived directory snapshot; refresh and deletion invalidate it immediately. -- Prompt Notes now use the ComfyUI user directory with recoverable legacy copying, atomic saves, and visible save failures. Material saves stage their files and roll back new assets if persistence fails. -- File identity now uses full-file SHA-256 only. BLAKE3 and tensor/header digests cannot become verified file identities. Older offline metadata is reverified on demand while preserving personal notes. - -### 🩺 Model Doctor -- **Manual size fallback (模型大小手动兜底)**: A unique same-size local model is now presented for explicit confirmation instead of being applied automatically. Background checks never apply it, while hash/size identity conflicts are rejected and shown in the Doctor dashboard. - -### 🧰 Workflow Recipes -- **Scope-aware recipes (局部/整体配方)**: Graphs with open required connections or no runnable output are saved as partial recipes and append to the current canvas. Independently runnable graphs are saved as complete recipes and open in a new workflow canvas. -- **Optional save-time model verification (保存时可选模型核验)**: Recipe saving now performs a lightweight cached-identity check. Recognized model references missing identity are listed in the save dialog, while full SHA-256 calculation remains explicitly opt-in. This is especially useful for hash-only Model Doctor categories such as VAE and text encoders. -- **Model lifecycle consistency (模型生命周期一致性)**: Partial append remaps and merges node-scoped model Hash records, while applying a local model match updates the workflow value, structured model reference, base-model summary, and Hash index together. -- **Recipe-scoped model notes (配方模型备注)**: Each model reference can keep a bounded personal note. Package export asks whether to include these notes, and version comparison reports note and identity changes. -- **Material Library MVP (统一素材库 MVP)**: Main Gallery and Recipe gallery images now expose a non-intrusive two-column parameter detail view and can be saved as one curated bundle containing the original image, exact embedded workflow, model provenance, and reusable node blocks. Core metrics remain concise, while a lazy two-level inspector exposes every serialized node widget value, node properties, mode, and volatile-parameter marker on demand. Every node card now has a direct “Save to Library” action; checked nodes share a colocated multi-save action above the list, while the footer is reserved for the full image/workflow snapshot. One generation-settings action saves the sampler and size nodes together, and prompt cards expose topology-based positive/negative detection, manual role overrides, and direct node saving. Saved Material details preserve those overrides and allow returning to automatic detection. Workflow Recipe parameters and saved Parameter Notebooks can now publish all widget-bearing nodes, one node, or an explicit node selection as image-free Recipe Parameter materials. The ambiguous plain-text share action has been removed in favor of a visible Material Library save entry. Focused node materials feed Node Assistant without exposing a misleading full-workflow action. Saved Material cards stay compact and open a focused in-library inspector with the uncropped reference image on the left and scoped model/node parameters on the right; returning releases the full response and detailed DOM. Detail navigation bounds parsed metadata to a 16-entry LRU, cancels obsolete reads, avoids a second full-PNG browser allocation, removes duplicated widget payloads, and defers local-model preview resolution until the Models tab opens. Main Gallery keeps its existing click, drag, delete, and cover-selection behavior. The Workspace Material Library opens complete snapshots in a new canvas, while Node Assistant filters matching blocks by the selected node type, skips volatile seeds, and hands remapped Hash evidence to Model Doctor. - -### ✨ Material capture and simpler pages -- 图片、配方参数与提示词笔记均可保存到素材库,保存后直接查看。提示词笔记支持整份组合或仅提示词,并可从素材载入为新笔记。 -- 图片保存表单、用途调整、节点技术信息、笔记模型区与配方多选改为按需展开;配方参数页去掉重复数值,统一使用“参数方案”名称。 - -### ✨ Interface & Access -- **Native Configurable Shortcut (原生可配置快捷键)**: Added `Ctrl + Shift + M` as the default shortcut for opening the browser. The plugin's Interface settings show the currently active binding and open ComfyUI's native key recorder for customization. Conflict detection, overwrite confirmation, removal, reserved-key guidance, and modal guards therefore remain centralized without a separate global keyboard listener. - -### 🐛 Bug Fixes -- **Prompt Studio Preview Popover Safe Zone (提示词预览浮窗全域安全区与防消失机制)**: 解决鼠标从词卡划向右侧浮窗按钮过程中浮窗提前消失的问题。引入全局动态指针安全区判定(`isPointInSafeZone`),将卡片、浮窗(向外各扩展 40px)与空间通道全部纳入安全驻留区,光标移动即实时取消关闭计时;并在定时器回调中执行终审坐标校验;结合 100ms 展开防抖与 `[📌 固定]` 钉住功能,确保用户可从容点击复制与添加按钮。 -- **Live Language Switching (语言即时切换)**: Changing the plugin language now immediately re-translates the open native Interface settings—including labels, tooltips, and combo choices—as well as the browser UI, without refreshing ComfyUI. - -## v1.56.1 Beta (Interface Preferences & Scan Reliability) — 2026-08-26 - -### ✨ Interface & Access -- **Native Interface Preferences (原生界面偏好)**: Added one native ComfyUI entry-mode setting with three strictly exclusive choices: floating button, action-bar button beside the run controls, or Extensions menu only. Floating size/style controls now appear only when floating mode is selected. A colocated language preference follows ComfyUI by default or applies a plugin-only Chinese/English override, and the existing in-browser language shortcut updates that same native setting. Entry selection remains authoritative during action-bar redraws, and every entry reuses the same browser instance and open path. - -### 🐛 Reliability & Scan Feedback -- **Interrupted Scan Recovery (异常扫描自动恢复)**: Closing ComfyUI during a folder or global scan no longer leaves the browser permanently locked. Versioned scan ownership records distinguish live workers from stale or legacy markers, clean interrupted state automatically, and allow an immediate retry without reinstalling the extension. -- **Real Scan Progress (真实扫描进度)**: Folder, custom-selection, and global scans now show a non-blocking localized progress panel backed by the scanner's actual folder/model counts and current filename. Reopening the browser reconnects to the running task through the existing status polling contract. - -## v1.56 Beta (Model Doctor Intelligence & Smart Civitai Routing) - -### 🚀 Major Improvements & Features -- **On-Demand Dynamic Hash Resolution (按需动态算哈希与智能认亲)**: Upgraded Model Doctor's fallback resolution engine. When a local candidate model matches the exact byte size of a missing reference but lacks local metadata (e.g., after renaming and deleting `.info` files), the backend dynamically calculates its SHA256 on demand. Upon cryptographic verification, the node is seamlessly resolved and an offline inferred `.info` record is saved locally. -- **Dedicated Hash Inspection Modal (🔑 查看哈希透视详情)**: Added a dedicated `🔑 查看哈希 (View Hash)` button on every model card in the Doctor Dashboard. Clicking it opens a crystal-clear inspection modal comparing the workflow-embedded provenance hash & size with local disk hash & size, featuring one-click clipboard copying. -- **Smart Civitai by-hash Routing & NSFW Domain Dispatch (C 站精准直达与 .red/.com 智能分流)**: Upgraded the "🌐 Civitai" action buttons across Model Doctor and Workflow Import. Instead of crude search bar queries, it now queries the official Civitai `by-hash` API to resolve exact Model IDs and Version IDs, automatically routing NSFW models to `civitai.red` and safe models to `civitai.com`. -- **UNET Default Scan Coverage (UNET 目录扫描补齐)**: Added `models/unet` to the default scanned model types, ensuring UNET / Diffusion Models are consistently indexed during background deep scans. -- **Parameter Notebook Renaming (参数笔记本重命名)**: Users can now rename saved parameter notebooks directly from both the recipe sidebar list and the detail heading via inline `✏️` action buttons and modal prompts, safely updating names with atomic writes without disrupting parameter provenance or canvas matching. -- **Cross-Platform Path Separator Normalization (跨平台斜杠全链路归一化)**: Fully unified Windows `\` and Unix `/` path handling across workflow serialization, hash retrieval, and node diagnostic matching, preventing lookup misses caused by cross-platform workflow exchange. - -### 🐛 Bug Fixes & UX Polish -- Fixed a false-negative rejection issue where size-matching renamed models without local `.info` files were incorrectly rejected as `identity_conflict`. -- Fixed duplicate key icons on the Model Doctor Hash Inspection modal header. -- Fixed a loop duplication issue in the workflow import modal preflight inspection. -- Updated unit test suite with comprehensive coverage for dynamic hash resolution and unet folder discovery. - -## v1.55 Beta (Workflow Recipes Preview) - -> **Beta scope:** Workflow Recipes, Parameter Notebooks, and the recipe-powered Parameter Presets tab in Node Assistant are preview features. The Node Assistant's Actions tab—visual model replacement and LoRA insertion—is not part of this beta. Save the current canvas and back up `workflows/anomalous_recipes` plus `workflows/anomalous_parameters` before updating, importing, restoring, deleting, or applying recipe data. The stable target for these preview features is v1.6. - -### 🚀 Major Features -- **Workflow Recipes (工作流配方)**: Save a complete workflow with a cover, name, tags, notes, model references, prompts, sampler settings, safe node parameters, and a reproducibility fingerprint. Recipes append to the current canvas without replacing it. -- **Recipe Detail Workspace (配方详情)**: Added Overview, Parameter Notebook, Gallery, and Versions tabs. Long values can be expanded or copied, model requirements are shown as separate cards, and the overview avoids exposing local model paths as display names. -- **Parameter Notebooks (参数笔记本)**: Create edited parameter notes, capture a fresh note from a matching live canvas, switch between saved notes with clear feedback, delete individual notes, and apply one note back to a workflow only after the complete recipe skeleton matches. Volatile values such as seeds are intentionally ignored. -- **Node Assistant Recipe Presets (节点助手配方预设)**: The Parameter Presets tab reads same-type node values saved by Workflow Recipes. Select a node such as KSampler and apply sampler, scheduler, steps, CFG, denoise, and other safe values in one click without replacing the rest of the workflow. -- **Model Identity and Local Matching (模型身份与本地匹配)**: Recipe model cards retain hashes, size, model category, available Civitai origin names/links, and previews. Imported recipes can match another author's model references to local files without trusting their folder paths. -- **Recipe Gallery and Parameter Comparison (配方图库与参数对比)**: Find historical outputs with the same node structure, inspect embedded generation data, compare differing parameters, and promote an output image to the recipe cover. -- **Version History (版本历史)**: Recipe updates retain bounded local history. Compare meaningful changes, restore an older version safely, and preserve the current version before restoration. -- **Portable Recipe Packages (便携配方包)**: Import or export recipes with optional preview snapshots and history. Model identity hashes can be removed for privacy. Preview snapshots contain presentation images only and never include model files. -- **Conservative Prompt Roles (保守的正负提示词识别)**: Native supported prompt paths are classified without title guessing. Unknown third-party text nodes remain unclassified until the user labels them as positive, negative, shared, or ignored. - -### ✨ Interface and Interaction -- Renamed the combined Notes and Recipes area to **Creative Workspace / 创作工作台**, with separate Prompt Notes and Workflow Recipes sections. -- Recipe models, LoRAs, prompt text, common parameters, and generic node controls use dedicated responsive layouts with copy and full-content expansion where needed. -- Returning from a model detail page restores the previous recipe position instead of jumping back to the top. -- The main output gallery refreshes when opened, avoiding continuous background polling. -- Beta and data-protection guidance is available in the Recipe list, the Node Assistant Parameter Presets tab, Settings Help, and README in both Chinese and English. - -### 🐛 Reliability Fixes -- Fixed a cross-panel state leak where opening a model from a Recipe Overview could overlap Node Assistant, then leave the next Workflow Recipe visit with an empty list. Returning normally, switching directly to another main panel, reopening Workspace, and closing Workspace mid-return now all restore one coherent surface. -- Fixed blank Workspace/Parameter Notebook surfaces and restored the plugin trigger when frontend module errors prevented registration. -- Fixed parameter application against current ComfyUI widget callbacks and added transactional rollback when a node update fails. -- Fixed positive/negative prompt duplication and legacy role recovery while keeping ambiguous third-party prompt nodes visibly unknown. -- Fixed panel closing and canvas handoff behavior, docked notebook confirmation controls, recipe scroll restoration, long model status text, and narrow parameter layouts. -- Added backend validation and regression coverage for recipe round-tripping, linked inputs, parameter-note deletion, model matching, and prompt-role metadata. - -## v1.5.1 (The Workflow Exchange & Deep Scan Update) -### 🚀 Major Features -- **Lossless Workflow Exchange (无损工作流交互)**: Added dedicated Import/Export buttons to the bottom-left of the plugin UI. This feature allows users to directly extract, share, and import pure workflow data without relying on original PNG images, completely bypassing the metadata-stripping compression used by social platforms (like WeChat or Discord) that causes workflow loss. -- **Force Overwrite Configs (强制覆盖已有配置)**: Added a new "Force Overwrite" toggle in the Scan Wizard. When enabled, the scanner will deliberately ignore existing local `.info` files and force a fresh SHA256 hash calculation and a complete metadata pull from Civitai. This is a lifesaver for repairing incorrectly matched or corrupted model profiles. -- **Model Card Settings (模型卡片设置)**: The Settings Hub now opens one focused panel for video-cover playback (`Always play` or recommended `Play on hover`) and card image quality (`Optimized thumbnail` or `Original cover`). Existing autoplay preferences remain compatible, while image-quality choice is intentionally simple and local to the browser. +- **GPT-SoVITS setup and import (GPT-SoVITS 准备与导入)**: an empty studio is a drop area with **Import character**, which also sits in the toolbar. **GPT-SoVITS settings** at the bottom of the sidebar opens a dialog with where characters are kept, pretrained files and missing Python packages; missing pretrained files only get a small dot on that entry, with "don't remind me", once your characters need them, and the import form notes what the chosen language will download. **Batch import (批量导入)**: the import window takes one or many characters. It first asks what you have (one character, many characters or a package, or files for a character you already have), with three steps at the top and a spotlight tour the first time ("?" replays it). Each character card leads with a checklist that lights up the next step (GPT model, SoVITS model, clips, main voice, name) and offers its button right there; the card says how many steps are left. Drop files or whole folders, or pick a folder on this PC (a GPT-SoVITS package): a folder holding one set of GPT + SoVITS weights and its clips is one character named after the folder (two characters whose weights are both called `model` stay apart; `派蒙/v2` becomes "派蒙 v2"); weights in a package's weight folders pair up by name (latest epoch kept); clips and line files follow their character folder, the weights in their folder or the character their folder or file name points at; an annotation file (`.list`) no character claims goes to every character; two clips with the same name (`开心/01.wav`, `难过/01.wav`) both go in, the second as `难过_01.wav`; anything unclear waits in a card of its own, one line per folder that can be assigned at once, and is uploaded only once it is put into a character. Clips outside 3–10 s are left out with their line files. Python environments and base models (`runtime`, `pretrained_models`, `venv`…) are skipped, and a package's program and training folders (`GPT_SoVITS`, `logs`, `output`, `TEMP`, `tools`) only inside a package: your own folders called `output` or `GPT_SoVITS`, and the folder you chose or dropped, are always read; the window says which folders were skipped or too deep (6 levels) instead of files going missing quietly. A big add opens 20 characters (about 800 files) at a time; **Next batch** opens the rest, and it opens by itself once a batch is imported, so a huge folder does not freeze the window. Every clip has **▶** to listen while importing — uploading, unassigned or picked on this PC — and clicking another clip switches to it for quick comparing. Long clip lists show the first 60, and a very large upload first suggests picking the folder on this PC instead; uploads run three at a time. Each character is a card: one line with its name, a short status and what it holds, unfolding into its model tiles and clips; files move between characters with the picker on each row. **Import N characters** imports each ready one on its own, so one failure leaves the others alone. Status is colour-coded throughout: green ready, amber still missing something, red a failed or refused file, blue uploading; **Import** stays at the bottom. Adding to a character skips files it already has and merges annotation files (Anomalous_TTS interface 5). There is one storage place (default `models/gpt_sovits`); changing it can move the existing characters there with progress. Download pretrained files with progress or use an existing GPT-SoVITS package, all without editing `extra_model_paths.yaml` or restarting. **Import character** builds a character from dropped or picked GPT / SoVITS weights, reference clips and annotation files: the name, SoVITS version, clip lengths and lines are filled in automatically, and everything is copied (originals stay). Drop files on a card, or use **Add files**, to add clips and emotions to an existing character. Lines are found in same-name `.txt` / `.lab` files, annotation files or, as a marked guess, the file name; a text file dropped on a clip's line box fills it. Needs Anomalous_TTS with interface version 11; file actions only work on the computer running ComfyUI. -### 🐛 Bug Fixes -- **Base Model Hash Misattribution**: Fixed a critical bug in the fast scanner where it would aggressively fuzzy-match any 64-character hex string in the metadata. This previously caused LoRAs (trained via Kohya) to inherit their Base Model's hash (`ss_sd_model_hash`), resulting in totally incorrect Civitai mappings. The scanner now strictly adheres to the official `modelspec.hash.sha256/blake3` standard. -- **Lossless Cover Reset**: Reset now restores the persistent `.civitai_bak.*` copy first, falls back to a bare original cover for non-Civitai models, and preserves the active preview with a visible warning when no recoverable source exists. -- **Safe Sidecar Lifecycle**: Deleting a model cleans recognized sidecars, while renaming migrates them—including the Civitai backup—to the new stem. Same-stem model files with different model extensions are never treated as sidecar garbage. -- **Bounded Sidecar I/O**: Repeated extension literals in the API and scanner were replaced with fixed tuples and exact-path checks. Rename, delete, and reset remain constant-bounded and never scan an entire model directory. -- **Reset/Rename Extension Integrity**: Fixed a reused loop variable that could change a model's extension when cover reset and physical rename were requested together. -- **Signature-Aware Metadata Cache**: Unchanged model metadata and embedded safetensors hashes are now served from bounded caches keyed by physical file signatures. Sidecar or model changes invalidate entries automatically, while defensive copies prevent cache pollution. -- **Stable Cover Caching**: Removed per-refresh random cover URLs and switched to nanosecond file-version tokens, allowing the browser to reuse unchanged images and videos while still updating changed covers immediately. -- **Responsive Large Libraries**: Folder/model disk work now runs outside the aiohttp event loop, current-folder discovery uses one `os.scandir()` inventory, stale requests are aborted, and cards render in animation-frame chunks with lazy media activation. -- **Batched Model Doctor Resolution**: Provenance-rich workflows skip a redundant full hash-cache walk, then resolve missing nodes through a type-grouped batch endpoint so each required category is walked once per repair pass. Legacy workflows still refresh the filename cache when needed. The original hash/size/type/ambiguity rules remain unchanged, with automatic fallback to the single-item endpoint. -- **Direct Preview Fast Path**: Visual replacement resolves contained relative model paths directly and performs a full library walk only for unresolved basename fallbacks. -- **Balanced Cover Memory Use**: Static grid covers default to cached 512px WebP derivatives while detail views retain the original. Derived files are stored in ComfyUI's temporary area, capped at 256 MiB, and never alter source covers. Closing the browser releases active media immediately and releases the warm card DOM after 90 seconds of inactivity. +### Fixes +- All-in-one loader recipes and cross-node prompt injection use one verified adapter table; prompt text never crosses between positive and negative. +- The Workflows, Prompt Notes and Material Library workspace no longer covers the header: the Models / Gallery / Workflows tabs stay clickable while it is open, and **Esc** closes it (back to what was open before) unless a dialog is above it or a text field has focus. Closing the Prompt Translator brings back the browser it folded away. + +## v1.57.3 Beta (Hotfix: Offline Scan, Prompt Workshop) — 2026-09-29 + +### 🔌 Offline scan and small fixes (离线扫描与小修复) +- **Offline scan stays offline (离线扫描不再联网)**: "Offline fast read" no longer sends the greyed-out rename and overwrite options, and an offline scan never fetches Civitai model pages or preview images, even for models whose `.info` came from Civitai. Scan requests without options are refused instead of starting an online scan. +- Clicking the drop zone at the end of the Prompt Workshop's assembly track focuses the card search again instead of failing silently. +- Malformed requests to translation, deletion, scanning and image-material routes get a clear 400 instead of a server error. + +## v1.57.2 Beta (Hotfix: Canvas Performance, Saving Workflows) — 2026-09-27 + +### 💾 Saving workflows and Prompt Notes (保存工作流与提示词笔记) +- **Saving a workflow works again (保存工作流失败)**: since v1.57, **Save Current Workflow** failed with "Could not save this Workflow Recipe" for any workflow with a model loader, because splitting the backend into smaller files left the save path calling a function that was no longer imported. The same split also made model cards load full-size cover images instead of thumbnails (slow on big folders), and made editing details of a model that had moved fail. +- **Prompt Notes with UNet models (提示词笔记与 UNet 模型)**: sending a note with a UNet / diffusion model to the canvas built a graph that could not run, because its LoRAs waited for a CLIP the UNet does not have. LoRAs of a UNet now load model-only, and the note says to connect your own text encoder to the two prompt nodes. Models whose metadata names no base model (most models not downloaded from Civitai) were missing from every group; they now appear under **No base model listed**. Listing models no longer blocks ComfyUI while it reads the folders. +- Installed v1.57.2 before 2026-09-27 through the version panel? Pick v1.57.2 there once more to get this build; updating through ComfyUI Manager gets it as usual. + +### ⚡ Smooth canvas dragging again (画布拖动卡顿修复) +- **Dragging and zooming the canvas no longer slows down with this plugin installed (安装插件后拖动画布掉帧)**: one stylesheet rule, which only hid the sidebar tooltip while the Toolbox or Settings popup was open, made the browser re-check the whole page every time any element's inline style changed. ComfyUI moves every text-box widget on every frame while you pan or zoom, so each frame paid for it, even with the browser closed. Measured on a 300-node workflow in ComfyUI: style work while panning drops from about 11× the no-plugin cost back to the same as without the plugin. +- **Large workflows with ComfyUI Prompt Assistant (与提示词小助手同时安装时的严重卡顿)**: the cost grew with the number of page elements, and Prompt Assistant adds a row of buttons to every text box, so together style work took about 19 ms per frame (more than a whole 60 fps frame): single-digit frame rates or freezes on big workflows. It is now back to the level of Prompt Assistant alone. The two plugins never conflicted otherwise. +- The tooltip still stays hidden while the Toolbox or Settings popup is open; the popups already hide it when they open and it does not reappear until they close. + +### 🧩 Material Library with a node selected (选中节点时素材库卡片错位) +- **One apply button per card (每张卡片只有一个应用按钮)**: with a node selected, the Material Library showed every card's **Apply Scheme** button twice, each with a doubled ⚡ icon. +- **No more overlapping text (卡片文字不再重叠)**: the extra button squeezed the badges, date, node types and tags on top of each other. Cards in apply mode now get a taller row with the button at the bottom; applying works as before. + +## v1.57.1 Beta (Version Panel: Update, Switch & Roll Back) — 2026-09-23 + +### 🏷️ Version & Updates Panel (版本号与一键更新 / 版本切换) +- **Version entry in the update guide (更新引导顶部的版本号与更新入口)**: the installed version (e.g. `v1.57.1-beta`) and a **Check for updates / switch version** button now appear at the top of the update guide (opened via the header **!** button), avoiding accidental clicks near the tool dock or crowding the header in docked/scaled layouts. Reading it is local only; nothing goes online. +- **Manual "Check for updates" (手动检查更新)**: the plugin never checks by itself. Clicking **Check for updates / switch version** opens the panel, and only the **Check for updates** button contacts GitHub. Each published release shows its notes, date and a GitHub link; pre-releases are hidden by default and are never offered as the latest version. +- **One-click update to published releases (一键更新到正式发布版本)**: updates only target releases published on GitHub, not in-progress commits, so what you install is a released build. +- **Switch or roll back (切换 / 回退版本)**: any published release can be installed from the list. Rolling back warns that older versions may not read settings or recipes saved by newer ones; nothing is deleted, and it all works again after returning. +- **Back to latest & undo (回到最新版 / 撤销上次切换)**: **Back to latest** returns to the default branch and pulls the newest code; **Undo last switch** returns to exactly where you were before. Versions older than v1.57.1 have no panel, so from those use **Update** for this plugin in ComfyUI Manager to come back. +- **Safe by default (安全保护)**: every switch is refused while plugin files have local modifications (the files are listed), and a local branch with unpublished commits is never overwritten. +- **Restart (重启生效)**: after a switch the panel offers to restart ComfyUI through ComfyUI Manager; without Manager it asks you to restart by hand. + +## v1.57.0 Beta (Toolbox Architecture, Gallery Search & Polymorphic Drag) — 2026-09-23 + +### 🎨 Pure Vector Iconography Overhaul (纯代码矢量图标体系全面升级) +- **Universal Code-Native SVG Icons (全套规范化纯代码矢量 SVG 图标)**: + - Completely phased out legacy emojis and pixel icons in favor of lightweight, mathematically crisp 24x24 code-native vector SVGs (`stroke-width: 2`, rounded joins/caps). + - Redesigned the floating entry trigger orb with the **Anomalous Hypercube Core** (`1b985ba`) and frosted glass lighting effect, maintaining smooth rendering and subtle hover micro-interactions. + - Sidebar and toolbox action buttons upgraded with distinct vector geometry: 3D Asset Cube for Toolbox, Radar Pulse for Scan Wizard, Stethoscope for Model Doctor, Four-Point Spark for Node Assistant, Layered Sheets for Material Library, and Obsidian Gear for Settings. + - Eliminated icon flicker, CSS pseudo-element clipping, and alignment shifts across all display scales. + +### 🔍 Multi-Dimensional Gallery Search (历史出图图库多维检索与标签块) +- **Multi-Field Real-Time Search (提示词/模型/种子/哈希多维检索)**: + - Upgraded the native output gallery header with an intelligent search bar capable of parsing prompts, model file names, exact numeric seeds, and cryptographic SHA-256 model hashes simultaneously (`1476faf`). + - Search terms are dynamically converted into visually distinct, standalone **Search Blocks (搜索标签块)** (`e077689`). + - Each search block clearly displays its matched category and provides an accessible `×` button for one-click removal, enabling flexible compound filtering without retyping. + - Backed by optimized chunk parsing (`api/image_search.py`) that reads only header metadata prior to pixel buffers, ensuring zero-lag gallery navigation. + +### 🚀 Polymorphic Canvas Drag & Drop (全域多态画布拖拽架构) +- **Universal Canvas Drag (全域多态画布拖拽)**: + - Dragging workflow materials onto empty canvas automatically reconstructs and opens the full workflow. + - Dragging prompt-only materials onto empty canvas auto-creates native `CLIPTextEncode` nodes with pure prompt text, bilingual titles, and standard ComfyUI dark theme colors (`#532323` for negative / `#235327` for positive). + - Dragging materials onto existing canvas nodes prioritizes the target node, injecting matching widget values and prompt text via semantic widget sniffing while strictly filtering out model file paths and loader blocks. +- **Floating Trigger Pre-Mount & Anti-FOUC (悬浮入口预挂载防闪烁与安全吸附)**: + - Completely eliminated FOUC flicker on page refresh via pre-mount positioning and smooth opacity reveal (`53c9f1b`). + - Hardened PointerEvents persistence, safe coordinate validation, and boundary clamping to prevent trigger entrapment or collapsing to the viewport origin (`09b06d1`). + +### 🧰 Toolbox Catalog & Dedicated Bottom Dock (实用工具箱与底部固定快捷栏架构) +- **Dedicated Bottom Action Bar (结构化底部快捷栏)**: + - Streamlined the lower-left sidebar into a dedicated 6-button utility bar: + 1. **Toolbox (🧰 实用工具箱)**: Anchored as the primary launcher on the left to access secondary tools. + 2. **Scan Wizard (🔄 扫描向导)**: Direct shortcut to launch model library scanning. + 3. **Model Doctor (🩺 模型医生)**: Direct shortcut to diagnose and auto-heal missing workflow models. + 4. **Node Assistant (🤖 节点助手)**: Direct shortcut for canvas node inspection, model swapping, and parameter injection. + 5. **Material Library (✨ 素材库)**: Direct shortcut to manage curated material bundles and polymorphic canvas drag-and-drop. + 6. **Settings (⚙️ 设置)**: Fixed anchor on the right for interface preferences. +- **Toolbox Modal Catalog (实用工具箱浮窗面板)**: + - Clicking the first icon (**🧰 实用工具箱**) opens a dedicated popup panel housing 5 secondary tools not on the main dock: + 1. **Workflow Transfer Center (导入导出)**: Lossless AMB format workflow share code import and export. + 2. **Prompt Studio (提示词工坊)**: Modular prompt mixer deck for lego-block prompt extraction, reordering, and assembly. + 3. **Prompt Translator (翻译助手)**: Bilingual real-time English/Chinese prompt translation. + 4. **Model Source Hub (模型来源)**: Online model source URL inspector with instant canvas Note node generation. + 5. **Prompt Notes (提示词笔记)**: Lightweight notebook for drafting and organizing reusable prompt snippets (`workflows/anomalous_notebooks`). +- **Enhanced Sidebar Tooltips & Reveal Timing (侧边栏操作提示与动画优化)**: + - 100ms quick hover text replacement on shortcut buttons and 600ms rich singleton floating tooltip bubble (`#anomalous-sidebar-tooltip-bubble`) with bold title and muted explanation, cleanly avoiding CSS pseudo-element clipping issues. + - Clicking or pressing any action button immediately suppresses hover text and floating tooltips. + - Maintained elegant obsidian glass / dark monochromatic aesthetics without harsh or distracting bright colors. + +### Navigation and update guide +- Sidebar icons reveal compact text labels on hover or keyboard focus while keeping their existing button size and actions. Detailed hints explain each tool; scan-state and language changes retain labels. Unclipped tooltips ensure explanations are fully readable. +- Update guide opens only from the persistent top-right exclamation button or Help. Dismissing never removes the entry. Sidebar labels slide upward on hover/focus; duplicate source-panel and obsolete notice styles were removed. See `docs/architecture/update-guide.md`. + +### Model Source Hub (模型来源中控中心) +- Added a unified Model Source Hub under Toolbox for dual-scope (current workflow and full library) model source URL inspection, one-click browser navigation to Civitai/HuggingFace/Liblib/ModelScope, custom source editing, workflow metadata synchronization (`extra.anomalous_model_sources`), and instant canvas `Note` node generation for non-intrusive workflow documentation. + +### Export availability +- Removed Prompt Studio's JSON export action and closed the Material Library file-import entry; copying prompts and local saves remain available. +- Recipe package import/export is disabled in the UI and API, including both upload inspection and import commit. The verified AMB0/AMB1 workflow Import / Export Center is restored in Toolbox as the only active import/export center. Ordinary downloads and image/workflow hash metadata are unchanged. +- Studio source cards automatically follow library additions, edits and deletions, display their origin, and cannot be deleted from the studio. Failed synchronization preserves existing cards; mixer copies remain editable. + +### Material Library +- Fixed grouped material filters, Recipe scope/tag filters, and prompt studio imports using actual detail payloads. Fixed side-studio draft reset/return state and guarded recipe drops against canvas changes. Recipe model status no longer treats saved hash verification as current availability. +- Separated the lower-left Material Library from the Recipes/Prompt Notes workspace. Added canvas drag-and-drop: parameter materials replace matching node values, while the simplified prompt panel inserts text at the beginning or end, with undo. +- Material Library now opens from the lower-left control; Workflow Recipes stays at the upper right. The workflow Import / Export Center is available in Toolbox. +- Added Prompt Combinations: ordered general/specific fragments, enable switches, separate positive/negative content, and saved snapshots. +- Library cards can apply matching parameters to one selected node through the same transaction as Node Assistant, with seed preservation, native-option validation and guarded undo. Prompt combinations support explicit text replacement or append. +- Material details initially expand prompt cards only; other node parameters render on first expansion. Details omit the complete source workflow, which loads separately when opening a full snapshot. Selected-node materials never return their hidden source graph. +- Material capture supports PNG workflows, Recipe parameters and Prompt Notes. Applying reference images to LoadImage remains future work. +- Added search by name, tag, or node type, type/tag filters, and pagination. Material names and tags can be edited in place without changing their workflow. +- Saving the same source image and node selection now prompts before creating another copy. Save labels distinguish full image/workflow snapshots, node parameters, and Prompt Notes. +- Shared parameter inspection between the library and image workbench, improved keyboard card access and clipboard failure feedback, and fixed confirmation dialogs being covered by the workbench. + +### Reliability +- Material browsing reuses signature-validated summaries and reads full workflows only for matching node types. Gallery pagination shares a short-lived directory snapshot; refresh and deletion invalidate it immediately. +- Prompt Notes now use the ComfyUI user directory with recoverable legacy copying, atomic saves, and visible save failures. Material saves stage their files and roll back new assets if persistence fails. +- File identity now uses full-file SHA-256 only. BLAKE3 and tensor/header digests cannot become verified file identities. Older offline metadata is reverified on demand while preserving personal notes. + +### 🩺 Model Doctor +- **Manual size fallback (模型大小手动兜底)**: A unique same-size local model is now presented for explicit confirmation instead of being applied automatically. Background checks never apply it, while hash/size identity conflicts are rejected and shown in the Doctor dashboard. + +### 🧰 Workflow Recipes +- **Scope-aware recipes (局部/整体配方)**: Graphs with open required connections or no runnable output are saved as partial recipes and append to the current canvas. Independently runnable graphs are saved as complete recipes and open in a new workflow canvas. +- **Optional save-time model verification (保存时可选模型核验)**: Recipe saving now performs a lightweight cached-identity check. Recognized model references missing identity are listed in the save dialog, while full SHA-256 calculation remains explicitly opt-in. This is especially useful for hash-only Model Doctor categories such as VAE and text encoders. +- **Model lifecycle consistency (模型生命周期一致性)**: Partial append remaps and merges node-scoped model Hash records, while applying a local model match updates the workflow value, structured model reference, base-model summary, and Hash index together. +- **Recipe-scoped model notes (配方模型备注)**: Each model reference can keep a bounded personal note. Package export asks whether to include these notes, and version comparison reports note and identity changes. +- **Material Library MVP (统一素材库 MVP)**: Main Gallery and Recipe gallery images now expose a non-intrusive two-column parameter detail view and can be saved as one curated bundle containing the original image, exact embedded workflow, model provenance, and reusable node blocks. Core metrics remain concise, while a lazy two-level inspector exposes every serialized node widget value, node properties, mode, and volatile-parameter marker on demand. Every node card now has a direct “Save to Library” action; checked nodes share a colocated multi-save action above the list, while the footer is reserved for the full image/workflow snapshot. One generation-settings action saves the sampler and size nodes together, and prompt cards expose topology-based positive/negative detection, manual role overrides, and direct node saving. Saved Material details preserve those overrides and allow returning to automatic detection. Workflow Recipe parameters and saved Parameter Notebooks can now publish all widget-bearing nodes, one node, or an explicit node selection as image-free Recipe Parameter materials. The ambiguous plain-text share action has been removed in favor of a visible Material Library save entry. Focused node materials feed Node Assistant without exposing a misleading full-workflow action. Saved Material cards stay compact and open a focused in-library inspector with the uncropped reference image on the left and scoped model/node parameters on the right; returning releases the full response and detailed DOM. Detail navigation bounds parsed metadata to a 16-entry LRU, cancels obsolete reads, avoids a second full-PNG browser allocation, removes duplicated widget payloads, and defers local-model preview resolution until the Models tab opens. Main Gallery keeps its existing click, drag, delete, and cover-selection behavior. The Workspace Material Library opens complete snapshots in a new canvas, while Node Assistant filters matching blocks by the selected node type, skips volatile seeds, and hands remapped Hash evidence to Model Doctor. + +### ✨ Material capture and simpler pages +- 图片、配方参数与提示词笔记均可保存到素材库,保存后直接查看。提示词笔记支持整份组合或仅提示词,并可从素材载入为新笔记。 +- 图片保存表单、用途调整、节点技术信息、笔记模型区与配方多选改为按需展开;配方参数页去掉重复数值,统一使用“参数方案”名称。 + +### ✨ Interface & Access +- **Native Configurable Shortcut (原生可配置快捷键)**: Added `Ctrl + Shift + M` as the default shortcut for opening the browser. The plugin's Interface settings show the currently active binding and open ComfyUI's native key recorder for customization. Conflict detection, overwrite confirmation, removal, reserved-key guidance, and modal guards therefore remain centralized without a separate global keyboard listener. + +### 🐛 Bug Fixes +- **Prompt Studio Preview Popover Safe Zone (提示词预览浮窗全域安全区与防消失机制)**: 解决鼠标从词卡划向右侧浮窗按钮过程中浮窗提前消失的问题。引入全局动态指针安全区判定(`isPointInSafeZone`),将卡片、浮窗(向外各扩展 40px)与空间通道全部纳入安全驻留区,光标移动即实时取消关闭计时;并在定时器回调中执行终审坐标校验;结合 100ms 展开防抖与 `[📌 固定]` 钉住功能,确保用户可从容点击复制与添加按钮。 +- **Live Language Switching (语言即时切换)**: Changing the plugin language now immediately re-translates the open native Interface settings—including labels, tooltips, and combo choices—as well as the browser UI, without refreshing ComfyUI. + +## v1.56.1 Beta (Interface Preferences & Scan Reliability) — 2026-08-26 + +### ✨ Interface & Access +- **Native Interface Preferences (原生界面偏好)**: Added one native ComfyUI entry-mode setting with three strictly exclusive choices: floating button, action-bar button beside the run controls, or Extensions menu only. Floating size/style controls now appear only when floating mode is selected. A colocated language preference follows ComfyUI by default or applies a plugin-only Chinese/English override, and the existing in-browser language shortcut updates that same native setting. Entry selection remains authoritative during action-bar redraws, and every entry reuses the same browser instance and open path. + +### 🐛 Reliability & Scan Feedback +- **Interrupted Scan Recovery (异常扫描自动恢复)**: Closing ComfyUI during a folder or global scan no longer leaves the browser permanently locked. Versioned scan ownership records distinguish live workers from stale or legacy markers, clean interrupted state automatically, and allow an immediate retry without reinstalling the extension. +- **Real Scan Progress (真实扫描进度)**: Folder, custom-selection, and global scans now show a non-blocking localized progress panel backed by the scanner's actual folder/model counts and current filename. Reopening the browser reconnects to the running task through the existing status polling contract. + +## v1.56 Beta (Model Doctor Intelligence & Smart Civitai Routing) + +### 🚀 Major Improvements & Features +- **On-Demand Dynamic Hash Resolution (按需动态算哈希与智能认亲)**: Upgraded Model Doctor's fallback resolution engine. When a local candidate model matches the exact byte size of a missing reference but lacks local metadata (e.g., after renaming and deleting `.info` files), the backend dynamically calculates its SHA256 on demand. Upon cryptographic verification, the node is seamlessly resolved and an offline inferred `.info` record is saved locally. +- **Dedicated Hash Inspection Modal (🔑 查看哈希透视详情)**: Added a dedicated `🔑 查看哈希 (View Hash)` button on every model card in the Doctor Dashboard. Clicking it opens a crystal-clear inspection modal comparing the workflow-embedded provenance hash & size with local disk hash & size, featuring one-click clipboard copying. +- **Smart Civitai by-hash Routing & NSFW Domain Dispatch (C 站精准直达与 .red/.com 智能分流)**: Upgraded the "🌐 Civitai" action buttons across Model Doctor and Workflow Import. Instead of crude search bar queries, it now queries the official Civitai `by-hash` API to resolve exact Model IDs and Version IDs, automatically routing NSFW models to `civitai.red` and safe models to `civitai.com`. +- **UNET Default Scan Coverage (UNET 目录扫描补齐)**: Added `models/unet` to the default scanned model types, ensuring UNET / Diffusion Models are consistently indexed during background deep scans. +- **Parameter Notebook Renaming (参数笔记本重命名)**: Users can now rename saved parameter notebooks directly from both the recipe sidebar list and the detail heading via inline `✏️` action buttons and modal prompts, safely updating names with atomic writes without disrupting parameter provenance or canvas matching. +- **Cross-Platform Path Separator Normalization (跨平台斜杠全链路归一化)**: Fully unified Windows `\` and Unix `/` path handling across workflow serialization, hash retrieval, and node diagnostic matching, preventing lookup misses caused by cross-platform workflow exchange. + +### 🐛 Bug Fixes & UX Polish +- Fixed a false-negative rejection issue where size-matching renamed models without local `.info` files were incorrectly rejected as `identity_conflict`. +- Fixed duplicate key icons on the Model Doctor Hash Inspection modal header. +- Fixed a loop duplication issue in the workflow import modal preflight inspection. +- Updated unit test suite with comprehensive coverage for dynamic hash resolution and unet folder discovery. + +## v1.55 Beta (Workflow Recipes Preview) + +> **Beta scope:** Workflow Recipes, Parameter Notebooks, and the recipe-powered Parameter Presets tab in Node Assistant are preview features. The Node Assistant's Actions tab—visual model replacement and LoRA insertion—is not part of this beta. Save the current canvas and back up `workflows/anomalous_recipes` plus `workflows/anomalous_parameters` before updating, importing, restoring, deleting, or applying recipe data. The stable target for these preview features is v1.6. + +### 🚀 Major Features +- **Workflow Recipes (工作流配方)**: Save a complete workflow with a cover, name, tags, notes, model references, prompts, sampler settings, safe node parameters, and a reproducibility fingerprint. Recipes append to the current canvas without replacing it. +- **Recipe Detail Workspace (配方详情)**: Added Overview, Parameter Notebook, Gallery, and Versions tabs. Long values can be expanded or copied, model requirements are shown as separate cards, and the overview avoids exposing local model paths as display names. +- **Parameter Notebooks (参数笔记本)**: Create edited parameter notes, capture a fresh note from a matching live canvas, switch between saved notes with clear feedback, delete individual notes, and apply one note back to a workflow only after the complete recipe skeleton matches. Volatile values such as seeds are intentionally ignored. +- **Node Assistant Recipe Presets (节点助手配方预设)**: The Parameter Presets tab reads same-type node values saved by Workflow Recipes. Select a node such as KSampler and apply sampler, scheduler, steps, CFG, denoise, and other safe values in one click without replacing the rest of the workflow. +- **Model Identity and Local Matching (模型身份与本地匹配)**: Recipe model cards retain hashes, size, model category, available Civitai origin names/links, and previews. Imported recipes can match another author's model references to local files without trusting their folder paths. +- **Recipe Gallery and Parameter Comparison (配方图库与参数对比)**: Find historical outputs with the same node structure, inspect embedded generation data, compare differing parameters, and promote an output image to the recipe cover. +- **Version History (版本历史)**: Recipe updates retain bounded local history. Compare meaningful changes, restore an older version safely, and preserve the current version before restoration. +- **Portable Recipe Packages (便携配方包)**: Import or export recipes with optional preview snapshots and history. Model identity hashes can be removed for privacy. Preview snapshots contain presentation images only and never include model files. +- **Conservative Prompt Roles (保守的正负提示词识别)**: Native supported prompt paths are classified without title guessing. Unknown third-party text nodes remain unclassified until the user labels them as positive, negative, shared, or ignored. + +### ✨ Interface and Interaction +- Renamed the combined Notes and Recipes area to **Creative Workspace / 创作工作台**, with separate Prompt Notes and Workflow Recipes sections. +- Recipe models, LoRAs, prompt text, common parameters, and generic node controls use dedicated responsive layouts with copy and full-content expansion where needed. +- Returning from a model detail page restores the previous recipe position instead of jumping back to the top. +- The main output gallery refreshes when opened, avoiding continuous background polling. +- Beta and data-protection guidance is available in the Recipe list, the Node Assistant Parameter Presets tab, Settings Help, and README in both Chinese and English. + +### 🐛 Reliability Fixes +- Fixed a cross-panel state leak where opening a model from a Recipe Overview could overlap Node Assistant, then leave the next Workflow Recipe visit with an empty list. Returning normally, switching directly to another main panel, reopening Workspace, and closing Workspace mid-return now all restore one coherent surface. +- Fixed blank Workspace/Parameter Notebook surfaces and restored the plugin trigger when frontend module errors prevented registration. +- Fixed parameter application against current ComfyUI widget callbacks and added transactional rollback when a node update fails. +- Fixed positive/negative prompt duplication and legacy role recovery while keeping ambiguous third-party prompt nodes visibly unknown. +- Fixed panel closing and canvas handoff behavior, docked notebook confirmation controls, recipe scroll restoration, long model status text, and narrow parameter layouts. +- Added backend validation and regression coverage for recipe round-tripping, linked inputs, parameter-note deletion, model matching, and prompt-role metadata. + +## v1.5.1 (The Workflow Exchange & Deep Scan Update) +### 🚀 Major Features +- **Lossless Workflow Exchange (无损工作流交互)**: Added dedicated Import/Export buttons to the bottom-left of the plugin UI. This feature allows users to directly extract, share, and import pure workflow data without relying on original PNG images, completely bypassing the metadata-stripping compression used by social platforms (like WeChat or Discord) that causes workflow loss. +- **Force Overwrite Configs (强制覆盖已有配置)**: Added a new "Force Overwrite" toggle in the Scan Wizard. When enabled, the scanner will deliberately ignore existing local `.info` files and force a fresh SHA256 hash calculation and a complete metadata pull from Civitai. This is a lifesaver for repairing incorrectly matched or corrupted model profiles. +- **Model Card Settings (模型卡片设置)**: The Settings Hub now opens one focused panel for video-cover playback (`Always play` or recommended `Play on hover`) and card image quality (`Optimized thumbnail` or `Original cover`). Existing autoplay preferences remain compatible, while image-quality choice is intentionally simple and local to the browser. + +### 🐛 Bug Fixes +- **Base Model Hash Misattribution**: Fixed a critical bug in the fast scanner where it would aggressively fuzzy-match any 64-character hex string in the metadata. This previously caused LoRAs (trained via Kohya) to inherit their Base Model's hash (`ss_sd_model_hash`), resulting in totally incorrect Civitai mappings. The scanner now strictly adheres to the official `modelspec.hash.sha256/blake3` standard. +- **Lossless Cover Reset**: Reset now restores the persistent `.civitai_bak.*` copy first, falls back to a bare original cover for non-Civitai models, and preserves the active preview with a visible warning when no recoverable source exists. +- **Safe Sidecar Lifecycle**: Deleting a model cleans recognized sidecars, while renaming migrates them—including the Civitai backup—to the new stem. Same-stem model files with different model extensions are never treated as sidecar garbage. +- **Bounded Sidecar I/O**: Repeated extension literals in the API and scanner were replaced with fixed tuples and exact-path checks. Rename, delete, and reset remain constant-bounded and never scan an entire model directory. +- **Reset/Rename Extension Integrity**: Fixed a reused loop variable that could change a model's extension when cover reset and physical rename were requested together. +- **Signature-Aware Metadata Cache**: Unchanged model metadata and embedded safetensors hashes are now served from bounded caches keyed by physical file signatures. Sidecar or model changes invalidate entries automatically, while defensive copies prevent cache pollution. +- **Stable Cover Caching**: Removed per-refresh random cover URLs and switched to nanosecond file-version tokens, allowing the browser to reuse unchanged images and videos while still updating changed covers immediately. +- **Responsive Large Libraries**: Folder/model disk work now runs outside the aiohttp event loop, current-folder discovery uses one `os.scandir()` inventory, stale requests are aborted, and cards render in animation-frame chunks with lazy media activation. +- **Batched Model Doctor Resolution**: Provenance-rich workflows skip a redundant full hash-cache walk, then resolve missing nodes through a type-grouped batch endpoint so each required category is walked once per repair pass. Legacy workflows still refresh the filename cache when needed. The original hash/size/type/ambiguity rules remain unchanged, with automatic fallback to the single-item endpoint. +- **Direct Preview Fast Path**: Visual replacement resolves contained relative model paths directly and performs a full library walk only for unresolved basename fallbacks. +- **Balanced Cover Memory Use**: Static grid covers default to cached 512px WebP derivatives while detail views retain the original. Derived files are stored in ComfyUI's temporary area, capped at 256 MiB, and never alter source covers. Closing the browser releases active media immediately and releases the warm card DOM after 90 seconds of inactivity. ## v1.5.0 (The Workflow & UI Evolution Update) ### 🚀 Major Features diff --git a/api/__init__.py b/api/__init__.py index f1eda62..bcad303 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -104,7 +104,7 @@ def setup_routes(app): app.router.add_post('/anomalous/upload_custom_cover', model_media.api_upload_custom_cover) app.router.add_get('/anomalous/model_images', media_routes.api_get_model_images) app.router.add_post('/anomalous/resolve_paths_to_previews', model_catalog.api_resolve_paths_to_previews) - app.router.add_post('/anomalous/scan_missing_models', api_scan_missing_models) + app.router.add_post('/anomalous/scan_missing_models', api_scan_missing_models) app.router.add_get('/anomalous/scan_summary', scan_summary.api_scan_summary) # Audio & Voice Studio Routes diff --git a/api/metadata.py b/api/metadata.py index 207c9bb..2466d09 100644 --- a/api/metadata.py +++ b/api/metadata.py @@ -4,214 +4,214 @@ import json import urllib.parse import subprocess import threading -import asyncio -import copy -from functools import lru_cache -from aiohttp import web +import asyncio +import copy +from functools import lru_cache +from aiohttp import web import folder_paths import struct -try: - from ..model_identity import USER_INFO_SUFFIX, is_unmatched, read_json, sidecar_file_hash, sidecar_info -except ImportError: - from model_identity import USER_INFO_SUFFIX, is_unmatched, read_json, sidecar_file_hash, sidecar_info - -# Fields the model editor sets: .anomalous.json key -> metadata key. -USER_FIELDS = {"custom_name": "custom_name", "custom_notes": "custom_notes", "source_url": "source_url"} - -def _select_info_file(data, file_path): - """Select the Civitai file entry that actually describes ``file_path``. - - A model version may contain a checkpoint, text encoder, and VAE in the same - ``files`` array. Taking the first SHA256 silently assigns the dependency's - hash to every local sidecar copied from that version. Physical byte size is - the strongest cheap discriminator available here and remains valid after a - local rename. Filenames are intentionally excluded: local renames are the - reason provenance recovery exists and cannot be identity evidence. - """ - entries = [] - for file_info in data.get("files", []): - if not isinstance(file_info, dict): - continue - hashes = file_info.get("hashes", {}) - sha256 = hashes.get("SHA256") if isinstance(hashes, dict) else None - if not sha256: - continue - - size_bytes = None - try: - if file_info.get("sizeKB") is not None: - size_bytes = int(round(float(file_info["sizeKB"]) * 1024)) - except (TypeError, ValueError, OverflowError): - pass - entries.append((file_info, str(sha256), size_bytes)) - - if not entries: - return None - - try: - physical_size = os.path.getsize(file_path) - except OSError: - physical_size = None - - if physical_size is not None: - size_matches = [entry for entry in entries if entry[2] == physical_size] - if len(size_matches) == 1: - return size_matches[0][0] - if len(size_matches) > 1: - return None - - # Offline-generated metadata commonly contains one injected hash without - # Civitai size/name data. It is safe only when there is a single candidate. - if len(entries) == 1: - return entries[0][0] - return None - - -def _select_info_hash(data, file_path): - selected = _select_info_file(data, file_path) - if not selected: - return "" - hashes = selected.get("hashes", {}) - return str(hashes.get("SHA256", "")) if isinstance(hashes, dict) else "" - - -def _positive_civitai_id(value): - """Return a real Civitai identifier, excluding offline sentinel values.""" - if isinstance(value, bool): - return None - try: - parsed = int(value) - except (TypeError, ValueError, OverflowError): - return None - return parsed if parsed > 0 else None - -def _read_metadata(file_path): - base_path = os.path.splitext(file_path)[0] - metadata = { - "name": os.path.basename(base_path), - "description": "", - "notes": "", - "trainedWords": [], - "baseModel": "", - "civitai_url": "", - "source_url": "", - "hash": "", - "hash_algorithm": "sha256", - "hash_scope": "file", - "hash_source": "", - "custom_name": "", - "custom_notes": "", - # "civitai" (matched), "local" (only what the file tells: no match, or offline) or "" (not scanned) - "info_source": "", - "user_fields": [], - } - - 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 = _positive_civitai_id(data.get("modelId")) - version_id = _positive_civitai_id(data.get("id")) - civitai_url = "" - if model_id is not None: - # 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 is not None: - civitai_url += f"?modelVersionId={version_id}" - - - selected_file = _select_info_file(data, file_path) - hash_val, hash_source = sidecar_file_hash(data, file_path, selected_file) - if hash_val: - metadata["hash_source"] = hash_source - - if name: metadata["name"] = name - if description: metadata["description"] = description - if notes: metadata["notes"] = notes - if trained_words: metadata["trainedWords"] = trained_words - if base_model: metadata["baseModel"] = base_model - if civitai_url: metadata["civitai_url"] = civitai_url - if model_id is not None: metadata["model_id"] = model_id - if version_id is not None: metadata["version_id"] = version_id - if hash_val: metadata["hash"] = hash_val - if "anomalous_custom_name" in data and data["anomalous_custom_name"]: metadata["custom_name"] = data["anomalous_custom_name"] - if "anomalous_custom_notes" in data and data["anomalous_custom_notes"]: metadata["custom_notes"] = data["anomalous_custom_notes"] - if "anomalous_source_url" in data and data["anomalous_source_url"]: metadata["source_url"] = data["anomalous_source_url"] - except Exception: - pass - - scan = sidecar_info(base_path) - if scan is not None: - metadata["info_source"] = "local" if is_unmatched(scan) else "civitai" - # The user's own layer comes last and wins, also when a field was cleared on purpose (""). - user = read_json(base_path + USER_INFO_SUFFIX) or {} - for key, field in USER_FIELDS.items(): - if isinstance(user.get(key), str): - metadata[field] = user[key] - metadata["user_fields"].append(field) - return metadata - - -def _file_signature(file_path): - try: - stat = os.stat(file_path) - return (stat.st_size, stat.st_mtime_ns, stat.st_ctime_ns) - except OSError: - return None - - -def _metadata_signature(file_path): - base_path = os.path.splitext(file_path)[0] - return ( - _file_signature(file_path), - _file_signature(f"{base_path}.info"), - _file_signature(f"{base_path}.civitai.info"), - _file_signature(base_path + USER_INFO_SUFFIX), - ) - - -@lru_cache(maxsize=4096) -def _get_metadata_cached(file_path, signature): - # ``signature`` is deliberately unused by the parser itself; it is part of - # the cache key and changes whenever the model or either metadata sidecar - # changes. The cache is bounded so removed/renamed models cannot grow it - # without limit. - return _read_metadata(file_path) - - -def get_metadata(file_path): - normalized_path = os.path.realpath(file_path) - signature = _metadata_signature(normalized_path) - # Metadata contains mutable lists. Never expose the cached object directly. - return copy.deepcopy(_get_metadata_cached(normalized_path, signature)) - - -def clear_metadata_cache(): - _get_metadata_cached.cache_clear() - - -def get_metadata_cache_info(): - return _get_metadata_cached.cache_info() +try: + from ..model_identity import USER_INFO_SUFFIX, is_unmatched, read_json, sidecar_file_hash, sidecar_info +except ImportError: + from model_identity import USER_INFO_SUFFIX, is_unmatched, read_json, sidecar_file_hash, sidecar_info + +# Fields the model editor sets: .anomalous.json key -> metadata key. +USER_FIELDS = {"custom_name": "custom_name", "custom_notes": "custom_notes", "source_url": "source_url"} + +def _select_info_file(data, file_path): + """Select the Civitai file entry that actually describes ``file_path``. + + A model version may contain a checkpoint, text encoder, and VAE in the same + ``files`` array. Taking the first SHA256 silently assigns the dependency's + hash to every local sidecar copied from that version. Physical byte size is + the strongest cheap discriminator available here and remains valid after a + local rename. Filenames are intentionally excluded: local renames are the + reason provenance recovery exists and cannot be identity evidence. + """ + entries = [] + for file_info in data.get("files", []): + if not isinstance(file_info, dict): + continue + hashes = file_info.get("hashes", {}) + sha256 = hashes.get("SHA256") if isinstance(hashes, dict) else None + if not sha256: + continue + + size_bytes = None + try: + if file_info.get("sizeKB") is not None: + size_bytes = int(round(float(file_info["sizeKB"]) * 1024)) + except (TypeError, ValueError, OverflowError): + pass + entries.append((file_info, str(sha256), size_bytes)) + + if not entries: + return None + + try: + physical_size = os.path.getsize(file_path) + except OSError: + physical_size = None + + if physical_size is not None: + size_matches = [entry for entry in entries if entry[2] == physical_size] + if len(size_matches) == 1: + return size_matches[0][0] + if len(size_matches) > 1: + return None + + # Offline-generated metadata commonly contains one injected hash without + # Civitai size/name data. It is safe only when there is a single candidate. + if len(entries) == 1: + return entries[0][0] + return None + + +def _select_info_hash(data, file_path): + selected = _select_info_file(data, file_path) + if not selected: + return "" + hashes = selected.get("hashes", {}) + return str(hashes.get("SHA256", "")) if isinstance(hashes, dict) else "" + + +def _positive_civitai_id(value): + """Return a real Civitai identifier, excluding offline sentinel values.""" + if isinstance(value, bool): + return None + try: + parsed = int(value) + except (TypeError, ValueError, OverflowError): + return None + return parsed if parsed > 0 else None + +def _read_metadata(file_path): + base_path = os.path.splitext(file_path)[0] + metadata = { + "name": os.path.basename(base_path), + "description": "", + "notes": "", + "trainedWords": [], + "baseModel": "", + "civitai_url": "", + "source_url": "", + "hash": "", + "hash_algorithm": "sha256", + "hash_scope": "file", + "hash_source": "", + "custom_name": "", + "custom_notes": "", + # "civitai" (matched), "local" (only what the file tells: no match, or offline) or "" (not scanned) + "info_source": "", + "user_fields": [], + } + + 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 = _positive_civitai_id(data.get("modelId")) + version_id = _positive_civitai_id(data.get("id")) + civitai_url = "" + if model_id is not None: + # 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 is not None: + civitai_url += f"?modelVersionId={version_id}" + + + selected_file = _select_info_file(data, file_path) + hash_val, hash_source = sidecar_file_hash(data, file_path, selected_file) + if hash_val: + metadata["hash_source"] = hash_source + + if name: metadata["name"] = name + if description: metadata["description"] = description + if notes: metadata["notes"] = notes + if trained_words: metadata["trainedWords"] = trained_words + if base_model: metadata["baseModel"] = base_model + if civitai_url: metadata["civitai_url"] = civitai_url + if model_id is not None: metadata["model_id"] = model_id + if version_id is not None: metadata["version_id"] = version_id + if hash_val: metadata["hash"] = hash_val + if "anomalous_custom_name" in data and data["anomalous_custom_name"]: metadata["custom_name"] = data["anomalous_custom_name"] + if "anomalous_custom_notes" in data and data["anomalous_custom_notes"]: metadata["custom_notes"] = data["anomalous_custom_notes"] + if "anomalous_source_url" in data and data["anomalous_source_url"]: metadata["source_url"] = data["anomalous_source_url"] + except Exception: + pass + + scan = sidecar_info(base_path) + if scan is not None: + metadata["info_source"] = "local" if is_unmatched(scan) else "civitai" + # The user's own layer comes last and wins, also when a field was cleared on purpose (""). + user = read_json(base_path + USER_INFO_SUFFIX) or {} + for key, field in USER_FIELDS.items(): + if isinstance(user.get(key), str): + metadata[field] = user[key] + metadata["user_fields"].append(field) + return metadata + + +def _file_signature(file_path): + try: + stat = os.stat(file_path) + return (stat.st_size, stat.st_mtime_ns, stat.st_ctime_ns) + except OSError: + return None + + +def _metadata_signature(file_path): + base_path = os.path.splitext(file_path)[0] + return ( + _file_signature(file_path), + _file_signature(f"{base_path}.info"), + _file_signature(f"{base_path}.civitai.info"), + _file_signature(base_path + USER_INFO_SUFFIX), + ) + + +@lru_cache(maxsize=4096) +def _get_metadata_cached(file_path, signature): + # ``signature`` is deliberately unused by the parser itself; it is part of + # the cache key and changes whenever the model or either metadata sidecar + # changes. The cache is bounded so removed/renamed models cannot grow it + # without limit. + return _read_metadata(file_path) + + +def get_metadata(file_path): + normalized_path = os.path.realpath(file_path) + signature = _metadata_signature(normalized_path) + # Metadata contains mutable lists. Never expose the cached object directly. + return copy.deepcopy(_get_metadata_cached(normalized_path, signature)) + + +def clear_metadata_cache(): + _get_metadata_cached.cache_clear() + + +def get_metadata_cache_info(): + return _get_metadata_cached.cache_info() diff --git a/api/notebooks.py b/api/notebooks.py index 14d7578..73185c1 100644 --- a/api/notebooks.py +++ b/api/notebooks.py @@ -1,140 +1,140 @@ -import asyncio -import hashlib -import json -import logging +import asyncio +import hashlib +import json +import logging import os import threading - + from aiohttp import web import folder_paths -from .trash import move_to_trash -from .utils import atomic_write_json, require_filename, resolve_within - - -MAX_NOTEBOOK_BYTES = 2 * 1024 * 1024 -LEGACY_NOTEBOOKS_DIR = os.path.join(os.path.dirname(__file__), "notebooks") -_notebooks_lock = threading.RLock() - - -def _read_notebook(path): - if os.path.getsize(path) > MAX_NOTEBOOK_BYTES: - raise ValueError("Notebook is too large") - with open(path, encoding="utf-8") as source: - data = json.load(source) - if not isinstance(data, dict): - raise ValueError("Invalid notebook") - return data - - -def _migrate_notebooks(directory): - marker = resolve_within(directory, ".legacy_imported.json") - if os.path.exists(marker): - return - skipped = [] - if os.path.isdir(LEGACY_NOTEBOOKS_DIR): - for filename in sorted(os.listdir(LEGACY_NOTEBOOKS_DIR)): - if not filename.endswith(".json") or filename.startswith("."): - continue - try: - source = resolve_within(LEGACY_NOTEBOOKS_DIR, require_filename(filename)) - data = _read_notebook(source) - except (OSError, ValueError): - skipped.append(filename) - logging.warning("Anomalous: could not migrate notebook %s; original preserved", filename) - continue - target = resolve_within(directory, filename) - if os.path.exists(target): - try: - if _read_notebook(target) == data: - continue - except (OSError, ValueError): - pass - digest = hashlib.sha256(json.dumps(data, sort_keys=True).encode()).hexdigest()[:12] - recovered = f"{os.path.splitext(filename)[0][:100]}_legacy_{digest}.json" - target = resolve_within(directory, recovered) - if os.path.exists(target): - if _read_notebook(target) != data: - raise ValueError("Conflicting migrated notebook") - continue - atomic_write_json(target, data, MAX_NOTEBOOK_BYTES) - atomic_write_json(marker, {"version": 1, "skipped": skipped}) - - -def get_notebooks_dir(): - user_dir = folder_paths.get_user_directory() - directory = os.path.join(user_dir, "workflows", "anomalous_notebooks") - os.makedirs(directory, exist_ok=True) - with _notebooks_lock: - _migrate_notebooks(directory) - return directory - - -def _list_notebooks(): - with _notebooks_lock: - directory = get_notebooks_dir() - notebooks = [] - for filename in sorted(os.listdir(directory)): - if not filename.endswith(".json") or filename.startswith("."): - continue - try: - data = _read_notebook(resolve_within(directory, filename)) - notebooks.append({"filename": filename, "name": data.get("name", filename[:-5]), "data": data}) - except (OSError, ValueError): - continue - return notebooks - +from .trash import move_to_trash +from .utils import atomic_write_json, require_filename, resolve_within + + +MAX_NOTEBOOK_BYTES = 2 * 1024 * 1024 +LEGACY_NOTEBOOKS_DIR = os.path.join(os.path.dirname(__file__), "notebooks") +_notebooks_lock = threading.RLock() + + +def _read_notebook(path): + if os.path.getsize(path) > MAX_NOTEBOOK_BYTES: + raise ValueError("Notebook is too large") + with open(path, encoding="utf-8") as source: + data = json.load(source) + if not isinstance(data, dict): + raise ValueError("Invalid notebook") + return data + + +def _migrate_notebooks(directory): + marker = resolve_within(directory, ".legacy_imported.json") + if os.path.exists(marker): + return + skipped = [] + if os.path.isdir(LEGACY_NOTEBOOKS_DIR): + for filename in sorted(os.listdir(LEGACY_NOTEBOOKS_DIR)): + if not filename.endswith(".json") or filename.startswith("."): + continue + try: + source = resolve_within(LEGACY_NOTEBOOKS_DIR, require_filename(filename)) + data = _read_notebook(source) + except (OSError, ValueError): + skipped.append(filename) + logging.warning("Anomalous: could not migrate notebook %s; original preserved", filename) + continue + target = resolve_within(directory, filename) + if os.path.exists(target): + try: + if _read_notebook(target) == data: + continue + except (OSError, ValueError): + pass + digest = hashlib.sha256(json.dumps(data, sort_keys=True).encode()).hexdigest()[:12] + recovered = f"{os.path.splitext(filename)[0][:100]}_legacy_{digest}.json" + target = resolve_within(directory, recovered) + if os.path.exists(target): + if _read_notebook(target) != data: + raise ValueError("Conflicting migrated notebook") + continue + atomic_write_json(target, data, MAX_NOTEBOOK_BYTES) + atomic_write_json(marker, {"version": 1, "skipped": skipped}) + + +def get_notebooks_dir(): + user_dir = folder_paths.get_user_directory() + directory = os.path.join(user_dir, "workflows", "anomalous_notebooks") + os.makedirs(directory, exist_ok=True) + with _notebooks_lock: + _migrate_notebooks(directory) + return directory + + +def _list_notebooks(): + with _notebooks_lock: + directory = get_notebooks_dir() + notebooks = [] + for filename in sorted(os.listdir(directory)): + if not filename.endswith(".json") or filename.startswith("."): + continue + try: + data = _read_notebook(resolve_within(directory, filename)) + notebooks.append({"filename": filename, "name": data.get("name", filename[:-5]), "data": data}) + except (OSError, ValueError): + continue + return notebooks + async def api_get_notebooks(request): - try: - notebooks = await asyncio.to_thread(_list_notebooks) - return web.json_response({"notebooks": notebooks}) - except (OSError, ValueError): - return web.json_response({"status": "error", "message": "Could not load notebooks"}, status=500) - - -def _save_notebook(filename, data): - with _notebooks_lock: - atomic_write_json(resolve_within(get_notebooks_dir(), filename), data, MAX_NOTEBOOK_BYTES) - + try: + notebooks = await asyncio.to_thread(_list_notebooks) + return web.json_response({"notebooks": notebooks}) + except (OSError, ValueError): + return web.json_response({"status": "error", "message": "Could not load notebooks"}, status=500) + + +def _save_notebook(filename, data): + with _notebooks_lock: + atomic_write_json(resolve_within(get_notebooks_dir(), filename), data, MAX_NOTEBOOK_BYTES) + async def api_save_notebook(request): try: - payload = await request.json() - filename = require_filename(payload.get("filename", "")) - if filename.startswith("."): - raise ValueError("Invalid notebook filename") - if not filename.endswith(".json"): - filename += ".json" - data = payload.get("data") - if not isinstance(data, dict): - raise ValueError("Invalid notebook") - await asyncio.to_thread(_save_notebook, filename, data) + payload = await request.json() + filename = require_filename(payload.get("filename", "")) + if filename.startswith("."): + raise ValueError("Invalid notebook filename") + if not filename.endswith(".json"): + filename += ".json" + data = payload.get("data") + if not isinstance(data, dict): + raise ValueError("Invalid notebook") + await asyncio.to_thread(_save_notebook, filename, data) return web.json_response({"status": "success"}) - except (AttributeError, TypeError, ValueError): - return web.json_response({"status": "error", "message": "Invalid notebook"}, status=400) - except OSError: - return web.json_response({"status": "error", "message": "Could not save notebook"}, status=500) - - -def _delete_notebook(filename): - with _notebooks_lock: - path = resolve_within(get_notebooks_dir(), filename) - if not os.path.isfile(path): - raise FileNotFoundError(path) - move_to_trash(path) - + except (AttributeError, TypeError, ValueError): + return web.json_response({"status": "error", "message": "Invalid notebook"}, status=400) + except OSError: + return web.json_response({"status": "error", "message": "Could not save notebook"}, status=500) + + +def _delete_notebook(filename): + with _notebooks_lock: + path = resolve_within(get_notebooks_dir(), filename) + if not os.path.isfile(path): + raise FileNotFoundError(path) + move_to_trash(path) + async def api_delete_notebook(request): try: - payload = await request.json() - filename = require_filename(payload.get("filename", "")) - if not filename.endswith(".json") or filename.startswith("."): - raise ValueError("Invalid notebook filename") - await asyncio.to_thread(_delete_notebook, filename) - return web.json_response({"status": "success"}) - except (AttributeError, TypeError, ValueError): - return web.json_response({"status": "error", "message": "Invalid notebook"}, status=400) - except FileNotFoundError: - return web.json_response({"status": "error", "message": "Notebook not found"}, status=404) - except OSError: - return web.json_response({"status": "error", "message": "Could not delete notebook"}, status=500) + payload = await request.json() + filename = require_filename(payload.get("filename", "")) + if not filename.endswith(".json") or filename.startswith("."): + raise ValueError("Invalid notebook filename") + await asyncio.to_thread(_delete_notebook, filename) + return web.json_response({"status": "success"}) + except (AttributeError, TypeError, ValueError): + return web.json_response({"status": "error", "message": "Invalid notebook"}, status=400) + except FileNotFoundError: + return web.json_response({"status": "error", "message": "Notebook not found"}, status=404) + except OSError: + return web.json_response({"status": "error", "message": "Could not delete notebook"}, status=500) diff --git a/api/scanner.py b/api/scanner.py index d35f180..9a19356 100644 --- a/api/scanner.py +++ b/api/scanner.py @@ -1,247 +1,247 @@ -from .metadata import get_metadata +from .metadata import get_metadata import os import sys import json import urllib.parse -import subprocess -import threading -import asyncio -import ctypes -from ctypes import wintypes -import time -import uuid -from aiohttp import web -import folder_paths -import struct -from .folder_types import get_active_folder_types, get_active_scan_paths -from .path_utils import resolve_folder_subdir -try: - from ..model_policies import is_physical_rename_protected -except ImportError: - from model_policies import is_physical_rename_protected - - -SCAN_SESSION_ID = uuid.uuid4().hex -SCAN_MARKER_LOCK = threading.Lock() -ACTIVE_SCAN_MARKERS = set() -SCAN_RUNTIME_STATE = {} - - -# Request field -> scraper switch, with the field's default. Both scan routes read these. -_SCRAPER_FLAGS = ( - ("offline_only", "--offline-only", False), - ("virtual_rename", "--virtual-rename", False), - ("force_overwrite", "--force-overwrite", False), - ("retry_unmatched", "--retry-unmatched", False), - ("skip_media", "--skip-media", False), -) - - -def _scraper_flags(data, skip_rename_default, physical_allowed): - """The scraper's command-line switches for one scan request.""" - flags = [flag for field, flag, default in _SCRAPER_FLAGS if data.get(field, default)] - if data.get("skip_rename", skip_rename_default): - flags.append("--skip-rename") - if data.get("physical_rename", False) and physical_allowed: - flags.append("--physical-rename") - if not data.get("use_local_metadata", True): - flags.append("--skip-local-metadata") - return flags - - -def _clear_folder_caches(): - """ComfyUI lists model folders from caches; a scan may have renamed files.""" - for cache in (getattr(folder_paths, "filename_list_cache", None), getattr(folder_paths, "cache_helper", None)): - try: - cache.clear() - except Exception: - pass - - -def _read_json_file(path): - try: - with open(path, 'r', encoding='utf-8') as f: - value = json.load(f) - return value if isinstance(value, dict) else None - except (OSError, ValueError, TypeError): - return None - - -def _remove_file(path): - try: - os.remove(path) - except FileNotFoundError: - return True - except OSError: - return False - return True - - -def _pid_is_running(pid): - try: - pid = int(pid) - except (TypeError, ValueError): - return False - if pid <= 0: - return False - if pid == os.getpid(): - return True - if os.name == 'nt': - kernel32 = ctypes.WinDLL('kernel32', use_last_error=True) - open_process = kernel32.OpenProcess - open_process.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD) - open_process.restype = wintypes.HANDLE - get_exit_code = kernel32.GetExitCodeProcess - get_exit_code.argtypes = (wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)) - get_exit_code.restype = wintypes.BOOL - close_handle = kernel32.CloseHandle - close_handle.argtypes = (wintypes.HANDLE,) - close_handle.restype = wintypes.BOOL - process = open_process(0x1000, False, pid) - if not process: - return False - try: - exit_code = wintypes.DWORD() - return bool(get_exit_code(process, ctypes.byref(exit_code))) and exit_code.value == 259 - finally: - close_handle(process) - try: - os.kill(pid, 0) - return True - except PermissionError: - return True - except OSError: - return False - - -def _write_marker(marker_file, marker): - temp_file = f"{marker_file}.{os.getpid()}.{threading.get_ident()}.tmp" - with open(temp_file, 'w', encoding='utf-8') as f: - json.dump(marker, f) - os.replace(temp_file, marker_file) - - -def _marker_owner_is_running(marker): - if not marker: - return False - if marker.get("session_id") == SCAN_SESSION_ID: - return marker.get("marker_file") in ACTIVE_SCAN_MARKERS - worker_pid = marker.get("worker_pid") - if worker_pid: - return _pid_is_running(worker_pid) - return _pid_is_running(marker.get("owner_pid")) - - -def _cleanup_scan_artifacts(marker_file, artifact_files=()): - _remove_file(marker_file) - for path in artifact_files: - _remove_file(path) - - -def _claim_scan_marker(marker_file, kind, artifact_files=()): - recovered = False - with SCAN_MARKER_LOCK: - if marker_file in ACTIVE_SCAN_MARKERS: - return False, False - if os.path.exists(marker_file): - marker = _read_json_file(marker_file) - if _marker_owner_is_running(marker): - return False, False - _cleanup_scan_artifacts(marker_file, artifact_files) - recovered = True - - marker = { - "version": 2, - "job_id": uuid.uuid4().hex, - "kind": kind, - "session_id": SCAN_SESSION_ID, - "owner_pid": os.getpid(), - "worker_pid": 0, - "started_at": time.time(), - "marker_file": marker_file, - } - try: - with open(marker_file, 'x', encoding='utf-8') as f: - json.dump(marker, f) - except FileExistsError: - return False, recovered - ACTIVE_SCAN_MARKERS.add(marker_file) - SCAN_RUNTIME_STATE[marker_file] = { - "phase": "preparing", - "error": "", - "recovered": recovered, - } - return True, recovered - - -def _update_scan_marker(marker_file, **values): - with SCAN_MARKER_LOCK: - if marker_file not in ACTIVE_SCAN_MARKERS: - return - marker = _read_json_file(marker_file) or {} - marker.update(values) - marker["marker_file"] = marker_file - _write_marker(marker_file, marker) - - -def _update_scan_state(marker_file, **values): - with SCAN_MARKER_LOCK: - if marker_file in ACTIVE_SCAN_MARKERS: - SCAN_RUNTIME_STATE.setdefault(marker_file, {}).update(values) - - -def _release_scan_marker(marker_file, artifact_files=()): - with SCAN_MARKER_LOCK: - ACTIVE_SCAN_MARKERS.discard(marker_file) - SCAN_RUNTIME_STATE.pop(marker_file, None) - _cleanup_scan_artifacts(marker_file, artifact_files) - - -def _scan_marker_status(marker_file, artifact_files=()): - with SCAN_MARKER_LOCK: - if marker_file in ACTIVE_SCAN_MARKERS: - return True, False, _read_json_file(marker_file) or {} - if not os.path.exists(marker_file): - return False, False, {} - marker = _read_json_file(marker_file) - if _marker_owner_is_running(marker): - return True, False, marker or {} - _cleanup_scan_artifacts(marker_file, artifact_files) - return False, True, marker or {} - - -def _scan_status_payload(marker_file, progress_file, artifact_files=()): - scanning, interrupted, marker = _scan_marker_status(marker_file, artifact_files) - data = { - "scanning": scanning, - "interrupted": interrupted, - "job_id": marker.get("job_id", ""), - "phase": "idle", - "total": 0, - "current": 0, - "filename": "", - } - if scanning: - with SCAN_MARKER_LOCK: - data.update(SCAN_RUNTIME_STATE.get(marker_file, {})) - progress = _read_json_file(progress_file) - if progress: - data.update(progress) - return data - - -def _protected_type_for_path(folder_path): - """Resolve protected registered aliases for a global physical-folder scan.""" - target = os.path.realpath(folder_path) - for folder_type in folder_paths.folder_names_and_paths.keys(): - if not is_physical_rename_protected(folder_type=folder_type): - continue - try: - if any(os.path.realpath(path) == target for path in folder_paths.get_folder_paths(folder_type)): - return folder_type - except Exception: - continue - return "" +import subprocess +import threading +import asyncio +import ctypes +from ctypes import wintypes +import time +import uuid +from aiohttp import web +import folder_paths +import struct +from .folder_types import get_active_folder_types, get_active_scan_paths +from .path_utils import resolve_folder_subdir +try: + from ..model_policies import is_physical_rename_protected +except ImportError: + from model_policies import is_physical_rename_protected + + +SCAN_SESSION_ID = uuid.uuid4().hex +SCAN_MARKER_LOCK = threading.Lock() +ACTIVE_SCAN_MARKERS = set() +SCAN_RUNTIME_STATE = {} + + +# Request field -> scraper switch, with the field's default. Both scan routes read these. +_SCRAPER_FLAGS = ( + ("offline_only", "--offline-only", False), + ("virtual_rename", "--virtual-rename", False), + ("force_overwrite", "--force-overwrite", False), + ("retry_unmatched", "--retry-unmatched", False), + ("skip_media", "--skip-media", False), +) + + +def _scraper_flags(data, skip_rename_default, physical_allowed): + """The scraper's command-line switches for one scan request.""" + flags = [flag for field, flag, default in _SCRAPER_FLAGS if data.get(field, default)] + if data.get("skip_rename", skip_rename_default): + flags.append("--skip-rename") + if data.get("physical_rename", False) and physical_allowed: + flags.append("--physical-rename") + if not data.get("use_local_metadata", True): + flags.append("--skip-local-metadata") + return flags + + +def _clear_folder_caches(): + """ComfyUI lists model folders from caches; a scan may have renamed files.""" + for cache in (getattr(folder_paths, "filename_list_cache", None), getattr(folder_paths, "cache_helper", None)): + try: + cache.clear() + except Exception: + pass + + +def _read_json_file(path): + try: + with open(path, 'r', encoding='utf-8') as f: + value = json.load(f) + return value if isinstance(value, dict) else None + except (OSError, ValueError, TypeError): + return None + + +def _remove_file(path): + try: + os.remove(path) + except FileNotFoundError: + return True + except OSError: + return False + return True + + +def _pid_is_running(pid): + try: + pid = int(pid) + except (TypeError, ValueError): + return False + if pid <= 0: + return False + if pid == os.getpid(): + return True + if os.name == 'nt': + kernel32 = ctypes.WinDLL('kernel32', use_last_error=True) + open_process = kernel32.OpenProcess + open_process.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD) + open_process.restype = wintypes.HANDLE + get_exit_code = kernel32.GetExitCodeProcess + get_exit_code.argtypes = (wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)) + get_exit_code.restype = wintypes.BOOL + close_handle = kernel32.CloseHandle + close_handle.argtypes = (wintypes.HANDLE,) + close_handle.restype = wintypes.BOOL + process = open_process(0x1000, False, pid) + if not process: + return False + try: + exit_code = wintypes.DWORD() + return bool(get_exit_code(process, ctypes.byref(exit_code))) and exit_code.value == 259 + finally: + close_handle(process) + try: + os.kill(pid, 0) + return True + except PermissionError: + return True + except OSError: + return False + + +def _write_marker(marker_file, marker): + temp_file = f"{marker_file}.{os.getpid()}.{threading.get_ident()}.tmp" + with open(temp_file, 'w', encoding='utf-8') as f: + json.dump(marker, f) + os.replace(temp_file, marker_file) + + +def _marker_owner_is_running(marker): + if not marker: + return False + if marker.get("session_id") == SCAN_SESSION_ID: + return marker.get("marker_file") in ACTIVE_SCAN_MARKERS + worker_pid = marker.get("worker_pid") + if worker_pid: + return _pid_is_running(worker_pid) + return _pid_is_running(marker.get("owner_pid")) + + +def _cleanup_scan_artifacts(marker_file, artifact_files=()): + _remove_file(marker_file) + for path in artifact_files: + _remove_file(path) + + +def _claim_scan_marker(marker_file, kind, artifact_files=()): + recovered = False + with SCAN_MARKER_LOCK: + if marker_file in ACTIVE_SCAN_MARKERS: + return False, False + if os.path.exists(marker_file): + marker = _read_json_file(marker_file) + if _marker_owner_is_running(marker): + return False, False + _cleanup_scan_artifacts(marker_file, artifact_files) + recovered = True + + marker = { + "version": 2, + "job_id": uuid.uuid4().hex, + "kind": kind, + "session_id": SCAN_SESSION_ID, + "owner_pid": os.getpid(), + "worker_pid": 0, + "started_at": time.time(), + "marker_file": marker_file, + } + try: + with open(marker_file, 'x', encoding='utf-8') as f: + json.dump(marker, f) + except FileExistsError: + return False, recovered + ACTIVE_SCAN_MARKERS.add(marker_file) + SCAN_RUNTIME_STATE[marker_file] = { + "phase": "preparing", + "error": "", + "recovered": recovered, + } + return True, recovered + + +def _update_scan_marker(marker_file, **values): + with SCAN_MARKER_LOCK: + if marker_file not in ACTIVE_SCAN_MARKERS: + return + marker = _read_json_file(marker_file) or {} + marker.update(values) + marker["marker_file"] = marker_file + _write_marker(marker_file, marker) + + +def _update_scan_state(marker_file, **values): + with SCAN_MARKER_LOCK: + if marker_file in ACTIVE_SCAN_MARKERS: + SCAN_RUNTIME_STATE.setdefault(marker_file, {}).update(values) + + +def _release_scan_marker(marker_file, artifact_files=()): + with SCAN_MARKER_LOCK: + ACTIVE_SCAN_MARKERS.discard(marker_file) + SCAN_RUNTIME_STATE.pop(marker_file, None) + _cleanup_scan_artifacts(marker_file, artifact_files) + + +def _scan_marker_status(marker_file, artifact_files=()): + with SCAN_MARKER_LOCK: + if marker_file in ACTIVE_SCAN_MARKERS: + return True, False, _read_json_file(marker_file) or {} + if not os.path.exists(marker_file): + return False, False, {} + marker = _read_json_file(marker_file) + if _marker_owner_is_running(marker): + return True, False, marker or {} + _cleanup_scan_artifacts(marker_file, artifact_files) + return False, True, marker or {} + + +def _scan_status_payload(marker_file, progress_file, artifact_files=()): + scanning, interrupted, marker = _scan_marker_status(marker_file, artifact_files) + data = { + "scanning": scanning, + "interrupted": interrupted, + "job_id": marker.get("job_id", ""), + "phase": "idle", + "total": 0, + "current": 0, + "filename": "", + } + if scanning: + with SCAN_MARKER_LOCK: + data.update(SCAN_RUNTIME_STATE.get(marker_file, {})) + progress = _read_json_file(progress_file) + if progress: + data.update(progress) + return data + + +def _protected_type_for_path(folder_path): + """Resolve protected registered aliases for a global physical-folder scan.""" + target = os.path.realpath(folder_path) + for folder_type in folder_paths.folder_names_and_paths.keys(): + if not is_physical_rename_protected(folder_type=folder_type): + continue + try: + if any(os.path.realpath(path) == target for path in folder_paths.get_folder_paths(folder_type)): + return folder_type + except Exception: + continue + return "" async def api_scan_status(request): folder_type = request.query.get('type', 'checkpoints') @@ -254,20 +254,20 @@ async def api_scan_status(request): paths = folder_paths.get_folder_paths(folder_type) except Exception: return web.json_response({"scanning": False}) - if not paths or path_idx < 0 or path_idx >= len(paths): - return web.json_response({"scanning": False}) - try: - base_dir, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder) - except ValueError: - return web.json_response({"scanning": False}) + if not paths or path_idx < 0 or path_idx >= len(paths): + return web.json_response({"scanning": False}) + try: + base_dir, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder) + except ValueError: + return web.json_response({"scanning": False}) - marker_file = os.path.join(target_dir, '.scan_in_progress') - progress_file = os.path.join(target_dir, '.scan_progress.json') - targets_file = os.path.join(target_dir, '.scan_targets.json') - result_file = os.path.join(target_dir, '.scan_result.json') - data = _scan_status_payload(marker_file, progress_file, (progress_file, targets_file)) - - if not data["scanning"] and os.path.exists(result_file): + marker_file = os.path.join(target_dir, '.scan_in_progress') + progress_file = os.path.join(target_dir, '.scan_progress.json') + targets_file = os.path.join(target_dir, '.scan_targets.json') + result_file = os.path.join(target_dir, '.scan_result.json') + data = _scan_status_payload(marker_file, progress_file, (progress_file, targets_file)) + + if not data["scanning"] and os.path.exists(result_file): try: with open(result_file, 'r', encoding='utf-8') as f: data["result"] = __import__('json').load(f) @@ -286,10 +286,10 @@ async def api_scan_folder(request): except: path_idx = 0 - try: - base_dir, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder) - except (ValueError, KeyError): - return web.json_response({"status": "error", "message": "Invalid folder type"}) + try: + base_dir, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder) + except (ValueError, KeyError): + return web.json_response({"status": "error", "message": "Invalid folder type"}) if not os.path.exists(target_dir): return web.json_response({"status": "error", "message": "Directory does not exist"}) @@ -320,55 +320,55 @@ async def api_scan_folder(request): target_files_str = request.query.get('target_files', '') target_files_list = [f.strip() for f in target_files_str.split(',')] if target_files_str else [] - claimed = False - try: - marker_file = os.path.join(target_dir, '.scan_in_progress') - progress_file = os.path.join(target_dir, '.scan_progress.json') - targets_file = os.path.join(target_dir, '.scan_targets.json') - claimed, recovered = _claim_scan_marker( - marker_file, - "folder", - (progress_file, targets_file), - ) - if not claimed: - return web.json_response({"status": "error", "message": "Scan already in progress"}, status=409) - - if target_files_list: - with open(targets_file, 'w', encoding='utf-8') as f: - __import__('json').dump(target_files_list, f) - - def run_bg(): - try: - cmd = [sys.executable, scraper_path, target_dir, "--folder-type", folder_type, *flags] - cmd.extend(["--progress-file", progress_file]) - process = subprocess.Popen(cmd, cwd=plugin_dir) - _update_scan_marker(marker_file, worker_pid=process.pid) - return_code = process.wait() - if return_code != 0: - result_file = os.path.join(target_dir, '.scan_result.json') - with open(result_file, 'w', encoding='utf-8') as f: - json.dump({"success": 0, "fail": 1, "error": f"Scanner exited with code {return_code}"}, f) - finally: - _clear_folder_caches() - _release_scan_marker(marker_file, (progress_file, targets_file)) + claimed = False + try: + marker_file = os.path.join(target_dir, '.scan_in_progress') + progress_file = os.path.join(target_dir, '.scan_progress.json') + targets_file = os.path.join(target_dir, '.scan_targets.json') + claimed, recovered = _claim_scan_marker( + marker_file, + "folder", + (progress_file, targets_file), + ) + if not claimed: + return web.json_response({"status": "error", "message": "Scan already in progress"}, status=409) + + if target_files_list: + with open(targets_file, 'w', encoding='utf-8') as f: + __import__('json').dump(target_files_list, f) + + def run_bg(): + try: + cmd = [sys.executable, scraper_path, target_dir, "--folder-type", folder_type, *flags] + cmd.extend(["--progress-file", progress_file]) + process = subprocess.Popen(cmd, cwd=plugin_dir) + _update_scan_marker(marker_file, worker_pid=process.pid) + return_code = process.wait() + if return_code != 0: + result_file = os.path.join(target_dir, '.scan_result.json') + with open(result_file, 'w', encoding='utf-8') as f: + json.dump({"success": 0, "fail": 1, "error": f"Scanner exited with code {return_code}"}, f) + finally: + _clear_folder_caches() + _release_scan_marker(marker_file, (progress_file, targets_file)) threading.Thread(target=run_bg, daemon=True).start() - return web.json_response({ - "status": "ok", - "message": "Scan started in background. Check console for details.", - "recovered": recovered, - }) - except Exception as e: - if claimed: - _release_scan_marker(marker_file, (progress_file, targets_file)) - return web.json_response({"status": "error", "message": str(e)}) + return web.json_response({ + "status": "ok", + "message": "Scan started in background. Check console for details.", + "recovered": recovered, + }) + except Exception as e: + if claimed: + _release_scan_marker(marker_file, (progress_file, targets_file)) + 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') - progress_file = os.path.join(plugin_dir, '.global_scan_progress.json') + scraper_path = os.path.join(plugin_dir, "scraper.py") + marker_file = os.path.join(plugin_dir, '.global_scan_in_progress') + progress_file = os.path.join(plugin_dir, '.global_scan_progress.json') try: data = await request.json() @@ -377,129 +377,129 @@ async def api_scan_all(request): if not isinstance(data, dict): return web.json_response({"status": "error", "message": "Invalid request body"}, status=400) - claimed = False - try: - claimed, recovered = _claim_scan_marker(marker_file, "global", (progress_file,)) - if not claimed: - return web.json_response({"status": "error", "message": "Global scan already in progress"}, status=409) - - def run_global_bg(): - try: - paths_to_scan = get_active_scan_paths() - paths_to_scan = [path for path in paths_to_scan if os.path.exists(path)] - _update_scan_state( - marker_file, - phase="preparing", - folder_total=len(paths_to_scan), - folder_current=0, - folder="", - ) - for folder_index, base_dir in enumerate(paths_to_scan, 1): - if not os.path.exists(base_dir): continue - try: - print(f"[Anomalous Browser] Global scan processing: {base_dir}") - _remove_file(progress_file) - _update_scan_state( - marker_file, - phase="enumerating", - folder_total=len(paths_to_scan), - folder_current=folder_index, - folder=os.path.basename(os.path.normpath(base_dir)) or base_dir, - error="", - ) - cmd = [sys.executable, scraper_path, base_dir] - protected_type = _protected_type_for_path(base_dir) - if protected_type: - cmd.extend(["--folder-type", protected_type]) - cmd.extend(_scraper_flags(data, True, not is_physical_rename_protected( - folder_type=protected_type, - folder_path=base_dir, - ))) - cmd.extend(["--progress-file", progress_file]) - process = subprocess.Popen(cmd, cwd=plugin_dir) - _update_scan_marker(marker_file, worker_pid=process.pid) - return_code = process.wait() - _update_scan_marker(marker_file, worker_pid=0) - if return_code != 0: - message = f"Scanner exited with code {return_code}: {base_dir}" - _update_scan_state(marker_file, error=message) - print(f"[Anomalous Browser] {message}") - except Exception as e: - _update_scan_state(marker_file, error=str(e)) - print(f"[Anomalous Browser] Global scan error on {base_dir}: {e}") + claimed = False + try: + claimed, recovered = _claim_scan_marker(marker_file, "global", (progress_file,)) + if not claimed: + return web.json_response({"status": "error", "message": "Global scan already in progress"}, status=409) + + def run_global_bg(): + try: + paths_to_scan = get_active_scan_paths() + paths_to_scan = [path for path in paths_to_scan if os.path.exists(path)] + _update_scan_state( + marker_file, + phase="preparing", + folder_total=len(paths_to_scan), + folder_current=0, + folder="", + ) + for folder_index, base_dir in enumerate(paths_to_scan, 1): + if not os.path.exists(base_dir): continue + try: + print(f"[Anomalous Browser] Global scan processing: {base_dir}") + _remove_file(progress_file) + _update_scan_state( + marker_file, + phase="enumerating", + folder_total=len(paths_to_scan), + folder_current=folder_index, + folder=os.path.basename(os.path.normpath(base_dir)) or base_dir, + error="", + ) + cmd = [sys.executable, scraper_path, base_dir] + protected_type = _protected_type_for_path(base_dir) + if protected_type: + cmd.extend(["--folder-type", protected_type]) + cmd.extend(_scraper_flags(data, True, not is_physical_rename_protected( + folder_type=protected_type, + folder_path=base_dir, + ))) + cmd.extend(["--progress-file", progress_file]) + process = subprocess.Popen(cmd, cwd=plugin_dir) + _update_scan_marker(marker_file, worker_pid=process.pid) + return_code = process.wait() + _update_scan_marker(marker_file, worker_pid=0) + if return_code != 0: + message = f"Scanner exited with code {return_code}: {base_dir}" + _update_scan_state(marker_file, error=message) + print(f"[Anomalous Browser] {message}") + except Exception as e: + _update_scan_state(marker_file, error=str(e)) + print(f"[Anomalous Browser] Global scan error on {base_dir}: {e}") finally: _clear_folder_caches() - _release_scan_marker(marker_file, (progress_file,)) + _release_scan_marker(marker_file, (progress_file,)) threading.Thread(target=run_global_bg, daemon=True).start() - return web.json_response({"status": "ok", "message": "Global scan started", "recovered": recovered}) - except Exception as e: - if claimed: - _release_scan_marker(marker_file, (progress_file,)) - return web.json_response({"status": "error", "message": str(e)}) + return web.json_response({"status": "ok", "message": "Global scan started", "recovered": recovered}) + except Exception as e: + if claimed: + _release_scan_marker(marker_file, (progress_file,)) + 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') - progress_file = os.path.join(plugin_dir, '.global_scan_progress.json') - return web.json_response(_scan_status_payload(marker_file, progress_file, (progress_file,))) -GLOBAL_SCAN_STATE = { +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') + progress_file = os.path.join(plugin_dir, '.global_scan_progress.json') + return web.json_response(_scan_status_payload(marker_file, progress_file, (progress_file,))) +GLOBAL_SCAN_STATE = { "scanning": False, "total": 0, "current": 0, "filename": "", "error": "" -} -GLOBAL_SCAN_LOCK = threading.Lock() +} +GLOBAL_SCAN_LOCK = threading.Lock() async def api_scan_missing_models_status(request): return web.json_response(GLOBAL_SCAN_STATE) -async def api_scan_missing_models(request): - import sys - import threading - import traceback +async def api_scan_missing_models(request): + import sys + import threading + import traceback plugin_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) if plugin_dir not in sys.path: sys.path.insert(0, plugin_dir) try: - from scraper import calculate_sha256, fetch_civitai_info, infer_base_model_from_header, computed_file_identity - except ImportError: - return web.json_response({"status": "error", "message": "Failed to load scraper module"}) - - with GLOBAL_SCAN_LOCK: - if GLOBAL_SCAN_STATE["scanning"]: - return web.json_response({"status": "error", "message": "Scan already in progress"}, status=409) - GLOBAL_SCAN_STATE["scanning"] = True - GLOBAL_SCAN_STATE["total"] = 0 - GLOBAL_SCAN_STATE["current"] = 0 - GLOBAL_SCAN_STATE["filename"] = "Initializing..." - GLOBAL_SCAN_STATE["error"] = "" - - try: - data = await request.json() + from scraper import calculate_sha256, fetch_civitai_info, infer_base_model_from_header, computed_file_identity + except ImportError: + return web.json_response({"status": "error", "message": "Failed to load scraper module"}) + + with GLOBAL_SCAN_LOCK: + if GLOBAL_SCAN_STATE["scanning"]: + return web.json_response({"status": "error", "message": "Scan already in progress"}, status=409) + GLOBAL_SCAN_STATE["scanning"] = True + GLOBAL_SCAN_STATE["total"] = 0 + GLOBAL_SCAN_STATE["current"] = 0 + GLOBAL_SCAN_STATE["filename"] = "Initializing..." + GLOBAL_SCAN_STATE["error"] = "" + + try: + data = await request.json() except Exception: data = {} force_overwrite = data.get("force_overwrite", False) - def run_deep_scan(): + def run_deep_scan(): try: paths_to_scan = get_active_scan_paths() files_to_scan = [] - for base_dir in paths_to_scan: - if not os.path.exists(base_dir): continue - for root, dirs, files in os.walk(base_dir): - for file in files: - if not file.endswith('.safetensors'): - continue - file_path = os.path.join(root, file) - has_valid_info = not force_overwrite and bool(get_metadata(file_path).get("hash")) - - if not has_valid_info: - files_to_scan.append(file_path) + for base_dir in paths_to_scan: + if not os.path.exists(base_dir): continue + for root, dirs, files in os.walk(base_dir): + for file in files: + if not file.endswith('.safetensors'): + continue + file_path = os.path.join(root, file) + has_valid_info = not force_overwrite and bool(get_metadata(file_path).get("hash")) + + if not has_valid_info: + files_to_scan.append(file_path) GLOBAL_SCAN_STATE["total"] = len(files_to_scan) @@ -509,9 +509,9 @@ async def api_scan_missing_models(request): GLOBAL_SCAN_STATE["filename"] = filename try: - file_hash = calculate_sha256(file_path) - civitai_data = fetch_civitai_info(file_hash) - + file_hash = calculate_sha256(file_path) + civitai_data = fetch_civitai_info(file_hash) + # Fallback 3: Local Offline Inference if not civitai_data: inferred_base = infer_base_model_from_header(file_path) @@ -531,15 +531,15 @@ async def api_scan_missing_models(request): "files": [{"hashes": {"SHA256": file_hash}}] } - civitai_data["anomalous_file_identity"] = computed_file_identity(file_path, file_hash) - + civitai_data["anomalous_file_identity"] = computed_file_identity(file_path, file_hash) + # Save info file info_path = os.path.splitext(file_path)[0] + ".info" with open(info_path, 'w', encoding='utf-8') as f: json.dump(civitai_data, f, ensure_ascii=True, indent=4) - except Exception as e: - GLOBAL_SCAN_STATE["error"] = f"{filename}: {e}" + except Exception as e: + GLOBAL_SCAN_STATE["error"] = f"{filename}: {e}" except Exception as e: GLOBAL_SCAN_STATE["error"] = str(e) @@ -550,10 +550,10 @@ async def api_scan_missing_models(request): _clear_folder_caches() - try: - threading.Thread(target=run_deep_scan, daemon=True).start() - except Exception as e: - GLOBAL_SCAN_STATE["scanning"] = False - GLOBAL_SCAN_STATE["error"] = str(e) - return web.json_response({"status": "error", "message": str(e)}, status=500) - return web.json_response({"status": "success", "message": "Deep scan started in background"}) + try: + threading.Thread(target=run_deep_scan, daemon=True).start() + except Exception as e: + GLOBAL_SCAN_STATE["scanning"] = False + GLOBAL_SCAN_STATE["error"] = str(e) + return web.json_response({"status": "error", "message": str(e)}, status=500) + return web.json_response({"status": "success", "message": "Deep scan started in background"}) diff --git a/scraper.py b/scraper.py index 63a469e..f53be97 100644 --- a/scraper.py +++ b/scraper.py @@ -1,9 +1,9 @@ -import os -import sys -_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) -if _SCRIPT_DIR not in sys.path: - sys.path.insert(0, _SCRIPT_DIR) -try: +import os +import sys +_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +if _SCRIPT_DIR not in sys.path: + sys.path.insert(0, _SCRIPT_DIR) +try: sys.stdout.reconfigure(encoding="utf-8") except: pass @@ -20,98 +20,98 @@ import urllib.error import urllib.parse import argparse import shutil -from typing import Dict, Optional -from model_policies import is_physical_rename_protected -from recycle_bin import move_to_trash -from model_identity import USER_INFO_SUFFIX, computed_file_identity, is_unmatched, scan_info_path, sidecar_file_hash, sidecar_info as read_local_info - - -# Fixed tuples avoid rebuilding long extension lists for every scanned model. -# Exact-path checks are intentionally used instead of directory-wide globbing. -MEDIA_EXTENSIONS = (".png", ".jpg", ".jpeg", ".webp", ".gif", ".avif", ".mp4", ".webm", ".mov", ".avi") -PREVIEW_SUFFIXES = tuple(f".preview{ext}" for ext in MEDIA_EXTENSIONS) -CIVITAI_BACKUP_SUFFIXES = tuple(f".civitai_bak{ext}" for ext in MEDIA_EXTENSIONS) -COVER_SUFFIXES = MEDIA_EXTENSIONS + PREVIEW_SUFFIXES -ACTIVE_COVER_SUFFIXES = PREVIEW_SUFFIXES + MEDIA_EXTENSIONS -SIDECAR_SUFFIXES = ( - ".info", ".civitai.info", USER_INFO_SUFFIX, ".json", ".txt", ".yaml", - *MEDIA_EXTENSIONS, - *PREVIEW_SUFFIXES, - *CIVITAI_BACKUP_SUFFIXES, -) - - -def same_file(a, b): - """Byte-for-byte equal (covers are small; size is checked first).""" - try: - return os.path.getsize(a) == os.path.getsize(b) and calculate_sha256(a) == calculate_sha256(b) - except OSError: - return False - - -def cover_owners(base): - """(Civitai's, the user's) active covers. A cover is Civitai's only when it is a copy of the - Civitai image saved last time (.civitai_bak.*); any other cover is the user's.""" - backups = [base + ext for ext in CIVITAI_BACKUP_SUFFIXES if os.path.exists(base + ext)] - covers = [base + ext for ext in ACTIVE_COVER_SUFFIXES if os.path.exists(base + ext)] - civitai = [c for c in covers if any(same_file(c, b) for b in backups)] - return civitai, [c for c in covers if c not in civitai] - - -def write_scan_progress(progress_file, phase, total=0, current=0, filename=""): - if not progress_file: - return - temp_file = f"{progress_file}.{os.getpid()}.tmp" - try: - with open(temp_file, 'w', encoding='utf-8') as f: - json.dump({ - "phase": phase, - "total": total, - "current": current, - "filename": filename, - }, f, ensure_ascii=False) - os.replace(temp_file, progress_file) - except OSError: - try: - os.remove(temp_file) - except OSError: - pass - - -def count_scan_files(target_folder, target_files_basenames, unmatched_only=False): - total = 0 - selected = set(target_files_basenames) - for root, _, files in os.walk(target_folder): - total += sum( - 1 for filename in files - if filename.endswith(".safetensors") and (not selected or filename in selected) - and (not unmatched_only or is_unmatched(read_local_info(os.path.join(root, os.path.splitext(filename)[0])))) - ) - return total +from typing import Dict, Optional +from model_policies import is_physical_rename_protected +from recycle_bin import move_to_trash +from model_identity import USER_INFO_SUFFIX, computed_file_identity, is_unmatched, scan_info_path, sidecar_file_hash, sidecar_info as read_local_info + + +# Fixed tuples avoid rebuilding long extension lists for every scanned model. +# Exact-path checks are intentionally used instead of directory-wide globbing. +MEDIA_EXTENSIONS = (".png", ".jpg", ".jpeg", ".webp", ".gif", ".avif", ".mp4", ".webm", ".mov", ".avi") +PREVIEW_SUFFIXES = tuple(f".preview{ext}" for ext in MEDIA_EXTENSIONS) +CIVITAI_BACKUP_SUFFIXES = tuple(f".civitai_bak{ext}" for ext in MEDIA_EXTENSIONS) +COVER_SUFFIXES = MEDIA_EXTENSIONS + PREVIEW_SUFFIXES +ACTIVE_COVER_SUFFIXES = PREVIEW_SUFFIXES + MEDIA_EXTENSIONS +SIDECAR_SUFFIXES = ( + ".info", ".civitai.info", USER_INFO_SUFFIX, ".json", ".txt", ".yaml", + *MEDIA_EXTENSIONS, + *PREVIEW_SUFFIXES, + *CIVITAI_BACKUP_SUFFIXES, +) + + +def same_file(a, b): + """Byte-for-byte equal (covers are small; size is checked first).""" + try: + return os.path.getsize(a) == os.path.getsize(b) and calculate_sha256(a) == calculate_sha256(b) + except OSError: + return False + + +def cover_owners(base): + """(Civitai's, the user's) active covers. A cover is Civitai's only when it is a copy of the + Civitai image saved last time (.civitai_bak.*); any other cover is the user's.""" + backups = [base + ext for ext in CIVITAI_BACKUP_SUFFIXES if os.path.exists(base + ext)] + covers = [base + ext for ext in ACTIVE_COVER_SUFFIXES if os.path.exists(base + ext)] + civitai = [c for c in covers if any(same_file(c, b) for b in backups)] + return civitai, [c for c in covers if c not in civitai] + + +def write_scan_progress(progress_file, phase, total=0, current=0, filename=""): + if not progress_file: + return + temp_file = f"{progress_file}.{os.getpid()}.tmp" + try: + with open(temp_file, 'w', encoding='utf-8') as f: + json.dump({ + "phase": phase, + "total": total, + "current": current, + "filename": filename, + }, f, ensure_ascii=False) + os.replace(temp_file, progress_file) + except OSError: + try: + os.remove(temp_file) + except OSError: + pass + + +def count_scan_files(target_folder, target_files_basenames, unmatched_only=False): + total = 0 + selected = set(target_files_basenames) + for root, _, files in os.walk(target_folder): + total += sum( + 1 for filename in files + if filename.endswith(".safetensors") and (not selected or filename in selected) + and (not unmatched_only or is_unmatched(read_local_info(os.path.join(root, os.path.splitext(filename)[0])))) + ) + return total # ============================================================================== # CIVITAI API 配置读取 # 请在插件目录 (Anomalous_Model_Browser) 下新建 config.json 文件: # { "CIVITAI_API_KEY": "你的KEY" } # ============================================================================== -CIVITAI_API_KEY = None -plugin_dir = os.path.dirname(os.path.abspath(__file__)) -config_paths = [os.path.join(plugin_dir, "api", "config.json"), os.path.join(plugin_dir, "config.json")] -for config_path in config_paths: - if not os.path.exists(config_path): - continue - try: - with open(config_path, 'r', encoding='utf-8') as f: - cfg = json.load(f) - configured_key = cfg.get("CIVITAI_API_KEY", "") - if isinstance(configured_key, str) and configured_key.strip(): - CIVITAI_API_KEY = configured_key.strip() - break - except Exception as e: - print(f"[-] 读取 config.json 失败: {e}") - -if not CIVITAI_API_KEY: - print("[!] 未配置 Civitai API Key。部分限制级模型或将无法获取图片。") +CIVITAI_API_KEY = None +plugin_dir = os.path.dirname(os.path.abspath(__file__)) +config_paths = [os.path.join(plugin_dir, "api", "config.json"), os.path.join(plugin_dir, "config.json")] +for config_path in config_paths: + if not os.path.exists(config_path): + continue + try: + with open(config_path, 'r', encoding='utf-8') as f: + cfg = json.load(f) + configured_key = cfg.get("CIVITAI_API_KEY", "") + if isinstance(configured_key, str) and configured_key.strip(): + CIVITAI_API_KEY = configured_key.strip() + break + except Exception as e: + print(f"[-] 读取 config.json 失败: {e}") + +if not CIVITAI_API_KEY: + print("[!] 未配置 Civitai API Key。部分限制级模型或将无法获取图片。") def calculate_sha256(file_path: str) -> str: """计算文件的 SHA256 哈希值 (用于 Civitai 匹配)""" @@ -243,25 +243,25 @@ def main(): 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") - parser.add_argument("--force-overwrite", action="store_true", help="强制覆盖已存在的信息文件") + parser.add_argument("--force-overwrite", action="store_true", help="强制覆盖已存在的信息文件") parser.add_argument("--retry-unmatched", action="store_true", help="只处理之前没在 Civitai 匹配到的模型,重新联网查找(文件没变时复用哈希,不替换封面)") parser.add_argument("--skip-local-metadata", action="store_true", help="忽略本地已有的.info / .json文件") - parser.add_argument("--target-files", type=str, default="", help="仅扫描逗号分隔的具体文件(相对路径)") - parser.add_argument("--folder-type", default="", help="由 ComfyUI 传入的模型目录类型,用于执行安全策略") - parser.add_argument("--progress-file", default="", help=argparse.SUPPRESS) - args = parser.parse_args() - if args.offline_only: - # Offline means no network at all: previews and model pages come from Civitai. - args.skip_media = True + parser.add_argument("--target-files", type=str, default="", help="仅扫描逗号分隔的具体文件(相对路径)") + parser.add_argument("--folder-type", default="", help="由 ComfyUI 传入的模型目录类型,用于执行安全策略") + parser.add_argument("--progress-file", default="", help=argparse.SUPPRESS) + args = parser.parse_args() + if args.offline_only: + # Offline means no network at all: previews and model pages come from Civitai. + args.skip_media = True target_folder = args.folder - if not os.path.isdir(target_folder): - print(f"[-] 错误: 文件夹不存在 -> {target_folder}") - sys.exit(1) - - if args.physical_rename and is_physical_rename_protected(args.folder_type, target_folder): - print("[*] 基础组件目录受保护:VAE、CLIP/Text Encoder 与 CLIP Vision 不执行物理重命名。") - args.physical_rename = False + if not os.path.isdir(target_folder): + print(f"[-] 错误: 文件夹不存在 -> {target_folder}") + sys.exit(1) + + if args.physical_rename and is_physical_rename_protected(args.folder_type, target_folder): + print("[*] 基础组件目录受保护:VAE、CLIP/Text Encoder 与 CLIP Vision 不执行物理重命名。") + args.physical_rename = False backup_log_path = os.path.join(target_folder, "backup_rename_log.json") @@ -288,7 +288,7 @@ def main(): old_base = os.path.splitext(old_path)[0] new_base = os.path.splitext(new_path)[0] - for ext in SIDECAR_SUFFIXES: + for ext in SIDECAR_SUFFIXES: new_ext_path = new_base + ext old_ext_path = old_base + ext if os.path.exists(new_ext_path): @@ -322,34 +322,34 @@ def main(): if args.target_files: target_files_basenames.extend([os.path.basename(t.strip()) for t in args.target_files.split(',')]) - print(f"[*] 开始扫描文件夹: {target_folder}") + print(f"[*] 开始扫描文件夹: {target_folder}") if args.dry_run: print("==================================================") print("[警告]: 当前处于 Dry-Run (空跑) 模式,不会修改系统中的任何文件!") print("==================================================") - write_scan_progress(args.progress_file, "enumerating") - total_files = count_scan_files(target_folder, target_files_basenames, args.retry_unmatched) - write_scan_progress(args.progress_file, "scanning", total_files) - current_file = 0 - last_progress_write = 0.0 - - for root, _, files in os.walk(target_folder): - for filename in files: + write_scan_progress(args.progress_file, "enumerating") + total_files = count_scan_files(target_folder, target_files_basenames, args.retry_unmatched) + write_scan_progress(args.progress_file, "scanning", total_files) + current_file = 0 + last_progress_write = 0.0 + + for root, _, files in os.walk(target_folder): + for filename in files: if not filename.endswith(".safetensors"): continue - if target_files_basenames and filename not in target_files_basenames: - continue - previous_info = read_local_info(os.path.join(root, os.path.splitext(filename)[0])) if args.retry_unmatched else None - if args.retry_unmatched and not is_unmatched(previous_info): - continue - - current_file += 1 - now = time.monotonic() - if current_file == 1 or current_file == total_files or now - last_progress_write >= 0.2: - write_scan_progress(args.progress_file, "scanning", total_files, current_file, filename) - last_progress_write = now + if target_files_basenames and filename not in target_files_basenames: + continue + previous_info = read_local_info(os.path.join(root, os.path.splitext(filename)[0])) if args.retry_unmatched else None + if args.retry_unmatched and not is_unmatched(previous_info): + continue + + current_file += 1 + now = time.monotonic() + if current_file == 1 or current_file == total_files or now - last_progress_write >= 0.2: + write_scan_progress(args.progress_file, "scanning", total_files, current_file, filename) + last_progress_write = now file_path = os.path.join(root, filename) old_base = os.path.splitext(file_path)[0] @@ -361,7 +361,7 @@ def main(): preview_exists = args.skip_media if not preview_exists: - for ext in COVER_SUFFIXES: + for ext in COVER_SUFFIXES: if os.path.exists(old_base + ext): preview_exists = True break @@ -394,8 +394,8 @@ def main(): file_hash = sidecar_file_hash(previous_info, file_path, (previous_info.get("files") or [{}])[0])[0] file_hash = file_hash or calculate_sha256(file_path) if not args.offline_only: - civitai_data = fetch_civitai_info(file_hash) - + civitai_data = fetch_civitai_info(file_hash) + # Fallback 3: Local Offline Inference (if Civitai still fails or offline_only) if not civitai_data: if args.skip_local_metadata: @@ -456,8 +456,8 @@ def main(): # 兼容性大刀阔斧改革:直接保存全宇宙最原汁原味的格式 # ========================================== info_data = civitai_data - if file_hash: - info_data["anomalous_file_identity"] = computed_file_identity(file_path, file_hash) + if file_hash: + info_data["anomalous_file_identity"] = computed_file_identity(file_path, file_hash) # Civitai's name for the card; a name set in the model editor lives in # .anomalous.json and is shown instead. @@ -472,10 +472,10 @@ def main(): else: print(f"[Dry-Run] 拟生成标准描述信息 -> .info") - media_url = None - images = civitai_data.get("images", []) - if images and len(images) > 0: - for img_obj in images: + media_url = None + images = civitai_data.get("images", []) + if images and len(images) > 0: + for img_obj in images: if not args.skip_media: if img_obj.get("url"): media_url = img_obj.get("url") @@ -510,41 +510,41 @@ def main(): print(f"[*] 物理重命名已跳过。仅保存 .info 及其可能包含的虚拟重命名。") success_count += 1 elif file_path != new_file_path and new_filename != filename: - if os.path.exists(new_file_path): - print(f"[*] 目标文件名已存在,正在验证内容是否完全相同: {filename}") - try: - files_identical = os.path.getsize(file_path) == os.path.getsize(new_file_path) - if files_identical: - files_identical = calculate_sha256(file_path) == calculate_sha256(new_file_path) - except OSError: - files_identical = False - - if not files_identical: - print(f"\033[91m[-] 目标名称冲突但文件内容不同,已保留两个模型并跳过重命名: {filename}\033[0m") - fail_count += 1 - elif not args.dry_run: - try: - print(f"[*] Hash 一致,把已确认的重复副本移到回收站: {filename}") - move_to_trash(file_path, *[old_base + ext for ext in SIDECAR_SUFFIXES]) - success_count += 1 - except Exception as e: - print(f"[-] 重复副本没有移走,两份都保留 (可能文件被占用或这个盘没有回收站): {e}") - fail_count += 1 - else: - print(f"[Dry-Run] Hash 一致,拟删除重复副本及其附属文件: {filename}") - else: + if os.path.exists(new_file_path): + print(f"[*] 目标文件名已存在,正在验证内容是否完全相同: {filename}") + try: + files_identical = os.path.getsize(file_path) == os.path.getsize(new_file_path) + if files_identical: + files_identical = calculate_sha256(file_path) == calculate_sha256(new_file_path) + except OSError: + files_identical = False + + if not files_identical: + print(f"\033[91m[-] 目标名称冲突但文件内容不同,已保留两个模型并跳过重命名: {filename}\033[0m") + fail_count += 1 + elif not args.dry_run: + try: + print(f"[*] Hash 一致,把已确认的重复副本移到回收站: {filename}") + move_to_trash(file_path, *[old_base + ext for ext in SIDECAR_SUFFIXES]) + success_count += 1 + except Exception as e: + print(f"[-] 重复副本没有移走,两份都保留 (可能文件被占用或这个盘没有回收站): {e}") + fail_count += 1 + else: + print(f"[Dry-Run] Hash 一致,拟删除重复副本及其附属文件: {filename}") + else: if not args.dry_run: os.rename(file_path, new_file_path) rename_log[file_path] = new_file_path - for ext in SIDECAR_SUFFIXES: + for ext in SIDECAR_SUFFIXES: old_ext = old_base + ext new_ext = new_base + ext if os.path.exists(old_ext): os.replace(old_ext, new_ext) - print(f"[+] 物理重命名完成: {filename} ==> {new_filename}") - success_count += 1 + print(f"[+] 物理重命名完成: {filename} ==> {new_filename}") + success_count += 1 else: print(f"[Dry-Run] 拟物理重命名文件: {filename} ==> {new_filename}") print(f"[Dry-Run] 拟连带重命名附属文件 (.info / .png 等)") @@ -553,9 +553,9 @@ def main(): success_count += 1 - # Save scan results - write_scan_progress(args.progress_file, "complete", total_files, total_files) - if not args.dry_run: + # Save scan results + write_scan_progress(args.progress_file, "complete", total_files, total_files) + if not args.dry_run: result_path = os.path.join(target_folder, ".scan_result.json") try: with open(result_path, 'w', encoding='utf-8') as f: @@ -569,4 +569,4 @@ def main(): print(f"\n[+] 重命名映射日志已保存至: {backup_log_path}") if __name__ == "__main__": - main() + main()