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,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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
clearAuthSession(event)
|
||||
await sendRedirect(event, '/login', 302)
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
export default defineEventHandler((event) => {
|
||||
const user = getSessionUser(event)
|
||||
return { user, authEnabled: authEnabled() }
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
export default defineEventHandler(async () => {
|
||||
const comfy = await probeComfy().catch(() => ({ ok: false, host: '' }))
|
||||
return {
|
||||
ok: true,
|
||||
service: 'aigen',
|
||||
comfy
|
||||
}
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
Reference in New Issue
Block a user