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
+5 -1
View File
@@ -1,4 +1,5 @@
import { addStudioJob, kickStudioQueue, listStudioJobs } from '~/server/utils/studioQueue'
import { parsePostedLoraStack, listStudioLoras, persistLoraFields } from '~/server/utils/loras'
import { comfyConfigured } from '~/server/utils/comfy'
import { imageDimensions } from '~/server/utils/resolution'
@@ -81,6 +82,8 @@ export default defineEventHandler(async (event) => {
: Math.floor(Math.random() * 2_147_483_647)
const size = imageDimensions(image.data)
const clipName = (fields.name || '').trim().slice(0, 80)
await listStudioLoras()
const loraStack = parsePostedLoraStack(fields.loraStack || fields.lora, 'image')
const still = await rememberInputStill({
ownerKey,
@@ -137,7 +140,8 @@ export default defineEventHandler(async (event) => {
negative: (fields.negative || '').trim(),
passes,
referenceStillId: savedRef?.id,
referenceStillFilename: savedRef?.filename
referenceStillFilename: savedRef?.filename,
...persistLoraFields(loraStack)
}
})
await kickStudioQueue()
+25 -7
View File
@@ -1,6 +1,9 @@
import { copyFileSync, existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { composeShotPrompt } from '~/utils/globalLocks'
import { parsePostedLoraStack, listStudioLoras, persistLoraFields } from '~/server/utils/loras'
import { readLoraStack } from '~/utils/loras'
import { defaultVideoSteps, isLtxWorkflow, LTX_DISABLED_MESSAGE, ltxWorkflowEnabled, parseVideoWorkflow } from '~/utils/videoModels'
const SAMPLERS = new Set(['res_multistep', 'euler', 'dpmpp_2m'])
const SCHEDULERS = new Set(['simple', 'ddim_uniform', 'sgm_uniform'])
@@ -39,7 +42,7 @@ function parseScheduler(raw: unknown) {
}
export default defineEventHandler(async (event) => {
const body = await readBody<{ clipId?: string; prompt?: string; duration?: number }>(event).catch(() => ({}))
const body = await readBody<{ clipId?: string; prompt?: string; duration?: number; lora?: unknown; loraStack?: unknown }>(event).catch(() => ({}))
const clipId = String(body?.clipId || '').trim()
const prompt = String(body?.prompt || '').trim()
if (!clipId) {
@@ -66,13 +69,21 @@ export default defineEventHandler(async (event) => {
shotIndex: (source.chainIndex || 0) + 1,
familyRefs: source.permanenceRefs
})
const workflow = parseVideoWorkflow(source.workflow)
if (isLtxWorkflow(workflow) && !ltxWorkflowEnabled()) {
throw createError({ statusCode: 400, statusMessage: LTX_DISABLED_MESSAGE })
}
const turbo = source.turbo !== false
const fps = parseFps(source.fps)
const cfg = parseCfg(source.cfg, turbo)
const samplerName = parseSampler(source.samplerName)
const scheduler = parseScheduler(source.scheduler)
const steps = Number(source.steps || defaultVideoSteps(turbo, parseVideoWorkflow(source.workflow)))
const steps = Number(source.steps || defaultVideoSteps(turbo, workflow))
const seed = Math.floor(Math.random() * 2_147_483_647)
await listStudioLoras()
const postedStack = parsePostedLoraStack(body?.loraStack ?? body?.lora, 'video')
const loraStack = postedStack.length ? postedStack : parsePostedLoraStack(readLoraStack(source), 'video')
const loraFields = persistLoraFields(loraStack)
const length = frameLength(durationSeconds, fps)
const width = source.width || 1344
const height = source.height || 768
@@ -109,9 +120,10 @@ export default defineEventHandler(async (event) => {
familyId: source.familyId || crypto.randomUUID(),
parentClipId: source.id,
chainIndex: (source.chainIndex || 0) + 1,
workflow: parseVideoWorkflow(source.workflow),
workflow,
globalLocks: source.globalLocks,
permanenceRefs: source.permanenceRefs
permanenceRefs: source.permanenceRefs,
...loraFields
}
emitJob(job, { type: 'status', message: 'Extracting last frame & stitching extension...', progress: 2 })
@@ -130,7 +142,8 @@ export default defineEventHandler(async (event) => {
cfg,
fps,
samplerName,
scheduler
scheduler,
...loraFields
}).catch((error) => {
removeExtendTemp(job.library?.extendTmpDir)
const message = error instanceof Error ? error.message : String(error)
@@ -169,6 +182,8 @@ async function runExtend(
fps: number
samplerName: string
scheduler: string
loraName?: string
loraStack?: import('~/utils/loras').LoraStackItem[]
}
) {
copyFileSync(params.sourcePath, params.part1Path)
@@ -218,7 +233,8 @@ async function runExtend(
filenamePrefix: comfyFilenamePrefix(),
sound,
workflow: job.library?.workflow,
duration: job.library?.duration
duration: job.library?.duration,
loraStack: params.loraStack || job.library?.loraStack || params.loraName || job.library?.loraName
})
const queued = await queuePrompt(graph, job.clientId)
@@ -254,7 +270,9 @@ async function runExtend(
workflow: job.library.workflow,
duration: job.library.duration,
globalLocks: job.library.globalLocks,
permanenceRefs: job.library.permanenceRefs
permanenceRefs: job.library.permanenceRefs,
loraName: job.library.loraName,
loraStack: job.library.loraStack
})
}
emitJob(job, { type: 'status', message: 'Job queued on ComfyUI', progress: 8 })
+29 -9
View File
@@ -1,5 +1,7 @@
import { addStudioJob, kickStudioQueue, listStudioJobs } from '~/server/utils/studioQueue'
import { defaultVideoSteps, isLtxWorkflow, isTextToVideo, parseVideoWorkflow } from '~/utils/videoModels'
import { listStudioLoras, parsePostedLoraStack, parseShotLoraStacks, persistLoraFields } from '~/server/utils/loras'
import type { LoraStackItem } from '~/utils/loras'
import { defaultVideoSteps, isLtxWorkflow, isTextToVideo, LTX_DISABLED_MESSAGE, ltxWorkflowEnabled, parseVideoWorkflow } from '~/utils/videoModels'
import { allowIdentityRefs, normalizePermanenceRefs, resolveGlobalLocks, type PermanenceRef } from '~/utils/globalLocks'
function parseDuration(raw: unknown) {
@@ -13,18 +15,23 @@ function parseExtendDuration(raw: unknown) {
}
function parseExtensions(raw: string | undefined) {
if (!raw) return [] as { prompt: string; duration: number; permanenceRefs?: PermanenceRef[] }[]
if (!raw) return [] as { prompt: string; duration: number; permanenceRefs?: PermanenceRef[]; loraName?: string; loraStack?: LoraStackItem[] }[]
try {
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
return parsed
.map((item: { prompt?: unknown; duration?: unknown; permanenceRefs?: unknown }) => ({
prompt: String(item?.prompt || '').trim(),
duration: parseExtendDuration(item?.duration),
permanenceRefs: normalizePermanenceRefs(item?.permanenceRefs)
}))
.map((item: { prompt?: unknown; duration?: unknown; permanenceRefs?: unknown; loraName?: unknown; loraStack?: unknown }) => {
const loraStack = parsePostedLoraStack(item?.loraStack ?? item?.loraName, 'video')
return {
prompt: String(item?.prompt || '').trim(),
duration: parseExtendDuration(item?.duration),
permanenceRefs: normalizePermanenceRefs(item?.permanenceRefs),
...persistLoraFields(loraStack)
}
})
.filter(item => item.prompt)
} catch {
} catch (error) {
if (error && typeof error === 'object' && 'statusCode' in error) throw error
return []
}
}
@@ -101,7 +108,11 @@ export default defineEventHandler(async (event) => {
}
}
await listStudioLoras()
const workflow = parseVideoWorkflow(fields.workflow)
if (isLtxWorkflow(workflow) && !ltxWorkflowEnabled()) {
throw createError({ statusCode: 400, statusMessage: LTX_DISABLED_MESSAGE })
}
const textToVideo = isTextToVideo(workflow)
if (!image && !textToVideo) {
throw createError({ statusCode: 400, statusMessage: 'An initial image is required' })
@@ -116,10 +127,16 @@ export default defineEventHandler(async (event) => {
const extensions = parseExtensions(fields.extensions)
const permanenceRefs = parseFamilyPermanenceRefs(fields.permanenceRefs)
const shotPermanenceRefs = parseShotPermanenceRefs(fields.shotPermanenceRefs, 1 + extensions.length)
const defaultStack = parsePostedLoraStack(fields.loraStack || fields.lora, 'video')
const shotLoraStacks = parseShotLoraStacks(fields.shotLoraStacks || fields.shotLoras, 1 + extensions.length)
if (defaultStack.length && !shotLoraStacks[0].length) shotLoraStacks[0] = defaultStack
for (const [index, item] of extensions.entries()) {
if (!shotPermanenceRefs[index + 1]?.length && item.permanenceRefs?.length) {
shotPermanenceRefs[index + 1] = item.permanenceRefs
}
const shotStack = shotLoraStacks[index + 1]
if (!shotStack.length && item.loraStack?.length) shotLoraStacks[index + 1] = item.loraStack
else if (shotStack.length) Object.assign(item, persistLoraFields(shotStack))
}
const requestedIdentity = workflow === 'v2' && !textToVideo && !isLtxWorkflow(workflow) && fields.useIdentityRefs === 'true'
const useIdentityRefs = allowIdentityRefs(requestedIdentity, permanenceRefs, globalLocks, shotPermanenceRefs)
@@ -216,7 +233,10 @@ export default defineEventHandler(async (event) => {
queueAutoRun,
globalLocks: globalLocks || undefined,
permanenceRefs: permanenceRefs.length ? permanenceRefs : undefined,
shotPermanenceRefs: shotPermanenceRefs.some(item => item.length) ? shotPermanenceRefs : undefined
shotPermanenceRefs: shotPermanenceRefs.some(item => item.length) ? shotPermanenceRefs : undefined,
...persistLoraFields(defaultStack),
shotLoras: shotLoraStacks.some(stack => stack.length) ? shotLoraStacks.map(stack => stack[0]?.name || '') : undefined,
shotLoraStacks: shotLoraStacks.some(stack => stack.length) ? shotLoraStacks : undefined
}
})
await kickStudioQueue()
+10
View File
@@ -0,0 +1,10 @@
import { listGenerationPresets } from '~/server/utils/generationPresets'
import { parseGenerationPresetKind } from '~/utils/generationPresets'
export default defineEventHandler((event) => {
const { owner } = assertLibraryOwner(event)
const kind = parseGenerationPresetKind(getQuery(event).kind)
return {
presets: listGenerationPresets(owner, kind)
}
})
+25
View File
@@ -0,0 +1,25 @@
import { listGenerationPresets, saveGenerationPreset } from '~/server/utils/generationPresets'
import { parseGenerationPresetKind } from '~/utils/generationPresets'
export default defineEventHandler(async (event) => {
const { owner } = assertLibraryOwner(event)
const body = await readBody<{
name?: string
kind?: string
loraStack?: unknown
settings?: unknown
overwrite?: boolean
}>(event).catch(() => ({}))
const kind = parseGenerationPresetKind(body?.kind)
const preset = await saveGenerationPreset(owner, {
name: body?.name || '',
kind,
loraStack: body?.loraStack,
settings: body?.settings,
overwrite: body?.overwrite === true
})
return {
preset,
presets: listGenerationPresets(owner, kind)
}
})
@@ -0,0 +1,13 @@
import { deleteGenerationPreset, listGenerationPresets } from '~/server/utils/generationPresets'
import { parseGenerationPresetKind } from '~/utils/generationPresets'
export default defineEventHandler(async (event) => {
const { owner } = assertLibraryOwner(event)
const id = String(getRouterParam(event, 'id') || '')
const kind = parseGenerationPresetKind(getQuery(event).kind)
await deleteGenerationPreset(owner, id)
return {
ok: true,
presets: listGenerationPresets(owner, kind)
}
})
+19 -15
View File
@@ -1,14 +1,15 @@
import { checkComfyHttp, fetchQueue } from '~/server/utils/comfyLifecycle'
import { comfyConfigured } from '~/server/utils/comfy'
import { imageComfyConfigured, getBeastImageHost, getSidecarImageHost } from '~/server/utils/imageComfy'
import { imageComfyConfigured, getBeastImageHost, getSidecarImageHost, sameImageHost } from '~/server/utils/imageComfy'
import { checkImageComfyHttp, fetchQueueAt } from '~/server/utils/imageComfyLifecycle'
export default defineEventHandler(async () => {
const videoConfigured = comfyConfigured()
const imageConfigured = imageComfyConfigured()
const beastHost = getBeastImageHost()
const sidecarHost = getSidecarImageHost()
const [comfy, queue, beast, beastQueue, sidecar, sidecarQueue] = await Promise.all([
const promptHost = getSidecarImageHost()
const promptIsBeast = Boolean(promptHost && beastHost && sameImageHost(promptHost, beastHost))
const [comfy, queue, beast, beastQueue, prompt, promptQueue] = await Promise.all([
videoConfigured
? checkComfyHttp().catch(() => ({ ok: false, host: '', stats: null }))
: Promise.resolve({ ok: false, host: '', stats: null }),
@@ -17,17 +18,20 @@ export default defineEventHandler(async () => {
? checkImageComfyHttp(2500, beastHost).catch(() => ({ ok: false, host: beastHost, stats: null }))
: Promise.resolve({ ok: false, host: beastHost, stats: null }),
beastHost ? fetchQueueAt(beastHost) : Promise.resolve({ running: 0, pending: 0 }),
sidecarHost
? checkImageComfyHttp(2500, sidecarHost).catch(() => ({ ok: false, host: sidecarHost, stats: null }))
: Promise.resolve({ ok: false, host: sidecarHost, stats: null }),
sidecarHost ? fetchQueueAt(sidecarHost) : Promise.resolve({ running: 0, pending: 0 })
promptHost && !promptIsBeast
? checkImageComfyHttp(2500, promptHost).catch(() => ({ ok: false, host: promptHost, stats: null }))
: Promise.resolve({ ok: false, host: promptHost, stats: null }),
promptHost && !promptIsBeast ? fetchQueueAt(promptHost) : Promise.resolve({ running: 0, pending: 0 })
])
const busy = Boolean(comfy.ok && queue.running > 0)
const beastBusy = Boolean(beast.ok && beastQueue.running > 0)
const sidecarBusy = Boolean(sidecar.ok && sidecarQueue.running > 0)
const promptOk = promptIsBeast ? Boolean(beast.ok || comfy.ok) : Boolean(prompt.ok)
const promptBusy = promptIsBeast ? (beastBusy || busy) : Boolean(prompt.ok && promptQueue.running > 0)
const imageOk = Boolean(beast.ok || comfy.ok)
const imageBusy = Boolean(imageConfigured && (beastBusy || busy))
const preferredHost = beastHost || comfy.host || ''
const promptQueueRunning = promptIsBeast ? ((beastQueue.running || queue.running) || 0) : (promptQueue.running || 0)
const promptQueuePending = promptIsBeast ? ((beastQueue.pending || queue.pending) || 0) : (promptQueue.pending || 0)
return {
ok: true,
service: useRuntimeConfig().public.instanceName || 'aigen',
@@ -55,13 +59,13 @@ export default defineEventHandler(async () => {
host: preferredHost
},
sidecar: {
label: 'Sidecar',
configured: Boolean(sidecarHost),
ok: Boolean(sidecar.ok),
busy: sidecarBusy,
host: sidecarHost,
queueRunning: sidecarQueue.running || 0,
queuePending: sidecarQueue.pending || 0
label: 'Qwen VL',
configured: Boolean(promptHost),
ok: promptOk,
busy: promptBusy,
host: promptHost,
queueRunning: promptQueueRunning,
queuePending: promptQueuePending
}
}
}
+9
View File
@@ -0,0 +1,9 @@
import { listStudioLoras } from '~/server/utils/loras'
export default defineEventHandler(async () => {
const listed = await listStudioLoras()
return {
image: listed.image,
video: listed.video
}
})
+1 -1
View File
@@ -5,7 +5,7 @@ export default defineEventHandler(async (event) => {
if (!promptComfyConfigured()) {
throw createError({
statusCode: 503,
statusMessage: 'Sidecar Qwen VL is not configured. Set IMAGE_COMFY_HOST.'
statusMessage: 'Qwen VL is not configured. Set COMFY_HOST or IMAGE_COMFY_HOST to Beast.'
})
}
+3 -1
View File
@@ -1,4 +1,5 @@
import { getShotQueue, patchShotQueueSegments, updateShotQueue, summarizeQueue } from '~/server/utils/shotQueue'
import { listStudioLoras } from '~/server/utils/loras'
export default defineEventHandler(async (event) => {
const { owner } = assertLibraryOwner(event)
@@ -6,10 +7,11 @@ export default defineEventHandler(async (event) => {
const body = await readBody<{
name?: string
autoRun?: boolean
segments?: Array<{ index: number; prompt?: string; duration?: number }>
segments?: Array<{ index: number; prompt?: string; duration?: number; loraName?: string; loraStack?: unknown }>
}>().catch(() => ({}))
if (Array.isArray(body?.segments) && body.segments.length) {
await listStudioLoras()
await patchShotQueueSegments(owner, id, body.segments)
}
if (typeof body?.name === 'string' || typeof body?.autoRun === 'boolean') {
+7 -2
View File
@@ -23,8 +23,13 @@ export default defineEventHandler((event) => {
]
: undefined)
: [
{ prompt: job.payload.prompt, duration: job.payload.duration },
...(job.payload.extensions || [])
{ prompt: job.payload.prompt, duration: job.payload.duration, loraName: job.payload.shotLoras?.[0] || job.payload.loraName, loraStack: job.payload.shotLoraStacks?.[0] || job.payload.loraStack },
...(job.payload.extensions || []).map((item, index) => ({
prompt: item.prompt,
duration: item.duration,
loraName: item.loraName || job.payload.shotLoras?.[index + 1],
loraStack: item.loraStack || job.payload.shotLoraStacks?.[index + 1]
}))
]
return {
...summarizeStudioJob(job),