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
+68 -2
View File
@@ -200,6 +200,19 @@
label="Duration"
@update:modelValue="saveSegment(job.shots, segment)"
/>
<LoraSelect
v-if="segment.status !== 'complete' && segment.status !== 'running'"
class="mt-3 max-w-md"
:model-value="readLoraStack(segment)"
:names="videoLoraOptions"
label="LoRA"
empty-label="Default"
add-label="Add LoRA"
compact
:clip-strength="false"
@update:model-value="(value) => { setSegmentStack(segment, value); saveSegment(job.shots, segment) }"
/>
<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">
<a
class="text-xs text-amber-200 hover:text-amber-100"
@@ -221,6 +234,7 @@
>
<p class="text-sm font-medium text-zinc-100">{{ job.kind === 'edit' ? `Pass ${index + 1}` : `Shot ${index + 1}` }} <span class="font-normal text-zinc-500">· queued with this job</span></p>
<p class="mt-1 max-h-24 overflow-y-auto whitespace-pre-wrap text-xs text-zinc-400 [overflow-wrap:anywhere]">{{ shot.prompt }}</p>
<p v-if="loraStackLabel(readLoraStack(shot))" class="mt-1 text-[11px] text-zinc-500">{{ loraStackLabel(readLoraStack(shot)) }}</p>
</li>
</ol>
</section>
@@ -328,6 +342,19 @@
label="Duration"
@update:modelValue="saveSegment(queue, segment)"
/>
<LoraSelect
v-if="segment.status !== 'complete' && segment.status !== 'running'"
class="mt-3 max-w-md"
:model-value="readLoraStack(segment)"
:names="videoLoraOptions"
label="LoRA"
empty-label="Default"
add-label="Add LoRA"
compact
:clip-strength="false"
@update:model-value="(value) => { setSegmentStack(segment, value); saveSegment(queue, segment) }"
/>
<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">
<a
class="text-xs text-amber-200 hover:text-amber-100"
@@ -382,6 +409,8 @@
</template>
<script setup lang="ts">
import { loraStackLabel, persistLoraFields, readLoraStack, type LoraStackItem } from '~/utils/loras'
type Segment = {
index: number
prompt: string
@@ -389,6 +418,8 @@ type Segment = {
status: 'pending' | 'running' | 'complete' | 'error'
clipId?: string
error?: string
loraName?: string
loraStack?: LoraStackItem[]
}
type Queue = {
@@ -401,6 +432,8 @@ type Queue = {
totalCount: number
currentJobId?: string
stopAfterCurrent?: boolean
loraName?: string
loraStack?: LoraStackItem[]
segments: Segment[]
}
@@ -422,7 +455,7 @@ type StudioJobRow = {
pausedByUser?: boolean
lastError?: string
shots?: Queue | null
plannedShots?: { prompt: string; duration: number }[]
plannedShots?: { prompt: string; duration: number; loraName?: string; loraStack?: LoraStackItem[] }[]
completedCount?: number
}
@@ -436,6 +469,7 @@ type PendingRemove = {
}
const instanceName = ref(String(useRuntimeConfig().public.instanceName || 'AIGen'))
const videoLoras = ref<string[]>([])
const authEnabled = ref(false)
const jobs = ref<StudioJobRow[]>([])
const orphans = ref<Queue[]>([])
@@ -450,6 +484,24 @@ const sources = new Map<string, EventSource>()
const pendingRemove = ref<PendingRemove | null>(null)
let pollTimer: ReturnType<typeof setInterval> | null = null
const videoLoraOptions = computed(() => {
const names = [...videoLoras.value]
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)
for (const item of queue.loraStack || []) {
if (item.name && !names.includes(item.name)) names.push(item.name)
}
for (const segment of queue.segments) {
if (segment.loraName && !names.includes(segment.loraName)) names.push(segment.loraName)
for (const item of segment.loraStack || []) {
if (item.name && !names.includes(item.name)) names.push(item.name)
}
}
}
return names
})
const canCutIn = computed(() => jobs.value.some(job => job.status === 'running' || job.status === 'held'))
const pauseBusy = computed(() => busyId.value === 'pause')
const anythingGenerating = computed(() => (
@@ -569,6 +621,17 @@ function segmentCaption(queue: Queue, segment: Segment) {
return '· waiting'
}
function setSegmentStack(segment: Segment, stack: LoraStackItem[]) {
const fields = persistLoraFields(stack)
segment.loraStack = fields.loraStack
segment.loraName = fields.loraName
}
function segmentLoraLabel(queue: Queue, segment: Segment) {
const stack = readLoraStack(segment).length ? readLoraStack(segment) : readLoraStack(queue)
return loraStackLabel(stack)
}
function segmentClass(queue: Queue, segment: Segment) {
if (segment.status === 'complete') return 'border-emerald-400/25 bg-emerald-400/5'
if (segment.status === 'running') return 'border-amber-300/50 bg-amber-400/10'
@@ -949,7 +1012,7 @@ function saveSegment(queue: Queue, segment: Segment) {
saveTimer = setTimeout(() => {
void $fetch(`/api/queues/${queue.id}`, {
method: 'PATCH',
body: { segments: [{ index: segment.index, prompt: segment.prompt, duration: segment.duration }] }
body: { segments: [{ index: segment.index, prompt: segment.prompt, duration: segment.duration, loraName: segment.loraName || '', loraStack: segment.loraStack || [] }] }
}).then(() => {
dirty.delete(dirtyKey(queue.id, segment.index))
}).catch(() => null)
@@ -960,6 +1023,9 @@ onMounted(async () => {
const me = await $fetch<{ user?: { name?: string }; authEnabled?: boolean; instanceName?: string }>('/api/auth/me').catch(() => ({ user: null }))
authEnabled.value = Boolean(me.authEnabled)
instanceName.value = me.instanceName || instanceName.value
void $fetch<{ video?: string[] }>('/api/loras').then((listed) => {
videoLoras.value = listed.video || []
}).catch(() => null)
window.addEventListener('keydown', onRemoveKey)
await loadQueues().catch((err: any) => {
error.value = err?.data?.statusMessage || err?.message || 'Could not load the queue'