Add Nuxt MiniMax H3 relay studio for local ComfyUI.
Headless image-to-video UI queues jobs on the desktop ComfyUI instance with live SSE progress and Authentik SSO.
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
const ASPECTS: Record<string, { width: number; height: number }> = {
|
||||
'16:9': { width: 1344, height: 768 },
|
||||
'9:16': { width: 768, height: 1344 },
|
||||
'1:1': { width: 1024, height: 1024 }
|
||||
}
|
||||
|
||||
function snap32(value: number) {
|
||||
return Math.max(32, Math.round(value / 32) * 32)
|
||||
}
|
||||
|
||||
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 aspect = ASPECTS[fields.aspect] || ASPECTS['16:9']
|
||||
const width = snap32(Number(fields.width || aspect.width))
|
||||
const height = snap32(Number(fields.height || aspect.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 job = createJob()
|
||||
job.maxStep = steps
|
||||
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,
|
||||
steps,
|
||||
turbo
|
||||
}
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
) {
|
||||
job.status = 'uploading'
|
||||
const uploaded = await uploadImage(params.image)
|
||||
emitJob(job, { type: 'status', message: 'Queueing MiniMax H3 job...', progress: 6 })
|
||||
|
||||
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 watchComfyJob(job)
|
||||
}
|
||||
Reference in New Issue
Block a user