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
+404
View File
@@ -0,0 +1,404 @@
<template>
<div class="min-h-screen">
<header class="sticky top-0 z-20 border-b border-white/5 bg-zinc-950/70 backdrop-blur-xl">
<div class="mx-auto flex max-w-7xl items-center justify-between px-4 py-4 sm:px-6">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-amber-400 text-zinc-950 font-display font-extrabold">A</div>
<div>
<h1 class="font-display text-xl font-bold leading-none">AIGen</h1>
<p class="text-[11px] uppercase tracking-[0.22em] text-zinc-500">MiniMax H3 · ComfyUI relay</p>
</div>
</div>
<div class="flex items-center gap-3 text-sm">
<span
class="hidden sm:inline-flex items-center gap-2 rounded-full border px-3 py-1"
:class="comfyOk ? 'border-emerald-400/30 text-emerald-300' : 'border-red-400/30 text-red-300'"
>
<span class="h-2 w-2 rounded-full" :class="comfyOk ? 'bg-emerald-400' : 'bg-red-400'" />
{{ comfyOk ? 'ComfyUI online' : 'ComfyUI offline' }}
</span>
<span class="hidden md:block text-zinc-400">{{ userName }}</span>
<a v-if="authEnabled" href="/api/auth/logout" class="text-zinc-400 hover:text-white">Sign out</a>
</div>
</div>
</header>
<main class="mx-auto grid max-w-7xl gap-6 px-4 py-6 sm:px-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<section class="glass rounded-3xl p-5 sm:p-6 space-y-5">
<div>
<h2 class="font-display text-2xl font-bold">Input</h2>
<p class="text-sm text-zinc-400">Drop a still, describe the motion, then relay the job to the local desktop.</p>
</div>
<label
class="relative block cursor-pointer rounded-2xl border border-dashed border-white/15 bg-zinc-950/50 p-4 transition hover:border-amber-300/50"
@dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false"
@drop.prevent="onDrop"
:class="{ 'border-amber-300/70 bg-amber-400/5': dragging }"
>
<input ref="fileInput" type="file" accept="image/*" class="hidden" @change="onFile">
<div v-if="preview" class="relative">
<img :src="preview" alt="Source still" class="max-h-72 w-full rounded-xl object-contain bg-black/40">
<button type="button" class="absolute right-3 top-3 rounded-full bg-zinc-950/80 px-3 py-1 text-xs" @click.prevent="resetImage">Reset</button>
</div>
<div v-else class="flex min-h-44 flex-col items-center justify-center text-center">
<p class="font-medium">Drag & drop an image</p>
<p class="mt-1 text-sm text-zinc-500">PNG, JPG, or WEBP · used as MiniMax H3 first frame</p>
</div>
</label>
<div>
<label class="mb-2 block text-sm font-medium text-zinc-300">Motion & scene prompt</label>
<textarea
v-model="prompt"
rows="6"
class="w-full rounded-2xl border border-white/10 bg-zinc-950/70 px-4 py-3 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
placeholder="Camera slowly pushes in. Soft daylight, fabric ripples, dust motes in the air. Audio: quiet room tone, distant birds."
/>
</div>
<div>
<p class="mb-2 text-sm font-medium text-zinc-300">Aspect ratio / quality</p>
<div class="grid grid-cols-3 gap-2">
<button
v-for="option in aspects"
:key="option.id"
type="button"
class="rounded-2xl border px-3 py-3 text-left text-sm transition"
:class="aspect === option.id ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 hover:border-white/20'"
@click="aspect = option.id"
>
<span class="block font-semibold">{{ option.label }}</span>
<span class="text-xs text-zinc-400">{{ option.hint }}</span>
</button>
</div>
</div>
<details class="rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3">
<summary class="cursor-pointer text-sm font-medium">Generation settings</summary>
<div class="mt-4 space-y-4">
<div>
<p class="mb-2 text-xs uppercase tracking-wider text-zinc-500">Steps</p>
<div class="grid grid-cols-2 gap-2">
<button type="button" class="rounded-xl border px-3 py-2 text-sm" :class="turbo ? 'border-amber-300 bg-amber-400/10' : 'border-white/10'" @click="turbo = true">8 Turbo · LoRA on</button>
<button type="button" class="rounded-xl border px-3 py-2 text-sm" :class="!turbo ? 'border-amber-300 bg-amber-400/10' : 'border-white/10'" @click="turbo = false">20 High-Fidelity · LoRA bypass</button>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<label class="text-sm">
<span class="mb-1 block text-zinc-400">Duration</span>
<select v-model.number="duration" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2">
<option :value="5">5 seconds</option>
<option :value="10">10 seconds</option>
</select>
</label>
<label class="text-sm">
<span class="mb-1 block text-zinc-400">Seed</span>
<input v-model="seedInput" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2" placeholder="random">
</label>
</div>
</div>
</details>
<div class="flex flex-wrap gap-3">
<button
type="button"
class="flex-1 rounded-2xl bg-amber-400 px-5 py-3 font-semibold text-zinc-950 transition hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="busy || !file || !prompt.trim()"
@click="generate"
>
{{ busy ? 'Generating…' : 'Generate Video' }}
</button>
<button
v-if="busy"
type="button"
class="rounded-2xl border border-red-400/40 px-4 py-3 text-red-300"
@click="cancelJob"
>
Cancel / Interrupt
</button>
</div>
</section>
<section class="space-y-5">
<div class="glass rounded-3xl p-5 sm:p-6">
<div class="flex items-center justify-between gap-3">
<h2 class="font-display text-2xl font-bold">Output</h2>
<p class="text-xs text-zinc-500">{{ elapsedLabel }} · step {{ step }}/{{ maxStep || (turbo ? 8 : 20) }}</p>
</div>
<div class="mt-4">
<div class="mb-2 flex items-center justify-between text-sm">
<span class="text-zinc-300">{{ statusMessage }}</span>
<span class="text-amber-200">{{ progress }}%</span>
</div>
<div class="h-2 overflow-hidden rounded-full bg-zinc-800">
<div class="h-full rounded-full bg-amber-400 progress-glow transition-all duration-300" :style="{ width: progress + '%' }" />
</div>
</div>
<div class="mt-5 overflow-hidden rounded-2xl bg-black/50">
<video
v-if="videoUrl"
:src="videoUrl"
class="aspect-video w-full bg-black"
controls
autoplay
loop
playsinline
/>
<div v-else class="flex aspect-video items-center justify-center text-sm text-zinc-500">
Generated MP4 appears here
</div>
</div>
<a
v-if="videoUrl"
:href="videoUrl"
download="aigen.mp4"
class="mt-4 inline-flex rounded-2xl border border-white/15 px-4 py-2 text-sm hover:border-amber-300/50"
>
Download MP4
</a>
</div>
<div class="glass rounded-3xl p-5 sm:p-6">
<h2 class="font-display text-xl font-bold">History</h2>
<p v-if="!history.length" class="mt-3 text-sm text-zinc-500">Past runs stay in this browser.</p>
<ul class="mt-4 space-y-3">
<li v-for="item in history" :key="item.id" class="flex gap-3 rounded-2xl border border-white/5 bg-zinc-950/40 p-3">
<img :src="item.thumbnail" alt="" class="h-16 w-16 rounded-xl object-cover">
<div class="min-w-0 flex-1">
<p class="truncate text-sm">{{ item.prompt }}</p>
<p class="text-xs text-zinc-500">{{ item.width }}×{{ item.height }} · {{ item.steps }} steps · seed {{ item.seed }}</p>
<div class="mt-2 flex gap-2">
<button type="button" class="text-xs text-amber-300" @click="rerun(item)">Rerun</button>
<a v-if="item.videoUrl" :href="item.videoUrl" class="text-xs text-sky-300" target="_blank">Open</a>
</div>
</div>
</li>
</ul>
</div>
</section>
</main>
<div class="pointer-events-none fixed bottom-4 right-4 z-50 space-y-2">
<div
v-for="toast in toasts"
:key="toast.id"
class="pointer-events-auto w-80 rounded-2xl border border-red-400/30 bg-zinc-950/90 px-4 py-3 text-sm text-red-100 shadow-2xl"
>
{{ toast.message }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface HistoryItem {
id: string
prompt: string
width: number
height: number
steps: number
turbo: boolean
seed: number
thumbnail: string
videoUrl?: string
createdAt: number
}
const aspects = [
{ id: '16:9', label: '16:9 Landscape', hint: '1344 × 768 · 0.98 MP' },
{ id: '9:16', label: '9:16 Portrait', hint: '768 × 1344 · 0.98 MP' },
{ id: '1:1', label: '1:1 Square', hint: '1024 × 1024' }
] as const
const fileInput = ref<HTMLInputElement | null>(null)
const file = ref<File | null>(null)
const preview = ref('')
const dragging = ref(false)
const prompt = ref('')
const aspect = ref<'16:9' | '9:16' | '1:1'>('16:9')
const turbo = ref(true)
const duration = ref(5)
const seedInput = ref('random')
const busy = ref(false)
const progress = ref(0)
const statusMessage = ref('Waiting for a still.')
const step = ref(0)
const maxStep = ref(8)
const elapsedMs = ref(0)
const videoUrl = ref('')
const jobId = ref('')
const toasts = ref<{ id: string; message: string }[]>([])
const history = ref<HistoryItem[]>([])
const comfyOk = ref(false)
const userName = ref('')
const authEnabled = ref(false)
let timer: ReturnType<typeof setInterval> | null = null
let source: EventSource | null = null
const elapsedLabel = computed(() => {
const seconds = Math.floor(elapsedMs.value / 1000)
const mm = String(Math.floor(seconds / 60)).padStart(2, '0')
const ss = String(seconds % 60).padStart(2, '0')
return `${mm}:${ss}`
})
onMounted(async () => {
history.value = JSON.parse(localStorage.getItem('aigen-history') || '[]')
const [health, me] = await Promise.all([
$fetch<{ comfy?: { ok?: boolean } }>('/api/health').catch(() => ({ comfy: { ok: false } })),
$fetch<{ user?: { name?: string; email?: string }; authEnabled?: boolean }>('/api/auth/me').catch(() => ({ user: null }))
])
comfyOk.value = Boolean(health.comfy?.ok)
userName.value = me.user?.name || me.user?.email || ''
authEnabled.value = Boolean(me.authEnabled)
})
onBeforeUnmount(() => {
stopTimer()
source?.close()
})
function toast(message: string) {
const id = crypto.randomUUID()
toasts.value.push({ id, message })
setTimeout(() => {
toasts.value = toasts.value.filter(item => item.id !== id)
}, 7000)
}
function resetImage() {
file.value = null
preview.value = ''
if (fileInput.value) fileInput.value.value = ''
}
function readFile(next: File) {
file.value = next
preview.value = URL.createObjectURL(next)
}
function onFile(event: Event) {
const input = event.target as HTMLInputElement
const next = input.files?.[0]
if (next) readFile(next)
}
function onDrop(event: DragEvent) {
dragging.value = false
const next = event.dataTransfer?.files?.[0]
if (next) readFile(next)
}
function startTimer() {
elapsedMs.value = 0
stopTimer()
const started = Date.now()
timer = setInterval(() => {
elapsedMs.value = Date.now() - started
}, 250)
}
function stopTimer() {
if (timer) clearInterval(timer)
timer = null
}
function persistHistory() {
localStorage.setItem('aigen-history', JSON.stringify(history.value.slice(0, 12)))
}
function rerun(item: HistoryItem) {
prompt.value = item.prompt
turbo.value = item.turbo
seedInput.value = String(item.seed)
if (item.width === 768) aspect.value = '9:16'
else if (item.width === 1024) aspect.value = '1:1'
else aspect.value = '16:9'
toast('Settings restored. Drop the same still if it is not already loaded.')
}
async function generate() {
if (!file.value || !prompt.value.trim()) return
busy.value = true
progress.value = 1
videoUrl.value = ''
statusMessage.value = 'Uploading image...'
startTimer()
try {
const body = new FormData()
body.append('image', file.value)
body.append('prompt', prompt.value.trim())
body.append('aspect', aspect.value)
body.append('turbo', String(turbo.value))
body.append('steps', String(turbo.value ? 8 : 20))
body.append('duration', String(duration.value))
body.append('seed', seedInput.value || 'random')
const started = await $fetch<{ jobId: string; seed: number; width: number; height: number; steps: number; turbo: boolean }>('/api/generate', {
method: 'POST',
body
})
jobId.value = started.jobId
maxStep.value = started.steps
listen(started.jobId, started)
} catch (error: any) {
busy.value = false
stopTimer()
const message = error?.data?.statusMessage || error?.statusMessage || error?.message || 'Failed to start generation'
statusMessage.value = message
toast(message)
}
}
function listen(id: string, meta: { seed: number; width: number; height: number; steps: number; turbo: boolean }) {
source?.close()
source = new EventSource(`/api/generate/${id}/stream`)
source.onmessage = (event) => {
const payload = JSON.parse(event.data)
if (payload.message) statusMessage.value = payload.message
if (typeof payload.progress === 'number') progress.value = payload.progress
if (typeof payload.step === 'number') step.value = payload.step
if (typeof payload.maxStep === 'number') maxStep.value = payload.maxStep
if (payload.type === 'complete') {
const url = `/api/media?filename=${encodeURIComponent(payload.filename)}&subfolder=${encodeURIComponent(payload.subfolder || 'video')}&type=${encodeURIComponent(payload.mediaType || 'output')}`
videoUrl.value = url
busy.value = false
stopTimer()
source?.close()
history.value.unshift({
id,
prompt: prompt.value.trim(),
width: meta.width,
height: meta.height,
steps: meta.steps,
turbo: meta.turbo,
seed: meta.seed,
thumbnail: preview.value,
videoUrl: url,
createdAt: Date.now()
})
persistHistory()
}
if (payload.type === 'error') {
busy.value = false
stopTimer()
source?.close()
toast(payload.error || payload.message || 'Generation failed')
}
}
source.onerror = () => {
if (!busy.value) return
toast('Progress stream disconnected. The ComfyUI job may still be running.')
}
}
async function cancelJob() {
try {
await $fetch('/api/interrupt', { method: 'POST', body: { jobId: jobId.value } })
statusMessage.value = 'Interrupt sent to ComfyUI'
} catch (error: any) {
toast(error?.statusMessage || 'Failed to interrupt ComfyUI')
}
}
</script>
+15
View File
@@ -0,0 +1,15 @@
<template>
<main class="min-h-screen flex items-center justify-center px-6">
<section class="glass w-full max-w-md rounded-3xl p-10 text-center">
<p class="text-amber-300/80 text-xs tracking-[0.3em] uppercase">Carr Garage</p>
<h1 class="font-display text-4xl font-extrabold mt-3">AIGen</h1>
<p class="mt-3 text-zinc-400">Headless MiniMax H3 image-to-video studio. Sign in with Authentik to reach the ComfyUI relay.</p>
<a
href="/api/auth/login"
class="mt-8 inline-flex w-full items-center justify-center rounded-2xl bg-amber-400 px-5 py-3 font-semibold text-zinc-950 hover:bg-amber-300 transition"
>
Continue with Authentik
</a>
</section>
</main>
</template>