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:
@@ -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) {
|
||||
const seconds = Number(raw)
|
||||
if (!Number.isFinite(seconds)) return 5
|
||||
@@ -39,8 +46,11 @@ export default defineEventHandler(async (event) => {
|
||||
const seed = fields.seed && fields.seed !== 'random'
|
||||
? Number(fields.seed)
|
||||
: 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 hideInput = fields.hideInput === 'true'
|
||||
const sound = fields.sound !== 'false'
|
||||
const clipName = (fields.name || '').trim().slice(0, 80)
|
||||
const ownerKey = libraryOwnerKey(event)
|
||||
const library = publicLibrary(event)
|
||||
@@ -53,13 +63,14 @@ export default defineEventHandler(async (event) => {
|
||||
const destFolder = library.folders.find(folder => folder.id === folderId)
|
||||
const folderLocked = Boolean(destFolder?.protected && !destFolder.unlocked)
|
||||
assertFolderExists(event, folderId)
|
||||
await saveStill({
|
||||
const still = await saveStill({
|
||||
ownerKey,
|
||||
folderId,
|
||||
filename: image.filename,
|
||||
data: image.data,
|
||||
width,
|
||||
height
|
||||
height,
|
||||
hideInput
|
||||
})
|
||||
|
||||
const job = createJob()
|
||||
@@ -69,6 +80,7 @@ export default defineEventHandler(async (event) => {
|
||||
ownerKey,
|
||||
folderId,
|
||||
hideThumbnail,
|
||||
hideInput,
|
||||
folderLocked,
|
||||
name: clipName,
|
||||
prompt,
|
||||
@@ -78,11 +90,46 @@ export default defineEventHandler(async (event) => {
|
||||
steps,
|
||||
turbo,
|
||||
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 })
|
||||
|
||||
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)
|
||||
job.status = 'error'
|
||||
job.error = message
|
||||
@@ -115,6 +162,7 @@ async function runGeneration(
|
||||
seed: number
|
||||
turbo: boolean
|
||||
length: number
|
||||
sound: boolean
|
||||
}
|
||||
) {
|
||||
await ensureComfyReady((status) => {
|
||||
@@ -131,8 +179,11 @@ async function runGeneration(
|
||||
const done = watchComfyJob(job)
|
||||
job.status = 'uploading'
|
||||
emitJob(job, { type: 'status', message: 'Uploading image to ComfyUI...', progress: 4 })
|
||||
const uploaded = await uploadImage(params.image)
|
||||
if (job.library) job.library.imageName = uploaded.name
|
||||
const uploaded = await uploadImage(params.image, job.id)
|
||||
if (job.library) {
|
||||
job.library.imageName = uploaded.name
|
||||
job.library.imageSubfolder = uploaded.subfolder
|
||||
}
|
||||
emitJob(job, { type: 'status', message: 'Queueing MiniMax H3 job...', progress: 6 })
|
||||
await waitForComfySocket(job, 4000)
|
||||
|
||||
@@ -145,12 +196,17 @@ async function runGeneration(
|
||||
seed: params.seed,
|
||||
turbo: params.turbo,
|
||||
length: params.length,
|
||||
filenamePrefix: comfyFilenamePrefix()
|
||||
filenamePrefix: comfyFilenamePrefix(),
|
||||
sound: params.sound
|
||||
})
|
||||
|
||||
const queued = await queuePrompt(graph, job.clientId)
|
||||
job.promptId = queued.prompt_id
|
||||
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) {
|
||||
writePendingJob({
|
||||
jobId: job.id,
|
||||
@@ -169,7 +225,8 @@ async function runGeneration(
|
||||
turbo: job.library.turbo,
|
||||
seed: job.library.seed,
|
||||
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 })
|
||||
|
||||
@@ -6,7 +6,7 @@ export default defineEventHandler(async (event) => {
|
||||
if (id) {
|
||||
const pending = readPendingJob(id)
|
||||
if (pending) {
|
||||
const done = await completePendingIfReady(pending).catch(() => null)
|
||||
const done = await resolvePendingJob(pending).catch(() => null)
|
||||
if (done) return done
|
||||
return {
|
||||
type: 'snapshot',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export default defineEventHandler((event) => {
|
||||
export default defineEventHandler(async (event) => {
|
||||
const owner = libraryOwnerKey(event)
|
||||
const live = listJobs()
|
||||
.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)
|
||||
.sort((a, b) => b.startedAt - a.startedAt)[0]
|
||||
if (pending) {
|
||||
const resolved = await resolvePendingJob(pending).catch(() => null)
|
||||
if (resolved) return resolved
|
||||
return {
|
||||
jobId: pending.jobId,
|
||||
type: 'snapshot',
|
||||
|
||||
@@ -7,7 +7,7 @@ export default defineEventHandler(async (event) => {
|
||||
if (jobId) {
|
||||
const pending = readPendingJob(jobId)
|
||||
if (pending) {
|
||||
const done = await completePendingIfReady(pending)
|
||||
const done = await resolvePendingJob(pending)
|
||||
if (done) return done
|
||||
return {
|
||||
type: 'snapshot',
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import { checkComfyHttp, fetchQueue } from '~/server/utils/comfyLifecycle'
|
||||
|
||||
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 {
|
||||
ok: true,
|
||||
service: useRuntimeConfig().public.instanceName || 'aigen',
|
||||
comfy: {
|
||||
ok: comfy.ok,
|
||||
busy,
|
||||
host: comfy.host || '',
|
||||
queueRunning: queue.running,
|
||||
queuePending: queue.pending,
|
||||
stats: comfy.ok ? comfy.stats : undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
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) {
|
||||
job.status = 'cancelled'
|
||||
emitJob(job, { type: 'status', message: 'Cancelling job...' })
|
||||
}
|
||||
if (jobId) deletePendingJob(jobId)
|
||||
const ok = await interruptComfy()
|
||||
if (job) {
|
||||
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 clip = getClip(owner, id)
|
||||
assertFolderAccess(event, clip.folderId)
|
||||
if (clip.hideThumbnail) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Thumbnail hidden' })
|
||||
}
|
||||
const path = clipThumbPath(owner, clip.id)
|
||||
if (!existsSync(path)) {
|
||||
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,
|
||||
data: image.data,
|
||||
width: Number(fields.width || 0),
|
||||
height: Number(fields.height || 0)
|
||||
height: Number(fields.height || 0),
|
||||
hideInput: fields.hideInput === 'true'
|
||||
})
|
||||
return { still, ...publicLibrary(event) }
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export default defineNitroPlugin(() => {
|
||||
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 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('type', 'input')
|
||||
const res = await comfyFetch('/upload/image', { method: 'POST', body })
|
||||
if (!res.ok) {
|
||||
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) {
|
||||
@@ -173,14 +188,44 @@ export function extractClipMetaFromHistory(entry: unknown) {
|
||||
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) {
|
||||
const video = extractVideo(history, promptId)
|
||||
if (!history) return { video: null, completed: false, error: null as string | null }
|
||||
const entry = (history[promptId] || history) as {
|
||||
status?: { status_str?: string; completed?: boolean }
|
||||
}
|
||||
const error = entry?.status?.status_str === 'error'
|
||||
? 'ComfyUI reported an execution error'
|
||||
const error = entry?.status?.status_str === 'error' || entry?.status?.status_str === 'interrupted'
|
||||
? (entry.status.status_str === 'interrupted' ? 'Job interrupted.' : 'ComfyUI reported an execution error')
|
||||
: null
|
||||
return { video, completed: Boolean(video), error }
|
||||
}
|
||||
@@ -217,6 +262,7 @@ export function purgeComfyEnabled() {
|
||||
async function purgeOnDesktop(opts: {
|
||||
video?: { filename: string; subfolder: string; type: string }
|
||||
imageName?: string
|
||||
imageSubfolder?: string
|
||||
}) {
|
||||
const config = useRuntimeConfig()
|
||||
const controlUrl = String(config.comfyControlUrl || process.env.COMFY_CONTROL_URL || '').replace(/\/$/, '')
|
||||
@@ -232,6 +278,7 @@ async function purgeOnDesktop(opts: {
|
||||
},
|
||||
body: JSON.stringify({
|
||||
imageName: opts.imageName || '',
|
||||
imageSubfolder: opts.imageSubfolder || '',
|
||||
video: opts.video || null
|
||||
}),
|
||||
signal: AbortSignal.timeout(8000)
|
||||
@@ -244,13 +291,14 @@ async function purgeOnDesktop(opts: {
|
||||
export async function purgeComfyArtifacts(opts: {
|
||||
video?: { filename: string; subfolder: string; type: string }
|
||||
imageName?: string
|
||||
imageSubfolder?: string
|
||||
promptId?: string
|
||||
}) {
|
||||
if (!purgeComfyEnabled()) return
|
||||
await purgeOnDesktop(opts)
|
||||
const files: { filename: string; subfolder: string; type: string }[] = []
|
||||
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) {
|
||||
try {
|
||||
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()
|
||||
try {
|
||||
const res = await fetch(`${host}/queue`, { signal: AbortSignal.timeout(2500) })
|
||||
@@ -189,7 +189,7 @@ async function waitWhileBusy(onStatus: StatusFn) {
|
||||
if (queue.running === 0) return
|
||||
onStatus({
|
||||
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,
|
||||
queueRunning: queue.running,
|
||||
queuePending: queue.pending
|
||||
@@ -199,7 +199,8 @@ async function waitWhileBusy(onStatus: StatusFn) {
|
||||
}
|
||||
throw createError({
|
||||
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 {
|
||||
type: string
|
||||
@@ -19,6 +19,7 @@ export interface JobEvent {
|
||||
queuePending?: number
|
||||
folderLocked?: boolean
|
||||
jobId?: string
|
||||
draftId?: string
|
||||
}
|
||||
|
||||
export interface Job {
|
||||
@@ -38,6 +39,7 @@ export interface Job {
|
||||
ownerKey: string
|
||||
folderId: string
|
||||
hideThumbnail: boolean
|
||||
hideInput?: boolean
|
||||
folderLocked?: boolean
|
||||
name?: string
|
||||
prompt: string
|
||||
@@ -49,6 +51,12 @@ export interface Job {
|
||||
seed: number
|
||||
thumb?: Buffer
|
||||
imageName?: string
|
||||
imageSubfolder?: string
|
||||
stillId?: string
|
||||
stillFilename?: string
|
||||
duration?: number
|
||||
sound?: boolean
|
||||
draftId?: string
|
||||
}
|
||||
error?: string
|
||||
socketReady?: boolean
|
||||
@@ -105,7 +113,8 @@ export function jobSnapshot(job: Job) {
|
||||
clipId: job.clipId,
|
||||
hideThumbnail: job.hideThumbnail,
|
||||
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
|
||||
hash: string
|
||||
createdAt: number
|
||||
hideInput: boolean
|
||||
}
|
||||
|
||||
export interface PublicFolder {
|
||||
@@ -52,10 +53,31 @@ export interface PublicFolder {
|
||||
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 {
|
||||
folders: LibraryFolder[]
|
||||
clips: LibraryClip[]
|
||||
stills: LibraryStill[]
|
||||
drafts: RetryDraft[]
|
||||
}
|
||||
|
||||
const writeChains = new Map<string, Promise<unknown>>()
|
||||
@@ -93,7 +115,8 @@ function emptyCatalog(): Catalog {
|
||||
passwordHash: null
|
||||
}],
|
||||
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 {
|
||||
const folders = Array.isArray(parsed.folders) && parsed.folders.length
|
||||
? parsed.folders.map(normalizeFolder)
|
||||
@@ -119,7 +155,8 @@ function normalizeCatalog(parsed: Partial<Catalog> & { passwordHash?: string | n
|
||||
return {
|
||||
folders,
|
||||
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),
|
||||
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
|
||||
width?: number
|
||||
height?: number
|
||||
hideInput?: boolean
|
||||
}) {
|
||||
const catalog = readCatalog(params.ownerKey)
|
||||
const folder = catalog.folders.find(item => item.id === params.folderId) || catalog.folders[0]
|
||||
if (!folder) throw createError({ statusCode: 400, statusMessage: 'No library folder available' })
|
||||
const hash = bytesHash(params.data)
|
||||
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 = {
|
||||
id: crypto.randomUUID(),
|
||||
folderId: folder.id,
|
||||
@@ -585,7 +633,8 @@ export async function saveStill(params: {
|
||||
width: params.width || 0,
|
||||
height: params.height || 0,
|
||||
hash,
|
||||
createdAt: Date.now()
|
||||
createdAt: Date.now(),
|
||||
hideInput: params.hideInput === true
|
||||
}
|
||||
mkdirSync(join(ownerRoot(params.ownerKey), 'stills'), { recursive: true })
|
||||
writeFileSync(stillPath(params.ownerKey, still.id), params.data)
|
||||
@@ -595,6 +644,66 @@ export async function saveStill(params: {
|
||||
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) {
|
||||
const still = readCatalog(owner).stills.find(item => item.id === id)
|
||||
if (!still) throw createError({ statusCode: 404, statusMessage: 'Image not found' })
|
||||
@@ -630,6 +739,7 @@ export async function saveClip(params: {
|
||||
turbo: boolean
|
||||
seed: number
|
||||
hideThumbnail: boolean
|
||||
hideInput?: boolean
|
||||
video: Buffer
|
||||
thumb?: Buffer | null
|
||||
comfyFilename?: string
|
||||
@@ -654,9 +764,9 @@ export async function saveClip(params: {
|
||||
}
|
||||
mkdirSync(clipDir(params.ownerKey, clip.id), { recursive: true })
|
||||
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)
|
||||
} else if (!params.hideThumbnail) {
|
||||
} else {
|
||||
await extractVideoThumb(clipVideoPath(params.ownerKey, clip.id), join(clipDir(params.ownerKey, clip.id), 'thumb.jpg'))
|
||||
}
|
||||
if (params.thumb?.length) {
|
||||
@@ -666,7 +776,8 @@ export async function saveClip(params: {
|
||||
filename: `${clip.id}.png`,
|
||||
data: params.thumb,
|
||||
width: params.width,
|
||||
height: params.height
|
||||
height: params.height,
|
||||
hideInput: params.hideInput
|
||||
})
|
||||
}
|
||||
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 }) {
|
||||
const subfolders = [...new Set([video.subfolder, 'video', ''])]
|
||||
let lastStatus = 0
|
||||
|
||||
+20
-1
@@ -19,6 +19,7 @@ export interface PendingJob {
|
||||
seed: number
|
||||
startedAt: number
|
||||
imageName?: string
|
||||
imageSubfolder?: string
|
||||
}
|
||||
|
||||
function pendingRoot() {
|
||||
@@ -84,7 +85,7 @@ export async function completePendingIfReady(pending: PendingJob) {
|
||||
thumb: null,
|
||||
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)
|
||||
return {
|
||||
type: 'complete' as const,
|
||||
@@ -100,3 +101,21 @@ export async function completePendingIfReady(pending: PendingJob) {
|
||||
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,
|
||||
seed: job.library.seed,
|
||||
hideThumbnail: job.library.hideThumbnail,
|
||||
hideInput: job.library.hideInput,
|
||||
video: buffer,
|
||||
thumb: job.library.hideThumbnail ? null : job.library.thumb,
|
||||
thumb: job.library.thumb,
|
||||
comfyFilename: video.filename
|
||||
})
|
||||
job.clipId = clip.id
|
||||
@@ -79,6 +80,7 @@ export function watchComfyJob(job: Job): Promise<void> {
|
||||
await purgeComfyArtifacts({
|
||||
video,
|
||||
imageName: job.library.imageName,
|
||||
imageSubfolder: job.library.imageSubfolder,
|
||||
promptId: job.promptId
|
||||
})
|
||||
}
|
||||
@@ -105,6 +107,8 @@ export function watchComfyJob(job: Job): Promise<void> {
|
||||
return true
|
||||
}
|
||||
|
||||
let dropMisses = 0
|
||||
|
||||
const pollHistory = async () => {
|
||||
if (settled || !job.promptId) return
|
||||
try {
|
||||
@@ -115,6 +119,15 @@ export function watchComfyJob(job: Job): Promise<void> {
|
||||
}
|
||||
if (inspected.video) {
|
||||
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 {
|
||||
// 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) })
|
||||
})
|
||||
|
||||
ws.addEventListener('error', () => {
|
||||
if (settled) return
|
||||
emitJob(job, {
|
||||
type: 'status',
|
||||
message: job.promptId
|
||||
? 'Live socket unavailable, polling ComfyUI history...'
|
||||
: 'Waiting for ComfyUI socket...',
|
||||
progress: Math.max(job.progress, 4)
|
||||
})
|
||||
ws.addEventListener('close', () => {
|
||||
if (settled || !job.promptId) return
|
||||
void pollHistory()
|
||||
})
|
||||
|
||||
ws.addEventListener('message', async (event) => {
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface GenerateParams {
|
||||
turbo: boolean
|
||||
length: number
|
||||
filenamePrefix?: string
|
||||
sound?: boolean
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user