Files
aigen/pages/index.vue
T
towsty 4c5c65db98 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.
2026-08-25 17:43:29 -05:00

405 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>