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
})