Add LoRA stacks with per-item strengths and named video/image generation presets so setups survive reload without mixing Klein onto MiniMax. Keep LTX Pink Cherry and xAIGen LoRAs on xAIGen, and run Recommend on Beast.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-08-28 15:24:44 -05:00
co-authored by Cursor
parent 0af3cddb14
commit f8db0d626a
36 changed files with 1767 additions and 246 deletions
+177
View File
@@ -0,0 +1,177 @@
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
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 isXaigenOnlyLora(name: string) {
return /^xaigen/i.test(loraBasename(name))
}
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 = loraKey(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
))
}
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))
}