Add LoRA activation words and hide LoRAs that do not match the current engine.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-08-31 20:32:12 -05:00
co-authored by Cursor
parent 6926538589
commit 529598cef2
8 changed files with 392 additions and 31 deletions
+23 -1
View File
@@ -32,6 +32,12 @@
<span class="block leading-none" aria-hidden="true">⋮⋮</span>
</button>
<span class="min-w-0 truncate text-zinc-200">{{ loraDisplayName(item.name) }}</span>
<LoraTriggerTip
:text="triggerFor(item.name)"
:aria-label="'Activation for ' + loraDisplayName(item.name)"
@insert="emit('insert-trigger', $event)"
@save="saveTrigger(item.name, $event)"
/>
</div>
<button
type="button"
@@ -99,6 +105,7 @@ import {
clampLoraStrength,
loraDisplayName,
loraIdentityKey,
loraTriggerFor,
makeLoraStackItem,
type LoraStackItem
} from '~/utils/loras'
@@ -112,6 +119,7 @@ const props = withDefaults(defineProps<{
compact?: boolean
disabled?: boolean
clipStrength?: boolean
triggers?: Record<string, string>
}>(), {
label: 'LoRA',
emptyLabel: 'None',
@@ -119,9 +127,15 @@ const props = withDefaults(defineProps<{
missingLabel: 'Wake GPU to load LoRAs',
compact: false,
disabled: false,
clipStrength: true
clipStrength: true,
triggers: () => ({})
})
const emit = defineEmits<{
'insert-trigger': [text: string]
'update-trigger': [name: string, trigger: string]
}>()
const model = defineModel<LoraStackItem[]>({ default: () => [] })
const draggingIndex = ref<number | null>(null)
@@ -201,4 +215,12 @@ function setStrength(index: number, key: 'strengthModel' | 'strengthClip', value
const next = clampLoraStrength(value)
model.value = model.value.map((item, i) => i === index ? { ...item, [key]: next } : item)
}
function triggerFor(name: string) {
return loraTriggerFor(name, props.triggers)
}
function saveTrigger(name: string, trigger: string) {
emit('update-trigger', name, trigger)
}
</script>
+82
View File
@@ -0,0 +1,82 @@
<template>
<div class="group relative shrink-0" @wheel.prevent="onIconWheel">
<button
type="button"
class="relative z-10 flex h-6 w-6 items-center justify-center rounded-full"
:class="text ? 'text-amber-200/80 hover:text-amber-100' : 'text-zinc-600 hover:text-zinc-400'"
:aria-label="ariaLabel"
:title="text ? 'Insert activation word' : 'Set activation word'"
@click.stop="onIconClick"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-4 w-4">
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4M12 8h.01" stroke-linecap="round" />
</svg>
</button>
<div class="invisible absolute left-0 top-full z-30 -mt-1 pt-2 group-hover:visible group-focus-within:visible">
<div
ref="panel"
class="pointer-events-auto w-64 max-w-[calc(100vw-2rem)] overflow-hidden rounded-xl border border-white/10 bg-zinc-950 p-3 text-xs leading-relaxed text-zinc-300 shadow-2xl"
@click.stop
>
<p class="text-[10px] uppercase tracking-wide text-zinc-500">Activation</p>
<p v-if="text" class="mt-1 max-h-24 overflow-y-auto whitespace-pre-wrap break-words">{{ text }}</p>
<p v-else class="mt-1 text-zinc-500">No word yet. Type one, then insert it into the prompt.</p>
<input
ref="input"
v-model="draft"
class="mt-2 w-full rounded-lg border border-white/10 bg-zinc-900 px-2 py-1 text-xs outline-none ring-amber-300/40 focus:ring-2"
maxlength="240"
placeholder="ohwx woman"
@keydown.enter.prevent="save"
>
<div class="mt-2 flex flex-wrap gap-2">
<button
type="button"
class="text-[11px] text-amber-200 hover:text-amber-100 disabled:text-zinc-600"
:disabled="!text"
@click="emit('insert', text)"
>
Insert into prompt
</button>
<button type="button" class="text-[11px] text-zinc-400 hover:text-white" @click="save">Save</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
text: string
ariaLabel: string
}>()
const emit = defineEmits<{
insert: [text: string]
save: [text: string]
}>()
const panel = ref<HTMLElement | null>(null)
const input = ref<HTMLInputElement | null>(null)
const draft = ref(props.text)
watch(() => props.text, (value) => {
draft.value = value
})
function onIconClick() {
if (props.text) emit('insert', props.text)
else input.value?.focus()
}
function save() {
emit('save', draft.value)
}
function onIconWheel(event: WheelEvent) {
const el = panel.value
if (!el) return
el.scrollTop += event.deltaY
}
</script>
+33 -10
View File
@@ -159,6 +159,7 @@
<p class="mt-1 text-sm text-zinc-400">
Short names used in auto-generated output titles. Leave blank to use the file stem
(<span class="text-zinc-300">XAIgen-cline</span> becomes <span class="text-zinc-300">cline</span>).
Activation is the word or phrase the <span class="text-zinc-300">i</span> on a LoRA slider inserts into the prompt.
</p>
</div>
<p v-if="loraRowsLoading" class="text-sm text-zinc-500">Loading LoRAs…</p>
@@ -172,13 +173,22 @@
<p class="truncate text-sm text-zinc-200">{{ row.file }}</p>
<p class="text-[10px] uppercase tracking-wide text-zinc-600">{{ row.kind === 'both' ? 'Image · Video' : row.kind }}</p>
</div>
<input
v-model="row.draft"
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm sm:w-44"
:placeholder="row.placeholder"
maxlength="24"
@change="saveLoraAlias(row)"
>
<div class="flex w-full flex-col gap-2 sm:w-56">
<input
v-model="row.draft"
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm"
:placeholder="row.placeholder"
maxlength="24"
@change="saveLoraAlias(row)"
>
<input
v-model="row.trigger"
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm"
placeholder="Activation word"
maxlength="240"
@change="saveLoraTrigger(row)"
>
</div>
</div>
</section>
@@ -279,7 +289,7 @@ const newPresetName = ref('')
const newPresetPrompt = ref('')
const promptPresets = ref<Array<{ id: string; name: string; description?: string; builtin?: boolean; systemPrompt?: string }>>([])
const loraRowsLoading = ref(true)
const loraRows = ref<Array<{ key: string; file: string; kind: 'image' | 'video' | 'both'; draft: string; placeholder: string }>>([])
const loraRows = ref<Array<{ key: string; file: string; kind: 'image' | 'video' | 'both'; draft: string; trigger: string; placeholder: string }>>([])
const loginUser = ref(props.username || '')
const loginCurrent = ref('')
const loginNext = ref('')
@@ -462,9 +472,10 @@ async function loadLoraKeys() {
loraRowsLoading.value = true
try {
const listed = await $fetch<{ image?: string[]; video?: string[] }>('/api/loras').catch(() => ({ image: [], video: [] }))
const library = await $fetch<{ loraAliases?: Record<string, string> }>('/api/library?light=1').catch(() => ({ loraAliases: {} }))
const library = await $fetch<{ loraAliases?: Record<string, string>; loraTriggers?: Record<string, string> }>('/api/library?light=1').catch(() => ({ loraAliases: {}, loraTriggers: {} }))
const aliases = library.loraAliases || {}
const map = new Map<string, { key: string; file: string; kind: 'image' | 'video' | 'both'; draft: string; placeholder: string }>()
const triggers = library.loraTriggers || {}
const map = new Map<string, { key: string; file: string; kind: 'image' | 'video' | 'both'; draft: string; trigger: string; placeholder: string }>()
const add = (name: string, kind: 'image' | 'video') => {
if (isFilenameSystemLora(name)) return
const key = loraIdentityKey(name)
@@ -479,6 +490,7 @@ async function loadLoraKeys() {
file: loraDisplayName(name),
kind,
draft: aliases[key] || '',
trigger: triggers[key] || '',
placeholder: defaultLoraFilenameKey(name)
})
}
@@ -501,6 +513,17 @@ async function saveLoraAlias(row: { key: string; draft: string }) {
}
}
async function saveLoraTrigger(row: { key: string; trigger: string }) {
try {
emit('updated', await $fetch('/api/library/lora-triggers', {
method: 'PUT',
body: { key: row.key, trigger: row.trigger }
}))
} catch (error: any) {
emit('error', error?.data?.statusMessage || 'Could not save LoRA activation word')
}
}
async function addPreset() {
const name = newPresetName.value.trim()
const systemPrompt = newPresetPrompt.value.trim()
+98 -14
View File
@@ -451,12 +451,15 @@
class="mt-2"
:model-value="shotLoraStacks[shot.n] || []"
:names="videoLoraOptions"
:triggers="loraTriggers"
label="LoRA"
empty-label="Default"
add-label="Add LoRA"
compact
:clip-strength="false"
@update:model-value="setShotLoraStack(shot.n, $event)"
@insert-trigger="insertLoraTrigger($event, { shot: shot.n })"
@update-trigger="saveLoraTrigger"
/>
<PermanenceRefStrip
class="mt-2"
@@ -504,12 +507,15 @@
class="mt-3"
:model-value="item.loraStack || []"
:names="videoLoraOptions"
:triggers="loraTriggers"
label="LoRA"
empty-label="Default"
add-label="Add LoRA"
compact
:clip-strength="false"
@update:model-value="item.loraStack = $event"
@insert-trigger="insertLoraTrigger($event, { extensionId: item.id })"
@update-trigger="saveLoraTrigger"
/>
<PermanenceRefStrip
class="mt-3"
@@ -724,7 +730,17 @@
</div>
<div v-if="studioMode === 'video'" class="max-w-md space-y-2">
<LoraSelect v-model="videoLoraStack" :names="videoLoraOptions" label="LoRA" empty-label="None" compact :clip-strength="false" />
<LoraSelect
v-model="videoLoraStack"
:names="videoLoraOptions"
:triggers="loraTriggers"
label="LoRA"
empty-label="None"
compact
:clip-strength="false"
@insert-trigger="insertLoraTrigger($event)"
@update-trigger="saveLoraTrigger"
/>
<GenerationPresetBar
:presets="videoGenerationPresets"
v-model:selected-id="videoPresetId"
@@ -779,10 +795,13 @@
<LoraSelect
v-model="imageV2LoraStack"
:names="imageV2LoraOptions"
:triggers="loraTriggers"
label="LoRA"
empty-label="Add LoRA"
missing-label="Wake GPU to load LoRAs"
compact
@insert-trigger="insertLoraTrigger($event)"
@update-trigger="saveLoraTrigger"
/>
<p v-if="v2Engine === 'krea'" class="mt-1 text-[11px] text-zinc-500">Flux LoRAs stay on the Flux engine. Switch back to restore that chain.</p>
</div>
@@ -897,7 +916,16 @@
<p class="mt-2 text-xs text-zinc-500">{{ imageAspectHelp }}</p>
</div>
<div class="max-w-md space-y-2">
<LoraSelect v-model="imageLoraStack" :names="imageLoraOptions" label="LoRA" empty-label="None" compact />
<LoraSelect
v-model="imageLoraStack"
:names="imageLoraOptions"
:triggers="loraTriggers"
label="LoRA"
empty-label="None"
compact
@insert-trigger="insertLoraTrigger($event)"
@update-trigger="saveLoraTrigger"
/>
<label class="flex cursor-pointer items-center gap-2 text-xs text-zinc-400">
<span class="relative inline-flex h-5 w-9 shrink-0 items-center">
<input v-model="editScaleToTotalPixels" type="checkbox" class="peer sr-only" role="switch" aria-label="Scale to 1 MP">
@@ -1345,7 +1373,17 @@
label="Extension duration"
hint="Type any length, or hover the wheel and scroll. Shift-scroll jumps by 1s."
/>
<LoraSelect v-model="extendLoraStack" :names="videoLoraOptions" label="LoRA" empty-label="Same as clip / none" compact :clip-strength="false" />
<LoraSelect
v-model="extendLoraStack"
:names="videoLoraOptions"
:triggers="loraTriggers"
label="LoRA"
empty-label="Same as clip / none"
compact
:clip-strength="false"
@insert-trigger="insertLoraTrigger($event, 'extend')"
@update-trigger="saveLoraTrigger"
/>
<button
type="button"
class="w-full rounded-2xl bg-amber-400 px-4 py-2.5 text-sm font-semibold text-zinc-950 transition hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
@@ -2043,8 +2081,12 @@ import {
filterLoraStackForImageEngine,
filterLoraStackForStudio,
filterLorasForImageEngine,
filterLorasForUse,
insertActivationInShotScript,
insertActivationPhrase,
isKleinImageLora,
kleinLoraOnKreaMessage,
loraIdentityKey,
loraStacksEqual,
normalizeLoraStack,
persistLoraFields,
@@ -2524,6 +2566,7 @@ const clips = ref<LibraryClip[]>([])
const stills = ref<LibraryStill[]>([])
const drafts = ref<RetryDraft[]>([])
const loraAliases = ref<Record<string, string>>({})
const loraTriggers = ref<Record<string, string>>({})
const folderId = ref('')
const browseFolderId = ref('')
const renamingId = ref('')
@@ -2578,17 +2621,23 @@ const minimaxGraph = ref<MiniMaxGraphId>('v2')
const videoWorkflow = computed(() => composeVideoWorkflow(videoEngine.value, videoStart.value, minimaxGraph.value))
const textToVideo = computed(() => isTextToVideo(videoWorkflow.value))
const ltxVideo = computed(() => isLtxWorkflow(videoWorkflow.value))
const videoLoraOptions = computed(() => mergeLoraOptions(videoLoras.value, [
...videoLoraStack.value.map(item => item.name),
...extendLoraStack.value.map(item => item.name),
...Object.values(shotLoraStacks.value).flatMap(stack => stack.map(item => item.name)),
...extensionQueue.value.flatMap(item => (item.loraStack || []).map(entry => entry.name))
]))
const imageLoraOptions = computed(() => mergeLoraOptions(imageLoras.value, imageLoraStack.value.map(item => item.name)))
const videoLoraOptions = computed(() => mergeLoraOptions(
filterLorasForUse(videoLoras.value, ltxVideo.value ? 'ltx' : 'minimax'),
[
...videoLoraStack.value.map(item => item.name),
...extendLoraStack.value.map(item => item.name),
...Object.values(shotLoraStacks.value).flatMap(stack => stack.map(item => item.name)),
...extensionQueue.value.flatMap(item => (item.loraStack || []).map(entry => entry.name))
]
))
const imageLoraOptions = computed(() => mergeLoraOptions(
filterLorasForUse(imageLoras.value, 'flux'),
imageLoraStack.value.map(item => item.name)
))
const imageV2ConceptFile = computed(() => imageV2ConceptName())
const imageV2SnofsOn = computed(() => imageV2LoraStack.value.some(item => isImageV2ConceptLora(item.name, v2Engine.value)))
const imageV2LoraOptions = computed(() => mergeLoraOptions(
filterLorasForImageEngine(imageLoras.value, v2Engine.value),
filterLorasForUse(filterLorasForImageEngine(imageLoras.value, v2Engine.value), v2Engine.value),
filterLorasForImageEngine([
...imageV2LoraStack.value.map(item => item.name),
...(v2ConceptLoraEnabled.value && imageV2ConceptFile.value ? [imageV2ConceptFile.value] : [])
@@ -3454,8 +3503,8 @@ function applyInstancePrivacy() {
hideThumbnail.value = readPrivacyToggle('aigen-hide-output-preview', true)
}
function applyLibrary(data: { folders?: LibraryFolder[]; clips?: LibraryClip[]; stills?: LibraryStill[]; drafts?: RetryDraft[]; loraAliases?: Record<string, string> } | unknown) {
const payload = (data || {}) as { folders?: LibraryFolder[]; clips?: LibraryClip[]; stills?: LibraryStill[]; drafts?: RetryDraft[]; loraAliases?: Record<string, string> }
function applyLibrary(data: { folders?: LibraryFolder[]; clips?: LibraryClip[]; stills?: LibraryStill[]; drafts?: RetryDraft[]; loraAliases?: Record<string, string>; loraTriggers?: Record<string, string> } | unknown) {
const payload = (data || {}) as { folders?: LibraryFolder[]; clips?: LibraryClip[]; stills?: LibraryStill[]; drafts?: RetryDraft[]; loraAliases?: Record<string, string>; loraTriggers?: Record<string, string> }
folders.value = payload.folders || []
clips.value = payload.clips || []
stills.value = payload.stills || []
@@ -3463,6 +3512,9 @@ function applyLibrary(data: { folders?: LibraryFolder[]; clips?: LibraryClip[];
if (payload.loraAliases && typeof payload.loraAliases === 'object') {
loraAliases.value = payload.loraAliases
}
if (payload.loraTriggers && typeof payload.loraTriggers === 'object') {
loraTriggers.value = payload.loraTriggers
}
if (!folderId.value || !folders.value.some(folder => folder.id === folderId.value)) {
folderId.value = folders.value[0]?.id || ''
}
@@ -3472,7 +3524,7 @@ function applyLibrary(data: { folders?: LibraryFolder[]; clips?: LibraryClip[];
}
async function loadLibrary(light = false) {
const data = await $fetch<{ folders?: LibraryFolder[]; clips?: LibraryClip[]; stills?: LibraryStill[]; drafts?: RetryDraft[]; loraAliases?: Record<string, string> }>(light ? '/api/library?light=1' : '/api/library')
const data = await $fetch<{ folders?: LibraryFolder[]; clips?: LibraryClip[]; stills?: LibraryStill[]; drafts?: RetryDraft[]; loraAliases?: Record<string, string>; loraTriggers?: Record<string, string> }>(light ? '/api/library?light=1' : '/api/library')
applyLibrary(data)
if (light) return
const queued = await $fetch<{ pendingCount?: number }>('/api/queues').catch(() => ({ pendingCount: 0 }))
@@ -3933,6 +3985,38 @@ function mergeLoraOptions(listed: string[], extra: string[]) {
return names
}
async function saveLoraTrigger(name: string, trigger: string) {
try {
applyLibrary(await $fetch('/api/library/lora-triggers', {
method: 'PUT',
body: { key: loraIdentityKey(name), trigger }
}))
} catch (error: any) {
toast(error?.data?.statusMessage || 'Could not save activation word')
}
}
function insertLoraTrigger(phrase: string, target: 'main' | 'extend' | { shot: number } | { extensionId: string } = 'main') {
const token = String(phrase || '').trim()
if (!token) return
if (target === 'extend') {
extendPrompt.value = insertActivationPhrase(extendPrompt.value, token)
return
}
if (typeof target === 'object' && 'extensionId' in target) {
const item = extensionQueue.value.find(entry => entry.id === target.extensionId)
if (item) item.prompt = insertActivationPhrase(item.prompt, token)
return
}
if (typeof target === 'object' && 'shot' in target) {
prompt.value = insertActivationInShotScript(prompt.value, target.shot, token)
promptDrawerOpen.value = true
return
}
prompt.value = insertActivationPhrase(prompt.value, token)
promptDrawerOpen.value = true
}
function setShotLoraStack(shotN: number, stack: LoraStackItem[]) {
const next = { ...shotLoraStacks.value }
const items = normalizeLoraStack(stack)
+29 -2
View File
@@ -193,12 +193,15 @@
class="mt-3 max-w-md"
:model-value="readLoraStack(segment)"
:names="videoLoraOptions"
:triggers="loraTriggers"
label="LoRA"
empty-label="Default"
add-label="Add LoRA"
compact
:clip-strength="false"
@update:model-value="(value) => { setSegmentStack(segment, value); saveSegment(job.shots, segment) }"
@insert-trigger="insertSegmentTrigger(job.shots, segment, $event)"
@update-trigger="saveLoraTrigger"
/>
<p v-else-if="segmentLoraLabel(job.shots, segment)" class="mt-1 text-[11px] text-zinc-500">{{ segmentLoraLabel(job.shots, segment) }}</p>
<div v-if="segment.clipId" class="mt-2">
@@ -335,12 +338,15 @@
class="mt-3 max-w-md"
:model-value="readLoraStack(segment)"
:names="videoLoraOptions"
:triggers="loraTriggers"
label="LoRA"
empty-label="Default"
add-label="Add LoRA"
compact
:clip-strength="false"
@update:model-value="(value) => { setSegmentStack(segment, value); saveSegment(queue, segment) }"
@insert-trigger="insertSegmentTrigger(queue, segment, $event)"
@update-trigger="saveLoraTrigger"
/>
<p v-else-if="segmentLoraLabel(queue, segment)" class="mt-1 text-[11px] text-zinc-500">{{ segmentLoraLabel(queue, segment) }}</p>
<div v-if="segment.clipId" class="mt-2">
@@ -397,7 +403,7 @@
</template>
<script setup lang="ts">
import { loraStackLabel, persistLoraFields, readLoraStack, type LoraStackItem } from '~/utils/loras'
import { insertActivationPhrase, loraIdentityKey, loraMatchesUse, loraStackLabel, persistLoraFields, readLoraStack, type LoraStackItem } from '~/utils/loras'
type Segment = {
index: number
@@ -458,6 +464,7 @@ type PendingRemove = {
}
const videoLoras = ref<string[]>([])
const loraTriggers = ref<Record<string, string>>({})
const jobs = ref<StudioJobRow[]>([])
const orphans = ref<Queue[]>([])
const waitingCount = ref(0)
@@ -472,7 +479,7 @@ const pendingRemove = ref<PendingRemove | null>(null)
let pollTimer: ReturnType<typeof setInterval> | null = null
const videoLoraOptions = computed(() => {
const names = [...videoLoras.value]
const names = videoLoras.value.filter(name => loraMatchesUse(name, 'minimax') || loraMatchesUse(name, 'ltx'))
for (const queue of [...jobs.value.map(job => job.shots), ...orphans.value]) {
if (!queue) continue
if (queue.loraName && !names.includes(queue.loraName)) names.push(queue.loraName)
@@ -614,6 +621,23 @@ function setSegmentStack(segment: Segment, stack: LoraStackItem[]) {
segment.loraName = fields.loraName
}
function insertSegmentTrigger(queue: Queue, segment: Segment, phrase: string) {
segment.prompt = insertActivationPhrase(segment.prompt, phrase)
saveSegment(queue, segment)
}
async function saveLoraTrigger(name: string, trigger: string) {
try {
const data = await $fetch<{ loraTriggers?: Record<string, string> }>('/api/library/lora-triggers', {
method: 'PUT',
body: { key: loraIdentityKey(name), trigger }
})
if (data.loraTriggers) loraTriggers.value = data.loraTriggers
} catch {
/* keep the last saved word */
}
}
function segmentLoraLabel(queue: Queue, segment: Segment) {
const stack = readLoraStack(segment).length ? readLoraStack(segment) : readLoraStack(queue)
return loraStackLabel(stack)
@@ -1016,6 +1040,9 @@ onMounted(async () => {
void $fetch<{ video?: string[] }>('/api/loras').then((listed) => {
videoLoras.value = listed.video || []
}).catch(() => null)
void $fetch<{ loraTriggers?: Record<string, string> }>('/api/library?light=1').then((library) => {
if (library.loraTriggers) loraTriggers.value = library.loraTriggers
}).catch(() => null)
window.addEventListener('keydown', onRemoveKey)
await loadQueues().catch((err: any) => {
error.value = err?.data?.statusMessage || err?.message || 'Could not load the queue'
+8
View File
@@ -0,0 +1,8 @@
import { libraryOwnerKey, publicLibrary, setLoraTrigger } from '~/server/utils/library'
export default defineEventHandler(async (event) => {
const body = await readBody<{ key?: unknown; trigger?: unknown }>(event)
const owner = libraryOwnerKey(event)
await setLoraTrigger(owner, String(body?.key || ''), String(body?.trigger || ''))
return publicLibrary(event)
})
+24 -4
View File
@@ -9,7 +9,7 @@ import { extractFirstFrame, extractFrameAt, extractLastFrame, probeDuration, rem
import { normalizePermanenceRefs, type PermanenceRef } from '~/utils/globalLocks'
import { normalizeStillSettings, type StillGenerationSettings } from '~/utils/libraryGeneration'
import { buildOutputBaseName, nextUniqueName, normalizeLoraAliases, sanitizeLoraAlias } from '~/utils/outputNames'
import { loraIdentityKey, type LoraStackItem } from '~/utils/loras'
import { loraIdentityKey, normalizeLoraTriggers, sanitizeLoraTrigger, type LoraStackItem } from '~/utils/loras'
const scryptAsync = promisify(scrypt)
@@ -131,6 +131,7 @@ interface Catalog {
stills: LibraryStill[]
drafts: RetryDraft[]
loraAliases: Record<string, string>
loraTriggers: Record<string, string>
}
const writeChains = new Map<string, Promise<unknown>>()
@@ -170,7 +171,8 @@ function emptyCatalog(): Catalog {
clips: [],
stills: [],
drafts: [],
loraAliases: {}
loraAliases: {},
loraTriggers: {}
}
}
@@ -230,7 +232,8 @@ function normalizeCatalog(parsed: Partial<Catalog> & { passwordHash?: string | n
clips: Array.isArray(parsed.clips) ? parsed.clips : [],
stills: Array.isArray(parsed.stills) ? parsed.stills.map(normalizeStill) : [],
drafts: Array.isArray(parsed.drafts) ? parsed.drafts : [],
loraAliases: normalizeLoraAliases(parsed.loraAliases)
loraAliases: normalizeLoraAliases(parsed.loraAliases),
loraTriggers: normalizeLoraTriggers(parsed.loraTriggers)
}
}
@@ -383,6 +386,22 @@ export function getLoraAliases(owner: string) {
return { ...(readCatalog(owner).loraAliases || {}) }
}
export function getLoraTriggers(owner: string) {
return { ...(readCatalog(owner).loraTriggers || {}) }
}
export async function setLoraTrigger(owner: string, identity: string, trigger: string) {
const key = loraIdentityKey(identity) || String(identity || '').trim().toLowerCase()
if (!key) throw createError({ statusCode: 400, statusMessage: 'A LoRA file is required' })
const cleaned = sanitizeLoraTrigger(trigger)
return mutate(owner, (catalog) => {
if (!catalog.loraTriggers) catalog.loraTriggers = {}
if (!cleaned) delete catalog.loraTriggers[key]
else catalog.loraTriggers[key] = cleaned
return { ...catalog.loraTriggers }
})
}
export async function setLoraAlias(owner: string, identity: string, alias: string) {
const key = loraIdentityKey(identity) || String(identity || '').trim().toLowerCase()
if (!key) throw createError({ statusCode: 400, statusMessage: 'A LoRA file is required' })
@@ -486,7 +505,8 @@ export function publicLibrary(event: H3Event, opts?: { light?: boolean }) {
.filter(still => unlockedIds.has(still.folderId) && !isPipelineStill(catalog, still, clipIds))
.map(still => ({ ...still, role: stillRole(still) })),
drafts: catalog.drafts,
loraAliases: catalog.loraAliases || {}
loraAliases: catalog.loraAliases || {},
loraTriggers: catalog.loraTriggers || {}
}
}
+95
View File
@@ -234,3 +234,98 @@ export function filterLoraStackForImageEngine(stack: unknown, engine: 'flux' | '
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<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)
}