360 lines
13 KiB
TypeScript
360 lines
13 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|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 loraAliasFor(name: string, aliases: Record<string, string> = {}) {
|
|
const id = loraIdentityKey(name)
|
|
return String(aliases[id] || aliases[loraBasename(name).toLowerCase()] || '').trim()
|
|
}
|
|
|
|
export function loraFriendlyName(name: string, aliases: Record<string, string> = {}) {
|
|
return loraAliasFor(name, aliases) || loraDisplayName(name)
|
|
}
|
|
|
|
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, aliases: Record<string, string> = {}) {
|
|
return (stack || []).map(item => loraFriendlyName(item.name, aliases)).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))
|
|
}
|
|
|
|
/** 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)
|
|
}
|
|
|
|
/**
|
|
* Image engine from the filename: `xaigen-flux-…` / `flux-…` vs `xaigen-krea-…` / `krea-…`.
|
|
* Klein / SNOFS files without that prefix still count as Flux or Krea.
|
|
*/
|
|
export function imageLoraEngineOf(name: string): 'flux' | 'krea' | null {
|
|
const id = loraIdentityKey(name)
|
|
if (/snofs_krea|(^|[_-])krea([_-]|$)/i.test(id) || /^krea\d*[-_.]/i.test(id)) return 'krea'
|
|
if (isKleinImageLora(name) || /flux-?2-klein|flux2-klein/i.test(id) || /^flux\d*[-_.]/i.test(id)) return 'flux'
|
|
return null
|
|
}
|
|
|
|
export function loraMatchesImageEngine(name: string, engine: 'flux' | 'krea') {
|
|
const tagged = imageLoraEngineOf(name)
|
|
return !tagged || tagged === engine
|
|
}
|
|
|
|
export function filterLorasForImageEngine(names: string[], engine: 'flux' | 'krea') {
|
|
return names.filter(name => loraMatchesImageEngine(name, engine))
|
|
}
|
|
|
|
export function filterLoraStackForImageEngine(stack: unknown, engine: 'flux' | 'krea') {
|
|
return normalizeLoraStack(stack).filter(item => loraMatchesImageEngine(item.name, engine))
|
|
}
|
|
|
|
export function imageLoraEngineMismatchMessage(name: string, engine: 'flux' | 'krea') {
|
|
const other = engine === 'krea' ? 'Flux' : 'Krea'
|
|
const current = engine === 'krea' ? 'Krea' : 'Flux'
|
|
return `${loraDisplayName(name)} is a ${other} LoRA and cannot be used with ${current}.`
|
|
}
|
|
|
|
export function kleinLoraOnKreaMessage(name: string) {
|
|
return imageLoraEngineMismatchMessage(name, '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 imageEngine = imageLoraEngineOf(name)
|
|
if (imageEngine) return [imageEngine]
|
|
const id = loraIdentityKey(name)
|
|
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<string, string> {
|
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}
|
|
const out: Record<string, string> = {}
|
|
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
|
|
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<string, string> = {}) {
|
|
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)
|
|
}
|