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:
+404
@@ -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>
|
||||
Reference in New Issue
Block a user