Ship MiniMax v2 identity refs and Qwen sidecar edits.
Keep v1 as the default video graph, relay image edits to the Qwen Lightning box, and keep /api/health up if one Comfy host is unset. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
import { imageComfyConfigured, uploadImageEdit, queueImagePrompt, waitForImageEdit, downloadEditedImage } from '~/server/utils/imageComfy'
|
||||
import { ensureImageComfyReady } from '~/server/utils/imageComfyLifecycle'
|
||||
import { buildEditWorkflow } from '~/server/utils/imageWorkflow'
|
||||
import { imageDimensions } from '~/server/utils/resolution'
|
||||
|
||||
function parseSteps(raw: unknown) {
|
||||
const value = Number(raw)
|
||||
if (!Number.isFinite(value)) return 4
|
||||
return Math.min(20, Math.max(1, Math.round(value)))
|
||||
}
|
||||
|
||||
function parseCfg(raw: unknown) {
|
||||
const value = Number(raw)
|
||||
if (!Number.isFinite(value)) return 1
|
||||
return Math.min(8, Math.max(0.5, Math.round(value * 10) / 10))
|
||||
}
|
||||
|
||||
function parseDenoise(raw: unknown) {
|
||||
const value = Number(raw)
|
||||
if (!Number.isFinite(value)) return 0.75
|
||||
return Math.min(1, Math.max(0.05, Math.round(value * 100) / 100))
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const form = await readMultipartFormData(event).catch(() => null)
|
||||
const fields: Record<string, string> = {}
|
||||
let image: { filename: string; data: Buffer; type?: string } | null = null
|
||||
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 && part.data) {
|
||||
fields[part.name] = part.data.toString('utf8')
|
||||
}
|
||||
}
|
||||
|
||||
const prompt = (fields.prompt || '').trim()
|
||||
if (!image) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'An input image is required' })
|
||||
}
|
||||
if (!prompt) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'An edit prompt is required' })
|
||||
}
|
||||
if (!imageComfyConfigured()) {
|
||||
throw createError({
|
||||
statusCode: 503,
|
||||
statusMessage: 'Image edit ComfyUI is not configured yet. Set IMAGE_COMFY_HOST on this instance.'
|
||||
})
|
||||
}
|
||||
|
||||
const ownerKey = libraryOwnerKey(event)
|
||||
const library = publicLibrary(event)
|
||||
const folderId = library.folders.some(folder => folder.id === fields.folderId)
|
||||
? fields.folderId
|
||||
: library.folders[0]?.id
|
||||
if (!folderId) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Create a library folder before editing' })
|
||||
}
|
||||
assertFolderExists(event, folderId)
|
||||
|
||||
const destFolder = library.folders.find(folder => folder.id === folderId)
|
||||
const folderLocked = Boolean(destFolder?.protected && !destFolder.unlocked)
|
||||
const hideInput = fields.hideInput === 'true'
|
||||
const steps = parseSteps(fields.steps)
|
||||
const cfg = parseCfg(fields.cfg)
|
||||
const denoise = parseDenoise(fields.denoise)
|
||||
const seed = fields.seed && fields.seed !== 'random'
|
||||
? Number(fields.seed)
|
||||
: Math.floor(Math.random() * 2_147_483_647)
|
||||
const size = imageDimensions(image.data)
|
||||
|
||||
const job = createJob()
|
||||
job.kind = 'edit'
|
||||
job.maxStep = steps
|
||||
job.hideThumbnail = hideInput
|
||||
job.library = {
|
||||
ownerKey,
|
||||
folderId,
|
||||
hideThumbnail: hideInput,
|
||||
hideInput,
|
||||
folderLocked,
|
||||
name: (fields.name || '').trim().slice(0, 80),
|
||||
prompt,
|
||||
aspect: 'auto',
|
||||
width: size?.width || 0,
|
||||
height: size?.height || 0,
|
||||
steps,
|
||||
turbo: true,
|
||||
seed,
|
||||
cfg
|
||||
}
|
||||
emitJob(job, { type: 'status', message: 'Checking sidecar ComfyUI...', progress: 2 })
|
||||
|
||||
void runEdit(job, {
|
||||
image,
|
||||
prompt,
|
||||
negative: (fields.negative || '').trim(),
|
||||
steps,
|
||||
seed,
|
||||
cfg,
|
||||
denoise
|
||||
}).catch(async (error) => {
|
||||
if (job.status === 'error' || job.status === 'cancelled') return
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
job.status = 'error'
|
||||
job.error = message
|
||||
emitJob(job, { type: 'error', error: message, message })
|
||||
})
|
||||
|
||||
return {
|
||||
jobId: job.id,
|
||||
seed,
|
||||
steps,
|
||||
hideThumbnail: hideInput,
|
||||
folderLocked
|
||||
}
|
||||
})
|
||||
|
||||
async function runEdit(
|
||||
job: ReturnType<typeof createJob>,
|
||||
params: {
|
||||
image: { filename: string; data: Buffer; type?: string }
|
||||
prompt: string
|
||||
negative: string
|
||||
steps: number
|
||||
seed: number
|
||||
cfg: number
|
||||
denoise: number
|
||||
}
|
||||
) {
|
||||
await ensureImageComfyReady((status) => {
|
||||
emitJob(job, {
|
||||
type: status.state === 'busy' ? 'busy' : 'status',
|
||||
message: status.message,
|
||||
progress: status.state === 'online' ? Math.max(job.progress, 6) : Math.max(job.progress, 3),
|
||||
busy: status.state === 'busy'
|
||||
})
|
||||
})
|
||||
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.')
|
||||
|
||||
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 })
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
@@ -100,7 +100,8 @@ export default defineEventHandler(async (event) => {
|
||||
extendSourceClipId: source.id,
|
||||
familyId: source.familyId || crypto.randomUUID(),
|
||||
parentClipId: source.id,
|
||||
chainIndex: (source.chainIndex || 0) + 1
|
||||
chainIndex: (source.chainIndex || 0) + 1,
|
||||
workflow: parseVideoWorkflow(source.workflow)
|
||||
}
|
||||
|
||||
emitJob(job, { type: 'status', message: 'Extracting last frame & stitching extension...', progress: 2 })
|
||||
@@ -163,7 +164,6 @@ async function runExtend(
|
||||
copyFileSync(params.sourcePath, params.part1Path)
|
||||
await extractLastFrame(params.part1Path, params.framePath)
|
||||
const frame = readFileSync(params.framePath)
|
||||
job.library!.thumb = frame
|
||||
const sound = await probeHasAudio(params.part1Path)
|
||||
|
||||
await ensureComfyReady((status) => {
|
||||
@@ -202,7 +202,9 @@ async function runExtend(
|
||||
samplerName: params.samplerName,
|
||||
scheduler: params.scheduler,
|
||||
filenamePrefix: comfyFilenamePrefix(),
|
||||
sound
|
||||
sound,
|
||||
workflow: job.library?.workflow,
|
||||
duration: job.library?.duration
|
||||
})
|
||||
|
||||
const queued = await queuePrompt(graph, job.clientId)
|
||||
|
||||
@@ -83,9 +83,14 @@ 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 }[] = []
|
||||
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' && part.filename && part.data?.length) {
|
||||
if (referenceImages.length < 4) {
|
||||
referenceImages.push({ filename: part.filename, data: part.data, type: part.type })
|
||||
}
|
||||
} else if (part.name && part.data) {
|
||||
fields[part.name] = part.data.toString('utf8')
|
||||
}
|
||||
@@ -99,6 +104,8 @@ export default defineEventHandler(async (event) => {
|
||||
throw createError({ statusCode: 400, statusMessage: 'A motion prompt is required' })
|
||||
}
|
||||
const extensions = parseExtensions(fields.extensions)
|
||||
const workflow = parseVideoWorkflow(fields.workflow)
|
||||
const useIdentityRefs = workflow === 'v2' && fields.useIdentityRefs === 'true' && referenceImages.length > 0
|
||||
|
||||
const { width, height } = resolveOutputSize(fields.aspect, image.data, Number(fields.width), Number(fields.height))
|
||||
const turbo = fields.turbo !== 'false'
|
||||
@@ -170,7 +177,8 @@ export default defineEventHandler(async (event) => {
|
||||
chainStep: 1,
|
||||
chainTotal,
|
||||
chainLabel: extensions.length ? 'Initial' : undefined,
|
||||
familyId: crypto.randomUUID()
|
||||
familyId: crypto.randomUUID(),
|
||||
workflow
|
||||
}
|
||||
emitChainJob(job, { type: 'status', message: 'Checking ComfyUI...', progress: 1 })
|
||||
|
||||
@@ -188,7 +196,11 @@ export default defineEventHandler(async (event) => {
|
||||
fps,
|
||||
samplerName,
|
||||
scheduler,
|
||||
extensions
|
||||
extensions,
|
||||
workflow,
|
||||
duration: durationSeconds,
|
||||
useIdentityRefs,
|
||||
referenceImages
|
||||
}).catch(async (error) => {
|
||||
removeExtendTemp(job.library?.extendTmpDir)
|
||||
if (isComfyBusyTimeout(error) && job.library) {
|
||||
@@ -215,7 +227,8 @@ export default defineEventHandler(async (event) => {
|
||||
fps: job.library.fps,
|
||||
samplerName: job.library.samplerName,
|
||||
scheduler: job.library.scheduler,
|
||||
extensions: job.library.extensions
|
||||
extensions: job.library.extensions,
|
||||
workflow: job.library.workflow
|
||||
})
|
||||
job.library.draftId = draft.id
|
||||
job.status = 'deferred'
|
||||
@@ -268,6 +281,10 @@ type GenerateParams = {
|
||||
samplerName: string
|
||||
scheduler: string
|
||||
extensions: { prompt: string; duration: number }[]
|
||||
workflow: 'v1' | 'v2'
|
||||
duration: number
|
||||
useIdentityRefs: boolean
|
||||
referenceImages: { filename: string; data: Buffer; type?: string }[]
|
||||
}
|
||||
|
||||
async function queueMiniMax(
|
||||
@@ -290,6 +307,17 @@ 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 chainIndex = job.library?.chainIndex || 0
|
||||
if (chainIndex === 0) {
|
||||
for (const [index, ref] of (params.referenceImages || []).entries()) {
|
||||
const next = await uploadImage({
|
||||
...ref,
|
||||
filename: `ref${index + 1}_${ref.filename || 'identity.png'}`
|
||||
}, job.id)
|
||||
referenceNames.push(next.name)
|
||||
}
|
||||
}
|
||||
if (job.library) {
|
||||
job.library.imageName = uploaded.name
|
||||
job.library.imageSubfolder = uploaded.subfolder
|
||||
@@ -311,7 +339,11 @@ async function queueMiniMax(
|
||||
samplerName: params.samplerName,
|
||||
scheduler: params.scheduler,
|
||||
filenamePrefix: comfyFilenamePrefix(),
|
||||
sound: params.sound
|
||||
sound: params.sound,
|
||||
workflow: params.workflow,
|
||||
duration: params.duration,
|
||||
useIdentityRefs: chainIndex === 0 && params.useIdentityRefs,
|
||||
referenceImageNames: chainIndex === 0 ? referenceNames : []
|
||||
})
|
||||
|
||||
const queued = await queuePrompt(graph, job.clientId)
|
||||
@@ -432,7 +464,11 @@ async function runGeneration(
|
||||
fps: params.fps,
|
||||
samplerName: params.samplerName,
|
||||
scheduler: params.scheduler,
|
||||
persist: isLast
|
||||
persist: isLast,
|
||||
workflow: params.workflow,
|
||||
duration: ext.duration,
|
||||
useIdentityRefs: false,
|
||||
referenceImages: []
|
||||
})
|
||||
|
||||
if (!isLast) {
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
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'
|
||||
|
||||
export default defineEventHandler(async () => {
|
||||
const [comfy, queue] = await Promise.all([
|
||||
checkComfyHttp().catch(() => ({ ok: false, host: '', stats: null })),
|
||||
fetchQueue()
|
||||
const videoConfigured = comfyConfigured()
|
||||
const imageConfigured = imageComfyConfigured()
|
||||
const [comfy, queue, imageComfy, imageQueue] = 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 })
|
||||
])
|
||||
const busy = Boolean(comfy.ok && queue.running > 0)
|
||||
const imageBusy = Boolean(imageComfy.ok && imageQueue.running > 0)
|
||||
return {
|
||||
ok: true,
|
||||
service: useRuntimeConfig().public.instanceName || 'aigen',
|
||||
@@ -16,6 +28,14 @@ export default defineEventHandler(async () => {
|
||||
queueRunning: queue.running,
|
||||
queuePending: queue.pending,
|
||||
stats: comfy.ok ? comfy.stats : undefined
|
||||
},
|
||||
imageComfy: {
|
||||
configured: imageConfigured,
|
||||
ok: imageComfy.ok,
|
||||
busy: imageBusy,
|
||||
host: imageComfy.host || '',
|
||||
queueRunning: imageQueue.running,
|
||||
queuePending: imageQueue.pending
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -8,7 +8,9 @@ export default defineEventHandler(async (event) => {
|
||||
removeExtendTemp(job.library?.extendTmpDir)
|
||||
}
|
||||
if (jobId) deletePendingJob(jobId)
|
||||
const ok = await interruptComfy()
|
||||
const ok = job?.kind === 'edit'
|
||||
? await interruptImageComfy()
|
||||
: await interruptComfy()
|
||||
if (job) {
|
||||
emitJob(job, { type: 'error', error: 'Job interrupted.', message: 'Job interrupted.' })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user