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:
co-authored by
Claude Opus 5.5
parent
5e9ca3e3ac
commit
4bc559eaa4
@@ -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
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user