Files
aigen/pages/index.vue
T
TowstyandCursor 83051e0ea3 Add a persistent clip library with folders, optional password, and hidden-thumbnail glow.
Generated videos save into a chosen folder, sensitive clips skip thumbnails until played, and Docker writes the catalog to a Coolify volume.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 18:03:26 -05:00

669 lines
26 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-2 gap-2 lg:grid-cols-4">
<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.id === 'auto' ? autoHint : option.hint }}</span>
</button>
</div>
<p class="mt-2 text-xs text-zinc-500">Output {{ outputSize.width }} × {{ outputSize.height }}{{ aspect === 'auto' ? ' · matching the still' : ' · override' }}</p>
</div>
<div>
<p class="mb-2 text-sm font-medium text-zinc-300">Save to library</p>
<div v-if="libraryLocked && !libraryUnlocked" class="rounded-2xl border border-amber-300/20 bg-zinc-950/50 px-4 py-3 text-sm text-zinc-400">
Unlock the library to choose a folder.
</div>
<div v-else class="space-y-3">
<div class="flex flex-wrap gap-2">
<select v-model="folderId" class="min-w-[12rem] flex-1 rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm">
<option v-for="folder in folders" :key="folder.id" :value="folder.id">{{ folder.name }}</option>
</select>
<input
v-model="newFolderName"
class="w-40 rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm"
placeholder="New folder"
@keydown.enter.prevent="addFolder"
>
<button type="button" class="rounded-xl border border-white/15 px-3 py-2 text-sm hover:border-amber-300/50" @click="addFolder">Add</button>
</div>
<label class="flex items-start gap-3 rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3 text-sm">
<input v-model="hideThumbnail" type="checkbox" class="mt-1 accent-amber-400">
<span>
<span class="block font-medium text-zinc-200">Hide thumbnail</span>
<span class="text-xs text-zinc-500">For sensitive prompts. The preview glows when ready until you play the clip.</span>
</span>
</label>
</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() || (libraryLocked && !libraryUnlocked)"
@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="relative mt-5 overflow-hidden rounded-2xl bg-black/50"
:class="{ 'sensitive-glow': awaitingReveal }"
:style="{ aspectRatio: `${playerSize.width} / ${playerSize.height}` }"
>
<video
v-if="videoUrl"
ref="player"
:src="videoUrl"
class="h-full w-full bg-black"
:class="{ 'opacity-0': awaitingReveal }"
:controls="!awaitingReveal"
:autoplay="!awaitingReveal"
loop
playsinline
@play="onPlayed"
/>
<button
v-if="awaitingReveal"
type="button"
class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-amber-100"
@click="revealAndPlay"
>
<span class="font-display text-lg font-bold">Ready</span>
<span class="text-sm text-amber-100/80">Click to play · thumbnail hidden</span>
</button>
<div v-else-if="!videoUrl" class="flex h-full min-h-[12rem] items-center justify-center text-sm text-zinc-500">
Generated MP4 appears here
</div>
</div>
<a
v-if="videoUrl && !awaitingReveal"
: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">
<div class="flex items-center justify-between gap-3">
<h2 class="font-display text-xl font-bold">Library</h2>
<button
v-if="libraryUnlocked && libraryLocked"
type="button"
class="text-xs text-zinc-400 hover:text-white"
@click="lockLibrary"
>
Lock
</button>
</div>
<form v-if="libraryLocked && !libraryUnlocked" class="mt-4 space-y-3" @submit.prevent="unlockLibrary">
<p class="text-sm text-zinc-400">This library is password protected.</p>
<input v-model="unlockPassword" type="password" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm" placeholder="Library password">
<button type="submit" class="w-full rounded-xl bg-amber-400 px-4 py-2 font-semibold text-zinc-950">Unlock</button>
</form>
<template v-else>
<div class="mt-4 flex flex-wrap gap-2">
<button
v-for="folder in folders"
:key="folder.id"
type="button"
class="rounded-full border px-3 py-1 text-xs"
:class="browseFolderId === folder.id ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400'"
@click="browseFolderId = folder.id"
>
{{ folder.name }}
<span class="text-zinc-500">({{ clipsIn(folder.id).length }})</span>
</button>
</div>
<p v-if="!visibleClips.length" class="mt-4 text-sm text-zinc-500">Nothing in this folder yet. Generate into it from the left.</p>
<ul class="mt-4 space-y-3">
<li v-for="item in visibleClips" :key="item.id" class="flex gap-3 rounded-2xl border border-white/5 bg-zinc-950/40 p-3">
<button type="button" class="relative h-16 w-16 shrink-0 overflow-hidden rounded-xl bg-zinc-900" @click="openClip(item)">
<span v-if="item.hideThumbnail" class="sensitive-glow block h-full w-full" />
<img v-else :src="`/api/library/clips/${item.id}/thumb`" alt="" class="h-full w-full object-cover">
</button>
<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-3">
<button type="button" class="text-xs text-amber-300" @click="openClip(item)">Open</button>
<button type="button" class="text-xs text-amber-300" @click="rerun(item)">Rerun</button>
<button type="button" class="text-xs text-red-300" @click="removeClip(item)">Delete</button>
</div>
</div>
</li>
</ul>
<details class="mt-5 rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3">
<summary class="cursor-pointer text-sm font-medium">Library password</summary>
<form class="mt-4 space-y-3" @submit.prevent="savePassword">
<input v-if="libraryLocked" v-model="currentPassword" type="password" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm" placeholder="Current password">
<input v-model="nextPassword" type="password" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm" :placeholder="libraryLocked ? 'New password (blank to remove)' : 'Set a password'">
<button type="submit" class="rounded-xl border border-white/15 px-4 py-2 text-sm hover:border-amber-300/50">
{{ libraryLocked ? 'Update password' : 'Protect library' }}
</button>
</form>
</details>
</template>
</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">
type AspectId = 'auto' | '16:9' | '9:16' | '1:1'
interface LibraryFolder {
id: string
name: string
createdAt: number
}
interface LibraryClip {
id: string
folderId: string
prompt: string
aspect: string
width: number
height: number
steps: number
turbo: boolean
seed: number
hideThumbnail: boolean
createdAt: number
}
const PRESETS: Record<Exclude<AspectId, 'auto'>, { width: number; height: number }> = {
'16:9': { width: 1344, height: 768 },
'9:16': { width: 768, height: 1344 },
'1:1': { width: 1024, height: 1024 }
}
const aspects = [
{ id: 'auto' as const, label: 'Automatic', hint: 'Match the still' },
{ id: '16:9' as const, label: '16:9 Landscape', hint: '1344 × 768 · 0.98 MP' },
{ id: '9:16' as const, label: '9:16 Portrait', hint: '768 × 1344 · 0.98 MP' },
{ id: '1:1' as const, label: '1:1 Square', hint: '1024 × 1024' }
]
const fileInput = ref<HTMLInputElement | null>(null)
const player = ref<HTMLVideoElement | null>(null)
const file = ref<File | null>(null)
const preview = ref('')
const dragging = ref(false)
const prompt = ref('')
const aspect = ref<AspectId>('auto')
const imageWidth = ref(0)
const imageHeight = ref(0)
const turbo = ref(true)
const duration = ref(5)
const seedInput = ref('random')
const hideThumbnail = ref(false)
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 awaitingReveal = ref(false)
const playerSize = ref({ width: 1344, height: 768 })
const toasts = ref<{ id: string; message: string }[]>([])
const comfyOk = ref(false)
const userName = ref('')
const authEnabled = ref(false)
const libraryLocked = ref(false)
const libraryUnlocked = ref(true)
const folders = ref<LibraryFolder[]>([])
const clips = ref<LibraryClip[]>([])
const folderId = ref('')
const browseFolderId = ref('')
const newFolderName = ref('')
const unlockPassword = ref('')
const currentPassword = ref('')
const nextPassword = ref('')
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}`
})
function snap32(value: number) {
return Math.max(32, Math.round(value / 32) * 32)
}
function autoResolution(srcWidth: number, srcHeight: number) {
const ratio = srcWidth / Math.max(srcHeight, 1)
let width: number
let height: number
if (ratio >= 1) {
height = 768
width = Math.min(1344, height * ratio)
} else {
width = 768
height = Math.min(1344, width / ratio)
}
return { width: snap32(width), height: snap32(height) }
}
const outputSize = computed(() => {
if (aspect.value !== 'auto') return PRESETS[aspect.value]
if (imageWidth.value && imageHeight.value) return autoResolution(imageWidth.value, imageHeight.value)
return { width: 1344, height: 768 }
})
const autoHint = computed(() => {
if (!imageWidth.value || !imageHeight.value) return 'Match the still'
const size = autoResolution(imageWidth.value, imageHeight.value)
return `${size.width} × ${size.height} · from still`
})
const visibleClips = computed(() => clips.value.filter(clip => clip.folderId === browseFolderId.value))
function clipsIn(id: string) {
return clips.value.filter(clip => clip.folderId === id)
}
function applyLibrary(data: { locked?: boolean; unlocked?: boolean; folders?: LibraryFolder[]; clips?: LibraryClip[] }) {
libraryLocked.value = Boolean(data.locked)
libraryUnlocked.value = data.unlocked !== false
folders.value = data.folders || []
clips.value = data.clips || []
if (!folderId.value || !folders.value.some(folder => folder.id === folderId.value)) {
folderId.value = folders.value[0]?.id || ''
}
if (!browseFolderId.value || !folders.value.some(folder => folder.id === browseFolderId.value)) {
browseFolderId.value = folderId.value
}
}
async function loadLibrary() {
const data = await $fetch<{ locked?: boolean; unlocked?: boolean; folders?: LibraryFolder[]; clips?: LibraryClip[] }>('/api/library')
applyLibrary(data)
}
onMounted(async () => {
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 })),
loadLibrary().catch(() => applyLibrary({ locked: false, unlocked: true, folders: [], clips: [] }))
])
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 = ''
imageWidth.value = 0
imageHeight.value = 0
if (fileInput.value) fileInput.value.value = ''
}
function readFile(next: File) {
file.value = next
preview.value = URL.createObjectURL(next)
const img = new Image()
img.onload = () => {
imageWidth.value = img.naturalWidth
imageHeight.value = img.naturalHeight
}
img.src = preview.value
}
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 rerun(item: LibraryClip) {
prompt.value = item.prompt
turbo.value = item.turbo
seedInput.value = String(item.seed)
aspect.value = (item.aspect as AspectId) || 'auto'
hideThumbnail.value = item.hideThumbnail
folderId.value = item.folderId
toast('Settings restored. Drop the same still if it is not already loaded.')
}
async function addFolder() {
const name = newFolderName.value.trim()
if (!name) return
try {
const folder = await $fetch<LibraryFolder>('/api/library/folders', { method: 'POST', body: { name } })
newFolderName.value = ''
await loadLibrary()
folderId.value = folder.id
browseFolderId.value = folder.id
} catch (error: any) {
toast(error?.data?.statusMessage || error?.statusMessage || 'Could not create folder')
}
}
async function unlockLibrary() {
try {
const data = await $fetch('/api/library/unlock', { method: 'POST', body: { password: unlockPassword.value } })
unlockPassword.value = ''
applyLibrary(data)
} catch (error: any) {
toast(error?.data?.statusMessage || error?.statusMessage || 'Unlock failed')
}
}
async function lockLibrary() {
try {
applyLibrary(await $fetch('/api/library/lock', { method: 'POST' }))
} catch {
await loadLibrary()
}
}
async function savePassword() {
try {
const data = await $fetch('/api/library/password', {
method: 'PUT',
body: { password: nextPassword.value, currentPassword: currentPassword.value }
})
currentPassword.value = ''
nextPassword.value = ''
applyLibrary(data)
toast(libraryLocked.value ? 'Library password updated.' : 'Library password saved.')
} catch (error: any) {
toast(error?.data?.statusMessage || error?.statusMessage || 'Could not update password')
}
}
function openClip(item: LibraryClip) {
videoUrl.value = `/api/library/clips/${item.id}/video`
awaitingReveal.value = item.hideThumbnail
playerSize.value = { width: item.width, height: item.height }
browseFolderId.value = item.folderId
statusMessage.value = item.hideThumbnail ? 'Hidden clip ready' : 'Loaded from library'
progress.value = 100
}
async function removeClip(item: LibraryClip) {
try {
await $fetch(`/api/library/clips/${item.id}`, { method: 'DELETE' })
if (videoUrl.value.includes(item.id)) {
videoUrl.value = ''
awaitingReveal.value = false
}
await loadLibrary()
} catch (error: any) {
toast(error?.data?.statusMessage || 'Could not delete clip')
}
}
function onPlayed() {
awaitingReveal.value = false
}
function revealAndPlay() {
awaitingReveal.value = false
nextTick(() => {
player.value?.play().catch(() => null)
})
}
async function generate() {
if (!file.value || !prompt.value.trim()) return
if (libraryLocked.value && !libraryUnlocked.value) {
toast('Unlock the library before generating.')
return
}
busy.value = true
progress.value = 1
videoUrl.value = ''
awaitingReveal.value = false
playerSize.value = outputSize.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('width', String(outputSize.value.width))
body.append('height', String(outputSize.value.height))
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')
body.append('folderId', folderId.value)
body.append('hideThumbnail', String(hideThumbnail.value))
const started = await $fetch<{ jobId: string; seed: number; width: number; height: number; steps: number; turbo: boolean; hideThumbnail?: boolean }>('/api/generate', {
method: 'POST',
body
})
jobId.value = started.jobId
maxStep.value = started.steps
playerSize.value = { width: started.width, height: started.height }
listen(started.jobId, started.hideThumbnail === true || hideThumbnail.value)
} 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, hidden: 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 clipUrl = payload.clipId
? `/api/library/clips/${payload.clipId}/video`
: `/api/media?filename=${encodeURIComponent(payload.filename)}&subfolder=${encodeURIComponent(payload.subfolder || 'video')}&type=${encodeURIComponent(payload.mediaType || 'output')}`
videoUrl.value = clipUrl
awaitingReveal.value = Boolean(payload.hideThumbnail || hidden)
busy.value = false
stopTimer()
source?.close()
loadLibrary().catch(() => null)
}
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>