Run image edit on Beast with Flux.2 Klein and stop routing jobs to Sidecar.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-08-27 09:06:50 -05:00
co-authored by Cursor
parent 6fec10febd
commit 13ecec949c
8 changed files with 899 additions and 212 deletions
+69 -33
View File
@@ -1,12 +1,13 @@
import { imageComfyConfigured, uploadImageEdit, queueImagePrompt, waitForImageEdit, downloadEditedImage, purgeImageComfyArtifacts, withImageComfyHost } from '~/server/utils/imageComfy'
import { pickImageEditHost } from '~/server/utils/imageComfyLifecycle'
import { ensureComfyReady } from '~/server/utils/comfyLifecycle'
import { getComfyHost, comfyConfigured, uploadImage, queuePrompt, purgeComfyArtifacts } from '~/server/utils/comfy'
import { withImageComfyHost, waitForImageEdit, downloadEditedImage } from '~/server/utils/imageComfy'
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)))
if (!Number.isFinite(value)) return 20
return Math.min(40, Math.max(1, Math.round(value)))
}
function parseCfg(raw: unknown) {
@@ -15,19 +16,16 @@ function parseCfg(raw: unknown) {
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
let reference: { 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 === 'image2' || part.name === 'refImage') && part.filename && part.data?.length) {
reference = { filename: part.filename, data: part.data, type: part.type }
} else if (part.name && part.data) {
fields[part.name] = part.data.toString('utf8')
}
@@ -40,10 +38,10 @@ export default defineEventHandler(async (event) => {
if (!prompt) {
throw createError({ statusCode: 400, statusMessage: 'An edit prompt is required' })
}
if (!imageComfyConfigured()) {
if (!comfyConfigured()) {
throw createError({
statusCode: 503,
statusMessage: 'Image edit ComfyUI is not configured yet. Set COMFY_HOST for Beast and IMAGE_COMFY_HOST for Sidecar overflow.'
statusMessage: 'Beast ComfyUI is not configured. Set COMFY_HOST.'
})
}
@@ -62,12 +60,32 @@ export default defineEventHandler(async (event) => {
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 still = isPipelineFrameFilename(image.filename)
? null
: await saveStill({
ownerKey,
folderId,
filename: image.filename,
data: image.data,
width: size?.width || 0,
height: size?.height || 0,
hideInput
})
if (reference && !isPipelineFrameFilename(reference.filename)) {
await saveStill({
ownerKey,
folderId,
filename: reference.filename,
data: reference.data,
hideInput
})
}
const job = createJob()
job.kind = 'edit'
job.maxStep = steps
@@ -86,18 +104,19 @@ export default defineEventHandler(async (event) => {
steps,
turbo: true,
seed,
cfg
cfg,
stillId: still?.id
}
emitJob(job, { type: 'status', message: 'Choosing Beast or Sidecar...', progress: 2 })
emitJob(job, { type: 'status', message: 'Checking Beast ComfyUI...', progress: 2 })
void runEdit(job, {
image,
reference,
prompt,
negative: (fields.negative || '').trim(),
steps,
seed,
cfg,
denoise
cfg
}).catch(async (error) => {
if (job.status === 'error' || job.status === 'cancelled') return
const message = error instanceof Error ? error.message : String(error)
@@ -111,7 +130,8 @@ export default defineEventHandler(async (event) => {
seed,
steps,
hideThumbnail: hideInput,
folderLocked
folderLocked,
dual: Boolean(reference)
}
})
@@ -119,15 +139,15 @@ async function runEdit(
job: ReturnType<typeof createJob>,
params: {
image: { filename: string; data: Buffer; type?: string }
reference: { filename: string; data: Buffer; type?: string } | null
prompt: string
negative: string
steps: number
seed: number
cfg: number
denoise: number
}
) {
const target = await pickImageEditHost((status) => {
await ensureComfyReady((status) => {
emitJob(job, {
type: status.state === 'busy' ? 'busy' : 'status',
message: status.message,
@@ -135,41 +155,56 @@ async function runEdit(
busy: status.state === 'busy'
})
})
job.imageComfyHost = target.host
if (job.status === 'cancelled') throw new Error('Job interrupted.')
job.imageComfyHost = getComfyHost()
await withImageComfyHost(target.host, async () => {
await withImageComfyHost(job.imageComfyHost, async () => {
const dual = Boolean(params.reference)
emitJob(job, {
type: 'status',
message: `Uploading still to ${target.label}...`,
message: dual ? 'Uploading both stills to Beast...' : 'Uploading still to Beast...',
progress: 8
})
const uploaded = await uploadImageEdit(params.image, job.id)
const uploaded = await uploadImage(params.image, job.id)
const uploadedRef = params.reference
? await uploadImage({
...params.reference,
filename: `ref_${params.reference.filename || 'image2.png'}`
}, job.id)
: null
if (job.status === 'cancelled') throw new Error('Job interrupted.')
emitJob(job, {
type: 'status',
message: `Queueing Qwen Image Edit on ${target.label}...`,
message: dual
? 'Queueing Flux.2 Klein two-image edit on Beast...'
: 'Queueing Flux.2 Klein edit on Beast...',
progress: 12
})
const graph = buildEditWorkflow({
prompt: params.prompt,
negative: params.negative,
imageName: uploaded.name,
referenceImageName: uploadedRef?.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)
const queued = await queuePrompt(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 })
emitJob(job, {
type: 'status',
message: dual ? 'Applying image 2 onto image 1...' : 'Editing still on Beast...',
progress: 18,
maxStep: params.steps
})
const output = await waitForImageEdit({
promptId: queued.prompt_id,
clientId: job.clientId,
timeoutMs: 10 * 60 * 1000,
onProgress: (event) => {
emitJob(job, {
type: 'status',
@@ -196,16 +231,17 @@ async function runEdit(
hideInput: job.library?.hideInput
})
job.stillId = still?.id
await purgeImageComfyArtifacts({
output,
inputName: uploaded.name,
inputSubfolder: uploaded.subfolder,
await purgeComfyArtifacts({
video: { filename: output.filename, subfolder: output.subfolder, type: output.type },
imageName: uploaded.name,
imageSubfolder: uploaded.subfolder,
extraImageNames: uploadedRef?.name ? [uploadedRef.name] : [],
promptId: job.promptId
})
job.status = 'complete'
emitJob(job, {
type: 'complete',
message: `Edit finished on ${target.label}`,
message: 'Edit finished on Beast',
progress: 100,
stillId: still?.id,
filename: output.filename,