Serve adjacent model previews in tooltips via /model_resolver/model-preview

This commit is contained in:
Dariusz L
2026-07-26 21:13:11 +02:00
parent c01c31df32
commit e1c4359dc3
9 changed files with 213 additions and 9 deletions
+36
View File
@@ -424,6 +424,7 @@ class ModelResolverExtension:
get_all_progress,
get_aria2_status,
get_download_directory,
get_existing_model_preview_path,
get_progress,
is_allowed_model_download_filename,
normalize_download_category,
@@ -934,6 +935,41 @@ class ModelResolverExtension:
get_local_model_hash_metadata(normalized_path, model=model)
)
@routes.get("/model_resolver/model-preview")
async def get_model_preview(request):
"""Serve an adjacent model preview from a configured model directory."""
model_path = str(request.query.get("path") or "").strip()
if not model_path:
return web.Response(text="path is required", status=400)
try:
normalized_path = os.path.realpath(
os.path.abspath(os.path.normpath(model_path))
)
except (OSError, TypeError, ValueError):
return web.Response(text="invalid model path", status=400)
if not os.path.isfile(normalized_path):
return web.Response(text="model file does not exist", status=404)
if not is_path_in_configured_model_roots(normalized_path):
return web.Response(
text="path is outside configured model directories",
status=403,
)
preview_path = get_existing_model_preview_path(normalized_path)
if not preview_path:
return web.Response(text="preview not found", status=404)
if not is_path_in_configured_model_roots(preview_path):
return web.Response(
text="preview is outside configured model directories",
status=403,
)
return web.FileResponse(
preview_path,
headers={"Cache-Control": "private, max-age=300"},
)
@routes.post("/model_resolver/workflow-model-hashes")
@json_api_endpoint("workflow-model-hashes")
async def workflow_model_hashes(request):
+3 -2
View File
@@ -719,7 +719,8 @@ def build_lora_manager_metadata(
return payload
def _existing_model_preview_path(dest_path: str) -> str:
def get_existing_model_preview_path(dest_path: str) -> str:
"""Return the first adjacent preview image available for a model file."""
model_base_path, _model_ext = os.path.splitext(dest_path)
for extension in MODEL_PREVIEW_EXTENSIONS:
candidate = f"{model_base_path}{extension}"
@@ -917,7 +918,7 @@ def create_model_preview(
metadata: Optional[Dict[str, Any]] = None,
) -> str:
"""Create a compact LoRA Manager-compatible JPEG preview when one is missing."""
existing_path = _existing_model_preview_path(dest_path)
existing_path = get_existing_model_preview_path(dest_path)
if existing_path:
return existing_path
+13
View File
@@ -269,6 +269,19 @@ class DownloaderMetadataSidecarTests(unittest.TestCase):
"https://image.civitai.com/example.jpeg"
)
def test_existing_model_preview_path_finds_adjacent_preview(self):
with tempfile.TemporaryDirectory() as tmpdir:
model_path = os.path.join(tmpdir, "model.safetensors")
preview_path = os.path.join(tmpdir, "model.jpeg")
with open(model_path, "wb") as model_file:
model_file.write(b"model")
with open(preview_path, "wb") as preview_file:
preview_file.write(b"preview")
result = downloader.get_existing_model_preview_path(model_path)
self.assertEqual(preview_path, result)
def test_existing_file_with_same_hash_is_marked_already_downloaded(self):
content = b"existing model"
expected_sha256 = hashlib.sha256(content).hexdigest()
@@ -64,6 +64,40 @@ const renderFormatMethodsSource = fs.readFileSync(
'utf8'
);
test('local and loaded model tooltips include preview image routes above full names', () => {
const getModelPreviewTooltipAttrs = eval(`(${extractMethod(renderFormatMethodsSource, 'getModelPreviewTooltipAttrs')})`);
const previousApi = globalThis.api;
globalThis.api = {
apiURL(route) {
return `/comfy${route}`;
},
};
try {
const attrs = getModelPreviewTooltipAttrs.call({
escapeHtml(value) {
return String(value).replace(/&/g, '&').replace(/"/g, '"');
},
}, {
resolved_path: 'E:\\AI Models\\KREA2\\model.safetensors',
}, 'KREA2\\model.safetensors');
assert.match(attrs, /data-tooltip="KREA2\\model\.safetensors"/);
assert.match(
attrs,
/data-tooltip-image="\/comfy\/model_resolver\/model-preview\?path=E%3A%5CAI%20Models%5CKREA2%5Cmodel\.safetensors"/
);
assert.match(searchPanelMethodsSource, /mr-match-filename[^]*getModelPreviewTooltipAttrs/);
assert.match(tabsLoadedMethodsSource, /mr-model-chip[^]*getModelPreviewTooltipAttrs/);
} finally {
if (previousApi === undefined) {
delete globalThis.api;
} else {
globalThis.api = previousApi;
}
}
});
test('CivitAI Comfy workflow metadata is extracted as pasteable JSON', () => {
const workflow = {
last_node_id: 2,
+41
View File
@@ -21,6 +21,7 @@ search_handler = None
civitai_search_handler = None
local_matches_by_hash_handler = None
custom_url_handler = None
model_preview_handler = None
def post_decorator(path):
def decorator(func):
@@ -38,6 +39,16 @@ def post_decorator(path):
mock_routes.post = post_decorator
def get_decorator(path):
def decorator(func):
global model_preview_handler
if path == "/model_resolver/model-preview":
model_preview_handler = func
return func
return decorator
mock_routes.get = get_decorator
# Make sure the parent package directory is in sys.path
parent_dir = r"e:\AI\AI\ComfyUI\ComfyUI_Windows_portable\ComfyUI\custom_nodes"
if parent_dir not in sys.path:
@@ -305,6 +316,36 @@ class SearchOrchestrationTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(body["local_hash_matches"][0]["hash_lookup_source"], "civitai")
self.assertEqual(body["local_hash_matches"][0]["hash_lookup_sha256"], file_hash)
async def test_model_preview_route_serves_only_adjacent_preview(self):
with tempfile.TemporaryDirectory() as temp_dir:
model_path = os.path.join(temp_dir, "model.safetensors")
preview_path = os.path.join(temp_dir, "model.jpeg")
with open(model_path, "wb") as model_file:
model_file.write(b"model")
with open(preview_path, "wb") as preview_file:
preview_file.write(b"preview")
mock_request = MagicMock()
mock_request.query = {"path": model_path}
with (
patch(
"folder_paths.folder_names_and_paths",
{"loras": ([temp_dir], {".safetensors"})},
create=True,
),
patch(
"folder_paths.get_folder_paths",
return_value=[temp_dir],
create=True,
),
):
response = await model_preview_handler(mock_request)
self.assertIsInstance(response, web.FileResponse)
self.assertEqual(os.path.realpath(preview_path), os.path.realpath(response._path))
self.assertEqual("private, max-age=300", response.headers["Cache-Control"])
async def test_search_collects_local_hash_matches_for_any_remote_result_with_hash(self):
file_hash = "c" * 64
with tempfile.TemporaryDirectory() as temp_dir:
+21
View File
@@ -7563,6 +7563,27 @@ a.mr-image-preview-resource-name:hover {
z-index: var(--mr-z-tooltip);
}
.mr-global-tooltip.mr-global-tooltip-with-image {
width: min(300px, calc(100vw - 24px));
padding: 8px;
white-space: normal;
}
.mr-tooltip-preview {
display: block;
width: 100%;
max-height: min(360px, 55vh);
object-fit: contain;
border-radius: 7px;
background: rgba(0, 0, 0, 0.28);
}
.mr-tooltip-label {
padding: 8px 3px 2px;
white-space: pre-line;
overflow-wrap: anywhere;
}
.mr-options-input-row {
display: grid;
grid-template-columns: minmax(150px, 1fr) minmax(0, 380px) 42px;
+42 -5
View File
@@ -794,9 +794,44 @@ export const searchPanelMethods = {
const text = target.getAttribute('data-tooltip');
if (!text) return;
this.tooltipElement.textContent = text;
this.tooltipElement.style.display = 'block';
const imageUrl = target.getAttribute('data-tooltip-image');
this._tooltipTarget = target;
this.tooltipElement.replaceChildren();
this.tooltipElement.classList.toggle('mr-global-tooltip-with-image', Boolean(imageUrl));
if (imageUrl) {
const image = document.createElement('img');
image.className = 'mr-tooltip-preview';
image.alt = '';
image.decoding = 'async';
const label = document.createElement('div');
label.className = 'mr-tooltip-label';
label.textContent = text;
image.addEventListener('load', () => {
if (this._tooltipTarget === target) {
this.positionTooltip(target);
}
});
image.addEventListener('error', () => {
if (this._tooltipTarget !== target) return;
image.remove();
this.tooltipElement.classList.remove('mr-global-tooltip-with-image');
this.positionTooltip(target);
});
image.src = imageUrl;
this.tooltipElement.append(image, label);
} else {
this.tooltipElement.textContent = text;
}
this.tooltipElement.style.display = 'block';
this.positionTooltip(target);
this.tooltipElement.setAttribute('data-visible', 'true');
},
positionTooltip(target) {
if (!target || !this.tooltipElement || this._tooltipTarget !== target) return;
const rect = target.getBoundingClientRect();
const tooltipRect = this.tooltipElement.getBoundingClientRect();
const margin = 12;
@@ -812,12 +847,14 @@ export const searchPanelMethods = {
this.tooltipElement.style.left = `${Math.round(left)}px`;
this.tooltipElement.style.top = `${Math.round(top)}px`;
this.tooltipElement.setAttribute('data-visible', 'true');
},
hideTooltip() {
if (!this.tooltipElement) return;
this._tooltipTarget = null;
this.tooltipElement.style.display = 'none';
this.tooltipElement.classList.remove('mr-global-tooltip-with-image');
this.tooltipElement.replaceChildren();
this.tooltipElement.removeAttribute('data-visible');
},
@@ -2741,7 +2778,7 @@ export const searchPanelMethods = {
html += `<div class="${rowClass}"${identityAttr}${this.getContextMenuAttrs(contextModel)}>`;
html += this.getConfidenceBadge(match.confidence);
html += `<span class="mr-match-filename" data-tooltip="${this.escapeHtml(matchPath)}">${this.escapeHtml(matchPath)}</span>`;
html += `<span class="mr-match-filename"${this.getModelPreviewTooltipAttrs(match.model || match, matchPath)}>${this.escapeHtml(matchPath)}</span>`;
html += this.renderLocalMatchStatusGroup(missing, match, hashLabelMap);
html += `<button id="${buttonId}" class="mr-btn mr-btn-secondary mr-btn-sm mr-btn-icon-only mr-local-link-btn" data-tooltip="Link this local match" aria-label="Link this local match">`;
html += getSvgIcon('link');
@@ -2774,7 +2811,7 @@ export const searchPanelMethods = {
: '';
html += `<div class="mr-match-row"${identityAttr}${this.getContextMenuAttrs(contextModel)}>`;
html += this.getConfidenceBadge(match.confidence);
html += `<span class="mr-match-filename" data-tooltip="${this.escapeHtml(matchPath)}">${this.escapeHtml(matchPath)}</span>`;
html += `<span class="mr-match-filename"${this.getModelPreviewTooltipAttrs(match.model || match, matchPath)}>${this.escapeHtml(matchPath)}</span>`;
html += this.renderLocalMatchStatusGroup(missing, match, hashLabelMap);
html += `<button id="${altBtnId}" class="mr-btn mr-btn-secondary mr-btn-sm mr-btn-icon-only mr-local-link-btn" data-tooltip="Link this local match" aria-label="Link this local match">${getSvgIcon('link')}</button>`;
html += `</div>`;
@@ -1,3 +1,4 @@
import { api } from "../../../../../scripts/api.js";
import { escapeHtml, escapeJsString, getFilenameFromPath, pollBackgroundTask } from "./html_utils.js";
export const renderFormatMethods = {
@@ -43,6 +44,24 @@ export const renderFormatMethods = {
};
},
getModelPreviewTooltipAttrs(model = {}, label = '') {
const tooltipText = String(label || '').trim();
const tooltipAttr = tooltipText
? ` data-tooltip="${this.escapeHtml(tooltipText)}"`
: '';
const modelPath = String(
model.resolved_path
|| model.path
|| model.full_path
|| ''
).trim();
if (!modelPath) return tooltipAttr;
const endpoint = api.apiURL('/model_resolver/model-preview');
const previewUrl = `${endpoint}?path=${encodeURIComponent(modelPath)}`;
return `${tooltipAttr} data-tooltip-image="${this.escapeHtml(previewUrl)}"`;
},
/**
* Get a colored confidence badge HTML
* @param {number} confidence - Confidence percentage (0-100)
+4 -2
View File
@@ -281,7 +281,8 @@ export const tabsLoadedMethods = {
for (const model of modelsObj.active) {
const { name, strength } = this.getModelNameAndStrength(model);
html += `<span class="mr-model-chip"${this.getContextMenuAttrs(this.getLoadedModelContext(model))}>${this.escapeHtml(name)}${strength !== null ? `<span class="mr-model-chip-strength">${this.escapeHtml(strength)}</span>` : ''}</span>`;
const fullName = model.original_path || model.name || name;
html += `<span class="mr-model-chip"${this.getContextMenuAttrs(this.getLoadedModelContext(model))}${this.getModelPreviewTooltipAttrs(model, fullName)}>${this.escapeHtml(name)}${strength !== null ? `<span class="mr-model-chip-strength">${this.escapeHtml(strength)}</span>` : ''}</span>`;
}
html += `</div></div>`;
}
@@ -298,7 +299,8 @@ export const tabsLoadedMethods = {
for (const model of modelsObj.inactive) {
const { name, strength } = this.getModelNameAndStrength(model);
html += `<span class="mr-model-chip"${this.getContextMenuAttrs(this.getLoadedModelContext(model))}>${this.escapeHtml(name)}${strength !== null ? `<span class="mr-model-chip-strength">${this.escapeHtml(strength)}</span>` : ''}</span>`;
const fullName = model.original_path || model.name || name;
html += `<span class="mr-model-chip"${this.getContextMenuAttrs(this.getLoadedModelContext(model))}${this.getModelPreviewTooltipAttrs(model, fullName)}>${this.escapeHtml(name)}${strength !== null ? `<span class="mr-model-chip-strength">${this.escapeHtml(strength)}</span>` : ''}</span>`;
}
html += `</div></div>`;
}