feat: support video previews in model downloads, tooltips, and info dialogs

This commit is contained in:
Dariusz L
2026-07-26 23:39:09 +02:00
parent 895de19392
commit 63ed2b8ca7
11 changed files with 531 additions and 94 deletions
+4 -12
View File
@@ -967,7 +967,7 @@ class ModelResolverExtension:
return web.FileResponse(
preview_path,
headers={"Cache-Control": "private, max-age=300"},
headers={"Cache-Control": "private, no-cache"},
)
@routes.post("/model_resolver/workflow-model-hashes")
@@ -2665,17 +2665,9 @@ class ModelResolverExtension:
):
preview_url = result.get("preview_url") or ""
if not preview_url:
model_base_path, _model_ext = _os.path.splitext(file_path)
for preview_ext in (
".preview.png",
".preview.jpg",
".preview.jpeg",
".preview.webp",
):
preview_path = f"{model_base_path}{preview_ext}"
if _os.path.exists(preview_path):
preview_url = preview_path.replace("\\", "/")
break
preview_path = get_existing_model_preview_path(file_path)
if preview_path:
preview_url = preview_path.replace("\\", "/")
metadata_payload = {
"source": "metadata_import",
"details_source": result.get("source") or "metadata",
+186 -47
View File
@@ -16,7 +16,7 @@ import time
from collections import deque
from io import BytesIO
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional
from typing import Any, Callable, Dict, List, Optional, Tuple
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
import requests
@@ -89,7 +89,12 @@ MODEL_PREVIEW_WIDTH = 480
MODEL_PREVIEW_MAX_HEIGHT = 4096
MODEL_PREVIEW_QUALITY = 85
MODEL_PREVIEW_MAX_DOWNLOAD_BYTES = 32 * 1024 * 1024
MODEL_PREVIEW_VIDEO_MAX_DOWNLOAD_BYTES = 128 * 1024 * 1024
MODEL_PREVIEW_EXTENSIONS = (
".mp4",
".webm",
".preview.mp4",
".preview.webm",
".webp",
".jpeg",
".jpg",
@@ -720,7 +725,7 @@ def build_lora_manager_metadata(
def get_existing_model_preview_path(dest_path: str) -> str:
"""Return the first adjacent preview image available for a model file."""
"""Return the first adjacent preview media file available for a model."""
model_base_path, _model_ext = os.path.splitext(dest_path)
for extension in MODEL_PREVIEW_EXTENSIONS:
candidate = f"{model_base_path}{extension}"
@@ -729,13 +734,16 @@ def get_existing_model_preview_path(dest_path: str) -> str:
return ""
def _first_model_preview_url(metadata: Dict[str, Any]) -> str:
candidates = [
metadata.get("preview_url"),
metadata.get("previewUrl"),
metadata.get("thumbnail_url"),
metadata.get("thumbnailUrl"),
]
def _preview_media_type(url: str, declared_type: Any = "") -> str:
media_type = str(declared_type or "").strip().lower()
if media_type == "video":
return "video"
extension = os.path.splitext(urlparse(str(url or "")).path)[1].lower()
return "video" if extension in {".mp4", ".webm"} else "image"
def _first_model_preview_asset(metadata: Dict[str, Any]) -> Tuple[str, str]:
candidates = []
image_sources = [
metadata.get("images"),
_as_dict(metadata.get("civitai")).get("images"),
@@ -749,24 +757,64 @@ def _first_model_preview_url(metadata: Dict[str, Any]) -> str:
for image in _as_list(images):
if not isinstance(image, dict):
continue
if str(image.get("type") or "").strip().lower() == "video":
continue
declared_type = image.get("type") or image.get("mediaType")
candidates.extend(
[
(value, declared_type)
for value in (
image.get("url"),
image.get("imageUrl"),
image.get("src"),
]
)
)
candidates.extend(
[
(metadata.get("preview_url"), metadata.get("preview_type")),
(metadata.get("previewUrl"), metadata.get("previewType")),
(metadata.get("thumbnail_url"), metadata.get("thumbnail_type")),
(metadata.get("thumbnailUrl"), metadata.get("thumbnailType")),
]
)
for value in candidates:
for value, declared_type in candidates:
url = str(value or "").strip()
if url.startswith(("http://", "https://")):
return url
return ""
return url, _preview_media_type(url, declared_type)
return "", ""
def _download_preview_image(url: str) -> bytes:
def _first_model_preview_url(metadata: Dict[str, Any]) -> str:
"""Return the first preview URL, retained for compatibility with callers."""
return _first_model_preview_asset(metadata)[0]
def _rewrite_civitai_preview_url(url: str, media_type: str) -> str:
parsed = urlparse(str(url or ""))
hostname = (parsed.hostname or "").lower()
if not (hostname == "civitai.com" or hostname.endswith(".civitai.com")):
return url
if "/original=true" not in parsed.path:
return url
transform = (
"/transcode=true,width=450,optimized=true"
if media_type == "video"
else "/width=450,optimized=true"
)
return urlunparse(parsed._replace(path=parsed.path.replace("/original=true", transform, 1)))
def _download_preview_asset(url: str, media_type: str = "image") -> bytes:
max_bytes = (
MODEL_PREVIEW_VIDEO_MAX_DOWNLOAD_BYTES
if media_type == "video"
else MODEL_PREVIEW_MAX_DOWNLOAD_BYTES
)
accept_header = (
"video/mp4,video/webm,video/*,*/*;q=0.8"
if media_type == "video"
else "image/avif,image/webp,image/*,*/*;q=0.8"
)
response = None
try:
response, _final_url, _final_headers = request_public_url(
@@ -774,15 +822,15 @@ def _download_preview_image(url: str) -> bytes:
url,
headers={
"User-Agent": DOWNLOAD_USER_AGENT,
"Accept": "image/avif,image/webp,image/*,*/*;q=0.8",
"Accept": accept_header,
},
timeout=20,
stream=True,
)
response.raise_for_status()
content_length = int(response.headers.get("Content-Length") or 0)
if content_length > MODEL_PREVIEW_MAX_DOWNLOAD_BYTES:
raise ValueError("Preview image exceeds the download size limit")
if content_length > max_bytes:
raise ValueError("Preview media exceeds the download size limit")
chunks = []
downloaded = 0
@@ -790,18 +838,31 @@ def _download_preview_image(url: str) -> bytes:
if not chunk:
continue
downloaded += len(chunk)
if downloaded > MODEL_PREVIEW_MAX_DOWNLOAD_BYTES:
raise ValueError("Preview image exceeds the download size limit")
if downloaded > max_bytes:
raise ValueError("Preview media exceeds the download size limit")
chunks.append(chunk)
return b"".join(chunks)
except requests.exceptions.SSLError:
return _download_preview_image_with_system_trust(url)
if media_type == "image":
return _download_preview_image_with_system_trust(url)
return _download_preview_asset_with_system_trust(
url,
media_type=media_type,
)
finally:
if response is not None:
response.close()
def _download_preview_image_with_system_trust(url: str) -> bytes:
def _download_preview_image(url: str) -> bytes:
"""Download an image preview, retained for compatibility with callers."""
return _download_preview_asset(url, media_type="image")
def _download_preview_asset_with_system_trust(
url: str,
media_type: str = "image",
) -> bytes:
import ssl
from urllib.request import (
HTTPRedirectHandler,
@@ -810,6 +871,17 @@ def _download_preview_image_with_system_trust(url: str) -> bytes:
build_opener,
)
max_bytes = (
MODEL_PREVIEW_VIDEO_MAX_DOWNLOAD_BYTES
if media_type == "video"
else MODEL_PREVIEW_MAX_DOWNLOAD_BYTES
)
accept_header = (
"video/mp4,video/webm,video/*,*/*;q=0.8"
if media_type == "video"
else "image/avif,image/webp,image/*,*/*;q=0.8"
)
class ValidatedRedirectHandler(HTTPRedirectHandler):
max_redirections = 5
@@ -829,7 +901,7 @@ def _download_preview_image_with_system_trust(url: str) -> bytes:
validated_url,
headers={
"User-Agent": DOWNLOAD_USER_AGENT,
"Accept": "image/avif,image/webp,image/*,*/*;q=0.8",
"Accept": accept_header,
},
method="GET",
)
@@ -839,8 +911,8 @@ def _download_preview_image_with_system_trust(url: str) -> bytes:
)
with opener.open(request, timeout=20) as response:
content_length = int(response.headers.get("Content-Length") or 0)
if content_length > MODEL_PREVIEW_MAX_DOWNLOAD_BYTES:
raise ValueError("Preview image exceeds the download size limit")
if content_length > max_bytes:
raise ValueError("Preview media exceeds the download size limit")
chunks = []
downloaded = 0
@@ -849,12 +921,44 @@ def _download_preview_image_with_system_trust(url: str) -> bytes:
if not chunk:
break
downloaded += len(chunk)
if downloaded > MODEL_PREVIEW_MAX_DOWNLOAD_BYTES:
raise ValueError("Preview image exceeds the download size limit")
if downloaded > max_bytes:
raise ValueError("Preview media exceeds the download size limit")
chunks.append(chunk)
return b"".join(chunks)
def _download_preview_image_with_system_trust(url: str) -> bytes:
"""Download an image through the Windows trust store fallback."""
return _download_preview_asset_with_system_trust(url, media_type="image")
def _save_preview_video(video_data: bytes, preview_path: str) -> None:
if not video_data:
raise ValueError("Preview video is empty")
temp_path = ""
try:
with tempfile.NamedTemporaryFile(
mode="wb",
dir=os.path.dirname(preview_path),
prefix=f".{os.path.basename(preview_path)}.",
suffix=".tmp",
delete=False,
) as temp_file:
temp_path = temp_file.name
temp_file.write(video_data)
temp_file.flush()
os.fsync(temp_file.fileno())
os.replace(temp_path, preview_path)
temp_path = ""
finally:
if temp_path and os.path.exists(temp_path):
try:
os.remove(temp_path)
except OSError:
pass
def _save_optimized_jpeg(image_data: bytes, preview_path: str) -> None:
from PIL import Image, ImageOps
@@ -917,30 +1021,65 @@ def create_model_preview(
dest_path: str,
metadata: Optional[Dict[str, Any]] = None,
) -> str:
"""Create a compact LoRA Manager-compatible JPEG preview when one is missing."""
"""Create adjacent preview media using LoRA Manager-compatible behavior."""
existing_path = get_existing_model_preview_path(dest_path)
if existing_path:
source = metadata if isinstance(metadata, dict) else {}
preview_url, media_type = _first_model_preview_asset(source)
if not preview_url:
return existing_path
source = metadata if isinstance(metadata, dict) else {}
preview_url = _first_model_preview_url(source)
if not preview_url:
return ""
existing_type = _preview_media_type(existing_path) if existing_path else ""
if existing_path and not (media_type == "video" and existing_type != "video"):
return existing_path
model_base_path, _model_ext = os.path.splitext(dest_path)
if media_type == "video":
source_extension = os.path.splitext(urlparse(preview_url).path)[1].lower()
preview_extension = source_extension if source_extension in {".mp4", ".webm"} else ".mp4"
preview_path = f"{model_base_path}{preview_extension}"
attempts = []
for candidate in (
_rewrite_civitai_preview_url(preview_url, media_type),
preview_url,
):
if candidate and candidate not in attempts:
attempts.append(candidate)
for candidate in attempts:
try:
video_data = _download_preview_asset(candidate, media_type="video")
_save_preview_video(video_data, preview_path)
log.info(f"Video preview saved: {preview_path}")
return preview_path
except Exception as exc:
log.warning(
"Could not download video preview "
f"for {get_filename_from_path(dest_path)} from {candidate}: {exc}"
)
return existing_path
preview_path = f"{model_base_path}.jpeg"
try:
image_data = _download_preview_image(preview_url)
if not image_data:
return ""
_save_optimized_jpeg(image_data, preview_path)
log.info(f"Preview saved: {preview_path}")
return preview_path
except Exception as exc:
log.warning(
f"Could not create preview for {get_filename_from_path(dest_path)}: {exc}"
)
return ""
attempts = []
for candidate in (
_rewrite_civitai_preview_url(preview_url, media_type),
preview_url,
):
if candidate and candidate not in attempts:
attempts.append(candidate)
for candidate in attempts:
try:
image_data = _download_preview_image(candidate)
if not image_data:
continue
_save_optimized_jpeg(image_data, preview_path)
log.info(f"Preview saved: {preview_path}")
return preview_path
except Exception as exc:
log.warning(
"Could not create image preview "
f"for {get_filename_from_path(dest_path)} from {candidate}: {exc}"
)
return existing_path
def write_lora_manager_metadata(
+7
View File
@@ -1968,6 +1968,13 @@ def _metadata_to_model_info(metadata: Dict[str, Any]) -> Dict[str, Any]:
"tags": as_list(metadata.get("tags") or model_info.get("tags")),
"trained_words": trained_words,
"images": images,
"preview_url": first_non_empty(
metadata.get("preview_url"),
metadata.get("previewUrl"),
metadata.get("thumbnail_url"),
metadata.get("thumbnailUrl"),
)
or "",
"clip_skip": civitai_data.get("clipSkip"),
"description": first_non_empty(
metadata.get("modelDescription"),
+6
View File
@@ -813,6 +813,12 @@ def normalize_model_image(image_data: Dict[str, Any], default_civitai_url: str =
return {
"url": url,
"type": (
image_data.get("type")
or image_data.get("mediaType")
or meta.get("type")
or ""
),
"civitaiUrl": civitai_url,
"seed": image_data.get("seed") or meta.get("seed"),
"steps": image_data.get("steps") or meta.get("steps"),
+60
View File
@@ -269,6 +269,66 @@ class DownloaderMetadataSidecarTests(unittest.TestCase):
"https://image.civitai.com/example.jpeg"
)
def test_writes_optimized_civitai_video_preview_and_prefers_it_over_jpeg(self):
video_data = b"\x00\x00\x00\x18ftypmp42preview-video"
response = MagicMock()
response.headers = {"Content-Length": str(len(video_data))}
response.iter_content.return_value = [video_data]
with tempfile.TemporaryDirectory() as tmpdir:
model_path = os.path.join(tmpdir, "video-model.safetensors")
old_image_path = os.path.join(tmpdir, "video-model.jpeg")
with open(model_path, "wb") as handle:
handle.write(b"model")
with open(old_image_path, "wb") as handle:
handle.write(b"old-image")
with patch(
"core.downloader.request_public_url",
return_value=(
response,
"https://image.civitai.com/example.mp4",
{},
),
) as request_preview:
metadata_path = write_lora_manager_metadata(
model_path,
{
"images": [
{
"url": (
"https://image.civitai.com/x/"
"original=true/12345.mp4"
),
"type": "video",
}
],
},
category="loras",
create_preview=True,
)
preview_path = os.path.join(tmpdir, "video-model.mp4")
self.assertTrue(os.path.isfile(preview_path))
with open(preview_path, "rb") as handle:
self.assertEqual(video_data, handle.read())
with open(metadata_path, "r", encoding="utf-8") as handle:
payload = json.load(handle)
self.assertEqual(
preview_path,
downloader.get_existing_model_preview_path(model_path),
)
requested_url = request_preview.call_args.args[1]
self.assertIn(
"/transcode=true,width=450,optimized=true/",
requested_url,
)
self.assertEqual(preview_path.replace(os.sep, "/"), payload["preview_url"])
response.raise_for_status.assert_called_once_with()
response.close.assert_called_once_with()
def test_existing_model_preview_path_finds_adjacent_preview(self):
with tempfile.TemporaryDirectory() as tmpdir:
model_path = os.path.join(tmpdir, "model.safetensors")
@@ -121,6 +121,99 @@ test('model tooltip stays hidden while the context menu is visible', () => {
);
});
test('model tooltip revalidates and detects video previews from the preview route', async () => {
const getTooltipPreviewMediaType = eval(`(${extractMethod(searchPanelMethodsSource, 'getTooltipPreviewMediaType')})`);
const previousFetch = globalThis.fetch;
let fetchCalls = 0;
globalThis.fetch = async () => {
fetchCalls += 1;
return {
ok: true,
headers: {
get(name) {
return name.toLowerCase() === 'content-type' ? 'video/mp4' : '';
},
},
};
};
try {
const dialog = {};
const previewUrl = '/model_resolver/model-preview?path=model.safetensors';
assert.equal(
await getTooltipPreviewMediaType.call(dialog, previewUrl),
'video'
);
assert.equal(
await getTooltipPreviewMediaType.call(dialog, previewUrl),
'video'
);
assert.equal(fetchCalls, 2);
} finally {
globalThis.fetch = previousFetch;
}
});
test('Show info uses the local preview route and renders video media', () => {
const isInfoPreviewVideo = eval(`(${extractMethod(modelInfoMethodsSource, 'isInfoPreviewVideo')})`);
const getInfoDialogMedia = eval(`(${extractMethod(modelInfoMethodsSource, 'getInfoDialogMedia')})`);
const renderInfoPreviewMedia = eval(`(${extractMethod(modelInfoMethodsSource, 'renderInfoPreviewMedia')})`);
const previousApi = globalThis.api;
globalThis.api = {
apiURL(route) {
return `/comfy${route}`;
},
};
try {
const dialog = {
isInfoPreviewVideo,
getModelInfoResolvedPath(data) {
return data.resolved_path || '';
},
escapeHtml(value) {
return String(value).replace(/&/g, '&').replace(/"/g, '"');
},
};
const media = getInfoDialogMedia.call(dialog, {
resolved_path: 'E:\\Models\\video-model.safetensors',
preview_url: 'E:/Models/video-model.mp4',
images: [{
url: 'https://image.civitai.com/original=true/12345.mp4',
type: 'video',
seed: 123,
}],
});
assert.equal(media.length, 1);
assert.equal(media[0].type, 'video');
assert.equal(media[0].seed, 123);
assert.match(
media[0].url,
/^\/comfy\/model_resolver\/model-preview\?path=E%3A%5CModels%5Cvideo-model\.safetensors$/
);
assert.equal(isInfoPreviewVideo({ url: media[0].url, type: 'video' }), true);
const thumbnail = renderInfoPreviewMedia.call(dialog, media[0], { thumbnail: true });
const fullPreview = renderInfoPreviewMedia.call(dialog, media[0]);
assert.match(thumbnail, /^<video /);
assert.match(thumbnail, /autoplay muted loop playsinline/);
assert.match(thumbnail, /draggable="false"/);
assert.doesNotMatch(thumbnail, / controls/);
assert.match(fullPreview, / controls autoplay muted loop playsinline/);
assert.match(
modelInfoMethodsSource,
/renderSourceModelDetails\(data = \{\}, contextModel = \{\}\)[^]*renderInfoPreviewMedia\(image, \{ thumbnail: true \}\)/
);
} 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,
+6 -3
View File
@@ -319,11 +319,14 @@ class SearchOrchestrationTests(unittest.IsolatedAsyncioTestCase):
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")
image_path = os.path.join(temp_dir, "model.jpeg")
preview_path = os.path.join(temp_dir, "model.mp4")
with open(model_path, "wb") as model_file:
model_file.write(b"model")
with open(image_path, "wb") as preview_file:
preview_file.write(b"old-image")
with open(preview_path, "wb") as preview_file:
preview_file.write(b"preview")
preview_file.write(b"video-preview")
mock_request = MagicMock()
mock_request.query = {"path": model_path}
@@ -344,7 +347,7 @@ class SearchOrchestrationTests(unittest.IsolatedAsyncioTestCase):
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"])
self.assertEqual("private, no-cache", response.headers["Cache-Control"])
async def test_search_collects_local_hash_matches_for_any_remote_result_with_hash(self):
file_hash = "c" * 64
+22
View File
@@ -83,6 +83,28 @@ class UnifiedHelpersTests(unittest.TestCase):
self.assertEqual(normalized["steps"], 25)
self.assertEqual(normalized["sampler"], "Euler a")
def test_normalize_model_image_preserves_video_type(self):
normalized = normalize_model_image({
"url": "https://image.civitai.com/x/original=true/12345.mp4",
"type": "video",
})
self.assertEqual(normalized["type"], "video")
def test_local_civitai_metadata_preserves_video_preview_path(self):
from core.sources.civitai import _metadata_to_model_info
result = _metadata_to_model_info({
"preview_url": "E:/Models/video-model.mp4",
"images": [{
"url": "https://image.civitai.com/x/original=true/12345.mp4",
"type": "video",
}],
})
self.assertEqual(result["preview_url"], "E:/Models/video-model.mp4")
self.assertEqual(result["images"][0]["type"], "video")
def test_normalize_model_image_preserves_metadata_when_normalized_again(self):
workflow = '{"workflow":{"nodes":[{"id":1,"type":"KSampler"}]}}'
normalized_image = {
+17 -4
View File
@@ -4756,7 +4756,8 @@ button.mr-info-link:disabled {
cursor: zoom-in;
}
.mr-info-image-item img {
.mr-info-image-item img,
.mr-info-image-item video {
width: 100%;
height: auto;
display: block;
@@ -4859,7 +4860,8 @@ button.mr-info-link:disabled {
font-weight: 700;
}
.mr-info-image-item:hover img {
.mr-info-image-item:hover img,
.mr-info-image-item:hover video {
transform: scale(1.01);
filter: brightness(0.82);
}
@@ -5061,7 +5063,8 @@ button.mr-info-link:disabled {
background: #0b0c0e;
}
.mr-image-preview-stage img {
.mr-image-preview-stage img,
.mr-image-preview-stage video {
max-width: 100%;
max-height: 100%;
object-fit: contain;
@@ -5525,12 +5528,16 @@ a.mr-image-preview-resource-name:hover {
cursor: grabbing;
}
.mr-model-details-image img {
.mr-model-details-image img,
.mr-model-details-image video {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
object-position: center;
pointer-events: none;
user-select: none;
-webkit-user-drag: none;
}
.mr-model-details-image-meta {
@@ -7584,6 +7591,12 @@ a.mr-image-preview-resource-name:hover {
overflow-wrap: anywhere;
}
.mr-global-tooltip-with-image .mr-tooltip-label {
width: 100%;
box-sizing: border-box;
text-align: center;
}
.mr-options-input-row {
display: grid;
grid-template-columns: minmax(150px, 1fr) minmax(0, 380px) 42px;
+55 -12
View File
@@ -784,7 +784,26 @@ export const searchPanelMethods = {
return `<span class="mr-node-chip-icon" aria-hidden="true">${getSvgIcon('locate')}</span>`;
},
showTooltip(target) {
async getTooltipPreviewMediaType(previewUrl) {
if (!previewUrl) return 'image';
let mediaType = 'image';
try {
const response = await fetch(previewUrl, {
method: 'HEAD',
cache: 'no-cache'
});
const contentType = String(response.headers.get('Content-Type') || '').toLowerCase();
if (response.ok && contentType.startsWith('video/')) {
mediaType = 'video';
}
} catch (error) {
console.debug('Model Resolver: Could not detect tooltip preview type:', error);
}
return mediaType;
},
async showTooltip(target) {
if (!target || !this.tooltipElement) return;
if (this.contextMenu?.style.display === 'block') {
this.hideTooltip();
@@ -801,31 +820,55 @@ export const searchPanelMethods = {
const imageUrl = target.getAttribute('data-tooltip-image');
this._tooltipTarget = target;
this.tooltipElement.replaceChildren();
this.tooltipElement.classList.toggle('mr-global-tooltip-with-image', Boolean(imageUrl));
this.tooltipElement.classList.remove('mr-global-tooltip-with-image');
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;
this.tooltipElement.append(label);
this.tooltipElement.style.display = 'block';
this.positionTooltip(target);
this.tooltipElement.setAttribute('data-visible', 'true');
image.addEventListener('load', () => {
const mediaType = await this.getTooltipPreviewMediaType(imageUrl);
if (
this._tooltipTarget !== target
|| this.contextMenu?.style.display === 'block'
) {
return;
}
const media = document.createElement(mediaType === 'video' ? 'video' : 'img');
media.className = 'mr-tooltip-preview';
if (mediaType === 'video') {
media.autoplay = true;
media.loop = true;
media.muted = true;
media.playsInline = true;
media.preload = 'metadata';
} else {
media.alt = '';
media.decoding = 'async';
}
media.addEventListener(mediaType === 'video' ? 'loadeddata' : 'load', () => {
if (this._tooltipTarget === target) {
this.positionTooltip(target);
if (mediaType === 'video') {
media.play?.().catch(() => {});
}
}
});
image.addEventListener('error', () => {
media.addEventListener('error', () => {
if (this._tooltipTarget !== target) return;
image.remove();
media.remove();
this.tooltipElement.classList.remove('mr-global-tooltip-with-image');
this.positionTooltip(target);
});
image.src = imageUrl;
this.tooltipElement.append(image, label);
media.src = imageUrl;
this.tooltipElement.prepend(media);
this.tooltipElement.classList.add('mr-global-tooltip-with-image');
} else {
this.tooltipElement.textContent = text;
}
+75 -16
View File
@@ -2281,12 +2281,71 @@ export const modelInfoMethods = {
}
}
// Update images
this.updateInfoDialogImages(dialog, data.images || data.modelImages || []);
// Update preview media
this.updateInfoDialogImages(
dialog,
this.getInfoDialogMedia(dialog._infoDialogData)
);
},
isInfoPreviewVideo(media = {}) {
const declaredType = String(media.type || media.mediaType || '').trim().toLowerCase();
if (declaredType === 'video') return true;
let url = String(media.url || media.preview_url || media.previewUrl || '');
try {
url = decodeURIComponent(url);
} catch (_error) {
// Keep the original URL when it contains malformed escape sequences.
}
return /\.(?:mp4|webm)(?:$|[?#&])/i.test(url);
},
getInfoDialogMedia(data = {}) {
const sourceMedia = (
Array.isArray(data.images)
? data.images
: Array.isArray(data.modelImages)
? data.modelImages
: []
).filter(media => media?.url);
const previewPath = String(data.preview_url || data.previewUrl || '').trim();
const modelPath = this.getModelInfoResolvedPath(data);
const isRemotePreview = /^(?:https?:|data:|blob:)/i.test(previewPath);
if (previewPath && modelPath && !isRemotePreview) {
const endpoint = api.apiURL('/model_resolver/model-preview');
const localPreview = {
...(sourceMedia[0] || {}),
url: `${endpoint}?path=${encodeURIComponent(modelPath)}`,
type: this.isInfoPreviewVideo({ url: previewPath }) ? 'video' : 'image'
};
return [localPreview, ...sourceMedia.slice(1)];
}
if (!sourceMedia.length && previewPath) {
return [{
url: previewPath,
type: this.isInfoPreviewVideo({ url: previewPath }) ? 'video' : 'image'
}];
}
return sourceMedia;
},
renderInfoPreviewMedia(media = {}, options = {}) {
const url = this.escapeHtml(media.url || '');
const thumbnail = Boolean(options.thumbnail);
if (this.isInfoPreviewVideo(media)) {
return thumbnail
? `<video src="${url}" autoplay muted loop playsinline preload="metadata" draggable="false" aria-label="Example video"></video>`
: `<video src="${url}" controls autoplay muted loop playsinline preload="metadata" aria-label="Preview video"></video>`;
}
return thumbnail
? `<img src="${url}" alt="Example" loading="lazy" draggable="false" />`
: `<img src="${url}" alt="Preview image">`;
},
/**
* Update images in the info dialog
* Update preview media in the info dialog
*/
updateInfoDialogImages(dialog, images) {
const imagesContainer = dialog.querySelector('.mr-info-images');
@@ -2319,8 +2378,8 @@ export const modelInfoMethods = {
CivitAI
</a>
` : ''}
<button type="button" class="mr-info-image-preview-btn" data-image-index="${index}" aria-label="Preview example image ${index + 1}">
<img src="${this.escapeHtml(img.url)}" alt="Example" loading="lazy" />
<button type="button" class="mr-info-image-preview-btn" data-image-index="${index}" aria-label="Preview example media ${index + 1}">
${this.renderInfoPreviewMedia(img, { thumbnail: true })}
<span class="mr-info-image-preview-label">${getSvgIcon('eye')} Preview</span>
</button>
<figcaption>${captionParts.join('')}</figcaption>
@@ -2329,7 +2388,7 @@ export const modelInfoMethods = {
`;
};
let imagesHtml = '<div class="mr-info-images-header">Example Images</div><div class="mr-info-images-layout">';
let imagesHtml = '<div class="mr-info-images-header">Example Media</div><div class="mr-info-images-layout">';
imagesHtml += visibleImages.map(renderImageCard).join('');
imagesHtml += '</div>';
imagesContainer.innerHTML = imagesHtml;
@@ -2421,7 +2480,7 @@ export const modelInfoMethods = {
const resources = this.getInfoImageResources(image);
const comfyWorkflow = extractComfyWorkflow(image);
const hasPrevious = images.length > 1;
const positionText = images.length > 1 ? `${index + 1} / ${images.length}` : '1 image';
const positionText = images.length > 1 ? `${index + 1} / ${images.length}` : '1 media';
const metadataHtml = metadataRows.length
? metadataRows.map(([label, value]) => `
@@ -2455,7 +2514,7 @@ export const modelInfoMethods = {
: '';
return `
<div class="mr-image-preview-shell" role="dialog" aria-modal="true" aria-label="Image preview">
<div class="mr-image-preview-shell" role="dialog" aria-modal="true" aria-label="Media preview">
<div class="mr-image-preview-topbar">
<button type="button" class="mr-image-preview-icon-btn" data-action="close" aria-label="Close preview">${getSvgIcon('x')}</button>
<div class="mr-image-preview-counter">${this.escapeHtml(positionText)}</div>
@@ -2465,9 +2524,9 @@ export const modelInfoMethods = {
</div>
<main class="mr-image-preview-main">
<section class="mr-image-preview-stage">
${hasPrevious ? `<button type="button" class="mr-image-preview-nav is-left" data-action="previous" aria-label="Previous image">&lsaquo;</button>` : ''}
<img src="${this.escapeHtml(image.url || '')}" alt="Preview image">
${hasPrevious ? `<button type="button" class="mr-image-preview-nav is-right" data-action="next" aria-label="Next image">&rsaquo;</button>` : ''}
${hasPrevious ? `<button type="button" class="mr-image-preview-nav is-left" data-action="previous" aria-label="Previous media">&lsaquo;</button>` : ''}
${this.renderInfoPreviewMedia(image)}
${hasPrevious ? `<button type="button" class="mr-image-preview-nav is-right" data-action="next" aria-label="Next media">&rsaquo;</button>` : ''}
</section>
<aside class="mr-image-preview-panel">
<section class="mr-image-preview-card">
@@ -2778,16 +2837,16 @@ export const modelInfoMethods = {
`).join('')}
</div>
<div class="mr-model-details-gallery ${images.length === 1 ? 'is-single' : ''}">
${images.length > 1 ? `<button type="button" class="mr-model-details-gallery-nav is-left" data-gallery-direction="-1" aria-label="Previous images">&lsaquo;</button>` : ''}
${images.length > 1 ? `<button type="button" class="mr-model-details-gallery-nav is-left" data-gallery-direction="-1" aria-label="Previous media">&lsaquo;</button>` : ''}
<div class="mr-model-details-gallery-strip">
${images.length ? images.map((image, index) => `
<button type="button" class="mr-model-details-image" style="${this.getSourceModelImageSizingStyle(image)}" data-image-index="${index}">
<img src="${this.escapeHtml(image.url)}" alt="Example image" loading="lazy" draggable="false">
<button type="button" class="mr-model-details-image" style="${this.getSourceModelImageSizingStyle(image)}" data-image-index="${index}" aria-label="Preview example media ${index + 1}">
${this.renderInfoPreviewMedia(image, { thumbnail: true })}
${this.renderSourceModelImageMeta(image)}
</button>
`).join('') : '<div class="mr-model-details-empty">No example images available.</div>'}
`).join('') : '<div class="mr-model-details-empty">No example media available.</div>'}
</div>
${images.length > 1 ? `<button type="button" class="mr-model-details-gallery-nav is-right" data-gallery-direction="1" aria-label="Next images">&rsaquo;</button>` : ''}
${images.length > 1 ? `<button type="button" class="mr-model-details-gallery-nav is-right" data-gallery-direction="1" aria-label="Next media">&rsaquo;</button>` : ''}
</div>
<div class="mr-model-details-description">
<h3>About</h3>