Files
TowstyandCursor ce85e7f513 Join pre/prompt/post into the first MiniMax prompt, not only later shots.
The motion prompt field is what Comfy actually runs. Wrappers are now composed into that string on submit, and shot 1 falls back to it if the separate pre/post fields do not arrive.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-03 07:24:00 -05:00

192 lines
6.4 KiB
TypeScript

import { addStudioJob, kickStudioQueue, listStudioJobs, videoJobsBusy } from '~/server/utils/studioQueue'
import { parsePostedLoraStack, listStudioLoras, persistLoraFields } from '~/server/utils/loras'
import { assertImageScaleToTotalPixelsNode, comfyConfigured } from '~/server/utils/comfy'
import { imageDimensions } from '~/server/utils/resolution'
import { parseImageAspect } from '~/utils/imageAspect'
import { clampImageCfg, clampImageScaleMegapixels, clampImageSteps, parseImagePassMode, parseImageScaleToTotalPixels } from '~/utils/generationPresets'
import { ingestPromptParts } from '~/utils/promptParts'
function parsePasses(raw: string | undefined) {
if (!raw) return [] as { prompt: string }[]
try {
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
return parsed
.map((item: { prompt?: unknown }) => ({ prompt: String(item?.prompt || '').trim() }))
.filter(item => item.prompt)
} catch {
return []
}
}
export default defineEventHandler(async (event) => {
const form = await readMultipartFormData(event).catch(() => null)
const fields: Record<string, string> = {}
let image: { filename: string; data: Buffer; type?: string } | null = null
let reference: { filename: string; data: Buffer; type?: string } | null = null
for (const part of form || []) {
if (part.name === 'image' && part.filename && part.data?.length) {
image = { filename: part.filename, data: part.data, type: part.type }
} else if ((part.name === 'image2' || part.name === 'refImage') && part.filename && part.data?.length) {
reference = { filename: part.filename, data: part.data, type: part.type }
} else if (part.name && part.data) {
fields[part.name] = part.data.toString('utf8')
}
}
const posted = ingestPromptParts(fields)
const promptMid = posted.promptMid
const promptPre = posted.promptPre
const promptPost = posted.promptPost
const prompt = posted.prompt
const extraPasses = parsePasses(fields.passes)
const passMode = parseImagePassMode(fields.passMode)
const aspect = parseImageAspect(fields.aspect)
if (!image) {
throw createError({ statusCode: 400, statusMessage: 'An input image is required' })
}
if (!promptMid && aspect === 'auto') {
throw createError({ statusCode: 400, statusMessage: 'An edit prompt is required' })
}
if (extraPasses.some(item => !item.prompt)) {
throw createError({ statusCode: 400, statusMessage: 'Each extra pass needs a prompt' })
}
if (!comfyConfigured()) {
throw createError({
statusCode: 503,
statusMessage: 'Beast ComfyUI is not configured. Set COMFY_HOST.'
})
}
const ownerKey = libraryOwnerKey(event)
const library = publicLibrary(event)
const folderId = library.folders.some(folder => folder.id === fields.folderId)
? fields.folderId
: library.folders[0]?.id
if (!folderId) {
throw createError({ statusCode: 400, statusMessage: 'Create a library folder before editing' })
}
assertFolderExists(event, folderId)
const destFolder = library.folders.find(folder => folder.id === folderId)
const folderLocked = Boolean(destFolder?.protected && !destFolder.unlocked)
const hideInput = fields.hideInput === 'true'
const hideThumbnail = fields.hideThumbnail === 'true'
const steps = clampImageSteps(fields.steps)
const cfg = clampImageCfg(fields.cfg)
const scaleToTotalPixels = parseImageScaleToTotalPixels(fields.scaleToTotalPixels)
const scaleMegapixels = clampImageScaleMegapixels(fields.scaleMegapixels)
if (scaleToTotalPixels) {
try {
await assertImageScaleToTotalPixelsNode()
} catch (error) {
const status = Number((error as { statusCode?: number })?.statusCode || 0)
if (status === 400) throw error
}
}
const seed = fields.seed && fields.seed !== 'random'
? Number(fields.seed)
: Math.floor(Math.random() * 2_147_483_647)
const size = imageDimensions(image.data)
await listStudioLoras()
const loraStack = parsePostedLoraStack(fields.loraStack || fields.lora, 'image')
let inputFilename = image.filename
const postedStillId = (fields.stillId || '').trim()
if (postedStillId) {
try {
const existing = getStill(ownerKey, postedStillId)
inputFilename = existing.name || existing.filename || inputFilename
} catch { /* use the uploaded filename */ }
}
const clipName = allocateOutputName(ownerKey, folderId, (fields.name || '').trim(), {
inputFilename,
engine: 'klein',
loraStack
})
const still = await rememberInputStill({
ownerKey,
folderId,
filename: image.filename,
data: image.data,
width: size?.width || 0,
height: size?.height || 0,
hideInput
})
const savedRef = reference
? await rememberInputStill({
ownerKey,
folderId,
filename: reference.filename,
data: reference.data,
hideInput
})
: null
const passes = extraPasses
const chainTotal = 1 + passes.length
const familyId = crypto.randomUUID()
const studio = await addStudioJob({
ownerKey,
familyId,
kind: 'edit',
payload: {
prompt,
promptMid: promptMid || undefined,
promptPre: promptPre || undefined,
promptPost: promptPost || undefined,
name: clipName,
folderId,
aspect,
width: size?.width || 0,
height: size?.height || 0,
steps,
turbo: true,
seed,
cfg,
fps: 24,
samplerName: 'res_multistep',
scheduler: 'simple',
duration: 0,
sound: false,
workflow: 'v1',
useIdentityRefs: false,
stillId: still?.id,
stillFilename: still?.filename,
hideThumbnail,
hideInput,
folderLocked,
referenceStillIds: [null, null, null, null],
extensions: [],
queueAutoRun: false,
negative: (fields.negative || '').trim(),
passes,
passMode,
referenceStillId: savedRef?.id,
referenceStillFilename: savedRef?.filename,
scaleToTotalPixels,
scaleMegapixels,
...persistLoraFields(loraStack)
}
})
await kickStudioQueue()
let latest = listStudioJobs(ownerKey).find(item => item.id === studio.id)
if (!latest?.liveJobId && !(await videoJobsBusy())) {
await kickStudioQueue()
latest = listStudioJobs(ownerKey).find(item => item.id === studio.id)
}
const liveJobId = latest?.liveJobId || ''
return {
jobId: liveJobId || studio.id,
studioJobId: studio.id,
queued: !liveJobId,
seed,
steps,
hideThumbnail,
folderLocked,
dual: Boolean(reference),
chainTotal
}
})