feat: enrich LoRA sidebar with shared media
This commit is contained in:
@@ -102,7 +102,8 @@ All nodes appear under the **Lora Manager** category in the ComfyUI node menu, w
|
|||||||
|
|
||||||
Selecting a LoRA loader opens the **LoRA Info** sidebar and follows the node's current selection. It supports the stock ComfyUI loader, LM Remote nodes, and third-party loaders that expose standard `lora_name`, numbered LoRA, stack, or `<lora:name:strength>` values.
|
Selecting a LoRA loader opens the **LoRA Info** sidebar and follows the node's current selection. It supports the stock ComfyUI loader, LM Remote nodes, and third-party loaders that expose standard `lora_name`, numbered LoRA, stack, or `<lora:name:strength>` values.
|
||||||
|
|
||||||
- If the selected LoRA is indexed by the remote LoRA Manager, the sidebar shows its preview, file details, base model, trigger words, tags, usage tips, and direct model links.
|
- If the selected LoRA is indexed by the remote LoRA Manager, the sidebar shows its image or video preview, file details, base model, trigger words, tags, usage tips, and direct model links.
|
||||||
|
- Cached community creations and Civitai examples appear with video controls, their shared prompt and negative prompt, generation settings, attribution, navigation, and one-click prompt copying.
|
||||||
- If a node contains multiple active LoRAs, use the selector at the top of the sidebar to switch cards.
|
- If a node contains multiple active LoRAs, use the selector at the top of the sidebar to switch cards.
|
||||||
- If no Manager card exists, the sidebar offers name searches on LoRA Manager, Civitai, Civitai Red, and CivArchive.
|
- If no Manager card exists, the sidebar offers name searches on LoRA Manager, Civitai, Civitai Red, and CivArchive.
|
||||||
- Duplicate filenames are not guessed: the sidebar asks you to choose the matching Manager path.
|
- Duplicate filenames are not guessed: the sidebar asks you to choose the matching Manager path.
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ _TEST_CONNECTION_ROUTE = "/api/lm-remote/test-connection"
|
|||||||
_PROXY_HOP_HEADER = "X-LM-Remote-Proxy"
|
_PROXY_HOP_HEADER = "X-LM-Remote-Proxy"
|
||||||
_MAX_CONFIG_BODY = 64 * 1024
|
_MAX_CONFIG_BODY = 64 * 1024
|
||||||
_MAX_TEST_RESPONSE = 64 * 1024
|
_MAX_TEST_RESPONSE = 64 * 1024
|
||||||
|
_MEDIA_STREAM_CHUNK_SIZE = 64 * 1024
|
||||||
|
_MEDIA_IDLE_TIMEOUT = 300
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# URL prefixes that should be forwarded to the remote LoRA Manager
|
# URL prefixes that should be forwarded to the remote LoRA Manager
|
||||||
@@ -616,6 +618,22 @@ def _is_ws_route(path: str) -> bool:
|
|||||||
return path in _WS_ROUTES
|
return path in _WS_ROUTES
|
||||||
|
|
||||||
|
|
||||||
|
def _is_streaming_media_route(path: str) -> bool:
|
||||||
|
"""Return whether a proxied response must be relayed incrementally."""
|
||||||
|
return path.startswith("/api/lm/previews") or path.startswith(
|
||||||
|
"/example_images_static/"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _media_request_timeout(snapshot: ConfigSnapshot) -> aiohttp.ClientTimeout:
|
||||||
|
"""Allow long playback while still bounding connect and idle stalls."""
|
||||||
|
return aiohttp.ClientTimeout(
|
||||||
|
total=None,
|
||||||
|
sock_connect=min(snapshot.timeout, 30),
|
||||||
|
sock_read=max(snapshot.timeout, _MEDIA_IDLE_TIMEOUT),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _proxy_ws(
|
async def _proxy_ws(
|
||||||
request: web.Request, snapshot: ConfigSnapshot
|
request: web.Request, snapshot: ConfigSnapshot
|
||||||
) -> web.WebSocketResponse:
|
) -> web.WebSocketResponse:
|
||||||
@@ -707,7 +725,9 @@ async def _proxy_ws(
|
|||||||
return local_ws
|
return local_ws
|
||||||
|
|
||||||
|
|
||||||
async def _proxy_http(request: web.Request, snapshot: ConfigSnapshot) -> web.Response:
|
async def _proxy_http(
|
||||||
|
request: web.Request, snapshot: ConfigSnapshot
|
||||||
|
) -> web.StreamResponse:
|
||||||
"""Forward an HTTP request to the remote LoRA Manager and return its response."""
|
"""Forward an HTTP request to the remote LoRA Manager and return its response."""
|
||||||
remote_url = f"{snapshot.remote_url}{request.path}"
|
remote_url = f"{snapshot.remote_url}{request.path}"
|
||||||
if request.query_string:
|
if request.query_string:
|
||||||
@@ -735,14 +755,48 @@ async def _proxy_http(request: web.Request, snapshot: ConfigSnapshot) -> web.Res
|
|||||||
headers[k] = v
|
headers[k] = v
|
||||||
headers[_PROXY_HOP_HEADER] = "1"
|
headers[_PROXY_HOP_HEADER] = "1"
|
||||||
|
|
||||||
|
streaming_media = _is_streaming_media_route(request.path)
|
||||||
|
downstream: web.StreamResponse | None = None
|
||||||
try:
|
try:
|
||||||
async with _proxy_session_lease(snapshot) as session:
|
async with _proxy_session_lease(snapshot) as session:
|
||||||
|
request_options = {
|
||||||
|
"method": request.method,
|
||||||
|
"url": remote_url,
|
||||||
|
"headers": headers,
|
||||||
|
"data": body,
|
||||||
|
}
|
||||||
|
if streaming_media:
|
||||||
|
request_options.update(
|
||||||
|
{
|
||||||
|
"timeout": _media_request_timeout(snapshot),
|
||||||
|
"auto_decompress": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
async with session.request(
|
async with session.request(
|
||||||
method=request.method,
|
**request_options,
|
||||||
url=remote_url,
|
|
||||||
headers=headers,
|
|
||||||
data=body,
|
|
||||||
) as resp:
|
) as resp:
|
||||||
|
if streaming_media:
|
||||||
|
resp_headers = {}
|
||||||
|
for k, v in resp.headers.items():
|
||||||
|
if k.lower() not in (
|
||||||
|
"transfer-encoding",
|
||||||
|
"connection",
|
||||||
|
"set-cookie",
|
||||||
|
):
|
||||||
|
resp_headers[k] = v
|
||||||
|
downstream = web.StreamResponse(
|
||||||
|
status=resp.status,
|
||||||
|
headers=resp_headers,
|
||||||
|
)
|
||||||
|
await downstream.prepare(request)
|
||||||
|
if request.method != "HEAD":
|
||||||
|
async for chunk in resp.content.iter_chunked(
|
||||||
|
_MEDIA_STREAM_CHUNK_SIZE
|
||||||
|
):
|
||||||
|
await downstream.write(chunk)
|
||||||
|
await downstream.write_eof()
|
||||||
|
return downstream
|
||||||
|
|
||||||
resp_body = await resp.read()
|
resp_body = await resp.read()
|
||||||
resp_headers = {}
|
resp_headers = {}
|
||||||
for k, v in resp.headers.items():
|
for k, v in resp.headers.items():
|
||||||
@@ -759,6 +813,14 @@ async def _proxy_http(request: web.Request, snapshot: ConfigSnapshot) -> web.Res
|
|||||||
headers=resp_headers,
|
headers=resp_headers,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
if downstream is not None and downstream.prepared:
|
||||||
|
logger.debug(
|
||||||
|
"[LM-Remote] Media stream ended for %s %s: %s",
|
||||||
|
request.method,
|
||||||
|
request.path,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
return downstream
|
||||||
logger.error(
|
logger.error(
|
||||||
"[LM-Remote] Proxy error for %s %s: %s", request.method, request.path, exc
|
"[LM-Remote] Proxy error for %s %s: %s", request.method, request.path, exc
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,8 +5,12 @@ import {
|
|||||||
buildExternalLinks,
|
buildExternalLinks,
|
||||||
extractLoraNames,
|
extractLoraNames,
|
||||||
getSelectedGraphNodes,
|
getSelectedGraphNodes,
|
||||||
|
isVideoMedia,
|
||||||
matchModelItems,
|
matchModelItems,
|
||||||
|
mergeCivitaiMetadata,
|
||||||
normalizeLoraIdentifier,
|
normalizeLoraIdentifier,
|
||||||
|
normalizeMediaSettings,
|
||||||
|
normalizeSharedMedia,
|
||||||
normalizeUsageTips,
|
normalizeUsageTips,
|
||||||
} from "../../web/comfyui/lora_manager_sidebar_utils.js";
|
} from "../../web/comfyui/lora_manager_sidebar_utils.js";
|
||||||
|
|
||||||
@@ -31,6 +35,168 @@ test("formats Manager usage presets and hides empty JSON", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("detects direct and encoded video preview URLs", () => {
|
||||||
|
assert.equal(
|
||||||
|
isVideoMedia(
|
||||||
|
"/api/lm/previews?path=%2Fmodels%2Floras%2Fexample.MP4"
|
||||||
|
),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert.equal(isVideoMedia("https://example.com/demo.WEBM?download=1"), true);
|
||||||
|
assert.equal(isVideoMedia("https://example.com/media/42", "video"), true);
|
||||||
|
assert.equal(isVideoMedia("https://example.com/still.png"), false);
|
||||||
|
assert.doesNotThrow(() => isVideoMedia("/preview?path=%E0%A4%A"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("merges full Civitai metadata without losing resolve fields", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
mergeCivitaiMetadata(
|
||||||
|
{ id: 7, modelId: 9, trainedWords: ["portrait"] },
|
||||||
|
{ description: "Full details", images: [{ id: 1 }] }
|
||||||
|
),
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
modelId: 9,
|
||||||
|
trainedWords: ["portrait"],
|
||||||
|
description: "Full details",
|
||||||
|
images: [{ id: 1 }],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizes community and Civitai media with associated prompts", () => {
|
||||||
|
const media = normalizeSharedMedia(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
civitai_image_id: 10,
|
||||||
|
preview_url:
|
||||||
|
"/api/lm/previews?path=%2Fexamples%2Fcommunity-video.mp4",
|
||||||
|
media_type: "video",
|
||||||
|
prompt: "community prompt",
|
||||||
|
negative_prompt: "community negative",
|
||||||
|
username: "artist",
|
||||||
|
steps: 20,
|
||||||
|
cfg_scale: 4.5,
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
url: "https://example.com/duplicate.mp4",
|
||||||
|
type: "video",
|
||||||
|
nsfwLevel: 16,
|
||||||
|
meta: { prompt: "duplicate prompt" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 11,
|
||||||
|
url: "https://example.com/still.webp",
|
||||||
|
meta: {
|
||||||
|
meta: {
|
||||||
|
prompt: "example prompt",
|
||||||
|
negativePrompt: "example negative",
|
||||||
|
sampler: "Euler",
|
||||||
|
seed: 12,
|
||||||
|
Size: "640x480",
|
||||||
|
Model: "Example checkpoint",
|
||||||
|
clipSkip: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
customImages: [
|
||||||
|
{ id: 12, url: "javascript:alert(1)", meta: { prompt: "unsafe" } },
|
||||||
|
{
|
||||||
|
id: 14,
|
||||||
|
url: "https://user:password@example.com/private.webp",
|
||||||
|
meta: { prompt: "credential leak" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 13,
|
||||||
|
url: "https://example.com/failed.webp",
|
||||||
|
downloadFailed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "local-one",
|
||||||
|
url: "",
|
||||||
|
meta: { prompt: "local custom prompt" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
[
|
||||||
|
{
|
||||||
|
name: "custom_local-one.webm",
|
||||||
|
path: "/example_images_static/abc/custom_local-one.webm",
|
||||||
|
is_video: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(media.length, 3);
|
||||||
|
assert.deepEqual(
|
||||||
|
{
|
||||||
|
source: media[0].source,
|
||||||
|
mediaType: media[0].mediaType,
|
||||||
|
prompt: media[0].prompt,
|
||||||
|
negativePrompt: media[0].negativePrompt,
|
||||||
|
username: media[0].username,
|
||||||
|
steps: media[0].steps,
|
||||||
|
cfgScale: media[0].cfgScale,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: "Community creation",
|
||||||
|
mediaType: "video",
|
||||||
|
prompt: "community prompt",
|
||||||
|
negativePrompt: "community negative",
|
||||||
|
username: "artist",
|
||||||
|
steps: 20,
|
||||||
|
cfgScale: 4.5,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert.equal(media[0].url.includes("/api/lm/previews"), true);
|
||||||
|
assert.equal(media[0].nsfwLevel, 16);
|
||||||
|
assert.equal(media[1].prompt, "example prompt");
|
||||||
|
assert.equal(media[1].negativePrompt, "example negative");
|
||||||
|
assert.equal(media[1].sampler, "Euler");
|
||||||
|
assert.equal(media[1].seed, 12);
|
||||||
|
assert.equal(media[1].width, 640);
|
||||||
|
assert.equal(media[1].height, 480);
|
||||||
|
assert.equal(media[1].modelName, "Example checkpoint");
|
||||||
|
assert.equal(media[1].clipSkip, 2);
|
||||||
|
assert.equal(media[2].url, "/example_images_static/abc/custom_local-one.webm");
|
||||||
|
assert.equal(media[2].mediaType, "video");
|
||||||
|
assert.equal(media[2].prompt, "local custom prompt");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("optimizes Civitai media and normalizes mature-content settings", () => {
|
||||||
|
const media = normalizeSharedMedia([], {
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
url: "https://image.civitai.com/example/original=true/video.mp4",
|
||||||
|
type: "video",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
assert.equal(media.length, 1);
|
||||||
|
assert.match(media[0].url, /transcode=true,width=450,optimized=true/);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
normalizeMediaSettings({
|
||||||
|
blur_mature_content: false,
|
||||||
|
mature_blur_level: "XXX",
|
||||||
|
show_only_sfw: true,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
blurMatureContent: false,
|
||||||
|
matureBlurLevel: 16,
|
||||||
|
showOnlySfw: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("extracts stock and numbered LoRA loader widgets", () => {
|
test("extracts stock and numbered LoRA loader widgets", () => {
|
||||||
const node = {
|
const node = {
|
||||||
comfyClass: "Power Lora Loader",
|
comfyClass: "Power Lora Loader",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import importlib.util
|
|||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
import types
|
import types
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -508,6 +509,103 @@ class FakeSession:
|
|||||||
self.closed = True
|
self.closed = True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_media_proxy_streams_range_response_without_buffering(
|
||||||
|
isolated_proxy, modules, monkeypatch
|
||||||
|
):
|
||||||
|
proxy, config = isolated_proxy
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
class UpstreamContent:
|
||||||
|
async def iter_chunked(self, size):
|
||||||
|
assert size == proxy._MEDIA_STREAM_CHUNK_SIZE
|
||||||
|
for chunk in (b"abc", b"def"):
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
class UpstreamResponse:
|
||||||
|
status = 206
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "video/mp4",
|
||||||
|
"Content-Length": "6",
|
||||||
|
"Content-Range": "bytes 0-5/20",
|
||||||
|
"Accept-Ranges": "bytes",
|
||||||
|
"Set-Cookie": "remote=secret",
|
||||||
|
}
|
||||||
|
content = UpstreamContent()
|
||||||
|
read_called = False
|
||||||
|
|
||||||
|
async def read(self):
|
||||||
|
self.read_called = True
|
||||||
|
raise AssertionError("streaming media must not call read()")
|
||||||
|
|
||||||
|
upstream = UpstreamResponse()
|
||||||
|
|
||||||
|
class RequestContext:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return upstream
|
||||||
|
|
||||||
|
async def __aexit__(self, *_args):
|
||||||
|
return None
|
||||||
|
|
||||||
|
class StreamingSession:
|
||||||
|
def request(self, **kwargs):
|
||||||
|
captured["request"] = kwargs
|
||||||
|
return RequestContext()
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def fake_lease(snapshot):
|
||||||
|
captured["generation"] = snapshot.generation
|
||||||
|
yield StreamingSession()
|
||||||
|
|
||||||
|
class DownstreamResponse:
|
||||||
|
def __init__(self, *, status, headers):
|
||||||
|
self.status = status
|
||||||
|
self.headers = headers
|
||||||
|
self.prepared = False
|
||||||
|
self.chunks = []
|
||||||
|
self.eof = False
|
||||||
|
|
||||||
|
async def prepare(self, request):
|
||||||
|
self.prepared = True
|
||||||
|
self.request = request
|
||||||
|
|
||||||
|
async def write(self, chunk):
|
||||||
|
self.chunks.append(chunk)
|
||||||
|
|
||||||
|
async def write_eof(self):
|
||||||
|
self.eof = True
|
||||||
|
|
||||||
|
monkeypatch.setattr(proxy, "_proxy_session_lease", fake_lease)
|
||||||
|
monkeypatch.setattr(proxy.web, "StreamResponse", DownstreamResponse)
|
||||||
|
snapshot = modules.config.ConfigSnapshot(
|
||||||
|
config.generation,
|
||||||
|
"http://manager.local:8188",
|
||||||
|
30,
|
||||||
|
(),
|
||||||
|
)
|
||||||
|
request = DummyRequest(
|
||||||
|
path="/api/lm/previews",
|
||||||
|
headers={"Range": "bytes=0-"},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await proxy._proxy_http(request, snapshot)
|
||||||
|
|
||||||
|
assert response.status == 206
|
||||||
|
assert response.chunks == [b"abc", b"def"]
|
||||||
|
assert response.eof is True
|
||||||
|
assert upstream.read_called is False
|
||||||
|
assert response.headers["Content-Range"] == "bytes 0-5/20"
|
||||||
|
assert response.headers["Accept-Ranges"] == "bytes"
|
||||||
|
assert response.headers["Content-Length"] == "6"
|
||||||
|
assert "Set-Cookie" not in response.headers
|
||||||
|
assert captured["request"]["headers"]["Range"] == "bytes=0-"
|
||||||
|
assert captured["request"]["auto_decompress"] is False
|
||||||
|
timeout = captured["request"]["timeout"]
|
||||||
|
assert timeout.total is None
|
||||||
|
assert timeout.sock_connect == 30
|
||||||
|
assert timeout.sock_read == proxy._MEDIA_IDLE_TIMEOUT
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_proxy_session_does_not_retain_remote_cookies(
|
async def test_proxy_session_does_not_retain_remote_cookies(
|
||||||
isolated_proxy, monkeypatch
|
isolated_proxy, monkeypatch
|
||||||
|
|||||||
@@ -163,13 +163,38 @@
|
|||||||
background: #101012;
|
background: #101012;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lmri-preview img {
|
.lmri-preview img,
|
||||||
|
.lmri-preview video {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: block;
|
display: block;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lmri-preview video {
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lmri-media-blurred {
|
||||||
|
filter: blur(22px) brightness(0.55);
|
||||||
|
transform: scale(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lmri-media-reveal {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border: 1px solid rgb(255 255 255 / 45%);
|
||||||
|
border-radius: 7px;
|
||||||
|
background: rgb(10 10 12 / 82%);
|
||||||
|
color: white;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
.lmri-card-body,
|
.lmri-card-body,
|
||||||
.lmri-notice {
|
.lmri-notice {
|
||||||
padding: 13px;
|
padding: 13px;
|
||||||
@@ -279,6 +304,191 @@
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lmri-shared {
|
||||||
|
margin-top: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--lmri-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: color-mix(in srgb, var(--lmri-bg) 58%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lmri-shared-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lmri-shared-header h3,
|
||||||
|
.lmri-shared-prompt h4 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--lmri-text);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lmri-shared-source {
|
||||||
|
display: block;
|
||||||
|
margin-top: 1px;
|
||||||
|
color: var(--lmri-muted);
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lmri-shared-navigation {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lmri-shared-nav,
|
||||||
|
.lmri-copy-button {
|
||||||
|
border: 1px solid var(--lmri-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--lmri-panel);
|
||||||
|
color: var(--lmri-text);
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lmri-shared-nav {
|
||||||
|
display: grid;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
place-items: center;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lmri-shared-position {
|
||||||
|
min-width: 29px;
|
||||||
|
color: var(--lmri-muted);
|
||||||
|
font-size: 9px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lmri-shared-dots {
|
||||||
|
display: flex;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 8px 9px 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lmri-shared-dot {
|
||||||
|
flex: 0 0 8px;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--lmri-muted) 52%, transparent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lmri-shared-dot.active {
|
||||||
|
background: var(--lmri-accent);
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--lmri-accent) 28%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lmri-shared-dot:focus-visible {
|
||||||
|
outline: 2px solid color-mix(in srgb, var(--lmri-accent) 75%, white 15%);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lmri-shared-viewer {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 120px;
|
||||||
|
max-height: 360px;
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
overflow: hidden;
|
||||||
|
border-block: 1px solid var(--lmri-border);
|
||||||
|
background: #101012;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lmri-shared-viewer img,
|
||||||
|
.lmri-shared-viewer video {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lmri-shared-params {
|
||||||
|
padding: 8px 9px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lmri-shared-prompt {
|
||||||
|
padding: 9px 10px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lmri-prompt-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lmri-copy-button {
|
||||||
|
min-width: 48px;
|
||||||
|
padding: 3px 7px;
|
||||||
|
color: var(--lmri-muted);
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lmri-copy-button:disabled {
|
||||||
|
cursor: wait;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lmri-shared-prompt-text,
|
||||||
|
.lmri-negative-prompt p {
|
||||||
|
margin: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
color: var(--lmri-text);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.45;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lmri-shared-prompt-text {
|
||||||
|
max-height: 9em;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lmri-negative-prompt {
|
||||||
|
padding: 0 10px 10px;
|
||||||
|
color: var(--lmri-muted);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lmri-negative-prompt summary {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lmri-negative-prompt p {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: var(--lmri-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lmri-enrichment-state {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
margin-top: 12px;
|
||||||
|
color: var(--lmri-muted);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lmri-shared-nav:hover,
|
||||||
|
.lmri-copy-button:hover,
|
||||||
|
.lmri-media-reveal:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--lmri-accent) 75%, var(--lmri-border));
|
||||||
|
}
|
||||||
|
|
||||||
.lmri-actions {
|
.lmri-actions {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
|||||||
@@ -6,9 +6,13 @@ import {
|
|||||||
cleanLoraName,
|
cleanLoraName,
|
||||||
extractLoraNames,
|
extractLoraNames,
|
||||||
getSelectedGraphNodes,
|
getSelectedGraphNodes,
|
||||||
|
isVideoMedia,
|
||||||
loraSearchTerm,
|
loraSearchTerm,
|
||||||
matchModelItems,
|
matchModelItems,
|
||||||
|
mergeCivitaiMetadata,
|
||||||
normalizeLoraIdentifier,
|
normalizeLoraIdentifier,
|
||||||
|
normalizeMediaSettings,
|
||||||
|
normalizeSharedMedia,
|
||||||
normalizeUsageTips,
|
normalizeUsageTips,
|
||||||
} from "./lora_manager_sidebar_utils.js";
|
} from "./lora_manager_sidebar_utils.js";
|
||||||
import { openRemoteConfigDialog } from "./remote_config_dialog.js";
|
import { openRemoteConfigDialog } from "./remote_config_dialog.js";
|
||||||
@@ -21,6 +25,8 @@ const AUTO_OPEN_SETTING = "LMRemote.LoraInfo.AutoOpen";
|
|||||||
const STYLE_ID = "lm-remote-lora-info-style";
|
const STYLE_ID = "lm-remote-lora-info-style";
|
||||||
const NODE_SELECTION_HOOK = Symbol.for("lmRemote.loraInfo.nodeSelectionHook");
|
const NODE_SELECTION_HOOK = Symbol.for("lmRemote.loraInfo.nodeSelectionHook");
|
||||||
const CANVAS_SELECTION_HOOK = Symbol.for("lmRemote.loraInfo.canvasSelectionHook");
|
const CANVAS_SELECTION_HOOK = Symbol.for("lmRemote.loraInfo.canvasSelectionHook");
|
||||||
|
const MAX_SHARED_MEDIA = 40;
|
||||||
|
const MATURE_MEDIA_LEVEL = 4;
|
||||||
|
|
||||||
let sidebarRoot = null;
|
let sidebarRoot = null;
|
||||||
let selectedNode = null;
|
let selectedNode = null;
|
||||||
@@ -98,7 +104,11 @@ function safePreviewUrl(value) {
|
|||||||
if (!value) return "";
|
if (!value) return "";
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(String(value), window.location.origin);
|
const parsed = new URL(String(value), window.location.origin);
|
||||||
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
|
if (
|
||||||
|
!parsed.username &&
|
||||||
|
!parsed.password &&
|
||||||
|
(parsed.protocol === "http:" || parsed.protocol === "https:")
|
||||||
|
) {
|
||||||
return parsed.href;
|
return parsed.href;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -107,6 +117,124 @@ function safePreviewUrl(value) {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createMediaElement(url, mediaType, alt) {
|
||||||
|
const safeUrl = safePreviewUrl(url);
|
||||||
|
if (!safeUrl) return null;
|
||||||
|
|
||||||
|
if (mediaType === "video") {
|
||||||
|
const video = document.createElement("video");
|
||||||
|
video.src = safeUrl;
|
||||||
|
video.controls = true;
|
||||||
|
video.muted = true;
|
||||||
|
video.loop = true;
|
||||||
|
video.playsInline = true;
|
||||||
|
video.preload = "metadata";
|
||||||
|
video.referrerPolicy = "no-referrer";
|
||||||
|
video.setAttribute("playsinline", "");
|
||||||
|
video.setAttribute("aria-label", alt);
|
||||||
|
return video;
|
||||||
|
}
|
||||||
|
|
||||||
|
const image = document.createElement("img");
|
||||||
|
image.src = safeUrl;
|
||||||
|
image.alt = alt;
|
||||||
|
image.loading = "lazy";
|
||||||
|
image.referrerPolicy = "no-referrer";
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMatureMediaGate(
|
||||||
|
container,
|
||||||
|
media,
|
||||||
|
level,
|
||||||
|
{ gateUnknown = false, settings = null } = {}
|
||||||
|
) {
|
||||||
|
const mediaSettings = settings || normalizeMediaSettings();
|
||||||
|
if (!mediaSettings.blurMatureContent) return;
|
||||||
|
const numericLevel = Number(level);
|
||||||
|
const hasKnownLevel =
|
||||||
|
level != null && Number.isFinite(numericLevel) && numericLevel > 0;
|
||||||
|
if (
|
||||||
|
(hasKnownLevel && numericLevel < mediaSettings.matureBlurLevel) ||
|
||||||
|
(!hasKnownLevel && !gateUnknown)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
media.classList.add("lmri-media-blurred");
|
||||||
|
const previousTabIndex = media.getAttribute("tabindex");
|
||||||
|
const hadControls = media instanceof HTMLVideoElement && media.controls;
|
||||||
|
if (media instanceof HTMLVideoElement) {
|
||||||
|
media.pause();
|
||||||
|
media.controls = false;
|
||||||
|
}
|
||||||
|
media.tabIndex = -1;
|
||||||
|
media.setAttribute("aria-hidden", "true");
|
||||||
|
const reveal = createElement(
|
||||||
|
"button",
|
||||||
|
"lmri-media-reveal",
|
||||||
|
hasKnownLevel ? "Show mature preview" : "Show unrated preview"
|
||||||
|
);
|
||||||
|
reveal.type = "button";
|
||||||
|
reveal.addEventListener("click", () => {
|
||||||
|
media.classList.remove("lmri-media-blurred");
|
||||||
|
media.removeAttribute("aria-hidden");
|
||||||
|
if (previousTabIndex == null) media.removeAttribute("tabindex");
|
||||||
|
else media.setAttribute("tabindex", previousTabIndex);
|
||||||
|
if (media instanceof HTMLVideoElement && hadControls) media.controls = true;
|
||||||
|
reveal.remove();
|
||||||
|
});
|
||||||
|
container.appendChild(reveal);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyText(value) {
|
||||||
|
const text = String(value || "");
|
||||||
|
if (!text) return false;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard?.writeText(text);
|
||||||
|
if (navigator.clipboard?.writeText) return true;
|
||||||
|
} catch {
|
||||||
|
// Fall through to the legacy copy path for non-secure ComfyUI origins.
|
||||||
|
}
|
||||||
|
|
||||||
|
const textarea = document.createElement("textarea");
|
||||||
|
const previousFocus = document.activeElement;
|
||||||
|
textarea.value = text;
|
||||||
|
textarea.readOnly = true;
|
||||||
|
textarea.style.position = "fixed";
|
||||||
|
textarea.style.opacity = "0";
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.focus();
|
||||||
|
textarea.select();
|
||||||
|
textarea.setSelectionRange(0, text.length);
|
||||||
|
try {
|
||||||
|
return document.execCommand?.("copy") === true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
textarea.remove();
|
||||||
|
previousFocus?.focus?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCopyButton(text) {
|
||||||
|
const button = createElement("button", "lmri-copy-button", "Copy");
|
||||||
|
button.type = "button";
|
||||||
|
button.title = "Copy shared prompt";
|
||||||
|
button.setAttribute("aria-live", "polite");
|
||||||
|
button.addEventListener("click", async () => {
|
||||||
|
button.disabled = true;
|
||||||
|
const copied = await copyText(text);
|
||||||
|
button.textContent = copied ? "Copied" : "Copy failed";
|
||||||
|
button.disabled = false;
|
||||||
|
button.focus();
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (button.isConnected) button.textContent = "Copy";
|
||||||
|
}, 1800);
|
||||||
|
});
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
function toDisplayList(value) {
|
function toDisplayList(value) {
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
return value.map((item) => String(item).trim()).filter(Boolean);
|
return value.map((item) => String(item).trim()).filter(Boolean);
|
||||||
@@ -230,11 +358,36 @@ function renderError(content) {
|
|||||||
content.appendChild(panel);
|
content.appendChild(panel);
|
||||||
}
|
}
|
||||||
|
|
||||||
function useResolvedCandidate(model) {
|
async function useResolvedCandidate(model) {
|
||||||
lookupGeneration += 1;
|
const generation = ++lookupGeneration;
|
||||||
lookupController?.abort();
|
lookupController?.abort();
|
||||||
lookupState = { status: "found", model };
|
lookupController = new AbortController();
|
||||||
|
lookupState = {
|
||||||
|
status: "found",
|
||||||
|
model,
|
||||||
|
enrichment: "loading",
|
||||||
|
mediaIndex: 0,
|
||||||
|
};
|
||||||
renderSidebar();
|
renderSidebar();
|
||||||
|
try {
|
||||||
|
const enriched = await safelyEnrichManagerCard(
|
||||||
|
model,
|
||||||
|
lookupController.signal
|
||||||
|
);
|
||||||
|
if (generation !== lookupGeneration) return;
|
||||||
|
lookupState = {
|
||||||
|
status: "found",
|
||||||
|
model: enriched,
|
||||||
|
enrichment: "ready",
|
||||||
|
mediaIndex: 0,
|
||||||
|
};
|
||||||
|
refreshEnrichmentSlot();
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.name !== "AbortError" && generation === lookupGeneration) {
|
||||||
|
lookupState = { ...lookupState, enrichment: "ready" };
|
||||||
|
renderSidebar();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAmbiguous(content) {
|
function renderAmbiguous(content) {
|
||||||
@@ -258,7 +411,9 @@ function renderAmbiguous(content) {
|
|||||||
createElement("strong", "", title),
|
createElement("strong", "", title),
|
||||||
createElement("span", "", path || model.file_path || "")
|
createElement("span", "", path || model.file_path || "")
|
||||||
);
|
);
|
||||||
button.addEventListener("click", () => useResolvedCandidate(model));
|
button.addEventListener("click", () => {
|
||||||
|
void useResolvedCandidate(model);
|
||||||
|
});
|
||||||
candidates.appendChild(button);
|
candidates.appendChild(button);
|
||||||
});
|
});
|
||||||
panel.appendChild(candidates);
|
panel.appendChild(candidates);
|
||||||
@@ -266,18 +421,213 @@ function renderAmbiguous(content) {
|
|||||||
content.appendChild(panel);
|
content.appendChild(panel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sharedParameterLabels(media) {
|
||||||
|
const values = [];
|
||||||
|
if (media.width && media.height) values.push(`${media.width}×${media.height}`);
|
||||||
|
if (media.steps != null && media.steps !== "") {
|
||||||
|
values.push(`${media.steps} steps`);
|
||||||
|
}
|
||||||
|
if (media.sampler) values.push(String(media.sampler));
|
||||||
|
if (media.cfgScale != null && media.cfgScale !== "") {
|
||||||
|
values.push(`CFG ${media.cfgScale}`);
|
||||||
|
}
|
||||||
|
if (media.seed != null && media.seed !== "") {
|
||||||
|
values.push(`Seed ${media.seed}`);
|
||||||
|
}
|
||||||
|
if (media.denoise != null && media.denoise !== "") {
|
||||||
|
values.push(`Denoise ${media.denoise}`);
|
||||||
|
}
|
||||||
|
if (media.clipSkip != null && media.clipSkip !== "") {
|
||||||
|
values.push(`Clip skip ${media.clipSkip}`);
|
||||||
|
}
|
||||||
|
if (media.modelName) values.push(`Model ${media.modelName}`);
|
||||||
|
if (media.baseModel) values.push(`Base ${media.baseModel}`);
|
||||||
|
if (media.reactionCount > 0) values.push(`♥ ${media.reactionCount}`);
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSharedMediaSection(model) {
|
||||||
|
const items = Array.isArray(model.sharedMedia) ? model.sharedMedia : [];
|
||||||
|
if (!items.length) return null;
|
||||||
|
|
||||||
|
const requestedIndex = Number(lookupState.mediaIndex);
|
||||||
|
const index = Number.isInteger(requestedIndex)
|
||||||
|
? Math.min(Math.max(requestedIndex, 0), items.length - 1)
|
||||||
|
: 0;
|
||||||
|
const media = items[index];
|
||||||
|
const section = createElement("section", "lmri-shared");
|
||||||
|
const header = createElement("div", "lmri-shared-header");
|
||||||
|
const heading = createElement("div", "");
|
||||||
|
heading.append(
|
||||||
|
createElement("h3", "", "Shared examples"),
|
||||||
|
createElement(
|
||||||
|
"span",
|
||||||
|
"lmri-shared-source",
|
||||||
|
[media.source, media.username ? `by ${media.username}` : ""]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
header.appendChild(heading);
|
||||||
|
|
||||||
|
if (items.length > 1) {
|
||||||
|
const navigation = createElement("div", "lmri-shared-navigation");
|
||||||
|
const previous = createElement("button", "lmri-shared-nav", "‹");
|
||||||
|
previous.type = "button";
|
||||||
|
previous.title = "Previous shared example";
|
||||||
|
previous.setAttribute("aria-label", previous.title);
|
||||||
|
const position = createElement(
|
||||||
|
"span",
|
||||||
|
"lmri-shared-position",
|
||||||
|
`${index + 1}/${items.length}`
|
||||||
|
);
|
||||||
|
position.setAttribute("role", "status");
|
||||||
|
position.setAttribute("aria-live", "polite");
|
||||||
|
const next = createElement("button", "lmri-shared-nav", "›");
|
||||||
|
next.type = "button";
|
||||||
|
next.title = "Next shared example";
|
||||||
|
next.setAttribute("aria-label", next.title);
|
||||||
|
previous.dataset.direction = "previous";
|
||||||
|
next.dataset.direction = "next";
|
||||||
|
const replaceAt = (nextIndex, focusSelector) => {
|
||||||
|
lookupState = { ...lookupState, mediaIndex: nextIndex };
|
||||||
|
const replacement = createSharedMediaSection(model);
|
||||||
|
if (!replacement) return;
|
||||||
|
section.replaceWith(replacement);
|
||||||
|
replacement.querySelector(focusSelector)?.focus();
|
||||||
|
};
|
||||||
|
previous.addEventListener("click", () => {
|
||||||
|
replaceAt(
|
||||||
|
(index - 1 + items.length) % items.length,
|
||||||
|
'[data-direction="previous"]'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
next.addEventListener("click", () => {
|
||||||
|
replaceAt((index + 1) % items.length, '[data-direction="next"]');
|
||||||
|
});
|
||||||
|
navigation.append(previous, position, next);
|
||||||
|
header.appendChild(navigation);
|
||||||
|
}
|
||||||
|
section.appendChild(header);
|
||||||
|
|
||||||
|
const viewer = createElement("div", "lmri-shared-viewer");
|
||||||
|
if (media.width && media.height) {
|
||||||
|
const aspect = media.width / media.height;
|
||||||
|
if (aspect >= 0.4 && aspect <= 2.5) {
|
||||||
|
viewer.style.aspectRatio = `${media.width} / ${media.height}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const mediaElement = createMediaElement(
|
||||||
|
media.url,
|
||||||
|
media.mediaType,
|
||||||
|
`${media.source} for ${model.model_name || activeName}`
|
||||||
|
);
|
||||||
|
if (mediaElement) {
|
||||||
|
mediaElement.addEventListener("error", () => viewer.remove());
|
||||||
|
viewer.appendChild(mediaElement);
|
||||||
|
addMatureMediaGate(viewer, mediaElement, media.nsfwLevel, {
|
||||||
|
gateUnknown: media.source === "Community creation",
|
||||||
|
settings: model.mediaSettings,
|
||||||
|
});
|
||||||
|
section.appendChild(viewer);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.length > 2) {
|
||||||
|
const dots = createElement("div", "lmri-shared-dots");
|
||||||
|
items.forEach((item, itemIndex) => {
|
||||||
|
const dot = createElement(
|
||||||
|
"button",
|
||||||
|
itemIndex === index ? "lmri-shared-dot active" : "lmri-shared-dot"
|
||||||
|
);
|
||||||
|
dot.type = "button";
|
||||||
|
dot.title = `${item.source} ${itemIndex + 1}`;
|
||||||
|
dot.setAttribute("aria-label", `Show shared example ${itemIndex + 1}`);
|
||||||
|
dot.dataset.mediaIndex = String(itemIndex);
|
||||||
|
if (itemIndex === index) dot.setAttribute("aria-current", "true");
|
||||||
|
dot.addEventListener("click", () => {
|
||||||
|
lookupState = { ...lookupState, mediaIndex: itemIndex };
|
||||||
|
const replacement = createSharedMediaSection(model);
|
||||||
|
if (!replacement) return;
|
||||||
|
section.replaceWith(replacement);
|
||||||
|
replacement
|
||||||
|
.querySelector(`[data-media-index="${itemIndex}"]`)
|
||||||
|
?.focus();
|
||||||
|
});
|
||||||
|
dots.appendChild(dot);
|
||||||
|
});
|
||||||
|
section.appendChild(dots);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parameters = sharedParameterLabels(media);
|
||||||
|
if (parameters.length) appendPills(section, parameters, "lmri-shared-params");
|
||||||
|
|
||||||
|
if (media.prompt) {
|
||||||
|
const prompt = createElement("section", "lmri-shared-prompt");
|
||||||
|
const promptHeader = createElement("div", "lmri-prompt-header");
|
||||||
|
promptHeader.append(
|
||||||
|
createElement("h4", "", "Shared prompt"),
|
||||||
|
createCopyButton(media.prompt)
|
||||||
|
);
|
||||||
|
prompt.append(
|
||||||
|
promptHeader,
|
||||||
|
createElement("p", "lmri-shared-prompt-text", media.prompt)
|
||||||
|
);
|
||||||
|
section.appendChild(prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (media.negativePrompt) {
|
||||||
|
const negative = createElement("details", "lmri-negative-prompt");
|
||||||
|
negative.append(
|
||||||
|
createElement("summary", "", "Negative prompt"),
|
||||||
|
createElement("p", "", media.negativePrompt)
|
||||||
|
);
|
||||||
|
section.appendChild(negative);
|
||||||
|
}
|
||||||
|
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateEnrichmentSlot(container, model) {
|
||||||
|
container.replaceChildren();
|
||||||
|
const sharedMedia = createSharedMediaSection(model);
|
||||||
|
if (sharedMedia) {
|
||||||
|
container.appendChild(sharedMedia);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (lookupState.enrichment === "loading") {
|
||||||
|
const loading = createElement("div", "lmri-enrichment-state");
|
||||||
|
loading.setAttribute("role", "status");
|
||||||
|
loading.setAttribute("aria-live", "polite");
|
||||||
|
loading.append(
|
||||||
|
createElement("i", "pi pi-spin pi-spinner"),
|
||||||
|
createElement("span", "", "Loading shared media…")
|
||||||
|
);
|
||||||
|
container.appendChild(loading);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshEnrichmentSlot() {
|
||||||
|
const slot = sidebarRoot?.querySelector(".lmri-enrichment-slot");
|
||||||
|
if (slot && lookupState.status === "found") {
|
||||||
|
populateEnrichmentSlot(slot, lookupState.model);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderModelCard(content, model) {
|
function renderModelCard(content, model) {
|
||||||
const card = createElement("article", "lmri-card");
|
const card = createElement("article", "lmri-card");
|
||||||
const previewUrl = safePreviewUrl(model.preview_url);
|
const previewUrl = safePreviewUrl(model.preview_url);
|
||||||
if (previewUrl) {
|
if (previewUrl) {
|
||||||
const preview = createElement("div", "lmri-preview");
|
const preview = createElement("div", "lmri-preview");
|
||||||
const image = document.createElement("img");
|
const media = createMediaElement(
|
||||||
image.src = previewUrl;
|
previewUrl,
|
||||||
image.alt = `Preview for ${model.model_name || activeName}`;
|
isVideoMedia(model.preview_url) ? "video" : "image",
|
||||||
image.loading = "lazy";
|
`Preview for ${model.model_name || activeName}`
|
||||||
image.addEventListener("error", () => preview.remove());
|
);
|
||||||
preview.appendChild(image);
|
if (media) {
|
||||||
card.appendChild(preview);
|
media.addEventListener("error", () => preview.remove());
|
||||||
|
preview.appendChild(media);
|
||||||
|
card.appendChild(preview);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = createElement("div", "lmri-card-body");
|
const body = createElement("div", "lmri-card-body");
|
||||||
@@ -358,6 +708,10 @@ function renderModelCard(content, model) {
|
|||||||
body.appendChild(section);
|
body.appendChild(section);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const enrichment = createElement("div", "lmri-enrichment-slot");
|
||||||
|
populateEnrichmentSlot(enrichment, model);
|
||||||
|
body.appendChild(enrichment);
|
||||||
|
|
||||||
const actions = createElement("div", "lmri-actions lmri-primary-actions");
|
const actions = createElement("div", "lmri-actions lmri-primary-actions");
|
||||||
actions.appendChild(
|
actions.appendChild(
|
||||||
makeExternalLink(
|
makeExternalLink(
|
||||||
@@ -493,6 +847,129 @@ async function resolveManagerCard(name, signal) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchCivitaiMetadata(model, signal) {
|
||||||
|
const filePath = String(model?.file_path || "").trim();
|
||||||
|
if (!filePath) return null;
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ file_path: filePath });
|
||||||
|
const response = await api.fetchApi(`/api/lm/loras/metadata?${params}`, {
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
if (!response.ok) return null;
|
||||||
|
const payload = await response.json();
|
||||||
|
return payload?.success && payload.metadata ? payload.metadata : null;
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.name === "AbortError") throw error;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function communityImagesForHash(payload, hash) {
|
||||||
|
const groups = payload?.images;
|
||||||
|
if (!groups || typeof groups !== "object" || Array.isArray(groups)) return [];
|
||||||
|
const normalizedHash = String(hash || "").toLowerCase();
|
||||||
|
for (const [key, images] of Object.entries(groups)) {
|
||||||
|
if (key.toLowerCase() === normalizedHash && Array.isArray(images)) {
|
||||||
|
return images;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCommunityImages(model, signal) {
|
||||||
|
const hash = String(model?.sha256 || "").trim();
|
||||||
|
if (!hash) return [];
|
||||||
|
try {
|
||||||
|
const response = await api.fetchApi(
|
||||||
|
"/api/lm/community-images/by-hashes",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ hashes: [hash] }),
|
||||||
|
signal,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!response.ok) return [];
|
||||||
|
const payload = await response.json();
|
||||||
|
return payload?.success ? communityImagesForHash(payload, hash) : [];
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.name === "AbortError") throw error;
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchExampleFiles(model, signal) {
|
||||||
|
const hash = String(model?.sha256 || "").trim();
|
||||||
|
if (!hash) return [];
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ model_hash: hash });
|
||||||
|
const response = await api.fetchApi(
|
||||||
|
`/api/lm/example-image-files?${params}`,
|
||||||
|
{ signal }
|
||||||
|
);
|
||||||
|
if (!response.ok) return [];
|
||||||
|
const payload = await response.json();
|
||||||
|
return payload?.success && Array.isArray(payload.files) ? payload.files : [];
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.name === "AbortError") throw error;
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchManagerSettings(signal) {
|
||||||
|
try {
|
||||||
|
const response = await api.fetchApi("/api/lm/settings", { signal });
|
||||||
|
if (!response.ok) return null;
|
||||||
|
const payload = await response.json();
|
||||||
|
return payload?.success ? payload.settings : null;
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.name === "AbortError") throw error;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enrichManagerCard(model, signal) {
|
||||||
|
const [details, communityImages, exampleFiles, rawSettings] = await Promise.all([
|
||||||
|
fetchCivitaiMetadata(model, signal),
|
||||||
|
fetchCommunityImages(model, signal),
|
||||||
|
fetchExampleFiles(model, signal),
|
||||||
|
fetchManagerSettings(signal),
|
||||||
|
]);
|
||||||
|
const civitai = mergeCivitaiMetadata(model?.civitai, details);
|
||||||
|
const mediaSettings = normalizeMediaSettings(rawSettings);
|
||||||
|
let sharedMedia = normalizeSharedMedia(
|
||||||
|
communityImages,
|
||||||
|
civitai,
|
||||||
|
exampleFiles
|
||||||
|
);
|
||||||
|
if (mediaSettings.showOnlySfw) {
|
||||||
|
sharedMedia = sharedMedia.filter(
|
||||||
|
(media) =>
|
||||||
|
typeof media.nsfwLevel === "number" &&
|
||||||
|
media.nsfwLevel < MATURE_MEDIA_LEVEL
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...model,
|
||||||
|
civitai,
|
||||||
|
mediaSettings,
|
||||||
|
sharedMedia: sharedMedia.slice(0, MAX_SHARED_MEDIA),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function safelyEnrichManagerCard(model, signal) {
|
||||||
|
try {
|
||||||
|
return await enrichManagerCard(model, signal);
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.name === "AbortError") throw error;
|
||||||
|
return {
|
||||||
|
...model,
|
||||||
|
mediaSettings: normalizeMediaSettings(),
|
||||||
|
sharedMedia: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function lookupActiveName() {
|
async function lookupActiveName() {
|
||||||
const name = cleanLoraName(activeName);
|
const name = cleanLoraName(activeName);
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
@@ -511,7 +988,26 @@ async function lookupActiveName() {
|
|||||||
throw new Error(result?.error || "The Manager lookup failed.");
|
throw new Error(result?.error || "The Manager lookup failed.");
|
||||||
}
|
}
|
||||||
if (result.found && result.model) {
|
if (result.found && result.model) {
|
||||||
lookupState = { status: "found", model: result.model };
|
lookupState = {
|
||||||
|
status: "found",
|
||||||
|
model: result.model,
|
||||||
|
enrichment: "loading",
|
||||||
|
mediaIndex: 0,
|
||||||
|
};
|
||||||
|
renderSidebar();
|
||||||
|
const enriched = await safelyEnrichManagerCard(
|
||||||
|
result.model,
|
||||||
|
lookupController.signal
|
||||||
|
);
|
||||||
|
if (generation !== lookupGeneration) return;
|
||||||
|
lookupState = {
|
||||||
|
status: "found",
|
||||||
|
model: enriched,
|
||||||
|
enrichment: "ready",
|
||||||
|
mediaIndex: 0,
|
||||||
|
};
|
||||||
|
refreshEnrichmentSlot();
|
||||||
|
return;
|
||||||
} else if (result.ambiguous && result.candidates?.length) {
|
} else if (result.ambiguous && result.candidates?.length) {
|
||||||
lookupState = {
|
lookupState = {
|
||||||
status: "ambiguous",
|
status: "ambiguous",
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
const WEIGHT_EXTENSION = /\.(?:safetensors|ckpt|pt|pth|bin)$/i;
|
const WEIGHT_EXTENSION = /\.(?:safetensors|ckpt|pt|pth|bin)$/i;
|
||||||
const LORA_SYNTAX = /<lora:([^:>]+)(?::[^>]*)?>/gi;
|
const LORA_SYNTAX = /<lora:([^:>]+)(?::[^>]*)?>/gi;
|
||||||
|
const VIDEO_EXTENSION = /\.(?:mp4|webm|mov|m4v)$/i;
|
||||||
|
const NSFW_LEVELS = {
|
||||||
|
pg: 1,
|
||||||
|
pg13: 2,
|
||||||
|
r: 4,
|
||||||
|
x: 8,
|
||||||
|
xxx: 16,
|
||||||
|
blocked: 32,
|
||||||
|
};
|
||||||
const DISABLED_VALUES = new Set([
|
const DISABLED_VALUES = new Set([
|
||||||
"",
|
"",
|
||||||
"none",
|
"none",
|
||||||
@@ -77,6 +86,315 @@ export function normalizeUsageTips(value) {
|
|||||||
.filter((entry) => entry.value !== "");
|
.filter((entry) => entry.value !== "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function decodeMediaPath(value) {
|
||||||
|
let decoded = String(value || "");
|
||||||
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||||
|
try {
|
||||||
|
const next = decodeURIComponent(decoded);
|
||||||
|
if (next === decoded) break;
|
||||||
|
decoded = next;
|
||||||
|
} catch {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediaPath(value) {
|
||||||
|
const text = String(value || "").trim();
|
||||||
|
if (!text) return "";
|
||||||
|
try {
|
||||||
|
const parsed = new URL(text, "http://localhost/");
|
||||||
|
return decodeMediaPath(parsed.searchParams.get("path") || parsed.pathname)
|
||||||
|
.split(/[?#]/, 1)[0]
|
||||||
|
.toLowerCase();
|
||||||
|
} catch {
|
||||||
|
return decodeMediaPath(text).split(/[?#]/, 1)[0].toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isVideoMedia(value, declaredType = "") {
|
||||||
|
const type = String(declaredType || "").trim().toLowerCase();
|
||||||
|
if (type === "video" || type.startsWith("video/")) return true;
|
||||||
|
return VIDEO_EXTENSION.test(mediaPath(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeMediaReference(value) {
|
||||||
|
if (typeof value !== "string" || !value.trim()) return "";
|
||||||
|
try {
|
||||||
|
const parsed = new URL(value.trim(), "http://localhost/");
|
||||||
|
return !parsed.username &&
|
||||||
|
!parsed.password &&
|
||||||
|
(parsed.protocol === "http:" || parsed.protocol === "https:")
|
||||||
|
? value.trim()
|
||||||
|
: "";
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringValue(value) {
|
||||||
|
return typeof value === "string" ? value.trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstString(...values) {
|
||||||
|
for (const value of values) {
|
||||||
|
const text = stringValue(value);
|
||||||
|
if (text) return text;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function objectValue(value) {
|
||||||
|
let parsed = value;
|
||||||
|
if (typeof value === "string") {
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||||
|
? parsed
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function generationMetadata(item) {
|
||||||
|
const direct = objectValue(item?.meta);
|
||||||
|
return { ...direct, ...objectValue(direct.meta) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNsfwLevel(value) {
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) {
|
||||||
|
return value > 0 ? value : null;
|
||||||
|
}
|
||||||
|
if (typeof value !== "string" || !value.trim()) return null;
|
||||||
|
const normalized = value.trim().toLowerCase().replace(/[\s_-]+/g, "");
|
||||||
|
if (normalized === "unknown") return null;
|
||||||
|
if (normalized in NSFW_LEVELS) return NSFW_LEVELS[normalized];
|
||||||
|
const numeric = Number(value);
|
||||||
|
return Number.isFinite(numeric) && numeric > 0 ? numeric : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optimizeMediaUrl(value, mediaType) {
|
||||||
|
const safe = safeMediaReference(value);
|
||||||
|
if (!safe) return "";
|
||||||
|
try {
|
||||||
|
const parsed = new URL(safe, "http://localhost/");
|
||||||
|
if (
|
||||||
|
parsed.hostname !== "civitai.com" &&
|
||||||
|
parsed.hostname.endsWith(".civitai.com") &&
|
||||||
|
parsed.pathname.includes("/original=true")
|
||||||
|
) {
|
||||||
|
const replacement =
|
||||||
|
mediaType === "video"
|
||||||
|
? "/transcode=true,width=450,optimized=true"
|
||||||
|
: "/width=450,optimized=true";
|
||||||
|
parsed.pathname = parsed.pathname.replace("/original=true", replacement);
|
||||||
|
return parsed.href;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return safe;
|
||||||
|
}
|
||||||
|
return safe;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediaIdentity(item, url) {
|
||||||
|
const id = item?.civitai_image_id ?? item?.id;
|
||||||
|
return id != null && String(id).trim()
|
||||||
|
? `id:${String(id).trim()}`
|
||||||
|
: `url:${url}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function localExampleFile(item, index, exampleFiles) {
|
||||||
|
if (!Array.isArray(exampleFiles) || !exampleFiles.length) return null;
|
||||||
|
if (typeof item?.id === "string" && item.id) {
|
||||||
|
const prefix = `custom_${item.id}`;
|
||||||
|
return exampleFiles.find((file) =>
|
||||||
|
String(file?.name || "").startsWith(prefix)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return exampleFiles.find((file) => {
|
||||||
|
const match = /(?:^|\/)image_(\d+)\./i.exec(String(file?.name || ""));
|
||||||
|
return match && Number(match[1]) === index;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMediaItem(item, source, localFile = null) {
|
||||||
|
if (!item || typeof item !== "object" || item.downloadFailed) return null;
|
||||||
|
const meta = generationMetadata(item);
|
||||||
|
const rawUrl = firstString(
|
||||||
|
localFile?.path,
|
||||||
|
item.preview_url,
|
||||||
|
item.image_url,
|
||||||
|
item.url
|
||||||
|
);
|
||||||
|
const mediaType = isVideoMedia(
|
||||||
|
rawUrl,
|
||||||
|
localFile?.is_video ? "video" : item.media_type || item.type
|
||||||
|
)
|
||||||
|
? "video"
|
||||||
|
: "image";
|
||||||
|
const url = optimizeMediaUrl(rawUrl, mediaType);
|
||||||
|
if (!url) return null;
|
||||||
|
|
||||||
|
const prompt = firstString(item.prompt, meta.prompt);
|
||||||
|
const negativePrompt = firstString(
|
||||||
|
item.negative_prompt,
|
||||||
|
item.negativePrompt,
|
||||||
|
meta.negative_prompt,
|
||||||
|
meta.negativePrompt
|
||||||
|
);
|
||||||
|
const sizeLabel = firstString(item.size, meta.Size, meta.size);
|
||||||
|
const sizeMatch = /^(\d+)\s*[x×]\s*(\d+)$/i.exec(sizeLabel);
|
||||||
|
const width = Number(item.width || sizeMatch?.[1]);
|
||||||
|
const height = Number(item.height || sizeMatch?.[2]);
|
||||||
|
const reactionCount = [
|
||||||
|
item.like_count,
|
||||||
|
item.heart_count,
|
||||||
|
item.laugh_count,
|
||||||
|
item.comment_count,
|
||||||
|
].reduce((sum, value) => {
|
||||||
|
const count = Number(value);
|
||||||
|
return sum + (Number.isFinite(count) && count > 0 ? count : 0);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: mediaIdentity(item, url),
|
||||||
|
source,
|
||||||
|
url,
|
||||||
|
thumbnailUrl: safeMediaReference(item.thumbnail_url),
|
||||||
|
mediaType,
|
||||||
|
prompt,
|
||||||
|
negativePrompt,
|
||||||
|
username: firstString(item.username, item.creator?.username),
|
||||||
|
width: Number.isFinite(width) && width > 0 ? width : null,
|
||||||
|
height: Number.isFinite(height) && height > 0 ? height : null,
|
||||||
|
steps: item.steps ?? meta.steps ?? meta.Steps ?? null,
|
||||||
|
sampler: item.sampler ?? meta.sampler ?? meta.Sampler ?? "",
|
||||||
|
cfgScale:
|
||||||
|
item.cfg_scale ?? meta.cfg_scale ?? meta.cfgScale ?? meta.CFG ?? null,
|
||||||
|
seed: item.seed ?? meta.seed ?? meta.Seed ?? null,
|
||||||
|
denoise: item.denoise ?? meta.denoise ?? null,
|
||||||
|
clipSkip: meta.clip_skip ?? meta.clipSkip ?? null,
|
||||||
|
modelName: firstString(item.model_name, meta.Model, meta.model),
|
||||||
|
baseModel: firstString(
|
||||||
|
item.base_model,
|
||||||
|
item.baseModel,
|
||||||
|
meta.base_model,
|
||||||
|
meta.baseModel
|
||||||
|
),
|
||||||
|
nsfwLevel: normalizeNsfwLevel(
|
||||||
|
item.nsfwLevel ?? item.nsfw_level ?? item.nsfw
|
||||||
|
),
|
||||||
|
reactionCount,
|
||||||
|
civitaiImageId: item.civitai_image_id ?? item.id ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function missingMediaValue(value) {
|
||||||
|
return value == null || value === "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeMediaItem(primary, supplement) {
|
||||||
|
const merged = { ...primary };
|
||||||
|
const fillFields = [
|
||||||
|
"thumbnailUrl",
|
||||||
|
"prompt",
|
||||||
|
"negativePrompt",
|
||||||
|
"username",
|
||||||
|
"width",
|
||||||
|
"height",
|
||||||
|
"steps",
|
||||||
|
"sampler",
|
||||||
|
"cfgScale",
|
||||||
|
"seed",
|
||||||
|
"denoise",
|
||||||
|
"clipSkip",
|
||||||
|
"modelName",
|
||||||
|
"baseModel",
|
||||||
|
"civitaiImageId",
|
||||||
|
];
|
||||||
|
for (const field of fillFields) {
|
||||||
|
if (missingMediaValue(merged[field]) && !missingMediaValue(supplement[field])) {
|
||||||
|
merged[field] = supplement[field];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!String(merged.url).startsWith("/") && String(supplement.url).startsWith("/")) {
|
||||||
|
merged.url = supplement.url;
|
||||||
|
}
|
||||||
|
if (supplement.mediaType === "video") merged.mediaType = "video";
|
||||||
|
const levels = [merged.nsfwLevel, supplement.nsfwLevel].filter(
|
||||||
|
(value) => typeof value === "number" && Number.isFinite(value)
|
||||||
|
);
|
||||||
|
merged.nsfwLevel = levels.length ? Math.max(...levels) : null;
|
||||||
|
merged.reactionCount = Math.max(
|
||||||
|
Number(merged.reactionCount) || 0,
|
||||||
|
Number(supplement.reactionCount) || 0
|
||||||
|
);
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeCivitaiMetadata(summary, details) {
|
||||||
|
const merged = { ...objectValue(summary) };
|
||||||
|
for (const [key, value] of Object.entries(objectValue(details))) {
|
||||||
|
if (value !== undefined) merged[key] = value;
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSharedMedia(
|
||||||
|
communityImages,
|
||||||
|
civitaiMetadata,
|
||||||
|
exampleFiles = []
|
||||||
|
) {
|
||||||
|
const civitai = objectValue(civitaiMetadata);
|
||||||
|
const regularImages = Array.isArray(civitai.images) ? civitai.images : [];
|
||||||
|
const customImages = Array.isArray(civitai.customImages)
|
||||||
|
? civitai.customImages
|
||||||
|
: [];
|
||||||
|
const candidates = [
|
||||||
|
...(Array.isArray(communityImages)
|
||||||
|
? communityImages.map((item) => [item, "Community creation", null])
|
||||||
|
: []),
|
||||||
|
...regularImages.map((item, index) => [
|
||||||
|
item,
|
||||||
|
"Civitai example",
|
||||||
|
localExampleFile(item, index, exampleFiles),
|
||||||
|
]),
|
||||||
|
...customImages.map((item, index) => [
|
||||||
|
item,
|
||||||
|
"Custom example",
|
||||||
|
localExampleFile(item, regularImages.length + index, exampleFiles),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
const indexes = new Map();
|
||||||
|
const media = [];
|
||||||
|
for (const [item, source, localFile] of candidates) {
|
||||||
|
const normalized = normalizeMediaItem(item, source, localFile);
|
||||||
|
if (!normalized) continue;
|
||||||
|
const existingIndex = indexes.get(normalized.id);
|
||||||
|
if (existingIndex != null) {
|
||||||
|
media[existingIndex] = mergeMediaItem(media[existingIndex], normalized);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
indexes.set(normalized.id, media.length);
|
||||||
|
media.push(normalized);
|
||||||
|
}
|
||||||
|
return media;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeMediaSettings(value) {
|
||||||
|
const settings = objectValue(value);
|
||||||
|
return {
|
||||||
|
blurMatureContent: settings.blur_mature_content !== false,
|
||||||
|
matureBlurLevel:
|
||||||
|
normalizeNsfwLevel(settings.mature_blur_level) ?? NSFW_LEVELS.r,
|
||||||
|
showOnlySfw: settings.show_only_sfw === true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function extractLoraSyntax(value) {
|
export function extractLoraSyntax(value) {
|
||||||
if (typeof value !== "string") return [];
|
if (typeof value !== "string") return [];
|
||||||
const names = [];
|
const names = [];
|
||||||
|
|||||||
Reference in New Issue
Block a user