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|snofs_krea/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 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() 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() 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)) } /** Klein / Flux.2 Klein adapters. Must not load on a Krea graph. */ export function isKleinImageLora(name: string) { const id = loraIdentityKey(name) return /flux-?2-klein|klein_snofs|flux2-klein-9b-consistency|(^|[^a-z0-9])klein([^a-z0-9]|$)/i.test(id) } export function filterLorasForImageEngine(names: string[], engine: 'flux' | 'krea') { if (engine !== 'krea') return names return names.filter(name => !isKleinImageLora(name)) } export function filterLoraStackForImageEngine(stack: unknown, engine: 'flux' | 'krea') { const items = normalizeLoraStack(stack) if (engine !== 'krea') return items return items.filter(item => !isKleinImageLora(item.name)) } export function kleinLoraOnKreaMessage(name: string) { return `${loraDisplayName(name)} is a Flux LoRA and cannot be used with Krea.` } export type LoraUse = 'minimax' | 'ltx' | 'flux' | 'krea' export function isSystemGraphLora(name: string) { const id = loraIdentityKey(name) if (id === loraIdentityKey(MINIMAX_TURBO_LORA)) return true if (/flux2-klein-9b-consistency/i.test(id)) return true if (/ltx-2\.3-22b-distilled/i.test(id)) return true return false } /** Filename hints. `null` means the LoRA can appear in any picker. */ export function inferLoraUse(name: string): LoraUse[] | null { const id = loraIdentityKey(name) if (/snofs_krea|(^|[_-])krea([_-]|$)/i.test(id)) return ['krea'] if (isKleinImageLora(name) || /flux-?2-klein|flux2-klein/i.test(id)) return ['flux'] if (/(^|[_-])ltx([_-]|$)|ltx-2/i.test(id)) return ['ltx'] if (/minimax|h3_fl2v|fl2v_turbo/i.test(id)) return ['minimax'] return null } export function loraMatchesUse(name: string, use: LoraUse) { if (isSystemGraphLora(name)) return false const inferred = inferLoraUse(name) if (!inferred) return true return inferred.includes(use) } export function filterLorasForUse(names: string[], use: LoraUse) { return names.filter(name => loraMatchesUse(name, use)) } export function sanitizeLoraTrigger(raw: unknown) { return String(raw || '') .replace(/\r\n/g, '\n') .replace(/[\0\r]/g, '') .replace(/[ \t]+\n/g, '\n') .trim() .slice(0, 240) } export function normalizeLoraTriggers(raw: unknown): Record { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {} const out: Record = {} for (const [key, value] of Object.entries(raw as Record)) { const id = loraIdentityKey(key) || String(key || '').trim().toLowerCase() const trigger = sanitizeLoraTrigger(value) if (!id || !trigger) continue out[id] = trigger } return out } export function loraTriggerFor(name: string, triggers: Record = {}) { const id = loraIdentityKey(name) return String(triggers[id] || triggers[loraBasename(name).toLowerCase()] || '').trim() } export function insertActivationPhrase(text: string, phrase: string) { const src = String(text || '') const token = sanitizeLoraTrigger(phrase) if (!token) return src if (src.includes(token)) return src if (!src.trim()) return token const needsSpace = !/\s$/.test(src) && !/^\s/.test(token) return needsSpace ? `${src} ${token}` : `${src}${token}` } export function insertActivationInShotScript(script: string, shotN: number, phrase: string) { const src = String(script || '') const token = sanitizeLoraTrigger(phrase) if (!token || src.includes(token)) return src const marker = new RegExp(`(^[ \\t]*shot\\s+${Number(shotN)}\\b[^\\n]*\\n?)`, 'im') const match = marker.exec(src) if (!match || match.index == null) { if (Number(shotN) === 1) { const nextShot = src.search(/^[ \t]*shot\s+\d+\b/m) if (nextShot > 0) { const head = src.slice(0, nextShot).replace(/\s+$/, '') const gap = src.slice(0, nextShot).match(/\s+$/)?.[0] || '\n\n' return insertActivationPhrase(head, token) + gap + src.slice(nextShot) } } return insertActivationPhrase(src, token) } const start = match.index + match[0].length const rest = src.slice(start) const next = rest.search(/^[ \t]*shot\s+\d+\b/m) const end = next < 0 ? src.length : start + next const block = src.slice(start, end) const lead = block.match(/^\s*/)?.[0] || '' const body = block.slice(lead.length).replace(/\s+$/, '') const tail = block.match(/\s+$/)?.[0] ?? (next < 0 ? '' : '\n') return src.slice(0, start) + lead + insertActivationPhrase(body, token) + tail + src.slice(end) }