Move library collections as a group and map v2 identity refs.
Collapsed collection actions now move, rename, delete, and rerun every part so chains stay together. Identity mode follows the v2u3 Ref2VA picture wiring. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+79
-62
@@ -1,5 +1,5 @@
|
||||
import { imageComfyConfigured, uploadImageEdit, queueImagePrompt, waitForImageEdit, downloadEditedImage } from '~/server/utils/imageComfy'
|
||||
import { ensureImageComfyReady } from '~/server/utils/imageComfyLifecycle'
|
||||
import { imageComfyConfigured, uploadImageEdit, queueImagePrompt, waitForImageEdit, downloadEditedImage, purgeImageComfyArtifacts, withImageComfyHost } from '~/server/utils/imageComfy'
|
||||
import { pickImageEditHost } from '~/server/utils/imageComfyLifecycle'
|
||||
import { buildEditWorkflow } from '~/server/utils/imageWorkflow'
|
||||
import { imageDimensions } from '~/server/utils/resolution'
|
||||
|
||||
@@ -43,7 +43,7 @@ export default defineEventHandler(async (event) => {
|
||||
if (!imageComfyConfigured()) {
|
||||
throw createError({
|
||||
statusCode: 503,
|
||||
statusMessage: 'Image edit ComfyUI is not configured yet. Set IMAGE_COMFY_HOST on this instance.'
|
||||
statusMessage: 'Image edit ComfyUI is not configured yet. Set COMFY_HOST for Beast and IMAGE_COMFY_HOST for Sidecar overflow.'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ export default defineEventHandler(async (event) => {
|
||||
seed,
|
||||
cfg
|
||||
}
|
||||
emitJob(job, { type: 'status', message: 'Checking sidecar ComfyUI...', progress: 2 })
|
||||
emitJob(job, { type: 'status', message: 'Choosing Beast or Sidecar...', progress: 2 })
|
||||
|
||||
void runEdit(job, {
|
||||
image,
|
||||
@@ -127,7 +127,7 @@ async function runEdit(
|
||||
denoise: number
|
||||
}
|
||||
) {
|
||||
await ensureImageComfyReady((status) => {
|
||||
const target = await pickImageEditHost((status) => {
|
||||
emitJob(job, {
|
||||
type: status.state === 'busy' ? 'busy' : 'status',
|
||||
message: status.message,
|
||||
@@ -135,67 +135,84 @@ async function runEdit(
|
||||
busy: status.state === 'busy'
|
||||
})
|
||||
})
|
||||
job.imageComfyHost = target.host
|
||||
if (job.status === 'cancelled') throw new Error('Job interrupted.')
|
||||
|
||||
emitJob(job, { type: 'status', message: 'Uploading still to Qwen Edit...', progress: 8 })
|
||||
const uploaded = await uploadImageEdit(params.image, job.id)
|
||||
if (job.status === 'cancelled') throw new Error('Job interrupted.')
|
||||
await withImageComfyHost(target.host, async () => {
|
||||
emitJob(job, {
|
||||
type: 'status',
|
||||
message: `Uploading still to ${target.label}...`,
|
||||
progress: 8
|
||||
})
|
||||
const uploaded = await uploadImageEdit(params.image, job.id)
|
||||
if (job.status === 'cancelled') throw new Error('Job interrupted.')
|
||||
|
||||
emitJob(job, { type: 'status', message: 'Queueing Qwen Image Edit Lightning...', progress: 12 })
|
||||
const graph = buildEditWorkflow({
|
||||
prompt: params.prompt,
|
||||
negative: params.negative,
|
||||
imageName: uploaded.name,
|
||||
steps: params.steps,
|
||||
seed: params.seed,
|
||||
cfg: params.cfg,
|
||||
denoise: params.denoise,
|
||||
filenamePrefix: `aigen_edit_${job.id.slice(0, 8)}`
|
||||
})
|
||||
const queued = await queueImagePrompt(graph, job.clientId)
|
||||
job.promptId = queued.prompt_id
|
||||
job.status = 'running'
|
||||
emitJob(job, { type: 'status', message: 'Editing still...', progress: 18, maxStep: params.steps })
|
||||
emitJob(job, {
|
||||
type: 'status',
|
||||
message: `Queueing Qwen Image Edit on ${target.label}...`,
|
||||
progress: 12
|
||||
})
|
||||
const graph = buildEditWorkflow({
|
||||
prompt: params.prompt,
|
||||
negative: params.negative,
|
||||
imageName: uploaded.name,
|
||||
steps: params.steps,
|
||||
seed: params.seed,
|
||||
cfg: params.cfg,
|
||||
denoise: params.denoise,
|
||||
filenamePrefix: `aigen_edit_${job.id.slice(0, 8)}`
|
||||
})
|
||||
const queued = await queueImagePrompt(graph, job.clientId)
|
||||
job.promptId = queued.prompt_id
|
||||
job.status = 'running'
|
||||
emitJob(job, { type: 'status', message: `Editing still on ${target.label}...`, progress: 18, maxStep: params.steps })
|
||||
|
||||
const output = await waitForImageEdit({
|
||||
promptId: queued.prompt_id,
|
||||
clientId: job.clientId,
|
||||
onProgress: (event) => {
|
||||
emitJob(job, {
|
||||
type: 'status',
|
||||
message: event.message,
|
||||
progress: event.progress,
|
||||
step: event.step,
|
||||
maxStep: event.maxStep || params.steps,
|
||||
node: event.node
|
||||
})
|
||||
},
|
||||
isCancelled: () => job.status === 'cancelled'
|
||||
})
|
||||
const output = await waitForImageEdit({
|
||||
promptId: queued.prompt_id,
|
||||
clientId: job.clientId,
|
||||
onProgress: (event) => {
|
||||
emitJob(job, {
|
||||
type: 'status',
|
||||
message: event.message,
|
||||
progress: event.progress,
|
||||
step: event.step,
|
||||
maxStep: event.maxStep || params.steps,
|
||||
node: event.node
|
||||
})
|
||||
},
|
||||
isCancelled: () => job.status === 'cancelled'
|
||||
})
|
||||
|
||||
emitJob(job, { type: 'status', message: 'Saving edited still...', progress: 94 })
|
||||
const buffer = await downloadEditedImage(output)
|
||||
const size = imageDimensions(buffer)
|
||||
const still = await saveStill({
|
||||
ownerKey: job.library!.ownerKey,
|
||||
folderId: job.library!.folderId,
|
||||
filename: job.library?.name ? `${job.library.name}.png` : output.filename,
|
||||
data: buffer,
|
||||
width: size?.width || 0,
|
||||
height: size?.height || 0,
|
||||
hideInput: job.library?.hideInput
|
||||
})
|
||||
job.stillId = still.id
|
||||
job.status = 'complete'
|
||||
emitJob(job, {
|
||||
type: 'complete',
|
||||
message: 'Edit finished',
|
||||
progress: 100,
|
||||
stillId: still.id,
|
||||
filename: output.filename,
|
||||
subfolder: output.subfolder,
|
||||
mediaType: 'image',
|
||||
hideThumbnail: job.hideThumbnail,
|
||||
folderLocked: job.library?.folderLocked
|
||||
emitJob(job, { type: 'status', message: 'Saving edited still...', progress: 94 })
|
||||
const buffer = await downloadEditedImage(output)
|
||||
const size = imageDimensions(buffer)
|
||||
const still = await saveStill({
|
||||
ownerKey: job.library!.ownerKey,
|
||||
folderId: job.library!.folderId,
|
||||
filename: job.library?.name ? `${job.library.name}.png` : output.filename,
|
||||
data: buffer,
|
||||
width: size?.width || 0,
|
||||
height: size?.height || 0,
|
||||
hideInput: job.library?.hideInput
|
||||
})
|
||||
job.stillId = still?.id
|
||||
await purgeImageComfyArtifacts({
|
||||
output,
|
||||
inputName: uploaded.name,
|
||||
inputSubfolder: uploaded.subfolder,
|
||||
promptId: job.promptId
|
||||
})
|
||||
job.status = 'complete'
|
||||
emitJob(job, {
|
||||
type: 'complete',
|
||||
message: `Edit finished on ${target.label}`,
|
||||
progress: 100,
|
||||
stillId: still?.id,
|
||||
filename: output.filename,
|
||||
subfolder: output.subfolder,
|
||||
mediaType: 'image',
|
||||
hideThumbnail: job.hideThumbnail,
|
||||
folderLocked: job.library?.folderLocked
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -165,6 +165,7 @@ async function runExtend(
|
||||
copyFileSync(params.sourcePath, params.part1Path)
|
||||
await extractLastFrame(params.part1Path, params.framePath)
|
||||
const frame = readFileSync(params.framePath)
|
||||
if (job.library) job.library.thumb = undefined
|
||||
const sound = await probeHasAudio(params.part1Path)
|
||||
|
||||
await ensureComfyReady((status) => {
|
||||
|
||||
+33
-20
@@ -83,13 +83,20 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const fields: Record<string, string> = {}
|
||||
let image: { filename: string; data: Buffer; type?: string } | null = null
|
||||
const referenceImages: { filename: string; data: Buffer; type?: string }[] = []
|
||||
const referenceImages: Array<{ filename: string; data: Buffer; type?: string } | null> = [null, null, null, null]
|
||||
let compactRefSlot = 0
|
||||
for (const part of form) {
|
||||
if (part.name === 'image' && part.filename && part.data?.length) {
|
||||
image = { filename: part.filename, data: part.data, type: part.type }
|
||||
} else if (part.name && /^refImage(\d)$/.test(part.name) && part.filename && part.data?.length) {
|
||||
const slot = Number(part.name.replace('refImage', ''))
|
||||
if (slot >= 0 && slot < 4) {
|
||||
referenceImages[slot] = { filename: part.filename, data: part.data, type: part.type }
|
||||
}
|
||||
} else if (part.name === 'refImage' && part.filename && part.data?.length) {
|
||||
if (referenceImages.length < 4) {
|
||||
referenceImages.push({ filename: part.filename, data: part.data, type: part.type })
|
||||
if (compactRefSlot < 4) {
|
||||
referenceImages[compactRefSlot] = { filename: part.filename, data: part.data, type: part.type }
|
||||
compactRefSlot += 1
|
||||
}
|
||||
} else if (part.name && part.data) {
|
||||
fields[part.name] = part.data.toString('utf8')
|
||||
@@ -105,7 +112,7 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
const extensions = parseExtensions(fields.extensions)
|
||||
const workflow = parseVideoWorkflow(fields.workflow)
|
||||
const useIdentityRefs = workflow === 'v2' && fields.useIdentityRefs === 'true' && referenceImages.length > 0
|
||||
const useIdentityRefs = workflow === 'v2' && fields.useIdentityRefs === 'true'
|
||||
|
||||
const { width, height } = resolveOutputSize(fields.aspect, image.data, Number(fields.width), Number(fields.height))
|
||||
const turbo = fields.turbo !== 'false'
|
||||
@@ -134,15 +141,17 @@ export default defineEventHandler(async (event) => {
|
||||
const destFolder = library.folders.find(folder => folder.id === folderId)
|
||||
const folderLocked = Boolean(destFolder?.protected && !destFolder.unlocked)
|
||||
assertFolderExists(event, folderId)
|
||||
const still = await saveStill({
|
||||
ownerKey,
|
||||
folderId,
|
||||
filename: image.filename,
|
||||
data: image.data,
|
||||
width,
|
||||
height,
|
||||
hideInput
|
||||
})
|
||||
const still = isPipelineFrameFilename(image.filename)
|
||||
? null
|
||||
: await saveStill({
|
||||
ownerKey,
|
||||
folderId,
|
||||
filename: image.filename,
|
||||
data: image.data,
|
||||
width,
|
||||
height,
|
||||
hideInput
|
||||
})
|
||||
|
||||
const chainTotal = 1 + extensions.length
|
||||
const job = createJob()
|
||||
@@ -167,9 +176,9 @@ export default defineEventHandler(async (event) => {
|
||||
fps,
|
||||
samplerName,
|
||||
scheduler,
|
||||
thumb: image.data,
|
||||
stillId: still.id,
|
||||
stillFilename: still.filename,
|
||||
thumb: isPipelineFrameFilename(image.filename) ? undefined : image.data,
|
||||
stillId: still?.id,
|
||||
stillFilename: still?.filename,
|
||||
duration: durationSeconds,
|
||||
sound,
|
||||
draftId: (fields.draftId || '').trim() || undefined,
|
||||
@@ -285,7 +294,7 @@ type GenerateParams = {
|
||||
workflow: 'v1' | 'v2'
|
||||
duration: number
|
||||
useIdentityRefs: boolean
|
||||
referenceImages: { filename: string; data: Buffer; type?: string }[]
|
||||
referenceImages: Array<{ filename: string; data: Buffer; type?: string } | null>
|
||||
}
|
||||
|
||||
async function queueMiniMax(
|
||||
@@ -308,15 +317,16 @@ async function queueMiniMax(
|
||||
: 'Queueing MiniMax H3 job...'
|
||||
emitChainJob(job, { type: 'status', message: uploading, progress: 4 })
|
||||
const uploaded = await uploadImage(params.image, job.id)
|
||||
const referenceNames: string[] = []
|
||||
const referenceNames: string[] = ['', '', '', '']
|
||||
const chainIndex = job.library?.chainIndex || 0
|
||||
if (chainIndex === 0) {
|
||||
for (const [index, ref] of (params.referenceImages || []).entries()) {
|
||||
if (!ref?.data?.length) continue
|
||||
const next = await uploadImage({
|
||||
...ref,
|
||||
filename: `ref${index + 1}_${ref.filename || 'identity.png'}`
|
||||
}, job.id)
|
||||
referenceNames.push(next.name)
|
||||
referenceNames[index] = next.name
|
||||
}
|
||||
}
|
||||
if (job.library) {
|
||||
@@ -378,7 +388,9 @@ async function queueMiniMax(
|
||||
extendPart1Path: job.library.extendPart1Path,
|
||||
familyId: job.library.familyId,
|
||||
parentClipId: job.library.parentClipId,
|
||||
chainIndex: job.library.chainIndex
|
||||
chainIndex: job.library.chainIndex,
|
||||
stillId: job.library.stillId,
|
||||
sound: job.library.sound
|
||||
})
|
||||
}
|
||||
emitChainJob(job, { type: 'status', message: 'Job queued on ComfyUI', progress: 8 })
|
||||
@@ -430,6 +442,7 @@ async function runGeneration(
|
||||
job.library.chainStep = i + 2
|
||||
job.library.chainLabel = `Extension ${i + 1}`
|
||||
job.library.extendPart1Path = undefined
|
||||
job.library.thumb = undefined
|
||||
|
||||
emitChainJob(job, { type: 'status', message: 'Waiting 3s buffer...', progress: 1 })
|
||||
await sleep(3000)
|
||||
|
||||
+40
-12
@@ -1,23 +1,36 @@
|
||||
import { checkComfyHttp, fetchQueue } from '~/server/utils/comfyLifecycle'
|
||||
import { comfyConfigured } from '~/server/utils/comfy'
|
||||
import { imageComfyConfigured } from '~/server/utils/imageComfy'
|
||||
import { checkImageComfyHttp, fetchImageQueue } from '~/server/utils/imageComfyLifecycle'
|
||||
import { imageComfyConfigured, getBeastImageHost, getSidecarImageHost, sameImageHost } from '~/server/utils/imageComfy'
|
||||
import { checkImageComfyHttp, fetchQueueAt } from '~/server/utils/imageComfyLifecycle'
|
||||
|
||||
export default defineEventHandler(async () => {
|
||||
const videoConfigured = comfyConfigured()
|
||||
const imageConfigured = imageComfyConfigured()
|
||||
const [comfy, queue, imageComfy, imageQueue] = await Promise.all([
|
||||
const beastHost = getBeastImageHost()
|
||||
const sidecarHost = getSidecarImageHost()
|
||||
const [comfy, queue, beast, beastQueue, sidecar, sidecarQueue] = await Promise.all([
|
||||
videoConfigured
|
||||
? checkComfyHttp().catch(() => ({ ok: false, host: '', stats: null }))
|
||||
: Promise.resolve({ ok: false, host: '', stats: null }),
|
||||
videoConfigured ? fetchQueue() : Promise.resolve({ running: 0, pending: 0 }),
|
||||
imageConfigured
|
||||
? checkImageComfyHttp().catch(() => ({ ok: false, host: '', stats: null }))
|
||||
: Promise.resolve({ ok: false, host: '', stats: null }),
|
||||
imageConfigured ? fetchImageQueue() : Promise.resolve({ running: 0, pending: 0 })
|
||||
beastHost
|
||||
? checkImageComfyHttp(2500, beastHost).catch(() => ({ ok: false, host: beastHost, stats: null }))
|
||||
: Promise.resolve({ ok: false, host: beastHost, stats: null }),
|
||||
beastHost ? fetchQueueAt(beastHost) : Promise.resolve({ running: 0, pending: 0 }),
|
||||
sidecarHost && (!beastHost || !sameImageHost(beastHost, sidecarHost))
|
||||
? checkImageComfyHttp(2500, sidecarHost).catch(() => ({ ok: false, host: sidecarHost, stats: null }))
|
||||
: Promise.resolve({ ok: false, host: sidecarHost, stats: null }),
|
||||
sidecarHost && (!beastHost || !sameImageHost(beastHost, sidecarHost))
|
||||
? fetchQueueAt(sidecarHost)
|
||||
: Promise.resolve({ running: 0, pending: 0 })
|
||||
])
|
||||
const busy = Boolean(comfy.ok && queue.running > 0)
|
||||
const imageBusy = Boolean(imageComfy.ok && imageQueue.running > 0)
|
||||
const beastBusy = Boolean(beast.ok && beastQueue.running > 0)
|
||||
const sidecarBusy = Boolean(sidecar.ok && sidecarQueue.running > 0)
|
||||
const sidecarConfigured = Boolean(sidecarHost && (!beastHost || !sameImageHost(beastHost, sidecarHost)))
|
||||
const imageOk = Boolean(beast.ok || sidecar.ok)
|
||||
const imageBusy = Boolean(imageConfigured && ((beastBusy && (!sidecarConfigured || sidecarBusy || !sidecar.ok)) || (!beast.ok && sidecarBusy)))
|
||||
const preferredHost = beast.ok && !beastBusy ? beastHost : (sidecar.ok ? sidecarHost : beastHost)
|
||||
return {
|
||||
ok: true,
|
||||
service: useRuntimeConfig().public.instanceName || 'aigen',
|
||||
@@ -31,11 +44,26 @@ export default defineEventHandler(async () => {
|
||||
},
|
||||
imageComfy: {
|
||||
configured: imageConfigured,
|
||||
ok: imageComfy.ok,
|
||||
ok: imageOk,
|
||||
busy: imageBusy,
|
||||
host: imageComfy.host || '',
|
||||
queueRunning: imageQueue.running,
|
||||
queuePending: imageQueue.pending
|
||||
host: preferredHost || '',
|
||||
box: beast.ok && !beastBusy ? 'beast' : (sidecar.ok ? 'sidecar' : (beast.ok ? 'beast' : '')),
|
||||
queueRunning: (beast.ok && !beastBusy ? beastQueue.running : sidecarQueue.running) || 0,
|
||||
queuePending: (beast.ok && !beastBusy ? beastQueue.pending : sidecarQueue.pending) || 0,
|
||||
beast: {
|
||||
label: 'Beast',
|
||||
configured: Boolean(beastHost),
|
||||
ok: Boolean(beast.ok),
|
||||
busy: beastBusy,
|
||||
host: beastHost || ''
|
||||
},
|
||||
sidecar: {
|
||||
label: 'Sidecar',
|
||||
configured: sidecarConfigured,
|
||||
ok: Boolean(sidecar.ok),
|
||||
busy: sidecarBusy,
|
||||
host: sidecarHost || ''
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -10,7 +10,7 @@ export default defineEventHandler(async (event) => {
|
||||
if (jobId) deletePendingJob(jobId)
|
||||
const kind = job?.kind || (body?.kind === 'edit' ? 'edit' : 'video')
|
||||
const ok = kind === 'edit'
|
||||
? await interruptImageComfy()
|
||||
? await interruptImageComfy(job?.imageComfyHost)
|
||||
: await interruptComfy()
|
||||
if (job) {
|
||||
emitJob(job, { type: 'error', error: 'Job interrupted.', message: 'Job interrupted.' })
|
||||
|
||||
@@ -3,6 +3,8 @@ export default defineEventHandler(async (event) => {
|
||||
const id = String(getRouterParam(event, 'id') || '')
|
||||
const clip = getClip(owner, id)
|
||||
assertFolderAccess(event, clip.folderId)
|
||||
await deleteClip(owner, id)
|
||||
const family = ['1', 'true'].includes(String(getQuery(event).family || '').toLowerCase())
|
||||
if (family) await deleteClipFamily(owner, id)
|
||||
else await deleteClip(owner, id)
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { owner } = assertLibraryOwner(event)
|
||||
const id = String(getRouterParam(event, 'id') || '')
|
||||
const body = await readBody<{ folderId?: string; name?: string }>(event).catch(() => ({} as { folderId?: string; name?: string }))
|
||||
const body = await readBody<{ folderId?: string; name?: string; family?: boolean }>(event).catch(() => ({} as { folderId?: string; name?: string; family?: boolean }))
|
||||
const folderId = String(body?.folderId || '')
|
||||
const hasName = typeof body?.name === 'string'
|
||||
const family = body?.family === true
|
||||
if (!folderId && !hasName) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Choose a library folder or a new name' })
|
||||
}
|
||||
@@ -11,8 +12,12 @@ export default defineEventHandler(async (event) => {
|
||||
assertFolderAccess(event, clip.folderId)
|
||||
if (folderId) {
|
||||
assertFolderExists(event, folderId)
|
||||
await moveClip(owner, id, folderId)
|
||||
if (family) await moveClipFamily(owner, id, folderId)
|
||||
else await moveClip(owner, id, folderId)
|
||||
}
|
||||
if (hasName) {
|
||||
if (family) await renameClipFamily(owner, id, body.name || '')
|
||||
else await renameClip(owner, id, body.name || '')
|
||||
}
|
||||
if (hasName) await renameClip(owner, id, body.name || '')
|
||||
return publicLibrary(event)
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ export default defineEventHandler(async (event) => {
|
||||
const folderId = library.folders.find(folder => folder.unlocked)?.id
|
||||
if (folderId) await importMissingComfyVideos(owner, folderId)
|
||||
await ensureClipThumbnails(owner)
|
||||
await sweepOwnerExtensionStills(owner)
|
||||
} catch {
|
||||
return publicLibrary(event)
|
||||
}
|
||||
|
||||
@@ -33,5 +33,5 @@ export default defineEventHandler(async (event) => {
|
||||
height: Number(fields.height || 0),
|
||||
hideInput: fields.hideInput === 'true'
|
||||
})
|
||||
return { still, ...publicLibrary(event) }
|
||||
return { still: still || undefined, ...publicLibrary(event) }
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user