Files
aigen/utils/loras.ts
T
TowstyandCursor ab5477c999 Keep the Klein Concept LoRA on xAIGen only.
AIGen hides the sliders, ignores snofs strengths, and strips the SNOFS node so that file cannot be queued from the public studio.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 07:08:33 -05:00

216 lines
7.4 KiB
TypeScript

export const XAIGEN_LORA_MESSAGE = 'This LoRA is only available on xAIGen'
export type LoraKind = 'image' | 'video'
export type LoraStackItem = {
name: string
strengthModel: number
strengthClip: number
}
export type LoraStack = LoraStackItem[]
export const LORA_STRENGTH_MIN = -2
export const LORA_STRENGTH_MAX = 2
export const LORA_STRENGTH_DEFAULT = 1
export const LORA_STRENGTH_STEP = 0.05
export const LORA_STACK_MAX = 8
/** Graph system LoRA for MiniMax turbo. Not a user pick. */
export const MINIMAX_TURBO_LORA = 'minimax_h3_fl2v_turbo_8step_v1.0_comfyui_bf16.safetensors'
export function loraBasename(name: string) {
const trimmed = String(name || '').trim().replace(/\\/g, '/')
const parts = trimmed.split('/').filter(Boolean)
return parts[parts.length - 1] || trimmed
}
export function loraIdentityKey(name: string) {
return loraBasename(name).replace(/^xaigen-?/i, '').toLowerCase()
}
export function isXaigenOnlyLora(name: string) {
const base = loraBasename(name)
return /^xaigen/i.test(base) || /klein_snofs/i.test(base)
}
/** Pick the real Comfy filename: exact path, then same basename ignoring `xaigen-`, then suffix/contains. */
export function resolveComfyLoraName(wanted: string, available: string[]) {
const target = String(wanted || '').trim().replace(/\\/g, '/')
if (!target) return ''
if (!available.length) return target
const targetLower = target.toLowerCase()
const exact = available.find(item => item.replace(/\\/g, '/') === target)
if (exact) return exact
const exactCi = available.find(item => item.replace(/\\/g, '/').toLowerCase() === targetLower)
if (exactCi) return exactCi
const identity = loraIdentityKey(target)
const identityHit = available.find(item => loraIdentityKey(item) === identity)
if (identityHit) return identityHit
const stem = identity.replace(/\.(safetensors|sft|ckpt|pt|bin)$/i, '')
if (!stem) return target
const containsHits = available.filter((item) => {
const base = loraBasename(item).toLowerCase()
return base.includes(stem) || loraIdentityKey(item).includes(stem)
})
if (!containsHits.length) return target
const suffix = containsHits.find((item) => {
const base = loraBasename(item).toLowerCase()
return base.endsWith(identity) || base.endsWith(stem) || loraIdentityKey(item).endsWith(identity)
})
return suffix || containsHits[0]
}
export function loraDisplayName(name: string) {
return loraBasename(name).replace(/\.(safetensors|sft|ckpt|pt|bin)$/i, '')
}
export function normalizeLoraName(raw: unknown) {
const value = String(raw || '').trim().replace(/\\/g, '/')
if (!value) return ''
if (value.length > 240) return ''
if (value.includes('..') || value.startsWith('/') || /[\0\n\r]/.test(value)) return ''
if (!/\.(safetensors|sft|ckpt|pt|bin)$/i.test(value)) return ''
return value
}
export function clampLoraStrength(raw: unknown) {
const value = Number(raw)
if (!Number.isFinite(value)) return LORA_STRENGTH_DEFAULT
const snapped = Math.round(value / LORA_STRENGTH_STEP) * LORA_STRENGTH_STEP
return Math.min(LORA_STRENGTH_MAX, Math.max(LORA_STRENGTH_MIN, Math.round(snapped * 100) / 100))
}
export function formatLoraStrength(value: number) {
const n = clampLoraStrength(value)
return Number.isInteger(n) ? String(n) : n.toFixed(2).replace(/0$/, '')
}
function loraKey(name: string) {
return name.replace(/\\/g, '/').toLowerCase()
}
export function makeLoraStackItem(
name: string,
strengthModel: unknown = LORA_STRENGTH_DEFAULT,
strengthClip: unknown = LORA_STRENGTH_DEFAULT
): LoraStackItem | null {
const normalized = normalizeLoraName(name)
if (!normalized) return null
return {
name: normalized,
strengthModel: clampLoraStrength(strengthModel),
strengthClip: clampLoraStrength(strengthClip)
}
}
function parseStackEntry(raw: unknown): LoraStackItem | null {
if (typeof raw === 'string') return makeLoraStackItem(raw)
if (!raw || typeof raw !== 'object') return null
const rec = raw as Record<string, unknown>
const name = rec.name ?? rec.loraName ?? rec.lora_name
return makeLoraStackItem(
name,
rec.strengthModel ?? rec.strength_model ?? LORA_STRENGTH_DEFAULT,
rec.strengthClip ?? rec.strength_clip ?? LORA_STRENGTH_DEFAULT
)
}
export function normalizeLoraStack(raw: unknown): LoraStackItem[] {
if (raw == null || raw === '') return []
let parsed: unknown = raw
if (typeof raw === 'string') {
const trimmed = raw.trim()
if (!trimmed) return []
if (trimmed.startsWith('[') || trimmed.startsWith('{')) {
try {
parsed = JSON.parse(trimmed)
} catch {
const item = makeLoraStackItem(trimmed)
return item ? [item] : []
}
} else {
const item = makeLoraStackItem(trimmed)
return item ? [item] : []
}
}
const entries = Array.isArray(parsed) ? parsed : [parsed]
const seen = new Set<string>()
const out: LoraStackItem[] = []
for (const entry of entries) {
if (out.length >= LORA_STACK_MAX) break
const item = parseStackEntry(entry)
if (!item) continue
const key = loraIdentityKey(item.name)
if (seen.has(key)) continue
seen.add(key)
out.push(item)
}
return out
}
export function readLoraStack(value?: { loraStack?: unknown; loraName?: unknown } | string | unknown[] | null) {
if (value == null || value === '') return [] as LoraStackItem[]
if (typeof value === 'string' || Array.isArray(value)) return normalizeLoraStack(value)
const stack = normalizeLoraStack(value.loraStack)
if (stack.length) return stack
return normalizeLoraStack(value.loraName)
}
export function resolveLoraName(fallback?: string | null, override?: string | null) {
const over = normalizeLoraName(override)
if (over) return over
return normalizeLoraName(fallback)
}
export function resolveLoraStack(fallback?: unknown, override?: unknown) {
const over = normalizeLoraStack(override)
if (over.length) return over
return normalizeLoraStack(fallback)
}
export function primaryLoraName(stack?: LoraStackItem[] | null) {
return stack?.[0]?.name || ''
}
export function loraStackLabel(stack?: LoraStackItem[] | null) {
return (stack || []).map(item => loraDisplayName(item.name)).join(' · ')
}
export function persistLoraFields(stack?: unknown) {
const items = normalizeLoraStack(stack)
if (!items.length) return { loraName: undefined as string | undefined, loraStack: undefined as LoraStackItem[] | undefined }
return { loraName: items[0].name, loraStack: items }
}
export function loraStacksEqual(a?: unknown, b?: unknown) {
const left = normalizeLoraStack(a)
const right = normalizeLoraStack(b)
if (left.length !== right.length) return false
return left.every((item, index) => (
loraKey(item.name) === loraKey(right[index].name)
&& item.strengthModel === right[index].strengthModel
&& item.strengthClip === right[index].strengthClip
))
}
/** User LoRA picker only. `xaigen-*` names are visible only on xAIGen. */
export function filterLorasForStudio(names: string[], xaigenStudio: boolean) {
const seen = new Set<string>()
const out: string[] = []
for (const raw of names) {
const name = String(raw || '').trim()
if (!name || seen.has(name)) continue
if (!xaigenStudio && isXaigenOnlyLora(name)) continue
seen.add(name)
out.push(name)
}
return out
}
export function filterLoraStackForStudio(stack: unknown, xaigenStudio: boolean) {
return normalizeLoraStack(stack).filter(item => xaigenStudio || !isXaigenOnlyLora(item.name))
}