Let clips move between folders, save busy jobs, and fail dropped Comfy runs.

Held jobs restore the form after a 3-minute wait, hidden stills stay blurred in the picker, and reloading Comfy no longer leaves xaigen spinning.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-08-25 21:46:24 -05:00
co-authored by Cursor
parent ba7a483aac
commit a7a810a7b3
22 changed files with 775 additions and 97 deletions
+213 -14
View File
@@ -12,10 +12,10 @@
<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'"
:class="comfyBadge.className"
>
<span class="h-2 w-2 rounded-full" :class="comfyOk ? 'bg-emerald-400' : 'bg-red-400'" />
{{ comfyOk ? 'ComfyUI online' : 'ComfyUI offline' }}
<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">
@@ -125,6 +125,17 @@
<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>
@@ -197,6 +208,24 @@
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">
@@ -331,9 +360,24 @@
</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">
<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="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>
@@ -360,6 +404,7 @@
:stills="stills"
:clips="clips"
:folder-id="folderId"
:hide-input-preview="hideInputPreview"
@close="pickerOpen = false"
@updated="applyLibrary"
@error="toast"
@@ -398,6 +443,7 @@ interface LibraryStill {
width: number
height: number
createdAt: number
hideInput?: boolean
}
interface LibraryClip {
@@ -416,6 +462,26 @@ interface LibraryClip {
createdAt: number
}
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
}
const PRESETS: Record<Exclude<AspectId, 'auto'>, { width: number; height: number }> = {
'16:9': { width: 1344, height: 768 },
'9:16': { width: 768, height: 1344 },
@@ -453,6 +519,7 @@ 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'
@@ -471,6 +538,7 @@ 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('')
@@ -478,6 +546,7 @@ 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 unlockPassword = ref('')
@@ -486,8 +555,10 @@ 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)
@@ -496,6 +567,28 @@ const elapsedLabel = computed(() => {
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 snap32(value: number) {
return Math.max(32, Math.round(value / 32) * 32)
}
@@ -538,11 +631,12 @@ 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[] }
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 || ''
}
@@ -552,30 +646,46 @@ function applyLibrary(data: { folders?: LibraryFolder[]; clips?: LibraryClip[];
}
async function loadLibrary() {
const data = await $fetch<{ folders?: LibraryFolder[]; clips?: LibraryClip[]; stills?: LibraryStill[] }>('/api/library')
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 } }>('/api/health').catch(() => ({ comfy: { ok: false } })),
$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: [] }))
loadLibrary().catch(() => applyLibrary({ folders: [], clips: [], stills: [], drafts: [] }))
])
comfyOk.value = Boolean(health.comfy?.ok)
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()
})
@@ -590,6 +700,12 @@ watch(hideInputPreview, (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)
})
@@ -672,7 +788,8 @@ function readFile(next: File) {
img.src = preview.value
}
function onPickedImage(next: File) {
function onPickedImage(next: File, hidden = false) {
if (hidden) hideInputPreview.value = true
readFile(next)
pickerOpen.value = false
}
@@ -681,6 +798,7 @@ 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) {
@@ -741,7 +859,7 @@ async function resumeActiveJob() {
concealOutput.value = hidden
settledUi = false
applyJobEvent(snap, hidden, locked, id)
const running = snap.status === 'queued' || snap.status === 'uploading' || snap.status === 'running' || snap.type === 'snapshot'
const running = snap.status === 'queued' || snap.status === 'uploading' || snap.status === 'running'
if (running && !settledUi) {
busy.value = true
statusMessage.value = snap.message || 'Reconnecting to ComfyUI...'
@@ -793,6 +911,39 @@ function rerun(item: LibraryClip) {
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 || ''
turbo.value = draft.turbo
seedInput.value = String(draft.seed)
aspect.value = (draft.aspect as AspectId) || 'auto'
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 } })
@@ -860,6 +1011,26 @@ async function removeClip(item: LibraryClip) {
}
}
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 onPlayed() {
awaitingReveal.value = false
}
@@ -903,7 +1074,10 @@ async function generate() {
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',
@@ -915,6 +1089,7 @@ async function generate() {
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
@@ -947,8 +1122,22 @@ function applyJobEvent(payload: Record<string, any>, hidden: boolean, folderLock
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)
@@ -978,7 +1167,9 @@ function applyJobEvent(payload: Record<string, any>, hidden: boolean, folderLock
stopTimer()
stopListen()
clearActiveJob()
activeDraftId = ''
loadLibrary().catch(() => null)
void pollComfyHealth()
return
}
@@ -989,6 +1180,7 @@ function applyJobEvent(payload: Record<string, any>, hidden: boolean, folderLock
stopTimer()
stopListen()
clearActiveJob()
void pollComfyHealth()
toast(payload.error || payload.message || 'Generation failed')
}
}
@@ -1033,9 +1225,16 @@ function listen(id: string, hidden: boolean, folderLocked = false) {
async function cancelJob() {
try {
await $fetch('/api/interrupt', { method: 'POST', body: { jobId: jobId.value } })
statusMessage.value = 'Interrupt sent to ComfyUI'
statusMessage.value = 'Job cancelled. ComfyUI is idle — generate again.'
} catch (error: any) {
toast(error?.statusMessage || 'Failed to interrupt ComfyUI')
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>