mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-06-19 00:42:05 -03:00
- Add 5 new Tabler SVG icons (currency-dollar, brush, user, git-merge, license) - Implement Set 2 rendering in ModelModal.js (standalone UI) with green/red permission indicators and preview_tooltip.js (ComfyUI widget) - Add use_new_license_icons setting (default: true) with toggle in settings UI - ComfyUI tooltip reads setting directly from preview-url API response to eliminate race conditions and respect standalone settings changes - Remove the now-unused separate ComfyUI setting loramanager.license_icon_style - Add CSS for both standalone (lora-modal.css) and widget (lm_styles.css) - i18n: translate licenseIcons keys into all 10 supported languages - Fix test to use classic style explicitly for continued coverage
456 lines
14 KiB
JavaScript
456 lines
14 KiB
JavaScript
import { api } from "../../scripts/api.js";
|
|
import { ensureLmStyles } from "./lm_styles_loader.js";
|
|
|
|
const LICENSE_ICON_PATH = "/loras_static/images/tabler/";
|
|
const LICENSE_FLAG_BITS = {
|
|
allowNoCredit: 1 << 0,
|
|
allowOnImages: 1 << 1,
|
|
allowOnCivitai: 1 << 2,
|
|
allowRental: 1 << 3,
|
|
allowSellingModels: 1 << 4,
|
|
allowDerivatives: 1 << 5,
|
|
allowRelicense: 1 << 6,
|
|
};
|
|
|
|
// ── Set 1 (classic) icon definitions ──
|
|
|
|
const LICENSE_ICON_COPY = {
|
|
credit: "Creator credit required",
|
|
image: "No selling generated content",
|
|
rentcivit: "No Civitai generation",
|
|
rent: "No generation services",
|
|
sell: "No selling models",
|
|
derivatives: "No sharing merges",
|
|
relicense: "Same permissions required",
|
|
};
|
|
|
|
const COMMERCIAL_ICON_CONFIG = [
|
|
{ bit: LICENSE_FLAG_BITS.allowOnImages, icon: "photo-off.svg", label: LICENSE_ICON_COPY.image },
|
|
{ bit: LICENSE_FLAG_BITS.allowOnCivitai, icon: "brush-off.svg", label: LICENSE_ICON_COPY.rentcivit },
|
|
{ bit: LICENSE_FLAG_BITS.allowRental, icon: "world-off.svg", label: LICENSE_ICON_COPY.rent },
|
|
{ bit: LICENSE_FLAG_BITS.allowSellingModels, icon: "shopping-cart-off.svg", label: LICENSE_ICON_COPY.sell },
|
|
];
|
|
|
|
// ── Set 2 (new CivitAI-style) icon definitions ──
|
|
|
|
const LNI = LICENSE_ICON_PATH; // alias for brevity
|
|
|
|
const NEW_LICENSE_ICON_COPY = {
|
|
commercial: { allowed: "Commercial use allowed", denied: "No commercial use" },
|
|
genServices: { allowed: "Generation services allowed", denied: "No generation services" },
|
|
credit: { allowed: "No credit required", denied: "Creator credit required" },
|
|
derivatives: { allowed: "Merges allowed", denied: "No merges allowed" },
|
|
relicense: { allowed: "Different permissions allowed on merges", denied: "Same permissions required on merges" },
|
|
};
|
|
|
|
const NEW_ICON_CONFIG = [
|
|
{
|
|
bitCombo: [LICENSE_FLAG_BITS.allowOnImages, LICENSE_FLAG_BITS.allowSellingModels],
|
|
icon: "currency-dollar.svg",
|
|
labelKey: "commercial",
|
|
allowedFn: (flags) => (flags & LICENSE_FLAG_BITS.allowOnImages) !== 0 || (flags & LICENSE_FLAG_BITS.allowSellingModels) !== 0,
|
|
},
|
|
{
|
|
bitCombo: [LICENSE_FLAG_BITS.allowOnCivitai, LICENSE_FLAG_BITS.allowRental],
|
|
icon: "brush.svg",
|
|
labelKey: "genServices",
|
|
allowedFn: (flags) => (flags & LICENSE_FLAG_BITS.allowOnCivitai) !== 0 || (flags & LICENSE_FLAG_BITS.allowRental) !== 0,
|
|
},
|
|
{
|
|
bitCombo: [LICENSE_FLAG_BITS.allowNoCredit],
|
|
icon: "user.svg",
|
|
labelKey: "credit",
|
|
allowedFn: (flags) => (flags & LICENSE_FLAG_BITS.allowNoCredit) !== 0,
|
|
},
|
|
{
|
|
bitCombo: [LICENSE_FLAG_BITS.allowDerivatives],
|
|
icon: "git-merge.svg",
|
|
labelKey: "derivatives",
|
|
allowedFn: (flags) => (flags & LICENSE_FLAG_BITS.allowDerivatives) !== 0,
|
|
},
|
|
{
|
|
bitCombo: [LICENSE_FLAG_BITS.allowRelicense],
|
|
icon: "license.svg",
|
|
labelKey: "relicense",
|
|
allowedFn: (flags) => (flags & LICENSE_FLAG_BITS.allowRelicense) !== 0,
|
|
},
|
|
];
|
|
|
|
function parseLicenseFlags(value) {
|
|
if (typeof value === "number") {
|
|
return Number.isFinite(value) ? value : null;
|
|
}
|
|
if (typeof value === "string" && value.trim() !== "") {
|
|
const parsed = Number.parseInt(value, 10);
|
|
return Number.isNaN(parsed) ? null : parsed;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function buildLicenseIconData(licenseFlags) {
|
|
if (licenseFlags == null) {
|
|
return [];
|
|
}
|
|
|
|
const icons = [];
|
|
|
|
if ((licenseFlags & LICENSE_FLAG_BITS.allowNoCredit) === 0) {
|
|
icons.push({ icon: "user-check.svg", label: LICENSE_ICON_COPY.credit });
|
|
}
|
|
|
|
COMMERCIAL_ICON_CONFIG.forEach((config) => {
|
|
if ((licenseFlags & config.bit) === 0) {
|
|
icons.push({ icon: config.icon, label: config.label });
|
|
}
|
|
});
|
|
|
|
if ((licenseFlags & LICENSE_FLAG_BITS.allowDerivatives) === 0) {
|
|
icons.push({ icon: "exchange-off.svg", label: LICENSE_ICON_COPY.derivatives });
|
|
}
|
|
|
|
if ((licenseFlags & LICENSE_FLAG_BITS.allowRelicense) === 0) {
|
|
icons.push({ icon: "rotate-2.svg", label: LICENSE_ICON_COPY.relicense });
|
|
}
|
|
|
|
return icons;
|
|
}
|
|
|
|
function createLicenseIconElement({ icon, label }) {
|
|
const element = document.createElement("span");
|
|
element.className = "lm-tooltip__license-icon";
|
|
element.setAttribute("role", "img");
|
|
element.setAttribute("aria-label", label);
|
|
element.title = label;
|
|
element.style.setProperty("--license-icon-image", `url('${LICENSE_ICON_PATH}${icon}')`);
|
|
return element;
|
|
}
|
|
|
|
// ── Set 2 (new style) helpers ──
|
|
|
|
function buildNewLicenseIconData(licenseFlags) {
|
|
if (licenseFlags == null) {
|
|
return [];
|
|
}
|
|
|
|
return NEW_ICON_CONFIG.map((config) => {
|
|
const allowed = config.allowedFn(licenseFlags);
|
|
const label = allowed
|
|
? NEW_LICENSE_ICON_COPY[config.labelKey].allowed
|
|
: NEW_LICENSE_ICON_COPY[config.labelKey].denied;
|
|
return {
|
|
icon: config.icon,
|
|
label,
|
|
allowed,
|
|
};
|
|
});
|
|
}
|
|
|
|
function createNewLicenseIconElement({ icon, label, allowed }) {
|
|
const element = document.createElement("span");
|
|
element.className = `lm-tooltip__license-icon-new ${allowed ? "allowed" : "denied"}`;
|
|
element.setAttribute("role", "img");
|
|
element.setAttribute("aria-label", label);
|
|
element.title = label;
|
|
element.style.setProperty("--license-icon-image", `url('${LICENSE_ICON_PATH}${icon}')`);
|
|
return element;
|
|
}
|
|
|
|
const LICENSE_ICON_STORAGE_KEY = "lm_license_icon_new_style";
|
|
|
|
// Module-level cache: null = not yet initialized
|
|
let _useNewIconsCached = null;
|
|
|
|
// Fetch the setting from the LoRA Manager backend API via the proper
|
|
// ComfyUI api helper (handles base URL, credentials, etc.).
|
|
// Stores the result in both the in-memory cache and localStorage so the
|
|
// value survives page reloads even before the API responds.
|
|
async function _fetchLicenseIconSetting() {
|
|
try {
|
|
const response = await api.fetchApi("/lm/settings");
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
const value = data.use_new_license_icons !== false;
|
|
_useNewIconsCached = value;
|
|
try { localStorage.setItem(LICENSE_ICON_STORAGE_KEY, String(value)); } catch (_) {}
|
|
}
|
|
} catch (_) {
|
|
// API not available; cached/localStorage fallback stays in place
|
|
}
|
|
}
|
|
|
|
function getUseNewLicenseIcons() {
|
|
// 1) In-memory cache hit
|
|
if (_useNewIconsCached !== null) {
|
|
return _useNewIconsCached;
|
|
}
|
|
|
|
// 2) localStorage — survives page reloads
|
|
try {
|
|
const stored = localStorage.getItem(LICENSE_ICON_STORAGE_KEY);
|
|
if (stored !== null) {
|
|
_useNewIconsCached = stored === "true";
|
|
// Refresh from API in background for next time
|
|
_fetchLicenseIconSetting();
|
|
return _useNewIconsCached;
|
|
}
|
|
} catch (_) {}
|
|
|
|
// 3) First-ever run: kick off API fetch, default to new style
|
|
_fetchLicenseIconSetting();
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Lightweight preview tooltip that can display images or videos for different model types.
|
|
*/
|
|
export class PreviewTooltip {
|
|
constructor(options = {}) {
|
|
const {
|
|
modelType = "loras",
|
|
previewUrlResolver,
|
|
displayNameFormatter,
|
|
} = options;
|
|
|
|
this.modelType = modelType;
|
|
this.previewUrlResolver =
|
|
typeof previewUrlResolver === "function"
|
|
? previewUrlResolver
|
|
: (name) => this.defaultPreviewUrlResolver(name);
|
|
this.displayNameFormatter =
|
|
typeof displayNameFormatter === "function"
|
|
? displayNameFormatter
|
|
: (name) => name;
|
|
|
|
ensureLmStyles();
|
|
|
|
// Pre-fetch license icon style from LM backend so the tooltip
|
|
// respects the standalone settings toggle as early as possible.
|
|
_fetchLicenseIconSetting();
|
|
|
|
this.element = document.createElement("div");
|
|
this.element.className = "lm-tooltip";
|
|
document.body.appendChild(this.element);
|
|
this.hideTimeout = null;
|
|
this.isFromAutocomplete = false;
|
|
this.currentModelName = null;
|
|
|
|
this.globalClickHandler = (event) => {
|
|
if (!event.target.closest(".comfy-autocomplete-dropdown")) {
|
|
this.hide();
|
|
}
|
|
};
|
|
document.addEventListener("click", this.globalClickHandler);
|
|
|
|
this.globalScrollHandler = () => this.hide();
|
|
document.addEventListener("scroll", this.globalScrollHandler, true);
|
|
}
|
|
|
|
async defaultPreviewUrlResolver(modelName) {
|
|
const response = await api.fetchApi(
|
|
`/lm/${this.modelType}/preview-url?name=${encodeURIComponent(modelName)}&license_flags=true`,
|
|
{ method: "GET" }
|
|
);
|
|
if (!response.ok) {
|
|
throw new Error("Failed to fetch preview URL");
|
|
}
|
|
const data = await response.json();
|
|
if (!data.success || !data.preview_url) {
|
|
throw new Error("No preview available");
|
|
}
|
|
return {
|
|
previewUrl: data.preview_url,
|
|
displayName: data.display_name ?? modelName,
|
|
licenseFlags: parseLicenseFlags(data.license_flags),
|
|
useNewLicenseIcons: data.use_new_license_icons,
|
|
};
|
|
}
|
|
|
|
async resolvePreviewData(modelName) {
|
|
const raw = await this.previewUrlResolver(modelName);
|
|
if (!raw) {
|
|
throw new Error("No preview data returned");
|
|
}
|
|
if (typeof raw === "string") {
|
|
return {
|
|
previewUrl: raw,
|
|
displayName: this.displayNameFormatter(modelName),
|
|
};
|
|
}
|
|
|
|
const { previewUrl, displayName, licenseFlags, useNewLicenseIcons } = raw;
|
|
if (!previewUrl) {
|
|
throw new Error("No preview URL available");
|
|
}
|
|
return {
|
|
previewUrl,
|
|
displayName:
|
|
displayName !== undefined
|
|
? displayName
|
|
: this.displayNameFormatter(modelName),
|
|
licenseFlags: parseLicenseFlags(licenseFlags),
|
|
useNewLicenseIcons,
|
|
};
|
|
}
|
|
|
|
async show(modelName, x, y, fromAutocomplete = false) {
|
|
try {
|
|
if (this.hideTimeout) {
|
|
clearTimeout(this.hideTimeout);
|
|
this.hideTimeout = null;
|
|
}
|
|
|
|
this.isFromAutocomplete = fromAutocomplete;
|
|
|
|
if (
|
|
this.element.style.display === "block" &&
|
|
this.currentModelName === modelName
|
|
) {
|
|
this.position(x, y);
|
|
return;
|
|
}
|
|
|
|
this.currentModelName = modelName;
|
|
const { previewUrl, displayName, licenseFlags, useNewLicenseIcons } = await this.resolvePreviewData(
|
|
modelName
|
|
);
|
|
|
|
while (this.element.firstChild) {
|
|
this.element.removeChild(this.element.firstChild);
|
|
}
|
|
|
|
const mediaContainer = document.createElement("div");
|
|
mediaContainer.className = "lm-tooltip__media-container";
|
|
|
|
const isVideo = previewUrl.endsWith(".mp4");
|
|
const mediaElement = isVideo
|
|
? document.createElement("video")
|
|
: document.createElement("img");
|
|
mediaElement.classList.add("lm-tooltip__media");
|
|
|
|
if (isVideo) {
|
|
mediaElement.autoplay = true;
|
|
mediaElement.loop = true;
|
|
mediaElement.muted = true;
|
|
mediaElement.controls = false;
|
|
}
|
|
|
|
const nameLabel = document.createElement("div");
|
|
nameLabel.textContent = displayName;
|
|
nameLabel.className = "lm-tooltip__label";
|
|
|
|
mediaContainer.appendChild(mediaElement);
|
|
this.renderLicenseOverlay(mediaContainer, licenseFlags, useNewLicenseIcons);
|
|
mediaContainer.appendChild(nameLabel);
|
|
this.element.appendChild(mediaContainer);
|
|
|
|
this.element.style.opacity = "0";
|
|
this.element.style.display = "block";
|
|
|
|
const waitForLoad = () =>
|
|
new Promise((resolve) => {
|
|
if (isVideo) {
|
|
if (mediaElement.readyState >= 2) {
|
|
resolve();
|
|
} else {
|
|
mediaElement.addEventListener("loadeddata", resolve, {
|
|
once: true,
|
|
});
|
|
mediaElement.addEventListener("error", resolve, { once: true });
|
|
}
|
|
} else if (mediaElement.complete) {
|
|
resolve();
|
|
} else {
|
|
mediaElement.addEventListener("load", resolve, { once: true });
|
|
mediaElement.addEventListener("error", resolve, { once: true });
|
|
}
|
|
|
|
setTimeout(resolve, 1000);
|
|
});
|
|
|
|
mediaElement.src = previewUrl;
|
|
await waitForLoad();
|
|
|
|
requestAnimationFrame(() => {
|
|
this.position(x, y);
|
|
this.element.style.transition = "opacity 0.15s ease";
|
|
this.element.style.opacity = "1";
|
|
});
|
|
} catch (error) {
|
|
console.warn("Failed to load preview:", error);
|
|
}
|
|
}
|
|
|
|
position(x, y) {
|
|
const rect = this.element.getBoundingClientRect();
|
|
const viewportWidth = window.innerWidth;
|
|
const viewportHeight = window.innerHeight;
|
|
|
|
let left = x + 10;
|
|
let top = y + 10;
|
|
|
|
if (left + rect.width > viewportWidth) {
|
|
left = x - rect.width - 10;
|
|
}
|
|
|
|
if (top + rect.height > viewportHeight) {
|
|
top = y - rect.height - 10;
|
|
}
|
|
|
|
left = Math.max(10, Math.min(left, viewportWidth - rect.width - 10));
|
|
top = Math.max(10, Math.min(top, viewportHeight - rect.height - 10));
|
|
|
|
Object.assign(this.element.style, {
|
|
left: `${left}px`,
|
|
top: `${top}px`,
|
|
});
|
|
}
|
|
|
|
hide() {
|
|
if (this.element.style.display === "block") {
|
|
this.element.style.opacity = "0";
|
|
this.hideTimeout = setTimeout(() => {
|
|
this.element.style.display = "none";
|
|
this.currentModelName = null;
|
|
this.isFromAutocomplete = false;
|
|
const video = this.element.querySelector("video");
|
|
if (video) {
|
|
video.pause();
|
|
}
|
|
this.hideTimeout = null;
|
|
}, 150);
|
|
}
|
|
}
|
|
|
|
renderLicenseOverlay(container, licenseFlags, useNewLicenseIcons) {
|
|
const useNew = useNewLicenseIcons !== undefined ? useNewLicenseIcons : getUseNewLicenseIcons();
|
|
const icons = useNew
|
|
? buildNewLicenseIconData(licenseFlags)
|
|
: buildLicenseIconData(licenseFlags);
|
|
if (!icons.length) {
|
|
return;
|
|
}
|
|
|
|
const overlay = document.createElement("div");
|
|
overlay.className = useNew
|
|
? "lm-tooltip__license-overlay-new"
|
|
: "lm-tooltip__license-overlay";
|
|
icons.forEach((descriptor) => {
|
|
overlay.appendChild(
|
|
useNew
|
|
? createNewLicenseIconElement(descriptor)
|
|
: createLicenseIconElement(descriptor)
|
|
);
|
|
});
|
|
container.appendChild(overlay);
|
|
}
|
|
|
|
cleanup() {
|
|
if (this.hideTimeout) {
|
|
clearTimeout(this.hideTimeout);
|
|
}
|
|
document.removeEventListener("click", this.globalClickHandler);
|
|
document.removeEventListener("scroll", this.globalScrollHandler, true);
|
|
this.element.remove();
|
|
}
|
|
}
|