Files
aigen/pages/index.vue
T
TowstyandCursor 2913909199 Fix library thumbnails and let clips keep a display name.
Extract a poster frame when a still-thumb is missing, and show the clip name in the library with the prompt on the info icon.

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

780 lines
30 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>
<button type="button" class="rounded-full border border-white/10 px-3 py-1 text-zinc-300 hover:border-amber-300/50 hover:text-white" @click="settingsOpen = true">
Settings
</button>
<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>
<div
class="relative cursor-pointer rounded-2xl border border-dashed border-white/15 bg-zinc-950/50 p-4 transition hover:border-amber-300/50"
:class="{ 'border-amber-300/70 bg-amber-400/5': dragging }"
@dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false"
@drop.prevent="onDrop"
@click="pickerOpen = true"
>
<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.stop="resetImage">Reset</button>
</div>
<div v-else class="flex min-h-44 flex-col items-center justify-center text-center">
<p class="font-medium">Choose an image</p>
<p class="mt-1 text-sm text-zinc-500">Browse the library or upload a new PNG, JPG, or WEBP</p>
</div>
</div>
<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 class="space-y-3">
<select v-model="folderId" class="w-full 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 }}{{ folder.protected ? (folder.unlocked ? ' · unlocked' : ' · locked') : '' }}
</option>
</select>
<label class="block text-sm">
<span class="mb-1 block text-zinc-400">Name</span>
<input
v-model="clipName"
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm"
placeholder="Shown in the library and used for the download filename"
maxlength="80"
>
</label>
<form v-if="currentFolder?.protected && !currentFolder.unlocked" class="space-y-2" @submit.prevent="unlockFolder(folderId)">
<p class="text-sm text-zinc-400">Unlock this folder to generate into it.</p>
<div class="flex gap-2">
<input v-model="unlockPassword" type="password" class="min-w-0 flex-1 rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm" placeholder="Folder password">
<button type="submit" class="rounded-xl bg-amber-400 px-3 py-2 text-sm font-semibold text-zinc-950">Unlock</button>
</div>
</form>
<label class="flex cursor-pointer items-center justify-between gap-3 rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3 text-sm">
<span class="font-medium text-zinc-200">Thumbnail</span>
<span class="relative inline-flex h-6 w-11 shrink-0 items-center">
<input v-model="showThumbnail" type="checkbox" class="peer sr-only" role="switch">
<span class="absolute inset-0 rounded-full bg-zinc-700 transition peer-checked:bg-amber-400" />
<span class="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white transition peer-checked:translate-x-5" />
</span>
</label>
<p class="text-xs text-zinc-500">Defaults to this folder’s thumbnail setting whenever you pick a new still. You can override it for this job.</p>
</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() || !currentFolder?.unlocked"
@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="`${outputDownloadName}.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="browseFolder?.protected && browseFolder.unlocked"
type="button"
class="text-xs text-zinc-400 hover:text-white"
@click="lockFolder(browseFolderId)"
>
Lock folder
</button>
</div>
<form v-if="browseFolder?.protected && !browseFolder.unlocked" class="mt-4 space-y-3" @submit.prevent="unlockFolder(browseFolderId)">
<p class="text-sm text-zinc-400">This folder 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="Folder 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>
<span v-if="folder.protected">{{ folder.unlocked ? '' : ' · lock' }}</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-if="item.hasThumbnail" :src="`/api/library/clips/${item.id}/thumb`" alt="" class="h-full w-full object-cover">
<video
v-else
:src="`/api/library/clips/${item.id}/video#t=0.1`"
class="h-full w-full object-cover"
muted
playsinline
preload="metadata"
/>
</button>
<div class="min-w-0 flex-1">
<div class="flex items-start gap-2">
<p class="min-w-0 flex-1 truncate text-sm font-medium">{{ item.name || item.prompt }}</p>
<div class="group relative shrink-0">
<button
type="button"
class="flex h-6 w-6 items-center justify-center rounded-full text-zinc-500 hover:text-amber-200"
:aria-label="'Prompt for ' + (item.name || 'clip')"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-4 w-4">
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4M12 8h.01" stroke-linecap="round" />
</svg>
</button>
<div class="pointer-events-none invisible absolute right-0 top-7 z-20 w-64 whitespace-pre-wrap rounded-xl border border-white/10 bg-zinc-950 p-3 text-xs leading-relaxed text-zinc-300 shadow-2xl group-hover:visible group-focus-within:visible">
{{ item.prompt }}
</div>
</div>
</div>
<p class="text-xs text-zinc-500">{{ item.width && item.height ? `${item.width}×${item.height}` : 'size unknown' }} · {{ 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>
</template>
</div>
</section>
</main>
<SettingsModal
v-if="settingsOpen"
:folders="folders"
@close="settingsOpen = false"
@updated="applyLibrary"
@error="toast"
/>
<ImagePickerModal
v-if="pickerOpen"
:folders="folders"
:stills="stills"
:clips="clips"
:folder-id="folderId"
@close="pickerOpen = false"
@updated="applyLibrary"
@error="toast"
@select="onPickedImage"
/>
<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
thumbnailDefault: boolean
protected: boolean
unlocked: boolean
}
interface LibraryStill {
id: string
folderId: string
filename: string
width: number
height: number
createdAt: number
}
interface LibraryClip {
id: string
folderId: string
name?: string
prompt: string
aspect: string
width: number
height: number
steps: number
turbo: boolean
seed: number
hideThumbnail: boolean
hasThumbnail?: 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 player = ref<HTMLVideoElement | null>(null)
const file = ref<File | null>(null)
const preview = ref('')
const dragging = ref(false)
const prompt = ref('')
const clipName = ref('')
const outputDownloadName = ref('aigen')
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 showThumbnail = computed({
get: () => !hideThumbnail.value,
set: (value: boolean) => { hideThumbnail.value = !value }
})
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 folders = ref<LibraryFolder[]>([])
const clips = ref<LibraryClip[]>([])
const stills = ref<LibraryStill[]>([])
const folderId = ref('')
const browseFolderId = ref('')
const unlockPassword = ref('')
const settingsOpen = ref(false)
const pickerOpen = ref(false)
let timer: ReturnType<typeof setInterval> | null = null
let source: EventSource | null = null
let pollTimer: ReturnType<typeof setInterval> | null = null
let settledUi = false
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))
const currentFolder = computed(() => folders.value.find(folder => folder.id === folderId.value))
const browseFolder = computed(() => folders.value.find(folder => folder.id === browseFolderId.value))
function clipsIn(id: string) {
return clips.value.filter(clip => clip.folderId === id)
}
function applyThumbnailDefault() {
hideThumbnail.value = currentFolder.value ? !currentFolder.value.thumbnailDefault : false
}
function applyLibrary(data: { folders?: LibraryFolder[]; clips?: LibraryClip[]; stills?: LibraryStill[] } | unknown) {
const payload = (data || {}) as { folders?: LibraryFolder[]; clips?: LibraryClip[]; stills?: LibraryStill[] }
folders.value = payload.folders || []
clips.value = payload.clips || []
stills.value = payload.stills || []
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<{ folders?: LibraryFolder[]; clips?: LibraryClip[]; stills?: LibraryStill[] }>('/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({ folders: [], clips: [], stills: [] }))
])
comfyOk.value = Boolean(health.comfy?.ok)
userName.value = me.user?.name || me.user?.email || ''
authEnabled.value = Boolean(me.authEnabled)
})
onBeforeUnmount(() => {
stopTimer()
stopListen()
})
watch(folderId, () => {
applyThumbnailDefault()
})
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
}
function readFile(next: File) {
file.value = next
preview.value = URL.createObjectURL(next)
applyThumbnailDefault()
const img = new Image()
img.onload = () => {
imageWidth.value = img.naturalWidth
imageHeight.value = img.naturalHeight
}
img.src = preview.value
}
function onPickedImage(next: File) {
readFile(next)
pickerOpen.value = false
}
async function persistDroppedStill(next: File) {
if (!currentFolder.value?.unlocked) {
toast('Unlock this folder before adding stills.')
return
}
const body = new FormData()
body.append('image', next)
body.append('folderId', folderId.value)
try {
applyLibrary(await $fetch('/api/library/stills', { method: 'POST', body }))
} catch (error: any) {
toast(error?.data?.statusMessage || 'Could not save still to the library')
}
}
function onDrop(event: DragEvent) {
dragging.value = false
const next = event.dataTransfer?.files?.[0]
if (!next) return
readFile(next)
void persistDroppedStill(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 clipFileName(name: string) {
return name.replace(/[<>:"/\\|?*'`]/g, '-').replace(/\s+/g, ' ').trim().slice(0, 80) || 'aigen'
}
function rerun(item: LibraryClip) {
prompt.value = item.prompt
clipName.value = item.name || ''
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. Choose the same still if it is not already loaded.')
}
async function unlockFolder(id: string) {
try {
const data = await $fetch('/api/library/unlock', { method: 'POST', body: { password: unlockPassword.value, folderId: id } })
unlockPassword.value = ''
applyLibrary(data)
} catch (error: any) {
toast(error?.data?.statusMessage || error?.statusMessage || 'Unlock failed')
}
}
async function lockFolder(id: string) {
try {
applyLibrary(await $fetch('/api/library/lock', { method: 'POST', body: { folderId: id } }))
} catch {
await loadLibrary()
}
}
function openClip(item: LibraryClip) {
videoUrl.value = `/api/library/clips/${item.id}/video`
awaitingReveal.value = item.hideThumbnail
playerSize.value = { width: item.width || 1344, height: item.height || 768 }
browseFolderId.value = item.folderId
outputDownloadName.value = clipFileName(item.name || item.prompt.split(/[.!?\n]/)[0] || 'aigen')
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 (!currentFolder.value?.unlocked) {
toast('Unlock the selected folder before generating.')
return
}
busy.value = true
settledUi = false
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))
body.append('name', clipName.value.trim())
outputDownloadName.value = clipFileName(clipName.value.trim() || prompt.value.trim().split(/[.!?\n]/)[0] || 'aigen')
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 stopListen() {
source?.close()
source = null
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
function applyJobEvent(payload: Record<string, any>, hidden: boolean) {
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
const failed = payload.type === 'error' || payload.status === 'error' || payload.status === 'cancelled'
const complete = payload.type === 'complete' || payload.status === 'complete'
if (settledUi) return
if (complete && (payload.clipId || payload.filename)) {
settledUi = true
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()
stopListen()
loadLibrary().catch(() => null)
return
}
if (failed) {
settledUi = true
busy.value = false
stopTimer()
stopListen()
toast(payload.error || payload.message || 'Generation failed')
}
}
function listen(id: string, hidden: boolean) {
stopListen()
source = new EventSource(`/api/generate/${id}/stream`)
source.onmessage = (event) => {
applyJobEvent(JSON.parse(event.data), hidden)
}
const tick = async () => {
if (settledUi) {
stopListen()
return
}
try {
const snap = await $fetch<Record<string, any>>(`/api/generate/${id}`)
applyJobEvent(snap, hidden)
} catch {
try {
const recovered = await $fetch<Record<string, any>>('/api/generate/recover', {
method: 'POST',
body: { jobId: id }
})
applyJobEvent(recovered, hidden)
} catch {
// Job snapshot can 404 briefly after a restart; keep polling while busy.
}
}
}
void tick()
pollTimer = setInterval(() => {
void tick()
}, 1000)
}
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>