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(); } }