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,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() }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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 */ }
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
Reference in New Issue
Block a user