Scan page: quick with many models or a network drive; says it is counting

On a cloud server with 1041 models not scanned yet, on the platform's
network model disk, GET /anomalous/scan_summary took 79 s, each time the
page opened, and the page stayed blank meanwhile: it read each new
model's header (for "looks like no drawing model") one after another.

The headers are now read 16 at a time (HEAD_READERS) and through
get_metadata, whose cache keeps each answer until the file changes, so
the page opened again reads none of them and the picker after it finds
them there. Opened afresh, a count slower than 400 ms shows "Counting
your models…" instead of a blank page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DemonGatanjieu
2026-10-16 19:39:15 +08:00
co-authored by Claude Opus 5.5
parent 5e9ca3e3ac
commit 4bc559eaa4
9 changed files with 104 additions and 12 deletions
+1
View File
@@ -90,6 +90,7 @@
- **Components on their own (基础组件)**: VAE, text encoders, CLIP, CLIP Vision and upscalers get a chip of their own, "7 components · not found is fine", whose list (found, not found, not scanned yet) says that many of them are not on Civitai at all and that this does not matter (the fingerprint is still kept for Model Check). The counts above, and the Not found and Not scanned yet lists, leave them out; their own list shows all of them.
- **The model picker is new**: every model a scan reads, as a list (name, folder, state, base model, size) or as covers. Chips by state, the same words as the scan page — **Not scanned yet, Not found, Damaged, Found, Never scan** — with counts; a search; the folders on the left with how many are picked in each, two folders of one type told apart by where they are. **Pick these N** picks everything shown (so "the 12 not scanned yet in LoRA" is two clicks), shift-click picks a run, **Picked only** shows the picks. The footer says how many are picked and how big they are.
- Only files a scan can read (.safetensors) are listed; the old picker also offered .ckpt and other files a scan never opens.
- **Quick to open with many models, or models on a network drive** (a NAS, a cloud server's model disk): the page reads the headers of models not scanned yet 16 at a time and remembers them until the file changes, so it opens again at once; while it counts, it says **Counting your models…** instead of staying blank. On a cloud server with 1041 unscanned models it had taken 79 seconds each time.
### ⚙️ Settings in tabs (设置分组)
- The settings page is split into tabs: **Look, Models, Workflows, Prompts, Backup, Help**. "Change where downloads go" opens Models directly.
+19 -6
View File
@@ -10,6 +10,7 @@ a scan reads with its name, size, cover and state."""
import asyncio
import os
from concurrent.futures import ThreadPoolExecutor
from aiohttp import web
@@ -20,10 +21,8 @@ from .scan_report import locate_root
try:
from ..model_identity import is_unmatched, scan_choice, sidecar_info, unmatched_reason
from ..model_kind import inspect_file
except ImportError: # loaded outside the package (tests)
from model_identity import is_unmatched, scan_choice, sidecar_info, unmatched_reason
from model_kind import inspect_file
MAX_LISTED = 2000 # models listed per kind; the counts stay exact
MAX_PICKABLE = 5000 # models the picker lists; its total stays exact
@@ -34,6 +33,11 @@ OTHER_MODEL_EXTENSIONS = (".ckpt", ".pt", ".pth", ".bin", ".gguf", ".onnx", ".sf
COMPONENT_TYPES = ("vae", "clip", "text_encoders", "clip_vision", "upscale_models")
# What the picker needs of a model's metadata: its card badge (model_source.js) and its name.
PICKER_FIELDS = ("info_source", "unmatched_reason", "scan_choice", "not_drawing", "baseModel", "base_guess", "custom_name")
# New models' headers are read this many at a time: on a network drive (a NAS, a cloud server's
# model disk) each read waits on the network, and a thousand one after another take minutes.
# They are read through get_metadata, whose cache keeps them until the file changes (the page
# opened again reads none, and the picker after it finds them there).
HEAD_READERS = 16
def _walk():
@@ -57,6 +61,11 @@ def _walk():
yield located, base_dir, path, other
def _looks_not_drawing(path):
"""Whether the header of a new model reads but names no drawing model kind."""
return bool(get_metadata(path).get("not_drawing"))
def _summarize():
"""Counts the files the scanner handles (.safetensors in the active scan folders), each once.
`pending`: unmatched models Civitai was never asked about (offline, or unreachable then),
@@ -67,7 +76,7 @@ def _summarize():
are listed again, matched ones as well, in `component_models` with their `state`
(matched | unmatched | new); `components` counts them. `covers`: see `_covers`."""
total = matched = 0
unmatched, new, skipped, excluded, components, scanned = [], [], [], [], [], []
unmatched, new, skipped, excluded, components, scanned, to_read = [], [], [], [], [], [], []
for located, base_dir, path, other in _walk():
item = {"type": located[0], "path_idx": located[1], "filename": os.path.basename(path),
"rel": os.path.relpath(path, base_dir).replace(os.sep, "/")}
@@ -91,14 +100,18 @@ def _summarize():
item["state"] = "matched"
continue
if info is None:
found = inspect_file(path)
if not choice and found["format"] == "safetensors" and not found["kind"]:
item["not_drawing"] = True
if not choice:
to_read.append((item, path))
item["state"] = "new"
new.append(item)
else:
item.update(state="unmatched", reason=unmatched_reason(info), base=info.get("baseModel") or "")
unmatched.append(item)
if to_read:
with ThreadPoolExecutor(max_workers=HEAD_READERS) as pool:
for (item, _path), not_drawing in zip(to_read, pool.map(_looks_not_drawing, [path for _item, path in to_read])):
if not_drawing:
item["not_drawing"] = True
return {
"total": total, "matched": matched, "unmatched": len(unmatched), "new": len(new),
"pending": sum(item["reason"] in ("offline", "network") for item in unmatched),
+5 -1
View File
@@ -171,7 +171,11 @@ folder skips it and the scan summary counts it apart (`excluded`), not in the ot
picked by hand (`targets`) it is scanned all the same. `"scan": "scan"` records that the user
was asked and kept it. Nothing decides this alone: the summary only marks new models whose
safetensors header reads but names no drawing model kind (`model_kind.inspect_file`, kind "",
a voice model say) as `not_drawing`, and the scan page asks. `POST /anomalous/scan_choice`
a voice model say) as `not_drawing`, and the scan page asks. Those headers are read
`HEAD_READERS` (16) at a time, since on a network drive each read waits on the network (1041
new models on a cloud server's model disk took 79 s one after another), through
`get_metadata`, whose cache keeps each answer until the file changes: the page opened again
reads none of them, and the picker after it finds them there. `POST /anomalous/scan_choice`
(`api/scan_choice.py`, this computer only) takes the picked models as scan `targets`, at most
2000, and writes only `.safetensors` files' layers that are readable.
+1 -1
View File
@@ -75,7 +75,7 @@ The main surfaces are:
- Grid and model detail: model browsing plus coordinated detail display in
`ui_detail.js` (its head with the actions in `model_detail_head.js`), metadata editing in
`ui_model_editor.js`; `model_widget_path.js` puts a
model path into a loader's widget. The scan page's model picker is `ui_scan_picker.js`, its settings card `ui_scan_settings.js`.
model path into a loader's widget. The scan page's model picker is `ui_scan_picker.js`, its settings card `ui_scan_settings.js`; opened afresh, a count slower than 400 ms shows "counting your models" instead of a blank page.
- Gallery: generated outputs (pictures and videos; All / Pictures / Videos beside the search
and the hidden formats in Settings, `gallery_filter.js` + `ui_gallery_filter.js`) and the
full-screen Image Detail Studio Workbench, for pictures only.
+18
View File
@@ -88,6 +88,24 @@ class ScanRetryTests(unittest.TestCase):
with mock.patch.object(scan_summary, "get_active_scan_paths", return_value=[self.temp.name]):
self.assertEqual(scan_summary._summarize()["damaged"], 1)
def test_new_models_headers_are_read_once_until_the_file_changes(self):
# On a network drive each read waits: the page opened again reads none of them.
header = json.dumps({"ema_model.transformer.w": {"dtype": "F32", "shape": [1], "data_offsets": [0, 4]}}).encode()
voice = Path(self.temp.name, "voice.safetensors")
voice.write_bytes(len(header).to_bytes(8, "little") + header + b"\0" * 4)
reads = []
real_inspect = metadata.inspect_file
metadata.clear_metadata_cache()
with mock.patch.object(scan_summary, "get_active_scan_paths", return_value=[self.temp.name]), \
mock.patch.object(metadata, "inspect_file", side_effect=lambda path: reads.append(path) or real_inspect(path)):
first = scan_summary._summarize()
again = scan_summary._summarize()
self.assertEqual(len(reads), 2) # new.safetensors and the voice model, each once
self.assertEqual(again["not_drawing"], first["not_drawing"])
voice.write_bytes(len(header).to_bytes(8, "little") + header + b"\0" * 8) # changed: read again
scan_summary._summarize()
self.assertEqual([Path(path).name for path in reads].count("voice.safetensors"), 2)
def test_models_set_not_to_be_scanned_are_left_out(self):
# A voice model's header names no drawing model kind: the page asks; "skip" leaves it out.
header = json.dumps({"ema_model.transformer.w": {"dtype": "F32", "shape": [1], "data_offsets": [0, 4]}}).encode()
+4
View File
@@ -239,6 +239,8 @@ export const i18n = {
scanPagePickModels: "挑选模型扫描…",
scanPickBackToAll: "清空,改回扫描全部",
scanPageLeadShort: "给模型配上封面、说明和底模;扫过的不会重复下载。",
scanCountingTitle: "正在清点你的模型…",
scanCountingHint: "模型很多,或者放在网络盘上时,第一次要等一会儿;之后再打开会快很多。",
scanPageAboutLink: "扫描是怎么回事?",
scanHeroWaiting: "还有 {count} 个模型待查",
scanHeroPicked: "已选 {count} 个模型",
@@ -2813,6 +2815,8 @@ export const i18n = {
scanPagePickModels: "Pick models to scan…",
scanPickBackToAll: "Clear, scan everything again",
scanPageLeadShort: "Gives your models a cover, a description and a base model; models already scanned are not downloaded again.",
scanCountingTitle: "Counting your models…",
scanCountingHint: "With many models, or models on a network drive, the first time takes a while; opening the page again is much quicker.",
scanPageAboutLink: "How scanning works",
scanHeroWaiting: "{count} {count|model|models} still to look up",
scanHeroPicked: "{count} {count|model|models} picked",
+22 -3
View File
@@ -12,6 +12,8 @@
* like no drawing model (don't scan them, scan them, see them). Then the settings (ui_scan_settings.js) and the last scan in one line.
* Lists are pages of their own (ui_scan_lists.js) with a way back. Scans run through
* scan_runner.js; the progress box is scan_progress.js's panel, hosted here while the page is shown.
* Opened afresh and the counts slow to come (many models, or on a network drive), it says it is
* counting instead of staying blank.
*/
import { translate as t } from './locales.js';
@@ -24,6 +26,7 @@ import { renderScanSettings, scanOptions as options } from './ui_scan_settings.j
const SHOWN_PICKS = 12; // picked models named on the page; the rest are counted
const BAND_TILES = 48; // pictures put in the scan card's band (as many as fit show)
const COUNTING_AFTER_MS = 400; // the counts not there by then: "counting your models" shows
// The view shown: '' the page itself, or a list: 'new' | 'unmatched' | 'skipped' | 'result'
// | 'notdrawing' | 'excluded' | 'components'. `scroll`: where to put a list shown again after a
// model's Back. `factsOpen`, `moreOpen`: the scan's full counts, the rarer settings unfolded.
@@ -393,12 +396,28 @@ function paint(owner, panel, data) {
setScanProgressHost(progressHost);
}
/** Renders the page into `panel` (again after each scan, so the counts are fresh). */
export async function renderScanPage(owner, panel) {
/** What the page shows while the counts are on their way. */
function countingPage() {
const page = el('div', 'anomalous-scan-page');
const box = el('div', 'anomalous-scan-counting');
box.setAttribute('role', 'status');
box.append(scanIcon('scan', 'anomalous-scan-counting-icon'), el('strong', '', t('scanCountingTitle')),
el('p', '', t('scanCountingHint')));
page.append(box);
return page;
}
/** Renders the page into `panel` (again after each scan, so the counts are fresh). `fresh`: the
* page was just opened, so a slow count shows "counting" instead of the page from before. */
export async function renderScanPage(owner, panel, fresh = false) {
const token = (panel._scanRender = (panel._scanRender || 0) + 1);
const counting = fresh && setTimeout(() => {
if (token === panel._scanRender) panel.replaceChildren(countingPage());
}, COUNTING_AFTER_MS);
const [summary, config, last] = await Promise.all([
fetchJson('/anomalous/scan_summary'), fetchJson('/anomalous/config'), fetchJson('/anomalous/last_scan'),
]);
clearTimeout(counting);
if (token !== panel._scanRender) return;
// Closed or left meanwhile: the progress box keeps floating instead of hiding in the page.
if (panel.style.display === 'none' || !owner.modal?.classList.contains('visible')) return;
@@ -422,7 +441,7 @@ export function openScanPage(owner, view = '', scroll = 0) {
owner.enterToolPage?.('scan');
owner.hideAllPanels();
owner.scanPanel.style.display = 'flex';
renderScanPage(owner, owner.scanPanel);
renderScanPage(owner, owner.scanPanel, true);
}
/** Leaving the page: the progress box floats again. */
+1 -1
View File
@@ -16,7 +16,7 @@
@import url("./styles/15-shell-rail.css?v=20261015-firstrun-workspace");
@import url("./styles/16-activity.css?v=20261007-theme-2");
@import url("./styles/17-model-types.css?v=20261007-theme-2");
@import url("./styles/18-scan-page.css?v=20261011-band-1");
@import url("./styles/18-scan-page.css?v=20261016-counting");
@import url("./styles/19-model-doctor.css?v=20261007-theme-2");
@import url("./styles/20-current-node.css?v=20261011-last");
@import url("./styles/21-prompt-studio.css?v=20261007-theme-2");
+33
View File
@@ -129,6 +129,39 @@
.anomalous-scan-band.is-busy .anomalous-scan-band-tile.is-empty { animation: none; }
}
/* The counts on their way (many models, or a network drive). */
.anomalous-scan-counting {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 72px 24px;
text-align: center;
color: var(--amb-text-main);
}
.anomalous-scan-counting p {
margin: 0;
max-width: 420px;
color: var(--amb-text-soft);
line-height: 1.5;
}
.anomalous-scan-counting-icon {
width: 40px;
height: 40px;
color: var(--amb-link);
animation: anomalous-scan-counting 1.4s ease-in-out infinite;
}
@keyframes anomalous-scan-counting {
50% { opacity: 0.35; }
}
@media (prefers-reduced-motion: reduce) {
.anomalous-scan-counting-icon { animation: none; }
}
.anomalous-scan-hero-body {
display: flex;
flex: 1 1 auto;