Files
aigen/pages/index.vue
T
TowstyandCursor dbe45fd2ff Add generation quality controls and bind them to the MiniMax workflow.
Expose aspect, CFG, FPS, sampler, and scheduler in Generation settings, persist them on clips and held jobs, and inject those values into the Comfy graph. Keep the image picker stills-only and allow renaming library clips.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 22:55:06 -05:00

1398 lines
55 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">{{ instanceName }}</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="comfyBadge.className"
>
<span class="h-2 w-2 rounded-full" :class="comfyBadge.dot" />
{{ comfyBadge.label }}
</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 && !hideInputPreview" 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-if="preview && hideInputPreview" class="relative flex min-h-44 flex-col items-center justify-center text-center">
<span class="sensitive-glow absolute inset-3 rounded-xl" />
<p class="relative font-medium">Still loaded · preview hidden</p>
<p class="relative mt-1 text-sm text-zinc-500">Toggle Hide input still off to show it</p>
<button type="button" class="relative mt-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">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">This folder is locked. You can still generate into it; unlock to view clips.</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">Hide input still</span>
<span class="relative inline-flex h-6 w-11 shrink-0 items-center">
<input v-model="hideInputPreview" 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>
<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">Hide output preview</span>
<span class="relative inline-flex h-6 w-11 shrink-0 items-center">
<input v-model="hideThumbnail" 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>
<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>
<span class="block font-medium text-zinc-200">Generate sound</span>
<span class="mt-0.5 block text-xs text-zinc-500">Off skips the audio VAE so Comfy encodes a silent clip.</span>
</span>
<span class="relative inline-flex h-6 w-11 shrink-0 items-center">
<input v-model="withSound" 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">Output preview hides library thumbs and autoplay. Click the player to reveal; click anywhere else on the page to hide it again. Folder default applies to output preview when you pick a new still.</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">Aspect ratio / resolution</p>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-3">
<button
v-for="option in aspects"
:key="option.id"
type="button"
class="rounded-xl border px-3 py-2 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>
<p class="mt-2 text-xs text-zinc-500">Output {{ outputSize.width }} × {{ outputSize.height }}</p>
</div>
<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="text-sm">
<span class="mb-1 block text-zinc-400">CFG / Guidance scale</span>
<div class="flex items-center gap-3">
<input
class="aigen-slider min-w-0 flex-1"
type="range"
:min="CFG_MIN"
:max="CFG_MAX"
:step="CFG_STEP"
:value="cfg"
:style="{ background: `linear-gradient(to right, #fbbf24 ${cfgPct}%, #27272a ${cfgPct}%)` }"
aria-label="CFG guidance scale"
@input="onCfgSlider"
>
<input
v-model.number="cfg"
type="number"
:min="CFG_MIN"
:max="CFG_MAX"
:step="CFG_STEP"
class="w-20 rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 focus:ring-2"
@change="onCfgNumber"
>
</div>
<p class="mt-1 text-[11px] text-zinc-500">{{ turbo ? 'Default 1.5 with Turbo LoRA' : 'Default 4.0 with High-Fidelity' }}</p>
</div>
<div class="grid grid-cols-2 gap-3">
<label class="text-sm">
<span class="mb-1 block text-zinc-400">Frame rate</span>
<select v-model.number="fps" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm">
<option :value="24">24 FPS</option>
<option :value="12">12 FPS</option>
<option :value="30">30 FPS</option>
</select>
<p class="mt-1 text-[11px] text-zinc-500">{{ frameCount }} frames · duration × FPS</p>
</label>
<div class="text-sm">
<span class="mb-1 block text-zinc-400">Duration</span>
<div class="relative">
<input
v-model="durationDraft"
type="text"
inputmode="decimal"
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 pr-8 cursor-ns-resize outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:cursor-text focus:ring-2"
:class="{ 'select-none caret-transparent': durationScrubbing }"
placeholder="seconds"
autocomplete="off"
spellcheck="false"
@blur="commitDuration"
@keydown.enter.prevent="commitDuration"
@pointerdown="onDurationPointerDown"
>
<span class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-xs text-zinc-500">s</span>
</div>
<input
class="aigen-slider mt-3 w-full"
type="range"
:min="DURATION_SLIDER_MIN"
:max="DURATION_SLIDER_MAX"
:step="0.1"
:value="durationSlider"
:style="{ background: `linear-gradient(to right, #fbbf24 ${durationSliderPct}%, #27272a ${durationSliderPct}%)` }"
aria-label="Duration in seconds"
@input="onDurationSlider"
>
<p class="mt-1 text-[11px] text-zinc-500">Type any length, use the slider, or drag up/down in the box.</p>
</div>
</div>
<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>
<p class="mb-2 text-xs uppercase tracking-wider text-zinc-500">Sampler & scheduler</p>
<div class="grid grid-cols-2 gap-3">
<label class="text-sm">
<span class="mb-1 block text-zinc-400">Sampler</span>
<select v-model="samplerName" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm">
<option value="res_multistep">res_multistep</option>
<option value="euler">euler</option>
<option value="dpmpp_2m">dpmpp_2m</option>
</select>
</label>
<label class="text-sm">
<span class="mb-1 block text-zinc-400">Scheduler</span>
<select v-model="scheduler" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm">
<option value="simple">simple</option>
<option value="ddim_uniform">ddim_uniform</option>
<option value="sgm_uniform">sgm_uniform</option>
</select>
</label>
</div>
</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() || !folderId"
@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>
<div v-if="drafts.length" class="rounded-2xl border border-amber-300/20 bg-amber-400/5 p-4">
<h3 class="text-sm font-semibold text-amber-100">Held jobs</h3>
<p class="mt-1 text-xs text-zinc-400">Comfy was still busy after 3 minutes. Click one to restore the still, prompt, and settings.</p>
<ul class="mt-3 space-y-2">
<li
v-for="draft in drafts"
:key="draft.id"
class="flex items-start gap-3 rounded-xl border border-white/10 bg-zinc-950/50 px-3 py-2"
>
<button type="button" class="min-w-0 flex-1 text-left" @click="loadDraft(draft)">
<p class="truncate text-sm font-medium text-zinc-100">{{ draft.name || draft.prompt }}</p>
<p class="mt-0.5 text-xs text-zinc-500">{{ draft.duration }}s · {{ draft.turbo ? 'turbo' : 'quality' }} · {{ draft.sound === false ? 'silent' : 'sound' }} · seed {{ draft.seed }}</p>
</button>
<button type="button" class="shrink-0 text-xs text-red-300 hover:text-red-200" @click="dismissDraft(draft)">Dismiss</button>
</li>
</ul>
</div>
</section>
<section class="space-y-5">
<div ref="outputPanel" 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="statusBusy ? 'text-amber-200' : '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 || lockedSave }"
:style="{ aspectRatio: `${playerSize.width} / ${playerSize.height}` }"
>
<video
v-if="videoUrl && !lockedSave"
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 && !lockedSave"
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 · preview hidden</span>
</button>
<div
v-else-if="lockedSave"
class="absolute inset-0 flex flex-col items-center justify-center gap-2 px-6 text-center text-amber-100"
>
<span class="font-display text-lg font-bold">Saved to a locked folder</span>
<span class="text-sm text-amber-100/80">Unlock that folder in the library to view this clip</span>
</div>
<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 && !lockedSave"
: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 locked. Unlock to view {{ browseFolder.clipCount || 0 }} clip{{ browseFolder.clipCount === 1 ? '' : 's' }}.</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">({{ folder.unlocked ? clipsIn(folder.id).length : folder.clipCount }})</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">
<input
v-if="renamingId === item.id"
ref="renameInput"
v-model="renameDraft"
class="min-w-0 flex-1 rounded-lg border border-amber-300/40 bg-zinc-950 px-2 py-1 text-sm outline-none ring-amber-300/40 focus:ring-2"
maxlength="80"
@keydown.enter.prevent="saveRename(item)"
@keydown.escape.prevent="cancelRename"
@blur="saveRename(item)"
>
<p v-else 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 flex-wrap items-center 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="startRename(item)">Rename</button>
<button type="button" class="text-xs text-amber-300" @click="rerun(item)">Rerun</button>
<select
v-if="folders.length > 1"
class="max-w-[9.5rem] rounded-lg border border-white/10 bg-zinc-950 px-1.5 py-0.5 text-xs text-amber-300"
:value="''"
@change="onMoveClip(item, $event)"
>
<option disabled value="">Move to…</option>
<option
v-for="folder in folders.filter(folder => folder.id !== item.folderId)"
:key="folder.id"
:value="folder.id"
>
{{ folder.name }}{{ folder.protected && !folder.unlocked ? ' · locked' : '' }}
</option>
</select>
<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"
:auth-mode="authMode"
:username="userName"
@close="settingsOpen = false"
@updated="applyLibrary"
@error="toast"
@renamed="userName = $event"
/>
<ImagePickerModal
v-if="pickerOpen"
:folders="folders"
:stills="stills"
:folder-id="folderId"
:hide-input-preview="hideInputPreview"
@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 = '16:9' | '9:16' | '1:1'
interface LibraryFolder {
id: string
name: string
createdAt: number
thumbnailDefault: boolean
protected: boolean
unlocked: boolean
clipCount?: number
}
interface LibraryStill {
id: string
folderId: string
filename: string
width: number
height: number
createdAt: number
hideInput?: boolean
}
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
cfg?: number
fps?: number
samplerName?: string
scheduler?: string
}
interface RetryDraft {
id: string
createdAt: number
folderId: string
stillId?: string
stillFilename?: string
name?: string
prompt: string
aspect: string
width: number
height: number
steps: number
turbo: boolean
seed: number
duration: number
hideThumbnail: boolean
sound: boolean
hideInput?: boolean
cfg?: number
fps?: number
samplerName?: string
scheduler?: string
}
const PRESETS: Record<AspectId, { width: number; height: number }> = {
'16:9': { width: 1344, height: 768 },
'9:16': { width: 768, height: 1344 },
'1:1': { width: 960, height: 960 }
}
const aspects = [
{ id: '16:9' as const, label: '16:9 Landscape', hint: '1344 × 768' },
{ id: '9:16' as const, label: '9:16 Portrait / Shorts', hint: '768 × 1344' },
{ id: '1:1' as const, label: '1:1 Square', hint: '960 × 960' }
]
const player = ref<HTMLVideoElement | null>(null)
const outputPanel = ref<HTMLElement | 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>('16:9')
const imageWidth = ref(0)
const imageHeight = ref(0)
const turbo = ref(true)
const CFG_MIN = 1
const CFG_MAX = 10
const CFG_STEP = 0.5
const CFG_TURBO = 1.5
const CFG_QUALITY = 4
const cfg = ref(CFG_TURBO)
const cfgTouched = ref(false)
const fps = ref(24)
const samplerName = ref('res_multistep')
const scheduler = ref('simple')
const DURATION_MIN = 0.5
const DURATION_MAX = 120
const DURATION_SLIDER_MIN = 1
const DURATION_SLIDER_MAX = 30
const duration = ref(5)
const durationDraft = ref('5')
const durationScrubbing = ref(false)
const durationSlider = computed(() => Math.min(DURATION_SLIDER_MAX, Math.max(DURATION_SLIDER_MIN, duration.value)))
const durationSliderPct = computed(() => {
return ((durationSlider.value - DURATION_SLIDER_MIN) / (DURATION_SLIDER_MAX - DURATION_SLIDER_MIN)) * 100
})
const seedInput = ref('random')
const withSound = ref(true)
const hideThumbnail = ref(false)
const hideInputPreview = ref(false)
const JOB_STORE = 'aigen-active-job'
const busy = ref(false)
const progress = ref(0)
const statusMessage = ref('Waiting for a still.')
const statusBusy = ref(false)
const step = ref(0)
const maxStep = ref(8)
const elapsedMs = ref(0)
const videoUrl = ref('')
const jobId = ref('')
const awaitingReveal = ref(false)
const concealOutput = ref(false)
const lockedSave = ref(false)
const playerSize = ref({ width: 1344, height: 768 })
const toasts = ref<{ id: string; message: string }[]>([])
const comfyOk = ref(false)
const comfyBusy = ref(false)
const userName = ref('')
const authEnabled = ref(false)
const authMode = ref('')
const instanceName = ref('AIGen')
const folders = ref<LibraryFolder[]>([])
const clips = ref<LibraryClip[]>([])
const stills = ref<LibraryStill[]>([])
const drafts = ref<RetryDraft[]>([])
const folderId = ref('')
const browseFolderId = ref('')
const renamingId = ref('')
const renameDraft = ref('')
const renameInput = ref<HTMLInputElement | null>(null)
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 healthTimer: ReturnType<typeof setInterval> | null = null
let settledUi = false
let listenGen = 0
let activeDraftId = ''
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}`
})
const comfyBadge = computed(() => {
if (!comfyOk.value) {
return {
label: 'ComfyUI offline',
className: 'border-red-400/30 text-red-300',
dot: 'bg-red-400'
}
}
if (comfyBusy.value) {
return {
label: 'ComfyUI busy',
className: 'border-amber-400/40 text-amber-200',
dot: 'bg-amber-400'
}
}
return {
label: 'ComfyUI online',
className: 'border-emerald-400/30 text-emerald-300',
dot: 'bg-emerald-400'
}
})
function normalizeAspect(raw?: string, width?: number, height?: number): AspectId {
if (raw === '16:9' || raw === '9:16' || raw === '1:1') return raw
if (width && height) {
if (width === height) return '1:1'
if (height > width) return '9:16'
}
return '16:9'
}
const outputSize = computed(() => PRESETS[aspect.value])
const frameCount = computed(() => Math.max(5, Math.floor(duration.value * fps.value)))
const cfgPct = computed(() => ((cfg.value - CFG_MIN) / (CFG_MAX - CFG_MIN)) * 100)
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[]; drafts?: RetryDraft[] } | unknown) {
const payload = (data || {}) as { folders?: LibraryFolder[]; clips?: LibraryClip[]; stills?: LibraryStill[]; drafts?: RetryDraft[] }
folders.value = payload.folders || []
clips.value = payload.clips || []
stills.value = payload.stills || []
drafts.value = payload.drafts || []
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[]; drafts?: RetryDraft[] }>('/api/library')
applyLibrary(data)
}
function applyComfyHealth(health: { comfy?: { ok?: boolean; busy?: boolean } } | null | undefined) {
comfyOk.value = Boolean(health?.comfy?.ok)
comfyBusy.value = Boolean(health?.comfy?.ok && health?.comfy?.busy)
}
async function pollComfyHealth() {
const health = await $fetch<{ comfy?: { ok?: boolean; busy?: boolean } }>('/api/health').catch(() => ({ comfy: { ok: false, busy: false } }))
applyComfyHealth(health)
}
onMounted(async () => {
try {
hideInputPreview.value = localStorage.getItem('aigen-hide-input-preview') === 'true'
withSound.value = localStorage.getItem('aigen-generate-sound') !== 'false'
} catch { /* ignore */ }
window.addEventListener('pointerdown', onSitePointerDown, true)
const [health, me] = await Promise.all([
$fetch<{ comfy?: { ok?: boolean; busy?: boolean } }>('/api/health').catch(() => ({ comfy: { ok: false, busy: false } })),
$fetch<{ user?: { name?: string; email?: string }; authEnabled?: boolean; authMode?: string; instanceName?: string }>('/api/auth/me').catch(() => ({ user: null })),
loadLibrary().catch(() => applyLibrary({ folders: [], clips: [], stills: [], drafts: [] }))
])
applyComfyHealth(health)
userName.value = me.user?.name || me.user?.email || ''
authEnabled.value = Boolean(me.authEnabled)
authMode.value = me.authMode || ''
instanceName.value = me.instanceName || 'AIGen'
healthTimer = setInterval(() => {
void pollComfyHealth()
}, 5000)
await resumeActiveJob()
})
onBeforeUnmount(() => {
window.removeEventListener('pointerdown', onSitePointerDown, true)
if (healthTimer) clearInterval(healthTimer)
healthTimer = null
stopTimer()
stopListen()
})
watch(folderId, () => {
applyThumbnailDefault()
})
watch(hideInputPreview, (value) => {
try {
localStorage.setItem('aigen-hide-input-preview', String(value))
} catch { /* ignore */ }
})
watch(withSound, (value) => {
try {
localStorage.setItem('aigen-generate-sound', String(value))
} catch { /* ignore */ }
})
watch(duration, (value) => {
if (!durationScrubbing.value) durationDraft.value = formatDuration(value)
})
watch(turbo, (on) => {
if (!cfgTouched.value) cfg.value = on ? CFG_TURBO : CFG_QUALITY
})
function clampDuration(value: number) {
if (!Number.isFinite(value)) return 5
return Math.min(DURATION_MAX, Math.max(DURATION_MIN, Math.round(value * 10) / 10))
}
function formatDuration(value: number) {
return Number.isInteger(value) ? String(value) : value.toFixed(1).replace(/\.0$/, '')
}
function commitDuration() {
const parsed = Number.parseFloat(durationDraft.value.replace(',', '.').trim())
duration.value = clampDuration(parsed)
durationDraft.value = formatDuration(duration.value)
}
function onDurationSlider(event: Event) {
duration.value = clampDuration(Number((event.target as HTMLInputElement).value))
durationDraft.value = formatDuration(duration.value)
}
function clampCfg(value: number) {
if (!Number.isFinite(value)) return turbo.value ? CFG_TURBO : CFG_QUALITY
return Math.min(CFG_MAX, Math.max(CFG_MIN, Math.round(value / CFG_STEP) * CFG_STEP))
}
function onCfgSlider(event: Event) {
cfgTouched.value = true
cfg.value = clampCfg(Number((event.target as HTMLInputElement).value))
}
function onCfgNumber() {
cfgTouched.value = true
cfg.value = clampCfg(Number(cfg.value))
}
function restoreFps(raw?: number) {
return raw === 12 || raw === 30 || raw === 24 ? raw : 24
}
function restoreSampler(raw?: string) {
return raw === 'euler' || raw === 'dpmpp_2m' || raw === 'res_multistep' ? raw : 'res_multistep'
}
function restoreScheduler(raw?: string) {
return raw === 'ddim_uniform' || raw === 'sgm_uniform' || raw === 'simple' ? raw : 'simple'
}
function restoreAdvanced(item: { turbo: boolean; aspect?: string; width?: number; height?: number; cfg?: number; fps?: number; samplerName?: string; scheduler?: string }) {
cfgTouched.value = true
turbo.value = item.turbo
aspect.value = normalizeAspect(item.aspect, item.width, item.height)
if (typeof item.cfg === 'number') {
cfg.value = clampCfg(item.cfg)
} else {
cfg.value = item.turbo ? CFG_TURBO : CFG_QUALITY
cfgTouched.value = false
}
fps.value = restoreFps(item.fps)
samplerName.value = restoreSampler(item.samplerName)
scheduler.value = restoreScheduler(item.scheduler)
}
function onDurationPointerDown(event: PointerEvent) {
if (event.button !== 0) return
const input = event.currentTarget as HTMLInputElement
const startY = event.clientY
const startValue = duration.value
let moved = false
const onMove = (move: PointerEvent) => {
const delta = startY - move.clientY
if (!moved && Math.abs(delta) < 5) return
if (!moved) {
moved = true
durationScrubbing.value = true
input.setPointerCapture(move.pointerId)
input.blur()
}
move.preventDefault()
const next = clampDuration(startValue + delta / 18)
duration.value = next
durationDraft.value = formatDuration(next)
}
const onUp = () => {
input.removeEventListener('pointermove', onMove)
input.removeEventListener('pointerup', onUp)
input.removeEventListener('pointercancel', onUp)
durationScrubbing.value = false
}
input.addEventListener('pointermove', onMove)
input.addEventListener('pointerup', onUp)
input.addEventListener('pointercancel', onUp)
}
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, hidden = false) {
if (hidden) hideInputPreview.value = true
readFile(next)
pickerOpen.value = false
}
async function persistDroppedStill(next: File) {
const body = new FormData()
body.append('image', next)
body.append('folderId', folderId.value)
body.append('hideInput', String(hideInputPreview.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(offset = 0) {
elapsedMs.value = offset
stopTimer()
const started = Date.now() - offset
timer = setInterval(() => {
elapsedMs.value = Date.now() - started
}, 250)
}
function persistActiveJob(id: string, hidden: boolean, folderLocked: boolean) {
try {
localStorage.setItem(JOB_STORE, JSON.stringify({ jobId: id, hideThumbnail: hidden, folderLocked, savedAt: Date.now() }))
} catch { /* ignore */ }
}
function clearActiveJob() {
try {
localStorage.removeItem(JOB_STORE)
} catch { /* ignore */ }
}
function readStoredJob() {
try {
const raw = localStorage.getItem(JOB_STORE)
if (!raw) return null
const parsed = JSON.parse(raw) as { jobId?: string; hideThumbnail?: boolean; folderLocked?: boolean }
return parsed?.jobId ? parsed : null
} catch {
return null
}
}
async function resumeActiveJob() {
const stored = readStoredJob()
const active = await $fetch<{ jobId?: string | null; hideThumbnail?: boolean; folderLocked?: boolean; status?: string; elapsedMs?: number } | null>('/api/generate/active').catch(() => null)
const id = stored?.jobId || active?.jobId
if (!id) return
const hidden = Boolean(stored?.hideThumbnail || active?.hideThumbnail)
const locked = Boolean(stored?.folderLocked || active?.folderLocked)
try {
const snap = await $fetch<Record<string, any>>(`/api/generate/${id}`)
jobId.value = id
concealOutput.value = hidden
settledUi = false
applyJobEvent(snap, hidden, locked, id)
const running = snap.status === 'queued' || snap.status === 'uploading' || snap.status === 'running'
if (running && !settledUi) {
busy.value = true
statusMessage.value = snap.message || 'Reconnecting to ComfyUI...'
startTimer(Number(snap.elapsedMs || 0))
persistActiveJob(id, hidden, locked)
listen(id, hidden, locked)
} else if (settledUi) {
clearActiveJob()
}
} catch {
clearActiveJob()
}
}
function onSitePointerDown(event: PointerEvent) {
if (!concealOutput.value || awaitingReveal.value || lockedSave.value || !videoUrl.value) return
const root = outputPanel.value
const target = event.target as Node | null
if (root && target && root.contains(target)) return
concealAndPause()
}
function concealAndPause() {
awaitingReveal.value = true
const el = player.value
if (el) {
el.pause()
el.currentTime = 0
}
}
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 || ''
seedInput.value = String(item.seed)
restoreAdvanced(item)
hideThumbnail.value = item.hideThumbnail
folderId.value = item.folderId
toast('Settings restored. Choose the same still if it is not already loaded.')
}
async function loadDraft(draft: RetryDraft) {
activeDraftId = draft.id
prompt.value = draft.prompt
clipName.value = draft.name || ''
seedInput.value = String(draft.seed)
restoreAdvanced(draft)
hideThumbnail.value = draft.hideThumbnail
folderId.value = draft.folderId
withSound.value = draft.sound !== false
if (draft.hideInput) hideInputPreview.value = true
duration.value = clampDuration(Number(draft.duration))
durationDraft.value = formatDuration(duration.value)
try {
const blob = await $fetch<Blob>(`/api/library/drafts/${draft.id}/still`, { responseType: 'blob' })
const filename = draft.stillFilename || 'held-still.png'
readFile(new File([blob], filename, { type: blob.type || 'image/png' }))
statusMessage.value = 'Held job restored. Generate when ComfyUI is free.'
} catch {
statusMessage.value = 'Held job settings restored. Re-drop the still if the image is missing.'
toast('Settings restored. Unlock the folder or re-drop the still if the image is missing.')
}
}
async function dismissDraft(draft: RetryDraft) {
try {
applyLibrary(await $fetch(`/api/library/drafts/${draft.id}`, { method: 'DELETE' }))
if (activeDraftId === draft.id) activeDraftId = ''
} catch (error: any) {
toast(error?.data?.statusMessage || 'Could not dismiss held job')
}
}
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)
if (lockedSave.value) {
const dest = folders.value.find(folder => folder.id === folderId.value)
if (dest?.unlocked) {
lockedSave.value = false
const newest = clips.value
.filter(clip => clip.folderId === folderId.value)
.sort((a, b) => b.createdAt - a.createdAt)[0]
if (newest) openClip(newest)
}
}
} 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()
}
const stillVisible = clips.value.some(clip => videoUrl.value.includes(clip.id))
if (videoUrl.value && !stillVisible) {
videoUrl.value = ''
awaitingReveal.value = false
lockedSave.value = false
concealOutput.value = false
statusMessage.value = 'Folder locked'
}
}
function clipVideoUrl(clipId: string) {
return `/api/library/clips/${clipId}/video?t=${Date.now()}`
}
function openClip(item: LibraryClip) {
lockedSave.value = false
concealOutput.value = item.hideThumbnail
videoUrl.value = clipVideoUrl(item.id)
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
concealOutput.value = false
lockedSave.value = false
}
await loadLibrary()
} catch (error: any) {
toast(error?.data?.statusMessage || 'Could not delete clip')
}
}
async function onMoveClip(item: LibraryClip, event: Event) {
const select = event.target as HTMLSelectElement
const destId = select.value
select.value = ''
if (!destId || destId === item.folderId) return
try {
applyLibrary(await $fetch(`/api/library/clips/${item.id}`, {
method: 'PATCH',
body: { folderId: destId }
}))
const dest = folders.value.find(folder => folder.id === destId)
if (dest?.unlocked) browseFolderId.value = destId
statusMessage.value = dest?.unlocked
? `Moved to ${dest.name}`
: `Moved to ${dest?.name || 'folder'} (locked)`
} catch (error: any) {
toast(error?.data?.statusMessage || 'Could not move clip')
}
}
function startRename(item: LibraryClip) {
renamingId.value = item.id
renameDraft.value = (item.name || '').trim() || clipFileName(item.prompt.split(/[.!?\n]/)[0] || 'aigen')
nextTick(() => {
renameInput.value?.focus()
renameInput.value?.select()
})
}
function cancelRename() {
renamingId.value = ''
renameDraft.value = ''
}
async function saveRename(item: LibraryClip) {
if (renamingId.value !== item.id) return
const name = renameDraft.value.trim().slice(0, 80)
if (!name) {
toast('A name is required')
nextTick(() => renameInput.value?.focus())
return
}
if (name === item.name) {
cancelRename()
return
}
try {
applyLibrary(await $fetch(`/api/library/clips/${item.id}`, {
method: 'PATCH',
body: { name }
}))
cancelRename()
if (videoUrl.value.includes(item.id)) {
outputDownloadName.value = clipFileName(name)
}
statusMessage.value = `Renamed to ${name}`
} catch (error: any) {
toast(error?.data?.statusMessage || 'Could not rename 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 (!folderId.value) {
toast('Choose a library folder before generating.')
return
}
statusBusy.value = false
busy.value = true
listenGen += 1
stopListen()
settledUi = false
progress.value = 1
videoUrl.value = ''
awaitingReveal.value = false
concealOutput.value = hideThumbnail.value
lockedSave.value = false
playerSize.value = outputSize.value
statusMessage.value = 'Checking ComfyUI...'
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('cfg', String(cfg.value))
body.append('fps', String(fps.value))
body.append('sampler_name', samplerName.value)
body.append('scheduler', scheduler.value)
body.append('seed', seedInput.value || 'random')
body.append('folderId', folderId.value)
body.append('hideThumbnail', String(hideThumbnail.value))
body.append('hideInput', String(hideInputPreview.value))
body.append('sound', String(withSound.value))
body.append('name', clipName.value.trim())
if (activeDraftId) body.append('draftId', activeDraftId)
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; folderLocked?: boolean }>('/api/generate', {
method: 'POST',
body
})
jobId.value = started.jobId
maxStep.value = started.steps
playerSize.value = { width: started.width, height: started.height }
settledUi = false
persistActiveJob(started.jobId, started.hideThumbnail === true || hideThumbnail.value, started.folderLocked === true)
listen(started.jobId, started.hideThumbnail === true || hideThumbnail.value, started.folderLocked === true)
void pollComfyHealth()
} catch (error: any) {
busy.value = false
statusBusy.value = false
stopTimer()
clearActiveJob()
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, folderLocked: boolean, expectedJobId: string) {
if (expectedJobId && jobId.value && expectedJobId !== jobId.value) return
if (payload.jobId && expectedJobId && payload.jobId !== expectedJobId) return
if (payload.message) statusMessage.value = payload.message
statusBusy.value = payload.type === 'busy' || payload.busy === true
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'
const deferred = payload.type === 'deferred' || payload.status === 'deferred'
if (settledUi) return
if (deferred) {
settledUi = true
busy.value = false
statusBusy.value = false
stopTimer()
stopListen()
clearActiveJob()
loadLibrary().catch(() => null)
void pollComfyHealth()
toast(payload.message || 'Comfy was still busy. This job was saved under Held jobs.')
return
}
if (complete && (payload.clipId || payload.filename)) {
settledUi = true
const dest = folders.value.find(folder => folder.id === folderId.value)
const locked = dest
? Boolean(dest.protected && !dest.unlocked)
: (folderLocked || payload.folderLocked === true)
if (locked) {
videoUrl.value = ''
awaitingReveal.value = false
concealOutput.value = false
lockedSave.value = true
browseFolderId.value = folderId.value
statusMessage.value = 'Saved to the locked folder. Unlock it to view.'
} else if (payload.clipId) {
videoUrl.value = clipVideoUrl(payload.clipId)
awaitingReveal.value = Boolean(payload.hideThumbnail || hidden)
concealOutput.value = awaitingReveal.value
lockedSave.value = false
} else {
videoUrl.value = `/api/media?filename=${encodeURIComponent(payload.filename)}&subfolder=${encodeURIComponent(payload.subfolder || 'video')}&type=${encodeURIComponent(payload.mediaType || 'output')}&t=${Date.now()}`
awaitingReveal.value = Boolean(payload.hideThumbnail || hidden)
concealOutput.value = awaitingReveal.value
lockedSave.value = false
}
busy.value = false
statusBusy.value = false
stopTimer()
stopListen()
clearActiveJob()
activeDraftId = ''
loadLibrary().catch(() => null)
void pollComfyHealth()
return
}
if (failed) {
settledUi = true
busy.value = false
statusBusy.value = false
stopTimer()
stopListen()
clearActiveJob()
void pollComfyHealth()
toast(payload.error || payload.message || 'Generation failed')
}
}
function listen(id: string, hidden: boolean, folderLocked = false) {
const gen = ++listenGen
stopListen()
source = new EventSource(`/api/generate/${id}/stream`)
source.onmessage = (event) => {
if (gen !== listenGen) return
applyJobEvent(JSON.parse(event.data), hidden, folderLocked, id)
}
const tick = async () => {
if (gen !== listenGen || settledUi) {
if (gen === listenGen && settledUi) stopListen()
return
}
try {
const snap = await $fetch<Record<string, any>>(`/api/generate/${id}`)
if (gen !== listenGen) return
applyJobEvent(snap, hidden, folderLocked, id)
} catch {
if (gen !== listenGen) return
try {
const recovered = await $fetch<Record<string, any>>('/api/generate/recover', {
method: 'POST',
body: { jobId: id }
})
if (gen !== listenGen) return
applyJobEvent(recovered, hidden, folderLocked, id)
} 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 = 'Job cancelled. ComfyUI is idle — generate again.'
} catch (error: any) {
toast(error?.statusMessage || error?.data?.statusMessage || 'Failed to interrupt ComfyUI')
statusMessage.value = 'Cancelled locally. Generate again when ComfyUI is free.'
}
settledUi = true
busy.value = false
statusBusy.value = false
stopTimer()
stopListen()
clearActiveJob()
}
</script>