Settings manages folders, thumbnail defaults, and passwords for the signed-in account, and choosing an image now browses saved stills instead of only the desktop file dialog. Co-authored-by: Cursor <cursoragent@cursor.com>
131 lines
3.7 KiB
TypeScript
131 lines
3.7 KiB
TypeScript
function frameLength(seconds: number) {
|
|
const raw = Math.max(5, Math.round(seconds * 24))
|
|
return raw + (5 - (raw % 17) + 17) % 17
|
|
}
|
|
|
|
export default defineEventHandler(async (event) => {
|
|
const form = await readMultipartFormData(event)
|
|
if (!form?.length) {
|
|
throw createError({ statusCode: 400, statusMessage: 'Expected multipart form data' })
|
|
}
|
|
|
|
const fields: Record<string, string> = {}
|
|
let image: { 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 && part.data) {
|
|
fields[part.name] = part.data.toString('utf8')
|
|
}
|
|
}
|
|
|
|
if (!image) {
|
|
throw createError({ statusCode: 400, statusMessage: 'An initial image is required' })
|
|
}
|
|
const prompt = (fields.prompt || '').trim()
|
|
if (!prompt) {
|
|
throw createError({ statusCode: 400, statusMessage: 'A motion prompt is required' })
|
|
}
|
|
|
|
const { width, height } = resolveOutputSize(fields.aspect, image.data, Number(fields.width), Number(fields.height))
|
|
const turbo = fields.turbo !== 'false'
|
|
const steps = Number(fields.steps || (turbo ? 8 : 20))
|
|
const seed = fields.seed && fields.seed !== 'random'
|
|
? Number(fields.seed)
|
|
: Math.floor(Math.random() * 2_147_483_647)
|
|
const length = frameLength(Number(fields.duration || 5))
|
|
const hideThumbnail = fields.hideThumbnail === 'true'
|
|
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 generating' })
|
|
}
|
|
assertFolderAccess(event, folderId)
|
|
await saveStill({
|
|
ownerKey,
|
|
folderId,
|
|
filename: image.filename,
|
|
data: image.data,
|
|
width,
|
|
height
|
|
})
|
|
|
|
const job = createJob()
|
|
job.maxStep = steps
|
|
job.hideThumbnail = hideThumbnail
|
|
job.library = {
|
|
ownerKey,
|
|
folderId,
|
|
hideThumbnail,
|
|
prompt,
|
|
aspect: fields.aspect || 'auto',
|
|
width,
|
|
height,
|
|
steps,
|
|
turbo,
|
|
seed,
|
|
thumb: image.data
|
|
}
|
|
emitJob(job, { type: 'status', message: 'Uploading image...', progress: 2 })
|
|
|
|
void runGeneration(job, { prompt, image, width, height, steps, seed, turbo, length }).catch((error) => {
|
|
const message = error instanceof Error ? error.message : String(error)
|
|
job.status = 'error'
|
|
job.error = message
|
|
emitJob(job, { type: 'error', error: message, message })
|
|
})
|
|
|
|
return {
|
|
jobId: job.id,
|
|
clientId: job.clientId,
|
|
seed,
|
|
width,
|
|
height,
|
|
aspect: fields.aspect || 'auto',
|
|
steps,
|
|
turbo,
|
|
folderId,
|
|
hideThumbnail
|
|
}
|
|
})
|
|
|
|
async function runGeneration(
|
|
job: ReturnType<typeof createJob>,
|
|
params: {
|
|
prompt: string
|
|
image: { filename: string; data: Buffer; type?: string }
|
|
width: number
|
|
height: number
|
|
steps: number
|
|
seed: number
|
|
turbo: boolean
|
|
length: number
|
|
}
|
|
) {
|
|
const done = watchComfyJob(job)
|
|
job.status = 'uploading'
|
|
const uploaded = await uploadImage(params.image)
|
|
emitJob(job, { type: 'status', message: 'Queueing MiniMax H3 job...', progress: 6 })
|
|
await waitForComfySocket(job, 4000)
|
|
|
|
const graph = buildWorkflow({
|
|
prompt: params.prompt,
|
|
imageName: uploaded.name,
|
|
width: params.width,
|
|
height: params.height,
|
|
steps: params.steps,
|
|
seed: params.seed,
|
|
turbo: params.turbo,
|
|
length: params.length
|
|
})
|
|
|
|
const queued = await queuePrompt(graph, job.clientId)
|
|
job.promptId = queued.prompt_id
|
|
job.status = 'running'
|
|
emitJob(job, { type: 'status', message: 'Job queued on ComfyUI', progress: 8 })
|
|
await done
|
|
}
|