Add MiniMax and PinkCherry LTX as pickable video engines, with still or text start.

Shot 1 can run text-to-video; later shots still extend from the last frame. Recommend output is also normalized into shot-script cards.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-08-27 19:38:53 -05:00
co-authored by Cursor
parent 2bebeea526
commit 40cd17f35c
15 changed files with 676 additions and 120 deletions
+30 -22
View File
@@ -1,5 +1,6 @@
import { frameLength, runGeneration } from '~/server/utils/videoChain'
import { createShotQueue, finishQueueBurst, setQueueJob } from '~/server/utils/shotQueue'
import { defaultVideoSteps, isLtxWorkflow, isTextToVideo, parseVideoWorkflow } from '~/utils/videoModels'
function isComfyBusyTimeout(error: unknown) {
const err = error as { statusCode?: number; status?: number; data?: { code?: string }; message?: string; statusMessage?: string }
@@ -42,16 +43,16 @@ function parseFps(raw: string | undefined) {
return fps === 12 || fps === 30 || fps === 24 ? fps : 24
}
function parseCfg(raw: string | undefined, turbo: boolean) {
const fallback = turbo ? 1.5 : 4
function parseCfg(raw: string | undefined, turbo: boolean, ltx = false) {
const fallback = ltx ? 3.5 : (turbo ? 1.5 : 4)
const value = Number(raw)
if (!Number.isFinite(value)) return fallback
const snapped = Math.round(value * 2) / 2
return Math.min(10, Math.max(1, snapped))
}
function parseSampler(raw: string | undefined) {
return SAMPLERS.has(raw || '') ? raw! : 'res_multistep'
function parseSampler(raw: string | undefined, ltx = false) {
return SAMPLERS.has(raw || '') ? raw! : (ltx ? 'euler' : 'res_multistep')
}
function parseScheduler(raw: string | undefined) {
@@ -86,7 +87,9 @@ export default defineEventHandler(async (event) => {
}
}
if (!image) {
const workflow = parseVideoWorkflow(fields.workflow)
const textToVideo = isTextToVideo(workflow)
if (!image && !textToVideo) {
throw createError({ statusCode: 400, statusMessage: 'An initial image is required' })
}
const prompt = (fields.prompt || '').trim()
@@ -94,10 +97,13 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 400, statusMessage: 'A motion prompt is required' })
}
const extensions = parseExtensions(fields.extensions)
const workflow = parseVideoWorkflow(fields.workflow)
const useIdentityRefs = workflow === 'v2' && fields.useIdentityRefs === 'true'
const { width, height } = resolveOutputSize(fields.aspect, image.data, Number(fields.width), Number(fields.height))
const useIdentityRefs = workflow === 'v2' && !textToVideo && !isLtxWorkflow(workflow) && fields.useIdentityRefs === 'true'
const { width, height } = resolveOutputSize(
fields.aspect,
image?.data || Buffer.alloc(0),
Number(fields.width),
Number(fields.height)
)
const turbo = fields.turbo !== 'false'
const steps = Number(fields.steps || defaultVideoSteps(turbo, workflow))
const seed = fields.seed && fields.seed !== 'random'
@@ -105,13 +111,13 @@ export default defineEventHandler(async (event) => {
: Math.floor(Math.random() * 2_147_483_647)
const durationSeconds = parseDuration(fields.duration || '5')
const fps = parseFps(fields.fps)
const cfg = parseCfg(fields.cfg, turbo)
const samplerName = parseSampler(fields.sampler_name)
const cfg = parseCfg(fields.cfg, turbo, isLtxWorkflow(workflow))
const samplerName = parseSampler(fields.sampler_name, isLtxWorkflow(workflow))
const scheduler = parseScheduler(fields.scheduler)
const length = frameLength(durationSeconds, fps)
const hideThumbnail = fields.hideThumbnail === 'true'
const hideInput = fields.hideInput === 'true'
const sound = fields.sound !== 'false'
const sound = !isLtxWorkflow(workflow) && fields.sound !== 'false'
const clipName = (fields.name || '').trim().slice(0, 80)
const ownerKey = libraryOwnerKey(event)
const library = publicLibrary(event)
@@ -124,15 +130,17 @@ export default defineEventHandler(async (event) => {
const destFolder = library.folders.find(folder => folder.id === folderId)
const folderLocked = Boolean(destFolder?.protected && !destFolder.unlocked)
assertFolderExists(event, folderId)
const still = await rememberInputStill({
ownerKey,
folderId,
filename: image.filename,
data: image.data,
width,
height,
hideInput
})
const still = image
? await rememberInputStill({
ownerKey,
folderId,
filename: image.filename,
data: image.data,
width,
height,
hideInput
})
: null
const referenceStillIds: Array<string | null> = [null, null, null, null]
if (useIdentityRefs) {
for (const [index, ref] of referenceImages.entries()) {
@@ -175,7 +183,7 @@ export default defineEventHandler(async (event) => {
fps,
samplerName,
scheduler,
thumb: isPipelineFrameFilename(image.filename) ? undefined : image.data,
thumb: image && !isPipelineFrameFilename(image.filename) ? image.data : undefined,
stillId: still?.id,
stillFilename: still?.filename,
referenceStillIds,