feat: support video previews in model downloads, tooltips, and info dialogs
This commit is contained in:
+4
-12
@@ -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
@@ -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(
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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">‹</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">›</button>` : ''}
|
||||
${hasPrevious ? `<button type="button" class="mr-image-preview-nav is-left" data-action="previous" aria-label="Previous media">‹</button>` : ''}
|
||||
${this.renderInfoPreviewMedia(image)}
|
||||
${hasPrevious ? `<button type="button" class="mr-image-preview-nav is-right" data-action="next" aria-label="Next media">›</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">‹</button>` : ''}
|
||||
${images.length > 1 ? `<button type="button" class="mr-model-details-gallery-nav is-left" data-gallery-direction="-1" aria-label="Previous media">‹</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">›</button>` : ''}
|
||||
${images.length > 1 ? `<button type="button" class="mr-model-details-gallery-nav is-right" data-gallery-direction="1" aria-label="Next media">›</button>` : ''}
|
||||
</div>
|
||||
<div class="mr-model-details-description">
|
||||
<h3>About</h3>
|
||||
|
||||
Reference in New Issue
Block a user