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,
+17 -26
View File
@@ -1,14 +1,13 @@
import { checkComfyHttp, fetchQueue } from '~/server/utils/comfyLifecycle'
import { comfyConfigured } from '~/server/utils/comfy'
import { imageComfyConfigured, getBeastImageHost, getSidecarImageHost, sameImageHost } from '~/server/utils/imageComfy'
import { imageComfyConfigured, getBeastImageHost } from '~/server/utils/imageComfy'
import { checkImageComfyHttp, fetchQueueAt } from '~/server/utils/imageComfyLifecycle'
export default defineEventHandler(async () => {
const videoConfigured = comfyConfigured()
const imageConfigured = imageComfyConfigured()
const beastHost = getBeastImageHost()
const sidecarHost = getSidecarImageHost()
const [comfy, queue, beast, beastQueue, sidecar, sidecarQueue] = await Promise.all([
const [comfy, queue, beast, beastQueue] = await Promise.all([
videoConfigured
? checkComfyHttp().catch(() => ({ ok: false, host: '', stats: null }))
: Promise.resolve({ ok: false, host: '', stats: null }),
@@ -16,21 +15,13 @@ export default defineEventHandler(async () => {
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 })
beastHost ? fetchQueueAt(beastHost) : Promise.resolve({ running: 0, pending: 0 })
])
const busy = Boolean(comfy.ok && queue.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)
const imageOk = Boolean(beast.ok || comfy.ok)
const imageBusy = Boolean(imageConfigured && (beastBusy || busy))
const preferredHost = beastHost || comfy.host || ''
return {
ok: true,
service: useRuntimeConfig().public.instanceName || 'aigen',
@@ -46,23 +37,23 @@ export default defineEventHandler(async () => {
configured: imageConfigured,
ok: imageOk,
busy: imageBusy,
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,
host: preferredHost,
box: imageOk ? 'beast' : '',
queueRunning: (beastQueue.running || queue.running) || 0,
queuePending: (beastQueue.pending || queue.pending) || 0,
beast: {
label: 'Beast',
configured: Boolean(beastHost),
ok: Boolean(beast.ok),
busy: beastBusy,
host: beastHost || ''
ok: Boolean(beast.ok || comfy.ok),
busy: beastBusy || busy,
host: preferredHost
},
sidecar: {
label: 'Sidecar',
configured: sidecarConfigured,
ok: Boolean(sidecar.ok),
busy: sidecarBusy,
host: sidecarHost || ''
configured: false,
ok: false,
busy: false,
host: ''
}
}
}
+1 -4
View File
@@ -8,10 +8,7 @@ export default defineEventHandler(async (event) => {
removeExtendTemp(job.library?.extendTmpDir)
}
if (jobId) deletePendingJob(jobId)
const kind = job?.kind || (body?.kind === 'edit' ? 'edit' : 'video')
const ok = kind === 'edit'
? await interruptImageComfy(job?.imageComfyHost)
: await interruptComfy()
const ok = await interruptComfy()
if (job) {
emitJob(job, { type: 'error', error: 'Job interrupted.', message: 'Job interrupted.' })
}
@@ -0,0 +1,613 @@
{
"9": {
"inputs": {
"filename_prefix": "Flux2-Klein-4b-base",
"images": [
"75:65",
0
]
},
"class_type": "SaveImage",
"_meta": {
"title": "Save Image"
}
},
"76": {
"inputs": {
"image": ""
},
"class_type": "LoadImage",
"_meta": {
"title": "Load Image"
}
},
"81": {
"inputs": {
"image": ""
},
"class_type": "LoadImage",
"_meta": {
"title": "Load Image"
}
},
"94": {
"inputs": {
"filename_prefix": "Flux2-Klein-4b-base",
"images": [
"92:104",
0
]
},
"class_type": "SaveImage",
"_meta": {
"title": "Save Image"
}
},
"75:61": {
"inputs": {
"sampler_name": "euler"
},
"class_type": "KSamplerSelect",
"_meta": {
"title": "KSamplerSelect"
}
},
"75:62": {
"inputs": {
"steps": 20,
"width": [
"75:100",
0
],
"height": [
"75:100",
1
]
},
"class_type": "Flux2Scheduler",
"_meta": {
"title": "Flux2Scheduler"
}
},
"75:63": {
"inputs": {
"cfg": 1,
"model": [
"75:131",
0
],
"positive": [
"75:124",
0
],
"negative": [
"75:122",
0
]
},
"class_type": "CFGGuider",
"_meta": {
"title": "CFG Guider"
}
},
"75:64": {
"inputs": {
"noise": [
"75:73",
0
],
"guider": [
"75:63",
0
],
"sampler": [
"75:61",
0
],
"sigmas": [
"75:62",
0
],
"latent_image": [
"75:66",
0
]
},
"class_type": "SamplerCustomAdvanced",
"_meta": {
"title": "SamplerCustomAdvanced"
}
},
"75:65": {
"inputs": {
"samples": [
"75:64",
0
],
"vae": [
"75:72",
0
]
},
"class_type": "VAEDecode",
"_meta": {
"title": "VAE Decode"
}
},
"75:73": {
"inputs": {
"noise_seed": 202848876233972
},
"class_type": "RandomNoise",
"_meta": {
"title": "RandomNoise"
}
},
"75:70": {
"inputs": {
"unet_name": "flux-2-klein-base-9b-fp8.safetensors",
"weight_dtype": "default"
},
"class_type": "UNETLoader",
"_meta": {
"title": "Load Diffusion Model"
}
},
"75:71": {
"inputs": {
"clip_name": "qwen_3_8b_fp8mixed.safetensors",
"type": "flux2",
"device": "default"
},
"class_type": "CLIPLoader",
"_meta": {
"title": "Load CLIP"
}
},
"75:74": {
"inputs": {
"text": "Placeholder prompt \n",
"clip": [
"75:131",
1
]
},
"class_type": "CLIPTextEncode",
"_meta": {
"title": "CLIP Text Encode (Positive Prompt)"
}
},
"75:67": {
"inputs": {
"text": "",
"clip": [
"75:71",
0
]
},
"class_type": "CLIPTextEncode",
"_meta": {
"title": "CLIP Text Encode (Negative Prompt)"
}
},
"75:72": {
"inputs": {
"vae_name": "full_encoder_small_decoder.safetensors"
},
"class_type": "VAELoader",
"_meta": {
"title": "Load VAE"
}
},
"75:66": {
"inputs": {
"width": [
"75:100",
0
],
"height": [
"75:100",
1
],
"batch_size": 1
},
"class_type": "EmptyFlux2LatentImage",
"_meta": {
"title": "Empty Flux 2 Latent"
}
},
"75:100": {
"inputs": {
"image": [
"76",
0
]
},
"class_type": "GetImageSize",
"_meta": {
"title": "Get Image Size"
}
},
"75:122": {
"inputs": {
"conditioning": [
"75:67",
0
],
"latent": [
"75:123",
0
]
},
"class_type": "ReferenceLatent",
"_meta": {
"title": "Set Reference Latent"
}
},
"75:123": {
"inputs": {
"pixels": [
"76",
0
],
"vae": [
"75:72",
0
]
},
"class_type": "VAEEncode",
"_meta": {
"title": "VAE Encode"
}
},
"75:124": {
"inputs": {
"conditioning": [
"75:74",
0
],
"latent": [
"75:123",
0
]
},
"class_type": "ReferenceLatent",
"_meta": {
"title": "Set Reference Latent"
}
},
"75:131": {
"inputs": {
"lora_name": "klein_snofs_v1_4.safetensors",
"strength_model": 1,
"strength_clip": 1,
"model": [
"75:70",
0
],
"clip": [
"75:71",
0
]
},
"class_type": "LoraLoader",
"_meta": {
"title": "Load LoRA (Model and CLIP)"
}
},
"92:102": {
"inputs": {
"sampler_name": "euler"
},
"class_type": "KSamplerSelect",
"_meta": {
"title": "KSamplerSelect"
}
},
"92:103": {
"inputs": {
"noise": [
"92:105",
0
],
"guider": [
"92:114",
0
],
"sampler": [
"92:102",
0
],
"sigmas": [
"92:115",
0
],
"latent_image": [
"92:109",
0
]
},
"class_type": "SamplerCustomAdvanced",
"_meta": {
"title": "SamplerCustomAdvanced"
}
},
"92:104": {
"inputs": {
"samples": [
"92:103",
0
],
"vae": [
"92:107",
0
]
},
"class_type": "VAEDecode",
"_meta": {
"title": "VAE Decode"
}
},
"92:105": {
"inputs": {
"noise_seed": 10865292823982
},
"class_type": "RandomNoise",
"_meta": {
"title": "RandomNoise"
}
},
"92:106": {
"inputs": {
"unet_name": "flux-2-klein-base-9b-fp8.safetensors",
"weight_dtype": "default"
},
"class_type": "UNETLoader",
"_meta": {
"title": "Load Diffusion Model"
}
},
"92:107": {
"inputs": {
"vae_name": "full_encoder_small_decoder.safetensors"
},
"class_type": "VAELoader",
"_meta": {
"title": "Load VAE"
}
},
"92:108": {
"inputs": {
"image": [
"76",
0
]
},
"class_type": "GetImageSize",
"_meta": {
"title": "Get Image Size"
}
},
"92:109": {
"inputs": {
"width": [
"92:108",
0
],
"height": [
"92:108",
1
],
"batch_size": 1
},
"class_type": "EmptyFlux2LatentImage",
"_meta": {
"title": "Empty Flux 2 Latent"
}
},
"92:85": {
"inputs": {
"upscale_method": "lanczos",
"megapixels": 1,
"resolution_steps": 1,
"image": [
"81",
0
]
},
"class_type": "ImageScaleToTotalPixels",
"_meta": {
"title": "Scale Image to Total Pixels"
}
},
"92:111": {
"inputs": {
"clip_name": "qwen_3_8b_fp8mixed.safetensors",
"type": "flux2",
"device": "default"
},
"class_type": "CLIPLoader",
"_meta": {
"title": "Load CLIP"
}
},
"92:113": {
"inputs": {
"text": "Placeholder prompt multi imagee ",
"clip": [
"92:132",
1
]
},
"class_type": "CLIPTextEncode",
"_meta": {
"title": "CLIP Text Encode (Positive Prompt)"
}
},
"92:87": {
"inputs": {
"text": "",
"clip": [
"92:111",
0
]
},
"class_type": "CLIPTextEncode",
"_meta": {
"title": "CLIP Text Encode ( Negative Prompt)"
}
},
"92:114": {
"inputs": {
"cfg": 1,
"model": [
"92:132",
0
],
"positive": [
"92:130",
0
],
"negative": [
"92:128",
0
]
},
"class_type": "CFGGuider",
"_meta": {
"title": "CFG Guider"
}
},
"92:115": {
"inputs": {
"steps": 20,
"width": [
"92:108",
0
],
"height": [
"92:108",
1
]
},
"class_type": "Flux2Scheduler",
"_meta": {
"title": "Flux2Scheduler"
}
},
"92:125": {
"inputs": {
"conditioning": [
"92:87",
0
],
"latent": [
"92:126",
0
]
},
"class_type": "ReferenceLatent",
"_meta": {
"title": "Set Reference Latent"
}
},
"92:126": {
"inputs": {
"pixels": [
"76",
0
],
"vae": [
"92:107",
0
]
},
"class_type": "VAEEncode",
"_meta": {
"title": "VAE Encode"
}
},
"92:127": {
"inputs": {
"conditioning": [
"92:113",
0
],
"latent": [
"92:126",
0
]
},
"class_type": "ReferenceLatent",
"_meta": {
"title": "Set Reference Latent"
}
},
"92:128": {
"inputs": {
"conditioning": [
"92:125",
0
],
"latent": [
"92:129",
0
]
},
"class_type": "ReferenceLatent",
"_meta": {
"title": "Set Reference Latent"
}
},
"92:129": {
"inputs": {
"pixels": [
"92:85",
0
],
"vae": [
"92:107",
0
]
},
"class_type": "VAEEncode",
"_meta": {
"title": "VAE Encode"
}
},
"92:130": {
"inputs": {
"conditioning": [
"92:127",
0
],
"latent": [
"92:129",
0
]
},
"class_type": "ReferenceLatent",
"_meta": {
"title": "Set Reference Latent"
}
},
"92:132": {
"inputs": {
"lora_name": "klein_snofs_v1_4.safetensors",
"strength_model": 1,
"strength_clip": 1,
"model": [
"92:106",
0
],
"clip": [
"92:111",
0
]
},
"class_type": "LoraLoader",
"_meta": {
"title": "Load LoRA (Model and CLIP)"
}
}
}
+6 -6
View File
@@ -55,7 +55,7 @@ export function getImageComfyHost() {
}
export function imageComfyConfigured() {
return Boolean(getBeastImageHost() || getSidecarImageHost())
return Boolean(getBeastImageHost())
}
export function sameImageHost(a: string, b: string) {
@@ -246,17 +246,17 @@ export async function waitForImageEdit(opts: {
const max = Math.max(1, Number(payload.data.max || 4))
const value = Number(payload.data.value || 0)
opts.onProgress?.({
message: 'Sampling edit...',
message: 'Sampling Flux.2 Klein...',
progress: Math.min(90, 20 + Math.round((value / max) * 60)),
step: value,
maxStep: max,
node: payload.data.node || '8'
node: payload.data.node || null
})
}
if (payload.type === 'executing' && payload.data?.node) {
opts.onProgress?.({
message: payload.data.node === '8' ? 'Sampling edit...' : 'Running Qwen Edit...',
progress: payload.data.node === '10' ? 92 : 30,
message: 'Running Flux.2 Klein...',
progress: payload.data.node === '9' || payload.data.node === '94' ? 92 : 30,
node: payload.data.node
})
}
@@ -291,7 +291,7 @@ export async function waitForImageEdit(opts: {
if (result) return result
throw createError({
statusCode: 502,
statusMessage: lastError || 'Image edit timed out waiting for Qwen Edit to finish'
statusMessage: lastError || 'Image edit timed out waiting for Flux.2 Klein to finish'
})
}
+27 -54
View File
@@ -1,4 +1,4 @@
import { getImageComfyHost, setImageComfyHostOverride, imageComfyConfigured, getBeastImageHost, getSidecarImageHost, sameImageHost, withImageComfyHost, type ImageEditBox } from '~/server/utils/imageComfy'
import { getImageComfyHost, setImageComfyHostOverride, imageComfyConfigured, getBeastImageHost, withImageComfyHost, type ImageEditBox } from '~/server/utils/imageComfy'
export type ImageComfyState = 'online' | 'booting' | 'busy' | 'starting' | 'offline'
@@ -231,67 +231,40 @@ export function ensureImageComfyReady(onStatus: StatusFn = () => undefined) {
export async function pickImageEditHost(onStatus: StatusFn = () => undefined): Promise<ImageEditTarget> {
const beast = getBeastImageHost()
const sidecar = getSidecarImageHost()
if (!beast && !sidecar) {
if (!beast) {
throw createError({
statusCode: 503,
statusMessage: 'No image ComfyUI host is configured. Set COMFY_HOST (Beast) and/or IMAGE_COMFY_HOST (Sidecar).'
statusMessage: 'Beast ComfyUI is not configured. Set COMFY_HOST.'
})
}
if (beast) {
const health = await checkImageComfyHttp(2500, beast)
if (health.ok) {
const queue = await fetchQueueAt(beast)
if (queue.running === 0) {
onStatus({
state: 'online',
message: 'Beast is free. Editing on this machine.',
host: beast,
box: 'beast',
queueRunning: 0,
queuePending: queue.pending
})
return { host: beast, box: 'beast', label: 'Beast' }
}
const canOverflow = Boolean(sidecar && !sameImageHost(beast, sidecar))
onStatus({
state: 'busy',
message: canOverflow
? 'Beast is busy. Rolling over to Sidecar...'
: `Beast is busy (${queue.running} running). Waiting for a free slot...`,
host: beast,
box: 'beast',
queueRunning: queue.running,
queuePending: queue.pending
})
if (!canOverflow) {
await withImageComfyHost(beast, () => waitWhileBusy(onStatus))
return { host: beast, box: 'beast', label: 'Beast' }
}
} else if (sidecar && !sameImageHost(beast, sidecar)) {
onStatus({
state: 'offline',
message: 'Beast is offline. Trying Sidecar...',
host: beast,
box: 'beast'
})
}
const health = await checkImageComfyHttp(2500, beast)
if (!health.ok) {
throw createError({
statusCode: 503,
statusMessage: 'Beast is offline. Image edit runs on the 5080 only.'
})
}
if (sidecar && (!beast || !sameImageHost(beast, sidecar))) {
const queue = await fetchQueueAt(beast)
if (queue.running === 0) {
onStatus({
state: 'starting',
message: 'Checking Sidecar for this edit...',
host: sidecar,
box: 'sidecar'
state: 'online',
message: 'Beast is free. Editing on this machine.',
host: beast,
box: 'beast',
queueRunning: 0,
queuePending: queue.pending
})
await withImageComfyHost(sidecar, () => ensureImageComfyReady(onStatus))
return { host: sidecar, box: 'sidecar', label: 'Sidecar' }
return { host: beast, box: 'beast', label: 'Beast' }
}
throw createError({
statusCode: 503,
statusMessage: 'Beast is unavailable and Sidecar is not configured.'
onStatus({
state: 'busy',
message: `Beast is busy (${queue.running} running). Waiting for a free slot...`,
host: beast,
box: 'beast',
queueRunning: queue.running,
queuePending: queue.pending
})
await withImageComfyHost(beast, () => waitWhileBusy(onStatus))
return { host: beast, box: 'beast', label: 'Beast' }
}
+75 -37
View File
@@ -1,61 +1,99 @@
import template from '../assets/workflow_qwen_edit.json'
import template from '../assets/workflow_flux2_klein_edit.json'
export const DEFAULT_EDIT_NEGATIVE = 'blurry, low quality, deformed, bad anatomy, watermark, text, extra fingers'
const BYPASS = 4
const IMAGE1 = '76'
const IMAGE2 = '81'
const SINGLE_SAVE = '9'
const DUAL_SAVE = '94'
const SINGLE_PROMPT = '75:74'
const SINGLE_NEGATIVE = '75:67'
const SINGLE_NOISE = '75:73'
const SINGLE_SCHEDULER = '75:62'
const SINGLE_CFG = '75:63'
const DUAL_PROMPT = '92:113'
const DUAL_NEGATIVE = '92:87'
const DUAL_NOISE = '92:105'
const DUAL_SCHEDULER = '92:115'
const DUAL_CFG = '92:114'
type WorkflowNode = { class_type: string; inputs: Record<string, unknown>; _meta?: { title?: string } }
type WorkflowNode = { class_type: string; inputs: Record<string, unknown>; mode?: number; _meta?: { title?: string } }
type WorkflowGraph = Record<string, WorkflowNode>
export interface EditParams {
prompt: string
negative?: string
imageName: string
referenceImageName?: string
steps: number
seed: number
cfg: number
denoise: number
samplerName?: string
scheduler?: string
filenamePrefix?: string
}
export function buildEditWorkflow(params: EditParams) {
const graph = structuredClone(template) as WorkflowGraph
const loadImage = nodeByClass(graph, 'LoadImage') || graph['4']
const qwenPrompt = nodeByClass(graph, 'TextEncodeQwenImageEdit') || graph['17']
const sampler = nodeByClass(graph, 'KSampler') || graph['8']
const save = nodeByClass(graph, 'SaveImage') || graph['10']
if (loadImage) loadImage.inputs.image = params.imageName
if (qwenPrompt) {
if ('prompt' in qwenPrompt.inputs) qwenPrompt.inputs.prompt = params.prompt
if ('text' in qwenPrompt.inputs) qwenPrompt.inputs.text = params.prompt
const dual = Boolean(params.referenceImageName)
const prompt = String(params.prompt || '').trim()
const negative = String(params.negative || '')
const prefix = params.filenamePrefix || 'aigen-edit'
setImage(graph, IMAGE1, params.imageName)
setImage(graph, IMAGE2, params.referenceImageName || '')
setText(graph, SINGLE_PROMPT, prompt)
setText(graph, DUAL_PROMPT, prompt)
setText(graph, SINGLE_NEGATIVE, negative)
setText(graph, DUAL_NEGATIVE, negative)
setInput(graph, SINGLE_NOISE, 'noise_seed', params.seed)
setInput(graph, DUAL_NOISE, 'noise_seed', params.seed)
setInput(graph, SINGLE_SCHEDULER, 'steps', params.steps)
setInput(graph, DUAL_SCHEDULER, 'steps', params.steps)
setInput(graph, SINGLE_CFG, 'cfg', params.cfg)
setInput(graph, DUAL_CFG, 'cfg', params.cfg)
setInput(graph, SINGLE_SAVE, 'filename_prefix', prefix)
setInput(graph, DUAL_SAVE, 'filename_prefix', prefix)
for (const [id, node] of Object.entries(graph)) {
if (!node) continue
if (id === IMAGE1) {
delete node.mode
continue
}
const dualNode = id === IMAGE2 || id === DUAL_SAVE || id.startsWith('92:')
const singleNode = id === SINGLE_SAVE || id.startsWith('75:')
if (dual ? singleNode : dualNode) node.mode = BYPASS
else delete node.mode
}
if (sampler) {
sampler.inputs.seed = params.seed
sampler.inputs.steps = params.steps
sampler.inputs.cfg = params.cfg
sampler.inputs.denoise = params.denoise
sampler.inputs.sampler_name = params.samplerName || 'euler'
sampler.inputs.scheduler = params.scheduler || 'simple'
}
if (save && params.filenamePrefix) save.inputs.filename_prefix = params.filenamePrefix
return graph
}
function nodeByClass(graph: WorkflowGraph, classType: string, index = 0) {
const matches = Object.values(graph).filter(node => node?.class_type === classType)
return matches[index]
function setImage(graph: WorkflowGraph, id: string, name: string) {
if (graph[id]) graph[id].inputs.image = name
}
function setText(graph: WorkflowGraph, id: string, text: string) {
if (graph[id]) graph[id].inputs.text = text
}
function setInput(graph: WorkflowGraph, id: string, key: string, value: unknown) {
if (graph[id]) graph[id].inputs[key] = value
}
export const EDIT_NODE_LABELS: Record<string, string> = {
'1': 'Loading Qwen Edit UNET',
'11': 'Applying Lightning LoRA',
'2': 'Loading Qwen CLIP',
'3': 'Loading Qwen VAE',
'4': 'Loading input still',
'17': 'Encoding Qwen edit prompt',
'19': 'Preparing negative',
'7': 'Encoding still',
'8': 'Sampling edit',
'9': 'Decoding still',
'10': 'Saving still'
'76': 'Loading image 1',
'81': 'Loading image 2',
'75:70': 'Loading Flux.2 Klein',
'75:71': 'Loading CLIP',
'75:72': 'Loading VAE',
'75:74': 'Encoding prompt',
'75:64': 'Sampling edit',
'75:65': 'Decoding still',
'9': 'Saving still',
'92:106': 'Loading Flux.2 Klein',
'92:111': 'Loading CLIP',
'92:107': 'Loading VAE',
'92:113': 'Encoding prompt',
'92:103': 'Sampling two-image edit',
'92:104': 'Decoding still',
'94': 'Saving still'
}