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:
+152
-18
@@ -7,8 +7,29 @@
|
|||||||
<h2 class="font-display text-2xl font-bold">Choose an image</h2>
|
<h2 class="font-display text-2xl font-bold">Choose an image</h2>
|
||||||
<p class="mt-1 text-sm text-zinc-400">Pick a still from this folder or upload a new one.</p>
|
<p class="mt-1 text-sm text-zinc-400">Pick a still from this folder or upload a new one.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
v-if="hasHiddenItems"
|
||||||
|
type="button"
|
||||||
|
class="rounded-full border px-3 py-1 text-sm"
|
||||||
|
:class="revealAll ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400 hover:text-white'"
|
||||||
|
:title="revealAll ? 'Hide flagged thumbnails' : 'Reveal all hidden thumbnails'"
|
||||||
|
@click="toggleRevealAll"
|
||||||
|
>
|
||||||
|
<span class="inline-flex items-center gap-1.5">
|
||||||
|
<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">
|
||||||
|
<path v-if="revealAll" stroke-linecap="round" d="M3 3l18 18M10.6 10.6a2 2 0 102.8 2.8M9.9 5.1A9.8 9.8 0 0121 12a9.8 9.8 0 01-1.7 2.9M6.1 6.1A9.8 9.8 0 003 12a9.8 9.8 0 0012.9 6.1" />
|
||||||
|
<template v-else>
|
||||||
|
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z" />
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
</template>
|
||||||
|
</svg>
|
||||||
|
{{ revealAll ? 'Hide all' : 'Reveal all' }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
<button type="button" class="rounded-full border border-white/10 px-3 py-1 text-sm text-zinc-400 hover:text-white" @click="emit('close')">Close</button>
|
<button type="button" class="rounded-full border border-white/10 px-3 py-1 text-sm text-zinc-400 hover:text-white" @click="emit('close')">Close</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mt-5 flex flex-wrap gap-2">
|
<div class="mt-5 flex flex-wrap gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -44,42 +65,114 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-if="current && (!current.protected || current.unlocked)">
|
<template v-if="current && (!current.protected || current.unlocked)">
|
||||||
|
|
||||||
<p v-if="!visibleStills.length && !visibleClipThumbs.length" class="mt-6 text-sm text-zinc-500">No uploaded stills in this folder yet.</p>
|
<p v-if="!visibleStills.length && !visibleClipThumbs.length" class="mt-6 text-sm text-zinc-500">No uploaded stills in this folder yet.</p>
|
||||||
<div v-if="visibleStills.length || visibleClipThumbs.length" class="mt-5 grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
|
<div v-if="visibleStills.length || visibleClipThumbs.length" class="mt-5 grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
|
||||||
<button
|
<article
|
||||||
v-for="still in visibleStills"
|
v-for="still in visibleStills"
|
||||||
:key="still.id"
|
:key="still.id"
|
||||||
|
class="overflow-hidden rounded-2xl border border-white/10 bg-zinc-950"
|
||||||
|
>
|
||||||
|
<div class="relative h-28 overflow-hidden bg-zinc-900">
|
||||||
|
<img
|
||||||
|
:src="`/api/library/stills/${still.id}`"
|
||||||
|
alt=""
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
:class="stillHidden(still) && !isRevealed(still.id) ? 'scale-110 blur-xl' : ''"
|
||||||
|
>
|
||||||
|
<span v-if="stillHidden(still) && !isRevealed(still.id)" class="sensitive-glow pointer-events-none absolute inset-0 opacity-80" />
|
||||||
|
<div class="absolute inset-x-0 top-0 flex items-start justify-between p-2">
|
||||||
|
<button
|
||||||
|
v-if="stillHidden(still)"
|
||||||
type="button"
|
type="button"
|
||||||
class="group overflow-hidden rounded-2xl border border-white/10 bg-zinc-950 text-left hover:border-amber-300/50"
|
class="rounded-full bg-zinc-950/80 p-1.5 text-amber-100 hover:bg-zinc-900"
|
||||||
|
:title="isRevealed(still.id) ? 'Hide thumbnail' : 'Reveal thumbnail'"
|
||||||
|
@click="toggleReveal(still.id)"
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
<template v-if="isRevealed(still.id)">
|
||||||
|
<path stroke-linecap="round" d="M3 3l18 18M10.6 10.6a2 2 0 102.8 2.8M9.9 5.1A9.8 9.8 0 0121 12a9.8 9.8 0 01-1.7 2.9M6.1 6.1A9.8 9.8 0 003 12a9.8 9.8 0 0012.9 6.1" />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z" />
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
</template>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<span v-else />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-full bg-amber-400 p-1.5 text-zinc-950 hover:bg-amber-300"
|
||||||
|
title="Use as input"
|
||||||
@click="pickStill(still)"
|
@click="pickStill(still)"
|
||||||
>
|
>
|
||||||
<img :src="`/api/library/stills/${still.id}`" alt="" class="h-28 w-full object-cover">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="h-4 w-4">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="flex items-center justify-between gap-2 px-3 py-2">
|
<div class="flex items-center justify-between gap-2 px-3 py-2">
|
||||||
<span class="truncate text-xs text-zinc-400">{{ still.filename }}</span>
|
<span class="truncate text-xs text-zinc-400">{{ still.filename }}</span>
|
||||||
<span class="text-[10px] text-red-300 opacity-0 group-hover:opacity-100" @click.stop="removeStill(still)">Delete</span>
|
<button type="button" class="text-[10px] text-red-300 hover:text-red-200" @click="removeStill(still)">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</article>
|
||||||
<button
|
<article
|
||||||
v-for="clip in visibleClipThumbs"
|
v-for="clip in visibleClipThumbs"
|
||||||
:key="'clip-' + clip.id"
|
:key="'clip-' + clip.id"
|
||||||
type="button"
|
class="overflow-hidden rounded-2xl border border-white/10 bg-zinc-950"
|
||||||
class="overflow-hidden rounded-2xl border border-white/10 bg-zinc-950 text-left hover:border-amber-300/50"
|
>
|
||||||
@click="pickClip(clip)"
|
<div class="relative h-28 overflow-hidden bg-zinc-900">
|
||||||
|
<img
|
||||||
|
v-if="clip.hasThumbnail || clip.hideThumbnail"
|
||||||
|
:src="`/api/library/clips/${clip.id}/thumb`"
|
||||||
|
alt=""
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
:class="clip.hideThumbnail && !isRevealed('clip-' + clip.id) ? 'scale-110 blur-xl' : ''"
|
||||||
>
|
>
|
||||||
<img v-if="clip.hasThumbnail" :src="`/api/library/clips/${clip.id}/thumb`" alt="" class="h-28 w-full object-cover">
|
|
||||||
<video
|
<video
|
||||||
v-else
|
v-else
|
||||||
:src="`/api/library/clips/${clip.id}/video#t=0.1`"
|
:src="`/api/library/clips/${clip.id}/video#t=0.1`"
|
||||||
class="h-28 w-full object-cover"
|
class="h-full w-full object-cover"
|
||||||
muted
|
muted
|
||||||
playsinline
|
playsinline
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
/>
|
/>
|
||||||
|
<span v-if="clip.hideThumbnail && !isRevealed('clip-' + clip.id)" class="sensitive-glow pointer-events-none absolute inset-0 opacity-80" />
|
||||||
|
<div class="absolute inset-x-0 top-0 flex items-start justify-between p-2">
|
||||||
|
<button
|
||||||
|
v-if="clip.hideThumbnail"
|
||||||
|
type="button"
|
||||||
|
class="rounded-full bg-zinc-950/80 p-1.5 text-amber-100 hover:bg-zinc-900"
|
||||||
|
:title="isRevealed('clip-' + clip.id) ? 'Hide thumbnail' : 'Reveal thumbnail'"
|
||||||
|
@click="toggleReveal('clip-' + clip.id)"
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
<template v-if="isRevealed('clip-' + clip.id)">
|
||||||
|
<path stroke-linecap="round" d="M3 3l18 18M10.6 10.6a2 2 0 102.8 2.8M9.9 5.1A9.8 9.8 0 0121 12a9.8 9.8 0 01-1.7 2.9M6.1 6.1A9.8 9.8 0 003 12a9.8 9.8 0 0012.9 6.1" />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z" />
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
</template>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<span v-else />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-full bg-amber-400 p-1.5 text-zinc-950 hover:bg-amber-300"
|
||||||
|
title="Use as input"
|
||||||
|
@click="pickClip(clip)"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="h-4 w-4">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="px-3 py-2">
|
<div class="px-3 py-2">
|
||||||
<span class="truncate text-xs text-zinc-400">{{ clip.name || 'From a clip' }}</span>
|
<span class="truncate text-xs text-zinc-400">{{ clip.name || 'From a clip' }}</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,6 +196,7 @@ export interface PickerStill {
|
|||||||
width: number
|
width: number
|
||||||
height: number
|
height: number
|
||||||
createdAt: number
|
createdAt: number
|
||||||
|
hideInput?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PickerClip {
|
export interface PickerClip {
|
||||||
@@ -119,19 +213,22 @@ const props = defineProps<{
|
|||||||
stills: PickerStill[]
|
stills: PickerStill[]
|
||||||
clips: PickerClip[]
|
clips: PickerClip[]
|
||||||
folderId: string
|
folderId: string
|
||||||
|
hideInputPreview?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
close: []
|
close: []
|
||||||
updated: [payload: unknown]
|
updated: [payload: unknown]
|
||||||
error: [message: string]
|
error: [message: string]
|
||||||
select: [file: File]
|
select: [file: File, hidden: boolean]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const fileInput = ref<HTMLInputElement | null>(null)
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
const dragging = ref(false)
|
const dragging = ref(false)
|
||||||
const password = ref('')
|
const password = ref('')
|
||||||
const activeFolderId = ref(props.folderId)
|
const activeFolderId = ref(props.folderId)
|
||||||
|
const revealAll = ref(false)
|
||||||
|
const revealedIds = ref<string[]>([])
|
||||||
|
|
||||||
function onKey(event: KeyboardEvent) {
|
function onKey(event: KeyboardEvent) {
|
||||||
if (event.key === 'Escape') emit('close')
|
if (event.key === 'Escape') emit('close')
|
||||||
@@ -148,7 +245,43 @@ watch(() => props.folderId, (id) => {
|
|||||||
|
|
||||||
const current = computed(() => props.folders.find(folder => folder.id === activeFolderId.value) || props.folders[0])
|
const current = computed(() => props.folders.find(folder => folder.id === activeFolderId.value) || props.folders[0])
|
||||||
const visibleStills = computed(() => props.stills.filter(still => still.folderId === (current.value?.id || activeFolderId.value)))
|
const visibleStills = computed(() => props.stills.filter(still => still.folderId === (current.value?.id || activeFolderId.value)))
|
||||||
const visibleClipThumbs = computed(() => props.clips.filter(clip => clip.folderId === (current.value?.id || activeFolderId.value) && !clip.hideThumbnail))
|
const visibleClipThumbs = computed(() => props.clips.filter(clip => clip.folderId === (current.value?.id || activeFolderId.value)))
|
||||||
|
const hasHiddenItems = computed(() => {
|
||||||
|
return visibleStills.value.some(still => stillHidden(still)) || visibleClipThumbs.value.some(clip => clip.hideThumbnail)
|
||||||
|
})
|
||||||
|
|
||||||
|
function stillHidden(still: PickerStill) {
|
||||||
|
return still.hideInput === true || props.hideInputPreview === true
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRevealed(id: string) {
|
||||||
|
return revealAll.value || revealedIds.value.includes(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleReveal(id: string) {
|
||||||
|
if (revealAll.value) {
|
||||||
|
revealAll.value = false
|
||||||
|
revealedIds.value = visibleHiddenIds().filter(item => item !== id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (revealedIds.value.includes(id)) {
|
||||||
|
revealedIds.value = revealedIds.value.filter(item => item !== id)
|
||||||
|
} else {
|
||||||
|
revealedIds.value = [...revealedIds.value, id]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleHiddenIds() {
|
||||||
|
return [
|
||||||
|
...visibleStills.value.filter(still => stillHidden(still)).map(still => still.id),
|
||||||
|
...visibleClipThumbs.value.filter(clip => clip.hideThumbnail).map(clip => 'clip-' + clip.id)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleRevealAll() {
|
||||||
|
revealAll.value = !revealAll.value
|
||||||
|
revealedIds.value = []
|
||||||
|
}
|
||||||
|
|
||||||
async function unlock() {
|
async function unlock() {
|
||||||
try {
|
try {
|
||||||
@@ -166,9 +299,10 @@ async function ingest(file: File) {
|
|||||||
const body = new FormData()
|
const body = new FormData()
|
||||||
body.append('image', file)
|
body.append('image', file)
|
||||||
body.append('folderId', current.value?.id || activeFolderId.value)
|
body.append('folderId', current.value?.id || activeFolderId.value)
|
||||||
|
body.append('hideInput', String(props.hideInputPreview === true))
|
||||||
try {
|
try {
|
||||||
emit('updated', await $fetch('/api/library/stills', { method: 'POST', body }))
|
emit('updated', await $fetch('/api/library/stills', { method: 'POST', body }))
|
||||||
emit('select', file)
|
emit('select', file, props.hideInputPreview === true)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
emit('error', error?.data?.statusMessage || 'Could not save image')
|
emit('error', error?.data?.statusMessage || 'Could not save image')
|
||||||
}
|
}
|
||||||
@@ -191,7 +325,7 @@ async function pickClip(clip: PickerClip) {
|
|||||||
try {
|
try {
|
||||||
const blob = await $fetch<Blob>(`/api/library/clips/${clip.id}/thumb`, { responseType: 'blob' })
|
const blob = await $fetch<Blob>(`/api/library/clips/${clip.id}/thumb`, { responseType: 'blob' })
|
||||||
const file = new File([blob], `${clip.id}.png`, { type: blob.type || 'image/png' })
|
const file = new File([blob], `${clip.id}.png`, { type: blob.type || 'image/png' })
|
||||||
emit('select', file)
|
emit('select', file, clip.hideThumbnail === true)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
emit('error', error?.data?.statusMessage || 'Could not load that still')
|
emit('error', error?.data?.statusMessage || 'Could not load that still')
|
||||||
}
|
}
|
||||||
@@ -201,7 +335,7 @@ async function pickStill(still: PickerStill) {
|
|||||||
try {
|
try {
|
||||||
const blob = await $fetch<Blob>(`/api/library/stills/${still.id}`, { responseType: 'blob' })
|
const blob = await $fetch<Blob>(`/api/library/stills/${still.id}`, { responseType: 'blob' })
|
||||||
const file = new File([blob], still.filename, { type: blob.type || 'image/png' })
|
const file = new File([blob], still.filename, { type: blob.type || 'image/png' })
|
||||||
emit('select', file)
|
emit('select', file, still.hideInput === true)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
emit('error', error?.data?.statusMessage || 'Could not load that still')
|
emit('error', error?.data?.statusMessage || 'Could not load that still')
|
||||||
}
|
}
|
||||||
|
|||||||
+213
-14
@@ -12,10 +12,10 @@
|
|||||||
<div class="flex items-center gap-3 text-sm">
|
<div class="flex items-center gap-3 text-sm">
|
||||||
<span
|
<span
|
||||||
class="hidden sm:inline-flex items-center gap-2 rounded-full border px-3 py-1"
|
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'" />
|
<span class="h-2 w-2 rounded-full" :class="comfyBadge.dot" />
|
||||||
{{ comfyOk ? 'ComfyUI online' : 'ComfyUI offline' }}
|
{{ comfyBadge.label }}
|
||||||
</span>
|
</span>
|
||||||
<span class="hidden md:block text-zinc-400">{{ userName }}</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">
|
<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 class="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white transition peer-checked:translate-x-5" />
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
@@ -197,6 +208,24 @@
|
|||||||
Cancel / Interrupt
|
Cancel / Interrupt
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<section class="space-y-5">
|
<section class="space-y-5">
|
||||||
@@ -331,9 +360,24 @@
|
|||||||
</div>
|
</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>
|
<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="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-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>
|
<button type="button" class="text-xs text-red-300" @click="removeClip(item)">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -360,6 +404,7 @@
|
|||||||
:stills="stills"
|
:stills="stills"
|
||||||
:clips="clips"
|
:clips="clips"
|
||||||
:folder-id="folderId"
|
:folder-id="folderId"
|
||||||
|
:hide-input-preview="hideInputPreview"
|
||||||
@close="pickerOpen = false"
|
@close="pickerOpen = false"
|
||||||
@updated="applyLibrary"
|
@updated="applyLibrary"
|
||||||
@error="toast"
|
@error="toast"
|
||||||
@@ -398,6 +443,7 @@ interface LibraryStill {
|
|||||||
width: number
|
width: number
|
||||||
height: number
|
height: number
|
||||||
createdAt: number
|
createdAt: number
|
||||||
|
hideInput?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LibraryClip {
|
interface LibraryClip {
|
||||||
@@ -416,6 +462,26 @@ interface LibraryClip {
|
|||||||
createdAt: number
|
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 }> = {
|
const PRESETS: Record<Exclude<AspectId, 'auto'>, { width: number; height: number }> = {
|
||||||
'16:9': { width: 1344, height: 768 },
|
'16:9': { width: 1344, height: 768 },
|
||||||
'9:16': { width: 768, height: 1344 },
|
'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
|
return ((durationSlider.value - DURATION_SLIDER_MIN) / (DURATION_SLIDER_MAX - DURATION_SLIDER_MIN)) * 100
|
||||||
})
|
})
|
||||||
const seedInput = ref('random')
|
const seedInput = ref('random')
|
||||||
|
const withSound = ref(true)
|
||||||
const hideThumbnail = ref(false)
|
const hideThumbnail = ref(false)
|
||||||
const hideInputPreview = ref(false)
|
const hideInputPreview = ref(false)
|
||||||
const JOB_STORE = 'aigen-active-job'
|
const JOB_STORE = 'aigen-active-job'
|
||||||
@@ -471,6 +538,7 @@ const lockedSave = ref(false)
|
|||||||
const playerSize = ref({ width: 1344, height: 768 })
|
const playerSize = ref({ width: 1344, height: 768 })
|
||||||
const toasts = ref<{ id: string; message: string }[]>([])
|
const toasts = ref<{ id: string; message: string }[]>([])
|
||||||
const comfyOk = ref(false)
|
const comfyOk = ref(false)
|
||||||
|
const comfyBusy = ref(false)
|
||||||
const userName = ref('')
|
const userName = ref('')
|
||||||
const authEnabled = ref(false)
|
const authEnabled = ref(false)
|
||||||
const authMode = ref('')
|
const authMode = ref('')
|
||||||
@@ -478,6 +546,7 @@ const instanceName = ref('AIGen')
|
|||||||
const folders = ref<LibraryFolder[]>([])
|
const folders = ref<LibraryFolder[]>([])
|
||||||
const clips = ref<LibraryClip[]>([])
|
const clips = ref<LibraryClip[]>([])
|
||||||
const stills = ref<LibraryStill[]>([])
|
const stills = ref<LibraryStill[]>([])
|
||||||
|
const drafts = ref<RetryDraft[]>([])
|
||||||
const folderId = ref('')
|
const folderId = ref('')
|
||||||
const browseFolderId = ref('')
|
const browseFolderId = ref('')
|
||||||
const unlockPassword = ref('')
|
const unlockPassword = ref('')
|
||||||
@@ -486,8 +555,10 @@ const pickerOpen = ref(false)
|
|||||||
let timer: ReturnType<typeof setInterval> | null = null
|
let timer: ReturnType<typeof setInterval> | null = null
|
||||||
let source: EventSource | null = null
|
let source: EventSource | null = null
|
||||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
let healthTimer: ReturnType<typeof setInterval> | null = null
|
||||||
let settledUi = false
|
let settledUi = false
|
||||||
let listenGen = 0
|
let listenGen = 0
|
||||||
|
let activeDraftId = ''
|
||||||
|
|
||||||
const elapsedLabel = computed(() => {
|
const elapsedLabel = computed(() => {
|
||||||
const seconds = Math.floor(elapsedMs.value / 1000)
|
const seconds = Math.floor(elapsedMs.value / 1000)
|
||||||
@@ -496,6 +567,28 @@ const elapsedLabel = computed(() => {
|
|||||||
return `${mm}:${ss}`
|
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) {
|
function snap32(value: number) {
|
||||||
return Math.max(32, Math.round(value / 32) * 32)
|
return Math.max(32, Math.round(value / 32) * 32)
|
||||||
}
|
}
|
||||||
@@ -538,11 +631,12 @@ function applyThumbnailDefault() {
|
|||||||
hideThumbnail.value = currentFolder.value ? !currentFolder.value.thumbnailDefault : false
|
hideThumbnail.value = currentFolder.value ? !currentFolder.value.thumbnailDefault : false
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyLibrary(data: { folders?: LibraryFolder[]; clips?: LibraryClip[]; stills?: LibraryStill[] } | unknown) {
|
function applyLibrary(data: { folders?: LibraryFolder[]; clips?: LibraryClip[]; stills?: LibraryStill[]; drafts?: RetryDraft[] } | unknown) {
|
||||||
const payload = (data || {}) as { folders?: LibraryFolder[]; clips?: LibraryClip[]; stills?: LibraryStill[] }
|
const payload = (data || {}) as { folders?: LibraryFolder[]; clips?: LibraryClip[]; stills?: LibraryStill[]; drafts?: RetryDraft[] }
|
||||||
folders.value = payload.folders || []
|
folders.value = payload.folders || []
|
||||||
clips.value = payload.clips || []
|
clips.value = payload.clips || []
|
||||||
stills.value = payload.stills || []
|
stills.value = payload.stills || []
|
||||||
|
drafts.value = payload.drafts || []
|
||||||
if (!folderId.value || !folders.value.some(folder => folder.id === folderId.value)) {
|
if (!folderId.value || !folders.value.some(folder => folder.id === folderId.value)) {
|
||||||
folderId.value = folders.value[0]?.id || ''
|
folderId.value = folders.value[0]?.id || ''
|
||||||
}
|
}
|
||||||
@@ -552,30 +646,46 @@ function applyLibrary(data: { folders?: LibraryFolder[]; clips?: LibraryClip[];
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadLibrary() {
|
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)
|
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 () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
hideInputPreview.value = localStorage.getItem('aigen-hide-input-preview') === 'true'
|
hideInputPreview.value = localStorage.getItem('aigen-hide-input-preview') === 'true'
|
||||||
|
withSound.value = localStorage.getItem('aigen-generate-sound') !== 'false'
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
window.addEventListener('pointerdown', onSitePointerDown, true)
|
window.addEventListener('pointerdown', onSitePointerDown, true)
|
||||||
const [health, me] = await Promise.all([
|
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 })),
|
$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 || ''
|
userName.value = me.user?.name || me.user?.email || ''
|
||||||
authEnabled.value = Boolean(me.authEnabled)
|
authEnabled.value = Boolean(me.authEnabled)
|
||||||
authMode.value = me.authMode || ''
|
authMode.value = me.authMode || ''
|
||||||
instanceName.value = me.instanceName || 'AIGen'
|
instanceName.value = me.instanceName || 'AIGen'
|
||||||
|
healthTimer = setInterval(() => {
|
||||||
|
void pollComfyHealth()
|
||||||
|
}, 5000)
|
||||||
await resumeActiveJob()
|
await resumeActiveJob()
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
window.removeEventListener('pointerdown', onSitePointerDown, true)
|
window.removeEventListener('pointerdown', onSitePointerDown, true)
|
||||||
|
if (healthTimer) clearInterval(healthTimer)
|
||||||
|
healthTimer = null
|
||||||
stopTimer()
|
stopTimer()
|
||||||
stopListen()
|
stopListen()
|
||||||
})
|
})
|
||||||
@@ -590,6 +700,12 @@ watch(hideInputPreview, (value) => {
|
|||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(withSound, (value) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('aigen-generate-sound', String(value))
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
})
|
||||||
|
|
||||||
watch(duration, (value) => {
|
watch(duration, (value) => {
|
||||||
if (!durationScrubbing.value) durationDraft.value = formatDuration(value)
|
if (!durationScrubbing.value) durationDraft.value = formatDuration(value)
|
||||||
})
|
})
|
||||||
@@ -672,7 +788,8 @@ function readFile(next: File) {
|
|||||||
img.src = preview.value
|
img.src = preview.value
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPickedImage(next: File) {
|
function onPickedImage(next: File, hidden = false) {
|
||||||
|
if (hidden) hideInputPreview.value = true
|
||||||
readFile(next)
|
readFile(next)
|
||||||
pickerOpen.value = false
|
pickerOpen.value = false
|
||||||
}
|
}
|
||||||
@@ -681,6 +798,7 @@ async function persistDroppedStill(next: File) {
|
|||||||
const body = new FormData()
|
const body = new FormData()
|
||||||
body.append('image', next)
|
body.append('image', next)
|
||||||
body.append('folderId', folderId.value)
|
body.append('folderId', folderId.value)
|
||||||
|
body.append('hideInput', String(hideInputPreview.value))
|
||||||
try {
|
try {
|
||||||
applyLibrary(await $fetch('/api/library/stills', { method: 'POST', body }))
|
applyLibrary(await $fetch('/api/library/stills', { method: 'POST', body }))
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -741,7 +859,7 @@ async function resumeActiveJob() {
|
|||||||
concealOutput.value = hidden
|
concealOutput.value = hidden
|
||||||
settledUi = false
|
settledUi = false
|
||||||
applyJobEvent(snap, hidden, locked, id)
|
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) {
|
if (running && !settledUi) {
|
||||||
busy.value = true
|
busy.value = true
|
||||||
statusMessage.value = snap.message || 'Reconnecting to ComfyUI...'
|
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.')
|
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) {
|
async function unlockFolder(id: string) {
|
||||||
try {
|
try {
|
||||||
const data = await $fetch('/api/library/unlock', { method: 'POST', body: { password: unlockPassword.value, folderId: id } })
|
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() {
|
function onPlayed() {
|
||||||
awaitingReveal.value = false
|
awaitingReveal.value = false
|
||||||
}
|
}
|
||||||
@@ -903,7 +1074,10 @@ async function generate() {
|
|||||||
body.append('seed', seedInput.value || 'random')
|
body.append('seed', seedInput.value || 'random')
|
||||||
body.append('folderId', folderId.value)
|
body.append('folderId', folderId.value)
|
||||||
body.append('hideThumbnail', String(hideThumbnail.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())
|
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')
|
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', {
|
const started = await $fetch<{ jobId: string; seed: number; width: number; height: number; steps: number; turbo: boolean; hideThumbnail?: boolean; folderLocked?: boolean }>('/api/generate', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -915,6 +1089,7 @@ async function generate() {
|
|||||||
settledUi = false
|
settledUi = false
|
||||||
persistActiveJob(started.jobId, started.hideThumbnail === true || hideThumbnail.value, started.folderLocked === true)
|
persistActiveJob(started.jobId, started.hideThumbnail === true || hideThumbnail.value, started.folderLocked === true)
|
||||||
listen(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) {
|
} catch (error: any) {
|
||||||
busy.value = false
|
busy.value = false
|
||||||
statusBusy.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 failed = payload.type === 'error' || payload.status === 'error' || payload.status === 'cancelled'
|
||||||
const complete = payload.type === 'complete' || payload.status === 'complete'
|
const complete = payload.type === 'complete' || payload.status === 'complete'
|
||||||
|
const deferred = payload.type === 'deferred' || payload.status === 'deferred'
|
||||||
if (settledUi) return
|
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)) {
|
if (complete && (payload.clipId || payload.filename)) {
|
||||||
settledUi = true
|
settledUi = true
|
||||||
const dest = folders.value.find(folder => folder.id === folderId.value)
|
const dest = folders.value.find(folder => folder.id === folderId.value)
|
||||||
@@ -978,7 +1167,9 @@ function applyJobEvent(payload: Record<string, any>, hidden: boolean, folderLock
|
|||||||
stopTimer()
|
stopTimer()
|
||||||
stopListen()
|
stopListen()
|
||||||
clearActiveJob()
|
clearActiveJob()
|
||||||
|
activeDraftId = ''
|
||||||
loadLibrary().catch(() => null)
|
loadLibrary().catch(() => null)
|
||||||
|
void pollComfyHealth()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -989,6 +1180,7 @@ function applyJobEvent(payload: Record<string, any>, hidden: boolean, folderLock
|
|||||||
stopTimer()
|
stopTimer()
|
||||||
stopListen()
|
stopListen()
|
||||||
clearActiveJob()
|
clearActiveJob()
|
||||||
|
void pollComfyHealth()
|
||||||
toast(payload.error || payload.message || 'Generation failed')
|
toast(payload.error || payload.message || 'Generation failed')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1033,9 +1225,16 @@ function listen(id: string, hidden: boolean, folderLocked = false) {
|
|||||||
async function cancelJob() {
|
async function cancelJob() {
|
||||||
try {
|
try {
|
||||||
await $fetch('/api/interrupt', { method: 'POST', body: { jobId: jobId.value } })
|
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) {
|
} 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>
|
</script>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import net from 'node:net'
|
|||||||
import { execFile, spawn } from 'node:child_process'
|
import { execFile, spawn } from 'node:child_process'
|
||||||
import { promisify } from 'node:util'
|
import { promisify } from 'node:util'
|
||||||
import { readdirSync, existsSync, rmSync } from 'node:fs'
|
import { readdirSync, existsSync, rmSync } from 'node:fs'
|
||||||
import { basename, join } from 'node:path'
|
import { basename, join, resolve, relative, isAbsolute } from 'node:path'
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile)
|
const execFileAsync = promisify(execFile)
|
||||||
const port = Number(process.env.COMFY_CONTROL_PORT || 8199)
|
const port = Number(process.env.COMFY_CONTROL_PORT || 8199)
|
||||||
@@ -123,11 +123,28 @@ function sharedRoots() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function inputRoots() {
|
||||||
|
const local = process.env.LOCALAPPDATA || ''
|
||||||
|
const roots = [
|
||||||
|
process.env.COMFY_INPUT_DIR || join(local, 'Comfy-Desktop', 'ComfyUI-Shared', 'input')
|
||||||
|
]
|
||||||
|
const installs = join(local, 'Comfy-Desktop', 'ComfyUI-Installs')
|
||||||
|
try {
|
||||||
|
for (const dir of readdirSync(installs, { withFileTypes: true })) {
|
||||||
|
if (!dir.isDirectory()) continue
|
||||||
|
roots.push(join(installs, dir.name, 'ComfyUI', 'input'))
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return roots
|
||||||
|
}
|
||||||
|
|
||||||
function safeFile(root, subfolder, filename) {
|
function safeFile(root, subfolder, filename) {
|
||||||
const name = basename(String(filename || ''))
|
const name = basename(String(filename || ''))
|
||||||
if (!name || name === '.' || name === '..') return null
|
if (!name || name === '.' || name === '..') return null
|
||||||
const target = join(root, String(subfolder || '').replace(/\.\./g, ''), name)
|
const rootAbs = resolve(root)
|
||||||
if (!target.startsWith(root)) return null
|
const target = resolve(rootAbs, String(subfolder || '').replace(/\.\./g, ''), name)
|
||||||
|
const rel = relative(rootAbs, target)
|
||||||
|
if (!rel || rel.startsWith('..') || isAbsolute(rel)) return null
|
||||||
return target
|
return target
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,9 +173,17 @@ function purgeDesktopFiles(body) {
|
|||||||
const roots = sharedRoots()
|
const roots = sharedRoots()
|
||||||
const deleted = []
|
const deleted = []
|
||||||
const imageName = String(body?.imageName || body?.filename || '')
|
const imageName = String(body?.imageName || body?.filename || '')
|
||||||
|
const imageSub = String(body?.imageSubfolder || '')
|
||||||
if (imageName) {
|
if (imageName) {
|
||||||
const inputPath = safeFile(roots.input, '', imageName)
|
for (const root of inputRoots()) {
|
||||||
if (removeFile(inputPath)) deleted.push(inputPath)
|
if (!existsSync(root)) continue
|
||||||
|
const nested = safeFile(root, imageSub, imageName)
|
||||||
|
if (removeFile(nested)) deleted.push(nested)
|
||||||
|
if (imageSub) {
|
||||||
|
const flat = safeFile(root, '', imageName)
|
||||||
|
if (removeFile(flat)) deleted.push(flat)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const video = body?.video || null
|
const video = body?.video || null
|
||||||
const videoName = String(video?.filename || '')
|
const videoName = String(video?.filename || '')
|
||||||
@@ -166,7 +191,7 @@ function purgeDesktopFiles(body) {
|
|||||||
const outputPath = safeFile(roots.output, video.subfolder || 'video', videoName)
|
const outputPath = safeFile(roots.output, video.subfolder || 'video', videoName)
|
||||||
if (removeFile(outputPath)) deleted.push(outputPath)
|
if (removeFile(outputPath)) deleted.push(outputPath)
|
||||||
}
|
}
|
||||||
console.log(JSON.stringify({ src: 'comfy-host-agent', event: 'purge', deleted: deleted.length, files: deleted.map((path) => basename(path)) }))
|
console.log(JSON.stringify({ src: 'comfy-host-agent', event: 'purge', deleted: deleted.length, files: deleted.map((path) => basename(path)), imageName }))
|
||||||
return { ok: true, deleted }
|
return { ok: true, deleted }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
function isComfyBusyTimeout(error: unknown) {
|
||||||
|
const err = error as { statusCode?: number; status?: number; data?: { code?: string }; message?: string; statusMessage?: string }
|
||||||
|
if (err?.statusCode === 409 || err?.status === 409 || err?.data?.code === 'COMFY_BUSY') return true
|
||||||
|
const text = `${err?.statusMessage || ''} ${err?.message || ''}`
|
||||||
|
return text.includes('still busy after 3 minutes')
|
||||||
|
}
|
||||||
|
|
||||||
function parseDuration(raw: string) {
|
function parseDuration(raw: string) {
|
||||||
const seconds = Number(raw)
|
const seconds = Number(raw)
|
||||||
if (!Number.isFinite(seconds)) return 5
|
if (!Number.isFinite(seconds)) return 5
|
||||||
@@ -39,8 +46,11 @@ export default defineEventHandler(async (event) => {
|
|||||||
const seed = fields.seed && fields.seed !== 'random'
|
const seed = fields.seed && fields.seed !== 'random'
|
||||||
? Number(fields.seed)
|
? Number(fields.seed)
|
||||||
: Math.floor(Math.random() * 2_147_483_647)
|
: Math.floor(Math.random() * 2_147_483_647)
|
||||||
const length = frameLength(parseDuration(fields.duration || '5'))
|
const durationSeconds = parseDuration(fields.duration || '5')
|
||||||
|
const length = frameLength(durationSeconds)
|
||||||
const hideThumbnail = fields.hideThumbnail === 'true'
|
const hideThumbnail = fields.hideThumbnail === 'true'
|
||||||
|
const hideInput = fields.hideInput === 'true'
|
||||||
|
const sound = fields.sound !== 'false'
|
||||||
const clipName = (fields.name || '').trim().slice(0, 80)
|
const clipName = (fields.name || '').trim().slice(0, 80)
|
||||||
const ownerKey = libraryOwnerKey(event)
|
const ownerKey = libraryOwnerKey(event)
|
||||||
const library = publicLibrary(event)
|
const library = publicLibrary(event)
|
||||||
@@ -53,13 +63,14 @@ export default defineEventHandler(async (event) => {
|
|||||||
const destFolder = library.folders.find(folder => folder.id === folderId)
|
const destFolder = library.folders.find(folder => folder.id === folderId)
|
||||||
const folderLocked = Boolean(destFolder?.protected && !destFolder.unlocked)
|
const folderLocked = Boolean(destFolder?.protected && !destFolder.unlocked)
|
||||||
assertFolderExists(event, folderId)
|
assertFolderExists(event, folderId)
|
||||||
await saveStill({
|
const still = await saveStill({
|
||||||
ownerKey,
|
ownerKey,
|
||||||
folderId,
|
folderId,
|
||||||
filename: image.filename,
|
filename: image.filename,
|
||||||
data: image.data,
|
data: image.data,
|
||||||
width,
|
width,
|
||||||
height
|
height,
|
||||||
|
hideInput
|
||||||
})
|
})
|
||||||
|
|
||||||
const job = createJob()
|
const job = createJob()
|
||||||
@@ -69,6 +80,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
ownerKey,
|
ownerKey,
|
||||||
folderId,
|
folderId,
|
||||||
hideThumbnail,
|
hideThumbnail,
|
||||||
|
hideInput,
|
||||||
folderLocked,
|
folderLocked,
|
||||||
name: clipName,
|
name: clipName,
|
||||||
prompt,
|
prompt,
|
||||||
@@ -78,11 +90,46 @@ export default defineEventHandler(async (event) => {
|
|||||||
steps,
|
steps,
|
||||||
turbo,
|
turbo,
|
||||||
seed,
|
seed,
|
||||||
thumb: image.data
|
thumb: image.data,
|
||||||
|
stillId: still.id,
|
||||||
|
stillFilename: still.filename,
|
||||||
|
duration: durationSeconds,
|
||||||
|
sound,
|
||||||
|
draftId: (fields.draftId || '').trim() || undefined
|
||||||
}
|
}
|
||||||
emitJob(job, { type: 'status', message: 'Checking ComfyUI...', progress: 1 })
|
emitJob(job, { type: 'status', message: 'Checking ComfyUI...', progress: 1 })
|
||||||
|
|
||||||
void runGeneration(job, { prompt, image, width, height, steps, seed, turbo, length }).catch((error) => {
|
void runGeneration(job, { prompt, image, width, height, steps, seed, turbo, length, sound }).catch(async (error) => {
|
||||||
|
if (isComfyBusyTimeout(error) && job.library) {
|
||||||
|
try {
|
||||||
|
const draft = await saveRetryDraft({
|
||||||
|
ownerKey: job.library.ownerKey,
|
||||||
|
id: job.library.draftId,
|
||||||
|
folderId: job.library.folderId,
|
||||||
|
stillId: job.library.stillId,
|
||||||
|
stillFilename: job.library.stillFilename,
|
||||||
|
name: job.library.name,
|
||||||
|
prompt: job.library.prompt,
|
||||||
|
aspect: job.library.aspect,
|
||||||
|
width: job.library.width,
|
||||||
|
height: job.library.height,
|
||||||
|
steps: job.library.steps,
|
||||||
|
turbo: job.library.turbo,
|
||||||
|
seed: job.library.seed,
|
||||||
|
duration: job.library.duration || durationSeconds,
|
||||||
|
hideThumbnail: job.library.hideThumbnail,
|
||||||
|
hideInput: job.library.hideInput === true,
|
||||||
|
sound: job.library.sound !== false
|
||||||
|
})
|
||||||
|
job.library.draftId = draft.id
|
||||||
|
job.status = 'deferred'
|
||||||
|
const message = 'ComfyUI was still busy after 3 minutes. Saved this job — click it under Held jobs to restore the form.'
|
||||||
|
emitJob(job, { type: 'deferred', draftId: draft.id, message, progress: job.progress })
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
// fall through to a normal error if the held job could not be saved
|
||||||
|
}
|
||||||
|
}
|
||||||
const message = error instanceof Error ? error.message : String(error)
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
job.status = 'error'
|
job.status = 'error'
|
||||||
job.error = message
|
job.error = message
|
||||||
@@ -115,6 +162,7 @@ async function runGeneration(
|
|||||||
seed: number
|
seed: number
|
||||||
turbo: boolean
|
turbo: boolean
|
||||||
length: number
|
length: number
|
||||||
|
sound: boolean
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
await ensureComfyReady((status) => {
|
await ensureComfyReady((status) => {
|
||||||
@@ -131,8 +179,11 @@ async function runGeneration(
|
|||||||
const done = watchComfyJob(job)
|
const done = watchComfyJob(job)
|
||||||
job.status = 'uploading'
|
job.status = 'uploading'
|
||||||
emitJob(job, { type: 'status', message: 'Uploading image to ComfyUI...', progress: 4 })
|
emitJob(job, { type: 'status', message: 'Uploading image to ComfyUI...', progress: 4 })
|
||||||
const uploaded = await uploadImage(params.image)
|
const uploaded = await uploadImage(params.image, job.id)
|
||||||
if (job.library) job.library.imageName = uploaded.name
|
if (job.library) {
|
||||||
|
job.library.imageName = uploaded.name
|
||||||
|
job.library.imageSubfolder = uploaded.subfolder
|
||||||
|
}
|
||||||
emitJob(job, { type: 'status', message: 'Queueing MiniMax H3 job...', progress: 6 })
|
emitJob(job, { type: 'status', message: 'Queueing MiniMax H3 job...', progress: 6 })
|
||||||
await waitForComfySocket(job, 4000)
|
await waitForComfySocket(job, 4000)
|
||||||
|
|
||||||
@@ -145,12 +196,17 @@ async function runGeneration(
|
|||||||
seed: params.seed,
|
seed: params.seed,
|
||||||
turbo: params.turbo,
|
turbo: params.turbo,
|
||||||
length: params.length,
|
length: params.length,
|
||||||
filenamePrefix: comfyFilenamePrefix()
|
filenamePrefix: comfyFilenamePrefix(),
|
||||||
|
sound: params.sound
|
||||||
})
|
})
|
||||||
|
|
||||||
const queued = await queuePrompt(graph, job.clientId)
|
const queued = await queuePrompt(graph, job.clientId)
|
||||||
job.promptId = queued.prompt_id
|
job.promptId = queued.prompt_id
|
||||||
job.status = 'running'
|
job.status = 'running'
|
||||||
|
if (job.library?.draftId) {
|
||||||
|
await deleteRetryDraft(job.library.ownerKey, job.library.draftId).catch(() => null)
|
||||||
|
job.library.draftId = undefined
|
||||||
|
}
|
||||||
if (job.library && job.promptId) {
|
if (job.library && job.promptId) {
|
||||||
writePendingJob({
|
writePendingJob({
|
||||||
jobId: job.id,
|
jobId: job.id,
|
||||||
@@ -169,7 +225,8 @@ async function runGeneration(
|
|||||||
turbo: job.library.turbo,
|
turbo: job.library.turbo,
|
||||||
seed: job.library.seed,
|
seed: job.library.seed,
|
||||||
startedAt: job.startedAt,
|
startedAt: job.startedAt,
|
||||||
imageName: job.library.imageName
|
imageName: job.library.imageName,
|
||||||
|
imageSubfolder: job.library.imageSubfolder
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
emitJob(job, { type: 'status', message: 'Job queued on ComfyUI', progress: 8 })
|
emitJob(job, { type: 'status', message: 'Job queued on ComfyUI', progress: 8 })
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
if (id) {
|
if (id) {
|
||||||
const pending = readPendingJob(id)
|
const pending = readPendingJob(id)
|
||||||
if (pending) {
|
if (pending) {
|
||||||
const done = await completePendingIfReady(pending).catch(() => null)
|
const done = await resolvePendingJob(pending).catch(() => null)
|
||||||
if (done) return done
|
if (done) return done
|
||||||
return {
|
return {
|
||||||
type: 'snapshot',
|
type: 'snapshot',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export default defineEventHandler((event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const owner = libraryOwnerKey(event)
|
const owner = libraryOwnerKey(event)
|
||||||
const live = listJobs()
|
const live = listJobs()
|
||||||
.filter(job => job.library?.ownerKey === owner && (job.status === 'queued' || job.status === 'uploading' || job.status === 'running'))
|
.filter(job => job.library?.ownerKey === owner && (job.status === 'queued' || job.status === 'uploading' || job.status === 'running'))
|
||||||
@@ -16,6 +16,8 @@ export default defineEventHandler((event) => {
|
|||||||
.filter(job => job.ownerKey === owner)
|
.filter(job => job.ownerKey === owner)
|
||||||
.sort((a, b) => b.startedAt - a.startedAt)[0]
|
.sort((a, b) => b.startedAt - a.startedAt)[0]
|
||||||
if (pending) {
|
if (pending) {
|
||||||
|
const resolved = await resolvePendingJob(pending).catch(() => null)
|
||||||
|
if (resolved) return resolved
|
||||||
return {
|
return {
|
||||||
jobId: pending.jobId,
|
jobId: pending.jobId,
|
||||||
type: 'snapshot',
|
type: 'snapshot',
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
if (jobId) {
|
if (jobId) {
|
||||||
const pending = readPendingJob(jobId)
|
const pending = readPendingJob(jobId)
|
||||||
if (pending) {
|
if (pending) {
|
||||||
const done = await completePendingIfReady(pending)
|
const done = await resolvePendingJob(pending)
|
||||||
if (done) return done
|
if (done) return done
|
||||||
return {
|
return {
|
||||||
type: 'snapshot',
|
type: 'snapshot',
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
|
import { checkComfyHttp, fetchQueue } from '~/server/utils/comfyLifecycle'
|
||||||
|
|
||||||
export default defineEventHandler(async () => {
|
export default defineEventHandler(async () => {
|
||||||
const comfy = await checkComfyHttp().catch(() => ({ ok: false, host: '', stats: null }))
|
const [comfy, queue] = await Promise.all([
|
||||||
|
checkComfyHttp().catch(() => ({ ok: false, host: '', stats: null })),
|
||||||
|
fetchQueue()
|
||||||
|
])
|
||||||
|
const busy = Boolean(comfy.ok && queue.running > 0)
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
service: useRuntimeConfig().public.instanceName || 'aigen',
|
service: useRuntimeConfig().public.instanceName || 'aigen',
|
||||||
comfy: {
|
comfy: {
|
||||||
ok: comfy.ok,
|
ok: comfy.ok,
|
||||||
|
busy,
|
||||||
host: comfy.host || '',
|
host: comfy.host || '',
|
||||||
|
queueRunning: queue.running,
|
||||||
|
queuePending: queue.pending,
|
||||||
stats: comfy.ok ? comfy.stats : undefined
|
stats: comfy.ok ? comfy.stats : undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const body = await readBody<{ jobId?: string }>(event).catch(() => ({}))
|
const body = await readBody<{ jobId?: string }>(event).catch(() => ({}))
|
||||||
const job = body?.jobId ? getJob(body.jobId) : undefined
|
const jobId = String(body?.jobId || '')
|
||||||
|
const job = jobId ? getJob(jobId) : undefined
|
||||||
if (job) {
|
if (job) {
|
||||||
job.status = 'cancelled'
|
job.status = 'cancelled'
|
||||||
emitJob(job, { type: 'status', message: 'Cancelling job...' })
|
emitJob(job, { type: 'status', message: 'Cancelling job...' })
|
||||||
}
|
}
|
||||||
|
if (jobId) deletePendingJob(jobId)
|
||||||
const ok = await interruptComfy()
|
const ok = await interruptComfy()
|
||||||
if (job) {
|
if (job) {
|
||||||
emitJob(job, { type: 'error', error: 'Job interrupted.', message: 'Job interrupted.' })
|
emitJob(job, { type: 'error', error: 'Job interrupted.', message: 'Job interrupted.' })
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const { owner } = assertLibraryOwner(event)
|
||||||
|
const id = String(getRouterParam(event, 'id') || '')
|
||||||
|
const body = await readBody<{ folderId?: string }>(event).catch(() => ({} as { folderId?: string }))
|
||||||
|
const folderId = String(body?.folderId || '')
|
||||||
|
if (!folderId) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'Choose a library folder' })
|
||||||
|
}
|
||||||
|
const clip = getClip(owner, id)
|
||||||
|
assertFolderAccess(event, clip.folderId)
|
||||||
|
assertFolderExists(event, folderId)
|
||||||
|
await moveClip(owner, id, folderId)
|
||||||
|
return publicLibrary(event)
|
||||||
|
})
|
||||||
@@ -5,9 +5,6 @@ export default defineEventHandler((event) => {
|
|||||||
const id = String(getRouterParam(event, 'id') || '')
|
const id = String(getRouterParam(event, 'id') || '')
|
||||||
const clip = getClip(owner, id)
|
const clip = getClip(owner, id)
|
||||||
assertFolderAccess(event, clip.folderId)
|
assertFolderAccess(event, clip.folderId)
|
||||||
if (clip.hideThumbnail) {
|
|
||||||
throw createError({ statusCode: 404, statusMessage: 'Thumbnail hidden' })
|
|
||||||
}
|
|
||||||
const path = clipThumbPath(owner, clip.id)
|
const path = clipThumbPath(owner, clip.id)
|
||||||
if (!existsSync(path)) {
|
if (!existsSync(path)) {
|
||||||
throw createError({ statusCode: 404, statusMessage: 'Thumbnail not found' })
|
throw createError({ statusCode: 404, statusMessage: 'Thumbnail not found' })
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const { owner } = assertLibraryOwner(event)
|
||||||
|
const id = String(getRouterParam(event, 'id') || '')
|
||||||
|
await deleteRetryDraft(owner, id)
|
||||||
|
return publicLibrary(event)
|
||||||
|
})
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { existsSync, readFileSync } from 'node:fs'
|
||||||
|
|
||||||
|
export default defineEventHandler((event) => {
|
||||||
|
const { owner } = assertLibraryOwner(event)
|
||||||
|
const id = String(getRouterParam(event, 'id') || '')
|
||||||
|
const draft = getRetryDraft(owner, id)
|
||||||
|
if (!draft.stillId) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: 'Held job has no still' })
|
||||||
|
}
|
||||||
|
const still = getStill(owner, draft.stillId)
|
||||||
|
const path = stillPath(owner, still.id)
|
||||||
|
if (!existsSync(path)) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: 'Image file is missing' })
|
||||||
|
}
|
||||||
|
const buf = readFileSync(path)
|
||||||
|
setHeader(event, 'Content-Type', imageContentType(buf))
|
||||||
|
setHeader(event, 'Cache-Control', 'private, max-age=3600')
|
||||||
|
return buf
|
||||||
|
})
|
||||||
@@ -30,7 +30,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
filename: image.filename,
|
filename: image.filename,
|
||||||
data: image.data,
|
data: image.data,
|
||||||
width: Number(fields.width || 0),
|
width: Number(fields.width || 0),
|
||||||
height: Number(fields.height || 0)
|
height: Number(fields.height || 0),
|
||||||
|
hideInput: fields.hideInput === 'true'
|
||||||
})
|
})
|
||||||
return { still, ...publicLibrary(event) }
|
return { still, ...publicLibrary(event) }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export default defineNitroPlugin(() => {
|
export default defineNitroPlugin(() => {
|
||||||
for (const pending of listPendingJobs()) {
|
for (const pending of listPendingJobs()) {
|
||||||
void completePendingIfReady(pending).catch(() => null)
|
void resolvePendingJob(pending).catch(() => null)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
+54
-6
@@ -43,17 +43,32 @@ export async function comfyFetch(path: string, init?: RequestInit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function uploadImage(file: { filename: string; data: Buffer; type?: string }) {
|
export async function uploadImage(file: { filename: string; data: Buffer; type?: string }, jobId?: string) {
|
||||||
const body = new FormData()
|
const body = new FormData()
|
||||||
const blob = new Blob([new Uint8Array(file.data)], { type: file.type || 'application/octet-stream' })
|
const blob = new Blob([new Uint8Array(file.data)], { type: file.type || 'application/octet-stream' })
|
||||||
body.append('image', blob, file.filename)
|
const filename = comfyInputFilename(file.filename, jobId)
|
||||||
|
body.append('image', blob, filename)
|
||||||
body.append('overwrite', 'true')
|
body.append('overwrite', 'true')
|
||||||
body.append('type', 'input')
|
body.append('type', 'input')
|
||||||
const res = await comfyFetch('/upload/image', { method: 'POST', body })
|
const res = await comfyFetch('/upload/image', { method: 'POST', body })
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw createError({ statusCode: 502, statusMessage: `Image upload failed (${res.status})` })
|
throw createError({ statusCode: 502, statusMessage: `Image upload failed (${res.status})` })
|
||||||
}
|
}
|
||||||
return (await res.json()) as { name: string; subfolder?: string; type?: string }
|
const uploaded = (await res.json()) as { name: string; subfolder?: string; type?: string }
|
||||||
|
return {
|
||||||
|
name: uploaded.name || filename,
|
||||||
|
subfolder: uploaded.subfolder || '',
|
||||||
|
type: uploaded.type || 'input'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function comfyInputFilename(original: string, jobId?: string) {
|
||||||
|
const raw = String(original || 'still.png')
|
||||||
|
const dot = raw.lastIndexOf('.')
|
||||||
|
const ext = (dot >= 0 ? raw.slice(dot) : '.png').replace(/[^.a-zA-Z0-9]/g, '') || '.png'
|
||||||
|
const base = (dot >= 0 ? raw.slice(0, dot) : raw).replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 48) || 'still'
|
||||||
|
const id = String(jobId || crypto.randomUUID()).replace(/-/g, '').slice(0, 10)
|
||||||
|
return `aigen_${id}_${base}${ext}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function queuePrompt(graph: unknown, clientId: string) {
|
export async function queuePrompt(graph: unknown, clientId: string) {
|
||||||
@@ -173,14 +188,44 @@ export function extractClipMetaFromHistory(entry: unknown) {
|
|||||||
return meta
|
return meta
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchLiveQueue() {
|
||||||
|
try {
|
||||||
|
const res = await comfyFetch('/queue', { signal: AbortSignal.timeout(2500) })
|
||||||
|
if (!res.ok) return null
|
||||||
|
const payload = await res.json() as { queue_running?: unknown[]; queue_pending?: unknown[] }
|
||||||
|
return {
|
||||||
|
running: Array.isArray(payload.queue_running) ? payload.queue_running.length : 0,
|
||||||
|
pending: Array.isArray(payload.queue_pending) ? payload.queue_pending.length : 0
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isComfyPromptDropped(promptId: string) {
|
||||||
|
if (!promptId) return false
|
||||||
|
const queue = await fetchLiveQueue()
|
||||||
|
if (!queue) return false
|
||||||
|
if (queue.running > 0 || queue.pending > 0) return false
|
||||||
|
const history = await fetchHistory(promptId)
|
||||||
|
const entry = history?.[promptId] as { status?: { status_str?: string; completed?: boolean } } | undefined
|
||||||
|
if (!entry) return true
|
||||||
|
const inspected = inspectHistory(history, promptId)
|
||||||
|
if (inspected.video) return false
|
||||||
|
const status = entry.status?.status_str
|
||||||
|
if (status === 'error' || status === 'interrupted') return true
|
||||||
|
if (entry.status?.completed) return true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
export function inspectHistory(history: Record<string, unknown> | null, promptId: string) {
|
export function inspectHistory(history: Record<string, unknown> | null, promptId: string) {
|
||||||
const video = extractVideo(history, promptId)
|
const video = extractVideo(history, promptId)
|
||||||
if (!history) return { video: null, completed: false, error: null as string | null }
|
if (!history) return { video: null, completed: false, error: null as string | null }
|
||||||
const entry = (history[promptId] || history) as {
|
const entry = (history[promptId] || history) as {
|
||||||
status?: { status_str?: string; completed?: boolean }
|
status?: { status_str?: string; completed?: boolean }
|
||||||
}
|
}
|
||||||
const error = entry?.status?.status_str === 'error'
|
const error = entry?.status?.status_str === 'error' || entry?.status?.status_str === 'interrupted'
|
||||||
? 'ComfyUI reported an execution error'
|
? (entry.status.status_str === 'interrupted' ? 'Job interrupted.' : 'ComfyUI reported an execution error')
|
||||||
: null
|
: null
|
||||||
return { video, completed: Boolean(video), error }
|
return { video, completed: Boolean(video), error }
|
||||||
}
|
}
|
||||||
@@ -217,6 +262,7 @@ export function purgeComfyEnabled() {
|
|||||||
async function purgeOnDesktop(opts: {
|
async function purgeOnDesktop(opts: {
|
||||||
video?: { filename: string; subfolder: string; type: string }
|
video?: { filename: string; subfolder: string; type: string }
|
||||||
imageName?: string
|
imageName?: string
|
||||||
|
imageSubfolder?: string
|
||||||
}) {
|
}) {
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const controlUrl = String(config.comfyControlUrl || process.env.COMFY_CONTROL_URL || '').replace(/\/$/, '')
|
const controlUrl = String(config.comfyControlUrl || process.env.COMFY_CONTROL_URL || '').replace(/\/$/, '')
|
||||||
@@ -232,6 +278,7 @@ async function purgeOnDesktop(opts: {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
imageName: opts.imageName || '',
|
imageName: opts.imageName || '',
|
||||||
|
imageSubfolder: opts.imageSubfolder || '',
|
||||||
video: opts.video || null
|
video: opts.video || null
|
||||||
}),
|
}),
|
||||||
signal: AbortSignal.timeout(8000)
|
signal: AbortSignal.timeout(8000)
|
||||||
@@ -244,13 +291,14 @@ async function purgeOnDesktop(opts: {
|
|||||||
export async function purgeComfyArtifacts(opts: {
|
export async function purgeComfyArtifacts(opts: {
|
||||||
video?: { filename: string; subfolder: string; type: string }
|
video?: { filename: string; subfolder: string; type: string }
|
||||||
imageName?: string
|
imageName?: string
|
||||||
|
imageSubfolder?: string
|
||||||
promptId?: string
|
promptId?: string
|
||||||
}) {
|
}) {
|
||||||
if (!purgeComfyEnabled()) return
|
if (!purgeComfyEnabled()) return
|
||||||
await purgeOnDesktop(opts)
|
await purgeOnDesktop(opts)
|
||||||
const files: { filename: string; subfolder: string; type: string }[] = []
|
const files: { filename: string; subfolder: string; type: string }[] = []
|
||||||
if (opts.video?.filename) files.push(opts.video)
|
if (opts.video?.filename) files.push(opts.video)
|
||||||
if (opts.imageName) files.push({ filename: opts.imageName, subfolder: '', type: 'input' })
|
if (opts.imageName) files.push({ filename: opts.imageName, subfolder: opts.imageSubfolder || '', type: 'input' })
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
try {
|
try {
|
||||||
await comfyFetch('/aigen/purge', {
|
await comfyFetch('/aigen/purge', {
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export async function checkComfyHttp(timeoutMs = 2500, host = getComfyHost()) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchQueue() {
|
export async function fetchQueue() {
|
||||||
const host = getComfyHost()
|
const host = getComfyHost()
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${host}/queue`, { signal: AbortSignal.timeout(2500) })
|
const res = await fetch(`${host}/queue`, { signal: AbortSignal.timeout(2500) })
|
||||||
@@ -189,7 +189,7 @@ async function waitWhileBusy(onStatus: StatusFn) {
|
|||||||
if (queue.running === 0) return
|
if (queue.running === 0) return
|
||||||
onStatus({
|
onStatus({
|
||||||
state: 'busy',
|
state: 'busy',
|
||||||
message: `ComfyUI is busy with another prompt (${queue.running} running, ${queue.pending} queued). Waiting...`,
|
message: `ComfyUI is busy with another prompt (${queue.running} running, ${queue.pending} queued). Waiting up to 3 minutes, then this job will be saved.`,
|
||||||
host,
|
host,
|
||||||
queueRunning: queue.running,
|
queueRunning: queue.running,
|
||||||
queuePending: queue.pending
|
queuePending: queue.pending
|
||||||
@@ -199,7 +199,8 @@ async function waitWhileBusy(onStatus: StatusFn) {
|
|||||||
}
|
}
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 409,
|
statusCode: 409,
|
||||||
statusMessage: 'ComfyUI is busy executing another prompt. Try again in a moment.'
|
statusMessage: 'ComfyUI is still busy after 3 minutes. Your prompt, still, and settings were saved.',
|
||||||
|
data: { code: 'COMFY_BUSY' }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+11
-2
@@ -1,4 +1,4 @@
|
|||||||
export type JobStatus = 'queued' | 'uploading' | 'running' | 'complete' | 'error' | 'cancelled'
|
export type JobStatus = 'queued' | 'uploading' | 'running' | 'complete' | 'error' | 'cancelled' | 'deferred'
|
||||||
|
|
||||||
export interface JobEvent {
|
export interface JobEvent {
|
||||||
type: string
|
type: string
|
||||||
@@ -19,6 +19,7 @@ export interface JobEvent {
|
|||||||
queuePending?: number
|
queuePending?: number
|
||||||
folderLocked?: boolean
|
folderLocked?: boolean
|
||||||
jobId?: string
|
jobId?: string
|
||||||
|
draftId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Job {
|
export interface Job {
|
||||||
@@ -38,6 +39,7 @@ export interface Job {
|
|||||||
ownerKey: string
|
ownerKey: string
|
||||||
folderId: string
|
folderId: string
|
||||||
hideThumbnail: boolean
|
hideThumbnail: boolean
|
||||||
|
hideInput?: boolean
|
||||||
folderLocked?: boolean
|
folderLocked?: boolean
|
||||||
name?: string
|
name?: string
|
||||||
prompt: string
|
prompt: string
|
||||||
@@ -49,6 +51,12 @@ export interface Job {
|
|||||||
seed: number
|
seed: number
|
||||||
thumb?: Buffer
|
thumb?: Buffer
|
||||||
imageName?: string
|
imageName?: string
|
||||||
|
imageSubfolder?: string
|
||||||
|
stillId?: string
|
||||||
|
stillFilename?: string
|
||||||
|
duration?: number
|
||||||
|
sound?: boolean
|
||||||
|
draftId?: string
|
||||||
}
|
}
|
||||||
error?: string
|
error?: string
|
||||||
socketReady?: boolean
|
socketReady?: boolean
|
||||||
@@ -105,7 +113,8 @@ export function jobSnapshot(job: Job) {
|
|||||||
clipId: job.clipId,
|
clipId: job.clipId,
|
||||||
hideThumbnail: job.hideThumbnail,
|
hideThumbnail: job.hideThumbnail,
|
||||||
error: job.error,
|
error: job.error,
|
||||||
folderLocked: job.library?.folderLocked
|
folderLocked: job.library?.folderLocked,
|
||||||
|
draftId: job.library?.draftId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+130
-8
@@ -40,6 +40,7 @@ export interface LibraryStill {
|
|||||||
height: number
|
height: number
|
||||||
hash: string
|
hash: string
|
||||||
createdAt: number
|
createdAt: number
|
||||||
|
hideInput: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PublicFolder {
|
export interface PublicFolder {
|
||||||
@@ -52,10 +53,31 @@ export interface PublicFolder {
|
|||||||
clipCount: number
|
clipCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RetryDraft {
|
||||||
|
id: string
|
||||||
|
createdAt: number
|
||||||
|
folderId: string
|
||||||
|
stillId?: 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
|
||||||
|
stillFilename?: string
|
||||||
|
}
|
||||||
|
|
||||||
interface Catalog {
|
interface Catalog {
|
||||||
folders: LibraryFolder[]
|
folders: LibraryFolder[]
|
||||||
clips: LibraryClip[]
|
clips: LibraryClip[]
|
||||||
stills: LibraryStill[]
|
stills: LibraryStill[]
|
||||||
|
drafts: RetryDraft[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const writeChains = new Map<string, Promise<unknown>>()
|
const writeChains = new Map<string, Promise<unknown>>()
|
||||||
@@ -93,7 +115,8 @@ function emptyCatalog(): Catalog {
|
|||||||
passwordHash: null
|
passwordHash: null
|
||||||
}],
|
}],
|
||||||
clips: [],
|
clips: [],
|
||||||
stills: []
|
stills: [],
|
||||||
|
drafts: []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,6 +130,19 @@ function normalizeFolder(folder: LibraryFolder): LibraryFolder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeStill(still: LibraryStill): LibraryStill {
|
||||||
|
return {
|
||||||
|
id: still.id,
|
||||||
|
folderId: still.folderId,
|
||||||
|
filename: still.filename,
|
||||||
|
width: still.width || 0,
|
||||||
|
height: still.height || 0,
|
||||||
|
hash: still.hash || '',
|
||||||
|
createdAt: still.createdAt || Date.now(),
|
||||||
|
hideInput: still.hideInput === true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeCatalog(parsed: Partial<Catalog> & { passwordHash?: string | null }): Catalog {
|
function normalizeCatalog(parsed: Partial<Catalog> & { passwordHash?: string | null }): Catalog {
|
||||||
const folders = Array.isArray(parsed.folders) && parsed.folders.length
|
const folders = Array.isArray(parsed.folders) && parsed.folders.length
|
||||||
? parsed.folders.map(normalizeFolder)
|
? parsed.folders.map(normalizeFolder)
|
||||||
@@ -119,7 +155,8 @@ function normalizeCatalog(parsed: Partial<Catalog> & { passwordHash?: string | n
|
|||||||
return {
|
return {
|
||||||
folders,
|
folders,
|
||||||
clips: Array.isArray(parsed.clips) ? parsed.clips : [],
|
clips: Array.isArray(parsed.clips) ? parsed.clips : [],
|
||||||
stills: Array.isArray(parsed.stills) ? parsed.stills : []
|
stills: Array.isArray(parsed.stills) ? parsed.stills.map(normalizeStill) : [],
|
||||||
|
drafts: Array.isArray(parsed.drafts) ? parsed.drafts : []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,7 +287,8 @@ export function publicLibrary(event: H3Event) {
|
|||||||
name: clipTitle(clip),
|
name: clipTitle(clip),
|
||||||
hasThumbnail: !clip.hideThumbnail && existsSync(clipThumbPath(owner, clip.id))
|
hasThumbnail: !clip.hideThumbnail && existsSync(clipThumbPath(owner, clip.id))
|
||||||
})),
|
})),
|
||||||
stills: catalog.stills.filter(still => unlockedIds.has(still.folderId))
|
stills: catalog.stills.filter(still => unlockedIds.has(still.folderId)),
|
||||||
|
drafts: catalog.drafts
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -571,13 +609,23 @@ export async function saveStill(params: {
|
|||||||
data: Buffer
|
data: Buffer
|
||||||
width?: number
|
width?: number
|
||||||
height?: number
|
height?: number
|
||||||
|
hideInput?: boolean
|
||||||
}) {
|
}) {
|
||||||
const catalog = readCatalog(params.ownerKey)
|
const catalog = readCatalog(params.ownerKey)
|
||||||
const folder = catalog.folders.find(item => item.id === params.folderId) || catalog.folders[0]
|
const folder = catalog.folders.find(item => item.id === params.folderId) || catalog.folders[0]
|
||||||
if (!folder) throw createError({ statusCode: 400, statusMessage: 'No library folder available' })
|
if (!folder) throw createError({ statusCode: 400, statusMessage: 'No library folder available' })
|
||||||
const hash = bytesHash(params.data)
|
const hash = bytesHash(params.data)
|
||||||
const existing = catalog.stills.find(item => item.folderId === folder.id && item.hash === hash)
|
const existing = catalog.stills.find(item => item.folderId === folder.id && item.hash === hash)
|
||||||
if (existing) return existing
|
if (existing) {
|
||||||
|
if (typeof params.hideInput === 'boolean' && existing.hideInput !== params.hideInput) {
|
||||||
|
await mutate(params.ownerKey, (next) => {
|
||||||
|
const item = next.stills.find(still => still.id === existing.id)
|
||||||
|
if (item) item.hideInput = params.hideInput === true
|
||||||
|
})
|
||||||
|
existing.hideInput = params.hideInput === true
|
||||||
|
}
|
||||||
|
return existing
|
||||||
|
}
|
||||||
const still: LibraryStill = {
|
const still: LibraryStill = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
folderId: folder.id,
|
folderId: folder.id,
|
||||||
@@ -585,7 +633,8 @@ export async function saveStill(params: {
|
|||||||
width: params.width || 0,
|
width: params.width || 0,
|
||||||
height: params.height || 0,
|
height: params.height || 0,
|
||||||
hash,
|
hash,
|
||||||
createdAt: Date.now()
|
createdAt: Date.now(),
|
||||||
|
hideInput: params.hideInput === true
|
||||||
}
|
}
|
||||||
mkdirSync(join(ownerRoot(params.ownerKey), 'stills'), { recursive: true })
|
mkdirSync(join(ownerRoot(params.ownerKey), 'stills'), { recursive: true })
|
||||||
writeFileSync(stillPath(params.ownerKey, still.id), params.data)
|
writeFileSync(stillPath(params.ownerKey, still.id), params.data)
|
||||||
@@ -595,6 +644,66 @@ export async function saveStill(params: {
|
|||||||
return still
|
return still
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function saveRetryDraft(params: {
|
||||||
|
ownerKey: string
|
||||||
|
id?: string
|
||||||
|
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
|
||||||
|
}) {
|
||||||
|
return mutate(params.ownerKey, (catalog) => {
|
||||||
|
const existing = params.id ? catalog.drafts.find(item => item.id === params.id) : undefined
|
||||||
|
const draft: RetryDraft = {
|
||||||
|
id: existing?.id || crypto.randomUUID(),
|
||||||
|
createdAt: existing?.createdAt || Date.now(),
|
||||||
|
folderId: params.folderId,
|
||||||
|
stillId: params.stillId,
|
||||||
|
stillFilename: params.stillFilename,
|
||||||
|
name: params.name,
|
||||||
|
prompt: params.prompt,
|
||||||
|
aspect: params.aspect,
|
||||||
|
width: params.width,
|
||||||
|
height: params.height,
|
||||||
|
steps: params.steps,
|
||||||
|
turbo: params.turbo,
|
||||||
|
seed: params.seed,
|
||||||
|
duration: params.duration,
|
||||||
|
hideThumbnail: params.hideThumbnail,
|
||||||
|
sound: params.sound,
|
||||||
|
hideInput: params.hideInput === true
|
||||||
|
}
|
||||||
|
catalog.drafts = [draft, ...catalog.drafts.filter(item => item.id !== draft.id)].slice(0, 20)
|
||||||
|
return draft
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRetryDraft(owner: string, id: string) {
|
||||||
|
const draft = readCatalog(owner).drafts.find(item => item.id === id)
|
||||||
|
if (!draft) throw createError({ statusCode: 404, statusMessage: 'Held job not found' })
|
||||||
|
return draft
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteRetryDraft(owner: string, id: string) {
|
||||||
|
return mutate(owner, (catalog) => {
|
||||||
|
const draft = catalog.drafts.find(item => item.id === id)
|
||||||
|
if (!draft) throw createError({ statusCode: 404, statusMessage: 'Held job not found' })
|
||||||
|
catalog.drafts = catalog.drafts.filter(item => item.id !== id)
|
||||||
|
return draft
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function getStill(owner: string, id: string) {
|
export function getStill(owner: string, id: string) {
|
||||||
const still = readCatalog(owner).stills.find(item => item.id === id)
|
const still = readCatalog(owner).stills.find(item => item.id === id)
|
||||||
if (!still) throw createError({ statusCode: 404, statusMessage: 'Image not found' })
|
if (!still) throw createError({ statusCode: 404, statusMessage: 'Image not found' })
|
||||||
@@ -630,6 +739,7 @@ export async function saveClip(params: {
|
|||||||
turbo: boolean
|
turbo: boolean
|
||||||
seed: number
|
seed: number
|
||||||
hideThumbnail: boolean
|
hideThumbnail: boolean
|
||||||
|
hideInput?: boolean
|
||||||
video: Buffer
|
video: Buffer
|
||||||
thumb?: Buffer | null
|
thumb?: Buffer | null
|
||||||
comfyFilename?: string
|
comfyFilename?: string
|
||||||
@@ -654,9 +764,9 @@ export async function saveClip(params: {
|
|||||||
}
|
}
|
||||||
mkdirSync(clipDir(params.ownerKey, clip.id), { recursive: true })
|
mkdirSync(clipDir(params.ownerKey, clip.id), { recursive: true })
|
||||||
writeFileSync(clipVideoPath(params.ownerKey, clip.id), params.video)
|
writeFileSync(clipVideoPath(params.ownerKey, clip.id), params.video)
|
||||||
if (!params.hideThumbnail && params.thumb?.length) {
|
if (params.thumb?.length) {
|
||||||
writeFileSync(join(clipDir(params.ownerKey, clip.id), 'thumb'), params.thumb)
|
writeFileSync(join(clipDir(params.ownerKey, clip.id), 'thumb'), params.thumb)
|
||||||
} else if (!params.hideThumbnail) {
|
} else {
|
||||||
await extractVideoThumb(clipVideoPath(params.ownerKey, clip.id), join(clipDir(params.ownerKey, clip.id), 'thumb.jpg'))
|
await extractVideoThumb(clipVideoPath(params.ownerKey, clip.id), join(clipDir(params.ownerKey, clip.id), 'thumb.jpg'))
|
||||||
}
|
}
|
||||||
if (params.thumb?.length) {
|
if (params.thumb?.length) {
|
||||||
@@ -666,7 +776,8 @@ export async function saveClip(params: {
|
|||||||
filename: `${clip.id}.png`,
|
filename: `${clip.id}.png`,
|
||||||
data: params.thumb,
|
data: params.thumb,
|
||||||
width: params.width,
|
width: params.width,
|
||||||
height: params.height
|
height: params.height,
|
||||||
|
hideInput: params.hideInput
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
await mutate(params.ownerKey, (next) => {
|
await mutate(params.ownerKey, (next) => {
|
||||||
@@ -691,6 +802,17 @@ export function deleteClip(owner: string, id: string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function moveClip(owner: string, id: string, folderId: string) {
|
||||||
|
return mutate(owner, (catalog) => {
|
||||||
|
const clip = catalog.clips.find(item => item.id === id)
|
||||||
|
if (!clip) throw createError({ statusCode: 404, statusMessage: 'Clip not found' })
|
||||||
|
const folder = catalog.folders.find(item => item.id === folderId)
|
||||||
|
if (!folder) throw createError({ statusCode: 404, statusMessage: 'Folder not found' })
|
||||||
|
clip.folderId = folder.id
|
||||||
|
return clip
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export async function downloadComfyVideo(video: { filename: string; subfolder: string; type: string }) {
|
export async function downloadComfyVideo(video: { filename: string; subfolder: string; type: string }) {
|
||||||
const subfolders = [...new Set([video.subfolder, 'video', ''])]
|
const subfolders = [...new Set([video.subfolder, 'video', ''])]
|
||||||
let lastStatus = 0
|
let lastStatus = 0
|
||||||
|
|||||||
+20
-1
@@ -19,6 +19,7 @@ export interface PendingJob {
|
|||||||
seed: number
|
seed: number
|
||||||
startedAt: number
|
startedAt: number
|
||||||
imageName?: string
|
imageName?: string
|
||||||
|
imageSubfolder?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function pendingRoot() {
|
function pendingRoot() {
|
||||||
@@ -84,7 +85,7 @@ export async function completePendingIfReady(pending: PendingJob) {
|
|||||||
thumb: null,
|
thumb: null,
|
||||||
comfyFilename: video.filename
|
comfyFilename: video.filename
|
||||||
})
|
})
|
||||||
await purgeComfyArtifacts({ video, imageName: pending.imageName, promptId: pending.promptId })
|
await purgeComfyArtifacts({ video, imageName: pending.imageName, imageSubfolder: pending.imageSubfolder, promptId: pending.promptId })
|
||||||
deletePendingJob(pending.jobId)
|
deletePendingJob(pending.jobId)
|
||||||
return {
|
return {
|
||||||
type: 'complete' as const,
|
type: 'complete' as const,
|
||||||
@@ -100,3 +101,21 @@ export async function completePendingIfReady(pending: PendingJob) {
|
|||||||
folderLocked: pending.folderLocked
|
folderLocked: pending.folderLocked
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function resolvePendingJob(pending: PendingJob) {
|
||||||
|
const done = await completePendingIfReady(pending)
|
||||||
|
if (done) return done
|
||||||
|
if (await isComfyPromptDropped(pending.promptId)) {
|
||||||
|
deletePendingJob(pending.jobId)
|
||||||
|
const message = 'ComfyUI dropped this job. Reloading the Comfy interface clears the queue. Generate again.'
|
||||||
|
return {
|
||||||
|
type: 'error' as const,
|
||||||
|
status: 'error' as const,
|
||||||
|
jobId: pending.jobId,
|
||||||
|
error: message,
|
||||||
|
message,
|
||||||
|
progress: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|||||||
+17
-10
@@ -69,8 +69,9 @@ export function watchComfyJob(job: Job): Promise<void> {
|
|||||||
turbo: job.library.turbo,
|
turbo: job.library.turbo,
|
||||||
seed: job.library.seed,
|
seed: job.library.seed,
|
||||||
hideThumbnail: job.library.hideThumbnail,
|
hideThumbnail: job.library.hideThumbnail,
|
||||||
|
hideInput: job.library.hideInput,
|
||||||
video: buffer,
|
video: buffer,
|
||||||
thumb: job.library.hideThumbnail ? null : job.library.thumb,
|
thumb: job.library.thumb,
|
||||||
comfyFilename: video.filename
|
comfyFilename: video.filename
|
||||||
})
|
})
|
||||||
job.clipId = clip.id
|
job.clipId = clip.id
|
||||||
@@ -79,6 +80,7 @@ export function watchComfyJob(job: Job): Promise<void> {
|
|||||||
await purgeComfyArtifacts({
|
await purgeComfyArtifacts({
|
||||||
video,
|
video,
|
||||||
imageName: job.library.imageName,
|
imageName: job.library.imageName,
|
||||||
|
imageSubfolder: job.library.imageSubfolder,
|
||||||
promptId: job.promptId
|
promptId: job.promptId
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -105,6 +107,8 @@ export function watchComfyJob(job: Job): Promise<void> {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let dropMisses = 0
|
||||||
|
|
||||||
const pollHistory = async () => {
|
const pollHistory = async () => {
|
||||||
if (settled || !job.promptId) return
|
if (settled || !job.promptId) return
|
||||||
try {
|
try {
|
||||||
@@ -115,6 +119,15 @@ export function watchComfyJob(job: Job): Promise<void> {
|
|||||||
}
|
}
|
||||||
if (inspected.video) {
|
if (inspected.video) {
|
||||||
await succeed()
|
await succeed()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (await isComfyPromptDropped(job.promptId)) {
|
||||||
|
dropMisses += 1
|
||||||
|
if (dropMisses >= 3) {
|
||||||
|
await fail('ComfyUI dropped this job. Reloading the Comfy interface clears the queue. Generate again.')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dropMisses = 0
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Transient ComfyUI history misses are expected while the graph is still running.
|
// Transient ComfyUI history misses are expected while the graph is still running.
|
||||||
@@ -134,15 +147,9 @@ export function watchComfyJob(job: Job): Promise<void> {
|
|||||||
emitJob(job, { type: 'status', message: 'Connected to ComfyUI', progress: Math.max(job.progress, 4) })
|
emitJob(job, { type: 'status', message: 'Connected to ComfyUI', progress: Math.max(job.progress, 4) })
|
||||||
})
|
})
|
||||||
|
|
||||||
ws.addEventListener('error', () => {
|
ws.addEventListener('close', () => {
|
||||||
if (settled) return
|
if (settled || !job.promptId) return
|
||||||
emitJob(job, {
|
void pollHistory()
|
||||||
type: 'status',
|
|
||||||
message: job.promptId
|
|
||||||
? 'Live socket unavailable, polling ComfyUI history...'
|
|
||||||
: 'Waiting for ComfyUI socket...',
|
|
||||||
progress: Math.max(job.progress, 4)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
ws.addEventListener('message', async (event) => {
|
ws.addEventListener('message', async (event) => {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface GenerateParams {
|
|||||||
turbo: boolean
|
turbo: boolean
|
||||||
length: number
|
length: number
|
||||||
filenamePrefix?: string
|
filenamePrefix?: string
|
||||||
|
sound?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type WorkflowNode = { class_type: string; inputs: Record<string, unknown>; _meta?: { title?: string } }
|
type WorkflowNode = { class_type: string; inputs: Record<string, unknown>; _meta?: { title?: string } }
|
||||||
@@ -102,6 +103,12 @@ export function buildWorkflow(params: GenerateParams) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (params.sound === false) {
|
||||||
|
delete graph['105:23']
|
||||||
|
delete graph['105:24']
|
||||||
|
if (graph['105:91']?.inputs) delete graph['105:91'].inputs.audio
|
||||||
|
}
|
||||||
|
|
||||||
return graph
|
return graph
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user