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:
2026-08-25 17:43:29 -05:00
commit 4c5c65db98
31 changed files with 12965 additions and 0 deletions
+48
View File
@@ -0,0 +1,48 @@
export default defineEventHandler(async (event) => {
if (!authEnabled()) {
throw createError({ statusCode: 404, statusMessage: 'Auth is not enabled' })
}
const config = useRuntimeConfig()
const query = getQuery(event)
const code = String(query.code || '')
const state = String(query.state || '')
if (!code || !consumeOauthState(event, state)) {
throw createError({ statusCode: 400, statusMessage: 'Invalid OAuth callback' })
}
const redirectUri = `${publicBaseUrl(event)}/api/auth/callback`
const tokenUrl = config.oidcTokenUrl || `${config.oidcIssuer}token/`
const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
client_id: config.oidcClientId,
client_secret: config.oidcClientSecret
})
const tokenRes = await fetch(tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
body
})
const tokenJson = await tokenRes.json().catch(() => ({})) as { access_token?: string; error?: string }
if (!tokenRes.ok || !tokenJson.access_token) {
throw createError({ statusCode: 401, statusMessage: 'OIDC token exchange failed' })
}
const userinfoUrl = config.oidcUserinfoUrl || `${config.oidcIssuer}userinfo/`
const userRes = await fetch(userinfoUrl, {
headers: { Authorization: `Bearer ${tokenJson.access_token}` }
})
const user = await userRes.json().catch(() => ({})) as { sub?: string; email?: string; name?: string; preferred_username?: string }
if (!userRes.ok || !user.sub) {
throw createError({ statusCode: 401, statusMessage: 'OIDC userinfo failed' })
}
setSessionUser(event, {
sub: user.sub,
email: user.email,
name: user.name || user.preferred_username || user.email
})
await sendRedirect(event, '/', 302)
})
+17
View File
@@ -0,0 +1,17 @@
export default defineEventHandler(async (event) => {
if (!authEnabled()) {
throw createError({ statusCode: 404, statusMessage: 'Auth is not enabled' })
}
const config = useRuntimeConfig()
const state = createOauthState(event)
const redirectUri = `${publicBaseUrl(event)}/api/auth/callback`
const params = new URLSearchParams({
response_type: 'code',
client_id: config.oidcClientId,
redirect_uri: redirectUri,
scope: 'openid email profile',
state
})
const authorize = config.oidcAuthorizeUrl || 'https://auth.carrgarage.com/application/o/authorize/'
await sendRedirect(event, `${authorize}?${params.toString()}`, 302)
})
+4
View File
@@ -0,0 +1,4 @@
export default defineEventHandler(async (event) => {
clearAuthSession(event)
await sendRedirect(event, '/login', 302)
})
+4
View File
@@ -0,0 +1,4 @@
export default defineEventHandler((event) => {
const user = getSessionUser(event)
return { user, authEnabled: authEnabled() }
})
+105
View File
@@ -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)
}
+41
View File
@@ -0,0 +1,41 @@
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')
const job = id ? getJob(id) : undefined
if (!job) {
throw createError({ statusCode: 404, statusMessage: 'Job not found' })
}
const stream = createEventStream(event)
const send = async (payload: unknown) => {
await stream.push(JSON.stringify(payload))
}
await send({
type: 'hello',
status: job.status,
message: job.message,
progress: job.progress,
step: job.step,
maxStep: job.maxStep,
promptId: job.promptId,
elapsedMs: Date.now() - job.startedAt
})
for (const past of job.events) {
await send(past)
}
const unsubscribe = subscribeJob(job, (payload) => {
void send(payload).then(() => {
if (payload.type === 'complete' || payload.type === 'error') {
void stream.close()
}
})
})
stream.onClosed(() => {
unsubscribe()
})
return stream.send()
})
+8
View File
@@ -0,0 +1,8 @@
export default defineEventHandler(async () => {
const comfy = await probeComfy().catch(() => ({ ok: false, host: '' }))
return {
ok: true,
service: 'aigen',
comfy
}
})
+13
View File
@@ -0,0 +1,13 @@
export default defineEventHandler(async (event) => {
const body = await readBody<{ jobId?: string }>(event).catch(() => ({}))
const job = body?.jobId ? getJob(body.jobId) : undefined
if (job) {
job.status = 'cancelled'
emitJob(job, { type: 'status', message: 'Cancelling job...' })
}
const ok = await interruptComfy()
if (job) {
emitJob(job, { type: 'error', error: 'Job interrupted.', message: 'Job interrupted.' })
}
return { ok }
})
+19
View File
@@ -0,0 +1,19 @@
export default defineEventHandler(async (event) => {
const query = getQuery(event)
const filename = String(query.filename || '')
if (!filename) {
throw createError({ statusCode: 400, statusMessage: 'filename is required' })
}
const subfolder = String(query.subfolder || 'video')
const type = String(query.type || 'output')
const params = new URLSearchParams({ filename, subfolder, type })
const res = await comfyFetch(`/view?${params.toString()}`)
if (!res.ok) {
throw createError({ statusCode: res.status, statusMessage: 'Failed to fetch video from ComfyUI' })
}
const buf = Buffer.from(await res.arrayBuffer())
setHeader(event, 'Content-Type', res.headers.get('content-type') || 'video/mp4')
setHeader(event, 'Content-Disposition', `inline; filename="${filename}"`)
setHeader(event, 'Cache-Control', 'private, max-age=3600')
return buf
})
+150
View File
@@ -0,0 +1,150 @@
{
"1": {
"class_type": "LoadImage",
"inputs": {
"image": "input.png"
},
"_meta": { "title": "Load Image" }
},
"2": {
"class_type": "CLIPLoader",
"inputs": {
"clip_name": "qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors",
"type": "minimax",
"device": "default"
},
"_meta": { "title": "Qwen3-VL CLIP" }
},
"3": {
"class_type": "UNETLoader",
"inputs": {
"unet_name": "minimax_h3_fl2va_pruned_int8_convrot.safetensors",
"weight_dtype": "default"
},
"_meta": { "title": "MiniMax H3 UNET" }
},
"4": {
"class_type": "VAELoader",
"inputs": {
"vae_name": "minimax_h3_video_vae_fp16.safetensors"
},
"_meta": { "title": "Video VAE" }
},
"5": {
"class_type": "VAELoader",
"inputs": {
"vae_name": "minimax_h3_audio_vae_fp32.safetensors"
},
"_meta": { "title": "Audio VAE" }
},
"6": {
"class_type": "LoraLoaderModelOnly",
"inputs": {
"model": ["3", 0],
"lora_name": "minimax_h3_fl2v_turbo_8step_v1.0_comfyui_bf16.safetensors",
"strength_model": 1
},
"_meta": { "title": "Turbo LoRA" }
},
"7": {
"class_type": "ComfySwitchNode",
"inputs": {
"switch": true,
"on_false": ["3", 0],
"on_true": ["6", 0]
},
"_meta": { "title": "LoRA Bypass Switch" }
},
"8": {
"class_type": "MiniMaxH3ImageToVideo",
"inputs": {
"clip": ["2", 0],
"vae": ["4", 0],
"first_frame": ["1", 0],
"prompt": "",
"width": 1344,
"height": 768,
"length": 124
},
"_meta": { "title": "MiniMax H3 Image to Video" }
},
"9": {
"class_type": "RandomNoise",
"inputs": {
"noise_seed": 0
},
"_meta": { "title": "Seed" }
},
"10": {
"class_type": "KSamplerSelect",
"inputs": {
"sampler_name": "res_multistep"
},
"_meta": { "title": "Sampler MiniMax" }
},
"11": {
"class_type": "BasicScheduler",
"inputs": {
"model": ["7", 0],
"scheduler": "simple",
"steps": 8,
"denoise": 1
},
"_meta": { "title": "Scheduler" }
},
"12": {
"class_type": "BasicGuider",
"inputs": {
"model": ["7", 0],
"conditioning": ["8", 0]
},
"_meta": { "title": "Guider" }
},
"13": {
"class_type": "SamplerCustomAdvanced",
"inputs": {
"noise": ["9", 0],
"guider": ["12", 0],
"sampler": ["10", 0],
"sigmas": ["11", 0],
"latent_image": ["8", 1]
},
"_meta": { "title": "KSampler" }
},
"14": {
"class_type": "VAEDecode",
"inputs": {
"samples": ["13", 0],
"vae": ["4", 0]
},
"_meta": { "title": "Decode Video" }
},
"15": {
"class_type": "VAEDecodeAudio",
"inputs": {
"samples": ["13", 0],
"vae": ["5", 0]
},
"_meta": { "title": "Decode Audio" }
},
"16": {
"class_type": "CreateVideo",
"inputs": {
"images": ["14", 0],
"audio": ["15", 0],
"fps": 24,
"bit_depth": 8
},
"_meta": { "title": "Create Video" }
},
"17": {
"class_type": "SaveVideo",
"inputs": {
"video": ["16", 0],
"filename_prefix": "video/MiniMax_H3",
"format": "auto",
"codec": "auto"
},
"_meta": { "title": "Save MP4" }
}
}
+19
View File
@@ -0,0 +1,19 @@
export default defineEventHandler((event) => {
if (!authEnabled()) return
const path = event.path || getRequestURL(event).pathname
if (
path.startsWith('/api/auth') ||
path.startsWith('/api/health') ||
path.startsWith('/_nuxt') ||
path.startsWith('/favicon') ||
path === '/login'
) {
return
}
const user = getSessionUser(event)
if (user) return
if (path.startsWith('/api/')) {
throw createError({ statusCode: 401, statusMessage: 'Authentication required' })
}
return sendRedirect(event, '/login', 302)
})
+102
View File
@@ -0,0 +1,102 @@
function comfyBase() {
const config = useRuntimeConfig()
const host = (config.comfyHost || process.env.COMFY_HOST || '').replace(/\/$/, '')
if (!host) {
throw createError({ statusCode: 500, statusMessage: 'COMFY_HOST is not configured' })
}
return host
}
export function getComfyHost() {
return comfyBase()
}
export function comfyWsUrl(clientId: string) {
return `${comfyBase().replace(/^http/, 'ws')}/ws?clientId=${encodeURIComponent(clientId)}`
}
export async function comfyFetch(path: string, init?: RequestInit) {
const url = `${comfyBase()}${path}`
try {
return await fetch(url, init)
} catch (error) {
throw createError({
statusCode: 502,
statusMessage: `ComfyUI host unreachable (${comfyBase()})`,
data: { cause: error instanceof Error ? error.message : String(error) }
})
}
}
export async function uploadImage(file: { filename: string; data: Buffer; type?: string }) {
const body = new FormData()
const blob = new Blob([new Uint8Array(file.data)], { type: file.type || 'application/octet-stream' })
body.append('image', blob, file.filename)
body.append('overwrite', 'true')
body.append('type', 'input')
const res = await comfyFetch('/upload/image', { method: 'POST', body })
if (!res.ok) {
throw createError({ statusCode: 502, statusMessage: `Image upload failed (${res.status})` })
}
return (await res.json()) as { name: string; subfolder?: string; type?: string }
}
export async function queuePrompt(graph: unknown, clientId: string) {
const res = await comfyFetch('/prompt', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt: graph, client_id: clientId })
})
const payload = await res.json().catch(() => ({}))
if (!res.ok) {
const message = (payload as { error?: { message?: string } }).error?.message
|| (payload as { node_errors?: unknown }).node_errors
|| `Queue failed (${res.status})`
throw createError({ statusCode: 502, statusMessage: String(message), data: payload })
}
return payload as { prompt_id: string; number?: number }
}
export async function interruptComfy() {
const res = await comfyFetch('/interrupt', { method: 'POST' })
return res.ok
}
export async function fetchHistory(promptId: string) {
const res = await comfyFetch(`/history/${encodeURIComponent(promptId)}`)
if (!res.ok) return null
return (await res.json()) as Record<string, unknown>
}
export function extractVideo(history: Record<string, unknown> | null, promptId: string) {
if (!history) return null
const entry = (history[promptId] || Object.values(history)[0]) as { outputs?: Record<string, Record<string, unknown>> } | undefined
const outputs = entry?.outputs || {}
for (const node of Object.values(outputs)) {
for (const key of ['videos', 'gifs', 'images', 'files']) {
const items = node[key]
if (!Array.isArray(items)) continue
const match = items.find((item: { filename?: string }) => String(item.filename || '').toLowerCase().endsWith('.mp4'))
|| items[0]
if (match?.filename) {
return {
filename: String(match.filename),
subfolder: String(match.subfolder || 'video'),
type: String(match.type || 'output')
}
}
}
}
return null
}
export async function probeComfy() {
try {
const res = await fetch(`${comfyBase()}/system_stats`, { signal: AbortSignal.timeout(2500) })
if (!res.ok) return { ok: false, host: comfyBase() }
const stats = await res.json()
return { ok: true, host: comfyBase(), stats }
} catch {
return { ok: false, host: comfyBase() }
}
}
+81
View File
@@ -0,0 +1,81 @@
export type JobStatus = 'queued' | 'uploading' | 'running' | 'complete' | 'error' | 'cancelled'
export interface JobEvent {
type: string
message?: string
progress?: number
step?: number
maxStep?: number
node?: string | null
filename?: string
subfolder?: string
mediaType?: string
error?: string
elapsedMs?: number
}
export interface Job {
id: string
promptId?: string
clientId: string
status: JobStatus
message: string
progress: number
step: number
maxStep: number
startedAt: number
video?: { filename: string; subfolder: string; type: string }
error?: string
events: JobEvent[]
listeners: Set<(event: JobEvent) => void>
}
const jobs = new Map<string, Job>()
const MAX_JOBS = 40
export function createJob(): Job {
const job: Job = {
id: crypto.randomUUID(),
clientId: crypto.randomUUID(),
status: 'queued',
message: 'Queued',
progress: 0,
step: 0,
maxStep: 0,
startedAt: Date.now(),
events: [],
listeners: new Set()
}
jobs.set(job.id, job)
while (jobs.size > MAX_JOBS) {
const oldest = jobs.keys().next().value
if (oldest) jobs.delete(oldest)
}
return job
}
export function getJob(id: string) {
return jobs.get(id)
}
export function emitJob(job: Job, event: JobEvent) {
if (event.message) job.message = event.message
if (typeof event.progress === 'number') job.progress = event.progress
if (typeof event.step === 'number') job.step = event.step
if (typeof event.maxStep === 'number') job.maxStep = event.maxStep
event.elapsedMs = Date.now() - job.startedAt
job.events.push(event)
if (job.events.length > 200) job.events.shift()
for (const listener of job.listeners) {
try {
listener(event)
} catch {
// ignore broken SSE listeners
}
}
}
export function subscribeJob(job: Job, listener: (event: JobEvent) => void) {
job.listeners.add(listener)
return () => job.listeners.delete(listener)
}
+85
View File
@@ -0,0 +1,85 @@
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
import type { H3Event } from 'h3'
export interface SessionUser {
sub: string
email?: string
name?: string
}
const COOKIE = 'aigen_session'
const STATE_COOKIE = 'aigen_oauth_state'
function secret() {
const config = useRuntimeConfig()
return config.sessionSecret || 'dev-only-change-me'
}
function sign(value: string) {
return createHmac('sha256', secret()).update(value).digest('base64url')
}
function seal(payload: unknown) {
const data = Buffer.from(JSON.stringify(payload)).toString('base64url')
return `${data}.${sign(data)}`
}
function unseal<T>(token: string | undefined): T | null {
if (!token || !token.includes('.')) return null
const [data, sig] = token.split('.')
const expected = sign(data)
const a = Buffer.from(sig)
const b = Buffer.from(expected)
if (a.length !== b.length || !timingSafeEqual(a, b)) return null
try {
return JSON.parse(Buffer.from(data, 'base64url').toString('utf8')) as T
} catch {
return null
}
}
function cookieOpts(event: H3Event) {
const proto = getRequestHeader(event, 'x-forwarded-proto') || getRequestProtocol(event)
return {
httpOnly: true,
sameSite: 'lax' as const,
path: '/',
secure: proto === 'https',
maxAge: 60 * 60 * 24 * 14
}
}
export function getSessionUser(event: H3Event): SessionUser | null {
return unseal<SessionUser>(getCookie(event, COOKIE))
}
export function setSessionUser(event: H3Event, user: SessionUser) {
setCookie(event, COOKIE, seal(user), cookieOpts(event))
}
export function clearAuthSession(event: H3Event) {
deleteCookie(event, COOKIE, { path: '/' })
}
export function createOauthState(event: H3Event) {
const state = randomBytes(24).toString('hex')
setCookie(event, STATE_COOKIE, state, { ...cookieOpts(event), maxAge: 600 })
return state
}
export function consumeOauthState(event: H3Event, incoming: string | undefined) {
const stored = getCookie(event, STATE_COOKIE)
deleteCookie(event, STATE_COOKIE, { path: '/' })
return Boolean(stored && incoming && stored === incoming)
}
export function authEnabled() {
const config = useRuntimeConfig()
return Boolean(config.public.authEnabled && config.oidcClientId && config.oidcClientSecret)
}
export function publicBaseUrl(event: H3Event) {
const proto = getRequestHeader(event, 'x-forwarded-proto') || getRequestProtocol(event)
const host = getRequestHeader(event, 'x-forwarded-host') || getRequestHost(event)
return `${proto}://${host}`
}
+138
View File
@@ -0,0 +1,138 @@
import { NODE_LABELS } from '~/server/utils/workflow'
import type { Job } from '~/server/utils/jobs'
function classifyError(message: string) {
const lower = message.toLowerCase()
if (lower.includes('out of memory') || lower.includes('cuda') && lower.includes('alloc') || lower.includes('vram')) {
return 'ComfyUI VRAM allocation failed. Try Turbo (8 steps) or a smaller aspect ratio.'
}
if (lower.includes('econnrefused') || lower.includes('unreachable') || lower.includes('fetch failed')) {
return 'ComfyUI host connection dropped. Confirm the desktop instance is running and reachable on the LAN.'
}
if (lower.includes('timeout')) {
return 'Network timeout talking to ComfyUI. The job may still be running on the desktop.'
}
return message
}
export async function watchComfyJob(job: Job) {
const ws = new WebSocket(comfyWsUrl(job.clientId))
let settled = false
const finish = async (error?: string) => {
if (settled) return
settled = true
try { ws.close() } catch { /* ignore */ }
if (error) {
job.status = job.status === 'cancelled' ? 'cancelled' : 'error'
job.error = classifyError(error)
emitJob(job, { type: 'error', error: job.error, message: job.error })
return
}
emitJob(job, { type: 'status', message: 'Fetching output...', progress: 96 })
const history = await fetchHistory(job.promptId || '')
const video = extractVideo(history, job.promptId || '')
if (!video) {
job.status = 'error'
job.error = 'Workflow finished but no MP4 was found in ComfyUI history.'
emitJob(job, { type: 'error', error: job.error, message: job.error })
return
}
job.video = video
job.status = 'complete'
emitJob(job, {
type: 'complete',
message: 'Video ready',
progress: 100,
filename: video.filename,
subfolder: video.subfolder,
mediaType: video.type
})
}
const timeout = setTimeout(() => {
finish('Timed out waiting for ComfyUI (15 minutes).')
}, 15 * 60 * 1000)
ws.addEventListener('open', () => {
emitJob(job, { type: 'status', message: 'Connected to ComfyUI', progress: 8 })
})
ws.addEventListener('error', () => {
if (!settled) finish('ComfyUI WebSocket connection dropped.')
})
ws.addEventListener('close', () => {
if (!settled) {
// Fall back to history polling in case completion arrived as HTTP only.
setTimeout(async () => {
if (settled) return
const history = await fetchHistory(job.promptId || '')
if (extractVideo(history, job.promptId || '')) {
clearTimeout(timeout)
await finish()
}
}, 1500)
}
})
ws.addEventListener('message', async (event) => {
let payload: { type?: string; data?: Record<string, unknown> }
try {
payload = JSON.parse(String(event.data))
} catch {
return
}
const data = payload.data || {}
if (job.promptId && data.prompt_id && data.prompt_id !== job.promptId) return
if (payload.type === 'progress') {
const value = Number(data.value || 0)
const max = Number(data.max || 1)
const pct = 12 + Math.round((value / Math.max(max, 1)) * 70)
job.status = 'running'
emitJob(job, {
type: 'progress',
step: value,
maxStep: max,
progress: pct,
node: data.node ? String(data.node) : null,
message: `Sampling step ${value}/${max} (${Math.round((value / Math.max(max, 1)) * 100)}%)`
})
}
if (payload.type === 'executing') {
const node = data.node === null || data.node === undefined ? null : String(data.node)
if (node === null) {
clearTimeout(timeout)
await finish()
return
}
const label = NODE_LABELS[node] || `Running node ${node}`
const encoding = node === '16' || node === '17'
emitJob(job, {
type: 'executing',
node,
progress: encoding ? 90 : Math.max(job.progress, 10),
message: encoding ? 'Encoding video...' : `${label}...`
})
}
if (payload.type === 'execution_error') {
clearTimeout(timeout)
const message = String(data.exception_message || data.message || 'ComfyUI node execution failed')
await finish(message)
}
if (payload.type === 'execution_interrupted') {
clearTimeout(timeout)
job.status = 'cancelled'
await finish('Job interrupted.')
}
})
return () => {
clearTimeout(timeout)
try { ws.close() } catch { /* ignore */ }
}
}
+49
View File
@@ -0,0 +1,49 @@
// Nitro bundles this JSON into the production server output.
import workflowTemplate from '../assets/workflow_api.json'
export interface GenerateParams {
prompt: string
imageName: string
width: number
height: number
steps: number
seed: number
turbo: boolean
length: number
}
type WorkflowGraph = Record<string, { class_type: string; inputs: Record<string, unknown>; _meta?: { title?: string } }>
export function buildWorkflow(params: GenerateParams) {
const graph = structuredClone(workflowTemplate) as WorkflowGraph
graph['1'].inputs.image = params.imageName
graph['8'].inputs.prompt = params.prompt
graph['8'].inputs.width = params.width
graph['8'].inputs.height = params.height
graph['8'].inputs.length = params.length
graph['9'].inputs.noise_seed = params.seed
graph['11'].inputs.steps = params.steps
graph['7'].inputs.switch = params.turbo
graph['6'].inputs.strength_model = params.turbo ? 1 : 0
return graph
}
export const NODE_LABELS: Record<string, string> = {
'1': 'Loading image',
'2': 'Loading Qwen3-VL encoder',
'3': 'Loading MiniMax H3 model',
'4': 'Loading video VAE',
'5': 'Loading audio VAE',
'6': 'Applying turbo LoRA',
'7': 'Selecting model path',
'8': 'Encoding prompt & latent',
'9': 'Preparing seed',
'10': 'Selecting sampler',
'11': 'Building schedule',
'12': 'Preparing guider',
'13': 'Sampling',
'14': 'Decoding video frames',
'15': 'Decoding audio',
'16': 'Encoding video',
'17': 'Saving MP4'
}