Compare commits
2
Commits
5bb3b3a349
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6778110c50 | ||
|
|
932272806c |
@@ -102,8 +102,10 @@ 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.
|
||||||
|
- LoRA Info closes automatically after the selected LoRA loader is cleared, but stays open while switching directly between loaders.
|
||||||
- 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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,13 +3,77 @@ import assert from "node:assert/strict";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
buildExternalLinks,
|
buildExternalLinks,
|
||||||
|
closeActiveSidebarTab,
|
||||||
extractLoraNames,
|
extractLoraNames,
|
||||||
|
getActiveSidebarTabId,
|
||||||
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";
|
||||||
|
|
||||||
|
test("closes only the active LoRA sidebar across ComfyUI API shapes", () => {
|
||||||
|
const managed = {
|
||||||
|
activeSidebarTabId: "lm-remote-lora-info",
|
||||||
|
setActiveSidebarTab(id) {
|
||||||
|
this.activeSidebarTabId = id;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert.equal(
|
||||||
|
closeActiveSidebarTab(managed, "lm-remote-lora-info"),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert.equal(getActiveSidebarTabId(managed), null);
|
||||||
|
|
||||||
|
const activeRef = { value: "lm-remote-lora-info" };
|
||||||
|
const refManager = { sidebarTab: { activeSidebarTabId: activeRef } };
|
||||||
|
assert.equal(
|
||||||
|
closeActiveSidebarTab(refManager, "lm-remote-lora-info"),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert.equal(activeRef.value, null);
|
||||||
|
|
||||||
|
let otherTabToggleCount = 0;
|
||||||
|
const otherTabManager = {
|
||||||
|
sidebarTab: {
|
||||||
|
activeSidebarTabId: "node-library",
|
||||||
|
toggleSidebarTab() {
|
||||||
|
otherTabToggleCount += 1;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert.equal(
|
||||||
|
closeActiveSidebarTab(otherTabManager, "lm-remote-lora-info"),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert.equal(otherTabToggleCount, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to a guarded toggle for readonly sidebar state", () => {
|
||||||
|
let activeId = "lm-remote-lora-info";
|
||||||
|
let toggleCount = 0;
|
||||||
|
const sidebarTab = {
|
||||||
|
toggleSidebarTab(tabId) {
|
||||||
|
toggleCount += 1;
|
||||||
|
activeId = activeId === tabId ? null : tabId;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
Object.defineProperty(sidebarTab, "activeSidebarTabId", {
|
||||||
|
get: () => activeId,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
closeActiveSidebarTab({ sidebarTab }, "lm-remote-lora-info"),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert.equal(activeId, null);
|
||||||
|
assert.equal(toggleCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
test("normalizes loader paths and weight extensions", () => {
|
test("normalizes loader paths and weight extensions", () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
normalizeLoraIdentifier("Styles\\Portrait.safetensors"),
|
normalizeLoraIdentifier("Styles\\Portrait.safetensors"),
|
||||||
@@ -31,6 +95,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));
|
||||||
|
|||||||
@@ -4,11 +4,17 @@ import { api } from "../../scripts/api.js";
|
|||||||
import {
|
import {
|
||||||
buildExternalLinks,
|
buildExternalLinks,
|
||||||
cleanLoraName,
|
cleanLoraName,
|
||||||
|
closeActiveSidebarTab,
|
||||||
extractLoraNames,
|
extractLoraNames,
|
||||||
|
getActiveSidebarTabId,
|
||||||
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 +27,9 @@ 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;
|
||||||
|
const SIDEBAR_CLOSE_DELAY_MS = 80;
|
||||||
|
|
||||||
let sidebarRoot = null;
|
let sidebarRoot = null;
|
||||||
let selectedNode = null;
|
let selectedNode = null;
|
||||||
@@ -31,6 +40,7 @@ let lookupState = { status: "idle" };
|
|||||||
let lookupGeneration = 0;
|
let lookupGeneration = 0;
|
||||||
let lookupController = null;
|
let lookupController = null;
|
||||||
let monitorTimer = null;
|
let monitorTimer = null;
|
||||||
|
let sidebarCloseTimer = null;
|
||||||
|
|
||||||
function createElement(tag, className, text) {
|
function createElement(tag, className, text) {
|
||||||
const element = document.createElement(tag);
|
const element = document.createElement(tag);
|
||||||
@@ -98,7 +108,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 +121,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 +362,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 +415,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 +425,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 +712,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 +851,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 +992,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",
|
||||||
@@ -534,27 +1034,15 @@ function autoOpenEnabled() {
|
|||||||
return app.extensionManager?.setting?.get?.(AUTO_OPEN_SETTING) !== false;
|
return app.extensionManager?.setting?.get?.(AUTO_OPEN_SETTING) !== false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function unwrapValue(value) {
|
|
||||||
return value && typeof value === "object" && "value" in value
|
|
||||||
? value.value
|
|
||||||
: value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function activeSidebarId(manager, sidebar) {
|
|
||||||
return unwrapValue(
|
|
||||||
sidebar?.activeSidebarTabId ?? manager?.activeSidebarTabId
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function openSidebarTab() {
|
function openSidebarTab() {
|
||||||
const manager = app.extensionManager;
|
const manager = app.extensionManager;
|
||||||
if (!manager) return false;
|
if (!manager) return false;
|
||||||
const sidebar = manager.sidebarTab || manager;
|
const sidebar = manager.sidebarTab || manager;
|
||||||
if (activeSidebarId(manager, sidebar) === TAB_ID) return true;
|
if (getActiveSidebarTabId(manager) === TAB_ID) return true;
|
||||||
|
|
||||||
if (typeof manager.setActiveSidebarTab === "function") {
|
if (typeof manager.setActiveSidebarTab === "function") {
|
||||||
manager.setActiveSidebarTab(TAB_ID);
|
manager.setActiveSidebarTab(TAB_ID);
|
||||||
if (activeSidebarId(manager, sidebar) === TAB_ID) return true;
|
if (getActiveSidebarTabId(manager) === TAB_ID) return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sidebar && "activeSidebarTabId" in sidebar) {
|
if (sidebar && "activeSidebarTabId" in sidebar) {
|
||||||
@@ -568,7 +1056,7 @@ function openSidebarTab() {
|
|||||||
} catch {
|
} catch {
|
||||||
// Some frontend versions expose a readonly store property.
|
// Some frontend versions expose a readonly store property.
|
||||||
}
|
}
|
||||||
if (activeSidebarId(manager, sidebar) === TAB_ID) return true;
|
if (getActiveSidebarTabId(manager) === TAB_ID) return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof sidebar?.toggleSidebarTab === "function") {
|
if (typeof sidebar?.toggleSidebarTab === "function") {
|
||||||
@@ -586,10 +1074,29 @@ function openSidebarTab() {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cancelSidebarClose() {
|
||||||
|
if (sidebarCloseTimer == null) return;
|
||||||
|
window.clearTimeout(sidebarCloseTimer);
|
||||||
|
sidebarCloseTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSidebarClose() {
|
||||||
|
cancelSidebarClose();
|
||||||
|
sidebarCloseTimer = window.setTimeout(() => {
|
||||||
|
sidebarCloseTimer = null;
|
||||||
|
const nodes = getSelectedGraphNodes(app.canvas);
|
||||||
|
const node = nodes.length === 1 ? nodes[0] : null;
|
||||||
|
if (node && extractLoraNames(node).length) return;
|
||||||
|
closeActiveSidebarTab(app.extensionManager, TAB_ID);
|
||||||
|
}, SIDEBAR_CLOSE_DELAY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
function updateSelection({ autoOpen = false, force = false } = {}) {
|
function updateSelection({ autoOpen = false, force = false } = {}) {
|
||||||
const nodes = getSelectedGraphNodes(app.canvas);
|
const nodes = getSelectedGraphNodes(app.canvas);
|
||||||
const node = nodes.length === 1 ? nodes[0] : null;
|
const node = nodes.length === 1 ? nodes[0] : null;
|
||||||
const names = node ? extractLoraNames(node) : [];
|
const names = node ? extractLoraNames(node) : [];
|
||||||
|
const shouldCloseSidebar = selectedNames.length > 0 && names.length === 0;
|
||||||
|
if (names.length) cancelSidebarClose();
|
||||||
const signature = `${node?.id ?? ""}|${names
|
const signature = `${node?.id ?? ""}|${names
|
||||||
.map(normalizeLoraIdentifier)
|
.map(normalizeLoraIdentifier)
|
||||||
.join("|")}`;
|
.join("|")}`;
|
||||||
@@ -616,6 +1123,8 @@ function updateSelection({ autoOpen = false, force = false } = {}) {
|
|||||||
if (activeName) {
|
if (activeName) {
|
||||||
if (autoOpen && autoOpenEnabled()) openSidebarTab();
|
if (autoOpen && autoOpenEnabled()) openSidebarTab();
|
||||||
lookupActiveName();
|
lookupActiveName();
|
||||||
|
} else if (shouldCloseSidebar) {
|
||||||
|
scheduleSidebarClose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -673,6 +1182,7 @@ function registerSidebarTab() {
|
|||||||
updateSelection({ force: true });
|
updateSelection({ force: true });
|
||||||
},
|
},
|
||||||
destroy() {
|
destroy() {
|
||||||
|
cancelSidebarClose();
|
||||||
lookupGeneration += 1;
|
lookupGeneration += 1;
|
||||||
lookupController?.abort();
|
lookupController?.abort();
|
||||||
sidebarRoot?.remove();
|
sidebarRoot?.remove();
|
||||||
|
|||||||
@@ -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,378 @@ 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrapSidebarValue(value) {
|
||||||
|
return value && typeof value === "object" && "value" in value
|
||||||
|
? value.value
|
||||||
|
: value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActiveSidebarTabId(manager) {
|
||||||
|
if (!manager) return null;
|
||||||
|
const sidebar = manager.sidebarTab || manager;
|
||||||
|
return unwrapSidebarValue(
|
||||||
|
sidebar?.activeSidebarTabId ?? manager.activeSidebarTabId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeActiveSidebarTab(manager, tabId) {
|
||||||
|
if (!manager || getActiveSidebarTabId(manager) !== tabId) return false;
|
||||||
|
const sidebar = manager.sidebarTab || manager;
|
||||||
|
|
||||||
|
if (typeof manager.setActiveSidebarTab === "function") {
|
||||||
|
try {
|
||||||
|
manager.setActiveSidebarTab(null);
|
||||||
|
if (getActiveSidebarTabId(manager) !== tabId) return true;
|
||||||
|
} catch {
|
||||||
|
// Older frontend wrappers may reject null.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
sidebar &&
|
||||||
|
(typeof sidebar === "object" || typeof sidebar === "function") &&
|
||||||
|
"activeSidebarTabId" in sidebar
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const current = sidebar.activeSidebarTabId;
|
||||||
|
if (current && typeof current === "object" && "value" in current) {
|
||||||
|
current.value = null;
|
||||||
|
} else {
|
||||||
|
sidebar.activeSidebarTabId = null;
|
||||||
|
}
|
||||||
|
if (getActiveSidebarTabId(manager) !== tabId) return true;
|
||||||
|
} catch {
|
||||||
|
// Some frontend versions expose a readonly store property.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleTargets = sidebar === manager ? [sidebar] : [sidebar, manager];
|
||||||
|
for (const target of toggleTargets) {
|
||||||
|
if (typeof target?.toggleSidebarTab !== "function") continue;
|
||||||
|
try {
|
||||||
|
target.toggleSidebarTab(tabId);
|
||||||
|
if (getActiveSidebarTabId(manager) !== tabId) return true;
|
||||||
|
} catch {
|
||||||
|
// Try the remaining compatibility paths.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof manager.command?.execute === "function") {
|
||||||
|
manager.command.execute(`Workspace.ToggleSidebarTab.${tabId}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
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